Files
84fa8e8906 CTT follow-up: monitored items, alarms, codecs, history, and model validation (#4002)
# Description

Follow-up to #3978 based on the latest OPC UA CTT aggregate, matrix,
Historical Access, Attribute, and Node Management runs.

## Aggregate run 16

- Ignore synthetic `BadBoundNotFound` raw-bound markers when feeding
aggregate calculators.
- Add direct and live historian regression coverage.

## Multi-dimensional Variant matrices

- Fix `Variant.Copy()` so rank-2 Variant matrices preserve their payload
and dimensions.
- Enforce valid Variant matrix dimensions consistently across Binary,
JSON, XML stream, and XML parser codecs.
- Encoders return `BadEncodingError`; decoders return `BadDecodingError`
for malformed matrix dimensions.
- Add cross-codec valid/null/empty/malformed tests and a batched
ReferenceServer Read regression.
- Correct the previous CTT issue attribution: the invalid
`Scalar_Static_Arrays2D_Variant` server encoding poisoned the batched
CTT Read.

## Node Management AddNodes

- Return `BadNodeIdRejected` for unsupported requested-NodeId
namespaces.
- Validate hierarchical ReferenceType source/target NodeClass
constraints.
- Document run-18 confirmation that all remaining 15 AddNodes errors are
CTT project/script configuration issues.

## Historical Access and Attribute Services run 18

- Reject whitespace in `NumericRange` syntax with
`BadIndexRangeInvalid`.
- Add HistoryRead tests for operation-level invalid syntax and
per-DataValue `BadIndexRangeNoData`.
- Document CTT defects:
- `012.js` expects `BadIndexRangeNoData` at the operation level instead
of on returned DataValues;
  - `Err-012.js` uses a non-historizing node for an access-denied test;
- generic Attribute array helpers omit NodeId[] and StatusCode[]
conversion paths.

## Base Information model validation run 19

- Run the CTT main session as `sysadmin` over Basic256Sha256
SignAndEncrypt so SecurityAdmin RolePermissions and EncryptionRequired
restrictions are both satisfied without weakening the server model.
- Preserve predefined well-known NodeIds while making generated dynamic
type and optional-child factories allocate compatible per-instance
NodeIds, remap internal references, and update references from the
owning subtree.
- Materialize dynamic RoleType properties through generated helpers and
keep role-child string NodeIds disjoint from numeric RoleManager root
IDs.
- Add net48/net10 regressions for generator output, NodeId
allocation/reference remapping, DI mandatory children, dynamic roles,
and low-privilege versus SecurityAdmin Browse visibility.
- Document CTT defects for permission-unaware mandatory-child
validation, the UA 1.04 reference model used against a UA 1.05 server,
stale ServerDiagnostics cache, and the `ConformanceUnits` scalar/array
mismatch.
## Related Issues

- Follow-up to #3960 and #3978.

## Checklist

- [x] I have signed the CLA and read the CONTRIBUTING document.
- [x] Tests prove the fixes and improve coverage.
- [x] Required CTT documentation is updated.
- [x] Focused builds/tests introduce no warnings.
- [x] Affected tests pass on .NET Framework and .NET 10.
- [ ] CI and CodeQL are green.
- [ ] All PR feedback is addressed.



## Latest CTT full run and A&C run 20

- Fix Monitor Value Change V2 `036.js`: an ambiguous
`DataValue(StatusCode)` call encoded `BadIndexRangeNoData` as a Good
value.
- Prove all 19 configured matrix monitored items notify when the
selected value actually changes; document the independent `042.js`
script defects.
- Rebase dynamic `CertificateExpired` / `TrustListOutOfDate` alarm roots
and descendants, restore standard type declarations and ModellingRules,
and expose the cross-node-manager references.
- Deliver method-triggered events to every authorized Session, fixing
the two-Session Confirm scenario.
- Preserve locale-only `LocalizedText` values consistently across
Binary, JSON, and XML and align Acknowledge/Confirm comment behavior and
tests.
- Document the remaining proven CTT defects for TransactionDiagnostics,
SemanticChange, History `013`, PolicyId scope, A&C, durable
subscriptions, and Subscription Minimum events.

## Base synchronization

Merged `master` through `007cfaf90` (`#4010`, repository layout
consolidation) and retained the matrix regression tests at their new
`tests/` locations.

## Latest validation

- Focused merged-tree tests pass on net10 and net48.
- `Opc.Ua.Server` and `Quickstarts.Servers` Release builds complete with
0 warnings and 0 errors on net10 and net48.
- The existing
`DisableEnableViaTypeAndInstanceMethodNoEventsDuringDisabledAsync`
timing test took its documented inconclusive timeout path after the
layout merge; no test failure was reported.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: romanett <romanett98@gmail.com>
2026-07-18 05:01:03 +02:00

293 lines
11 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Xml.Linq;
using NUnit.Framework;
using Opc.Ua.Core.TestFramework;
namespace Opc.Ua.Core.Encoders.Tests
{
/// <summary>
/// Verifies consistent Variant matrix validation across all supported codecs.
/// </summary>
[TestFixture]
[Category("Encoder")]
[Category("Matrix")]
[SetCulture("en-us")]
[SetUICulture("en-us")]
[Parallelizable]
public class VariantMatrixConformanceTests : EncoderCommon
{
[Theory]
public void Int32MatrixRoundTripsAcrossCodecs(
[ValueSource(nameof(EncodingTypesAll))] EncodingTypeGroup encoding)
{
MatrixOf<int> expected = new int[2, 3]
{
{ 1, 2, 3 },
{ 4, 5, 6 }
};
Variant actual = RoundTrip(encoding, Variant.From(expected));
MatrixOf<int> actualMatrix = actual.GetInt32Matrix();
Assert.That(actualMatrix.Dimensions, Has.Length.EqualTo(2));
Assert.That(actualMatrix.Dimensions[0], Is.EqualTo(2));
Assert.That(actualMatrix.Dimensions[1], Is.EqualTo(3));
Assert.That(actualMatrix.Count, Is.EqualTo(expected.Count));
for (int i = 0; i < expected.Count; i++)
{
Assert.That(actualMatrix.Span[i], Is.EqualTo(expected.Span[i]));
}
}
[Theory]
public void NullInt32ArrayDecodesAsNoElementsAcrossCodecs(
[ValueSource(nameof(EncodingTypesAll))] EncodingTypeGroup encoding)
{
Variant actual = RoundTripVariantValue(encoding, Variant.From(ArrayOf<int>.Null));
Assert.That(actual.TypeInfo, Is.EqualTo(TypeInfo.Arrays.Int32));
Assert.That(actual.GetInt32Array().Count, Is.Zero);
}
[Theory]
public void EmptyInt32ArrayRoundTripsAcrossCodecs(
[ValueSource(nameof(EncodingTypesAll))] EncodingTypeGroup encoding)
{
Variant actual = RoundTripVariantValue(encoding, Variant.From(ArrayOf<int>.Empty));
Assert.That(actual.TypeInfo, Is.EqualTo(TypeInfo.Arrays.Int32));
Assert.That(actual.GetInt32Array().IsNull, Is.False);
Assert.That(actual.GetInt32Array().Count, Is.Zero);
}
[Theory]
public void RankOneMatrixDimensionsAreRejectedAcrossCodecs(
[ValueSource(nameof(EncodingTypesAll))] EncodingTypeGroup encoding)
{
AssertInvalidDimensions(encoding, [6]);
}
[Theory]
public void ZeroMatrixDimensionIsRejectedAcrossCodecs(
[ValueSource(nameof(EncodingTypesAll))] EncodingTypeGroup encoding)
{
AssertInvalidDimensions(encoding, [2, 0]);
}
[Theory]
public void MatrixDimensionProductMismatchIsRejectedAcrossCodecs(
[ValueSource(nameof(EncodingTypesAll))] EncodingTypeGroup encoding)
{
AssertInvalidDimensions(encoding, [2, 2]);
}
private Variant RoundTrip(EncodingTypeGroup encoding, Variant value)
{
byte[] payload = EncodeDataValue(encoding, value);
return DecodeDataValue(encoding, payload).WrappedValue;
}
private Variant RoundTripVariantValue(EncodingTypeGroup encoding, Variant value)
{
using var stream = new MemoryStream();
using (IEncoder encoder = CreateEncoder(
encoding.EncoderType,
Context,
stream,
typeof(int),
encoding.JsonEncodingType))
{
encoder.WriteVariantValue("Value", value);
}
stream.Position = 0;
using IDecoder decoder = CreateDecoder(
encoding.EncoderType,
encoding.UseXmlParser,
Context,
stream,
typeof(int));
return decoder.ReadVariantValue("Value", value.TypeInfo);
}
private void AssertInvalidDimensions(EncodingTypeGroup encoding, int[] dimensions)
{
MatrixOf<int> matrix = new int[2, 3]
{
{ 1, 2, 3 },
{ 4, 5, 6 }
};
byte[] payload = EncodeDataValue(encoding, Variant.From(matrix));
byte[] invalidPayload = ReplaceDimensions(encoding.EncoderType, payload, dimensions);
ServiceResultException exception = Assert.Throws<ServiceResultException>(
() => DecodeDataValue(encoding, invalidPayload));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadDecodingError));
}
private byte[] EncodeDataValue(EncodingTypeGroup encoding, Variant value)
{
using var stream = new MemoryStream();
using (IEncoder encoder = CreateEncoder(
encoding.EncoderType,
Context,
stream,
typeof(DataValue),
encoding.JsonEncodingType))
{
encoder.WriteDataValue("DataValue", new DataValue(value));
}
return stream.ToArray();
}
private DataValue DecodeDataValue(EncodingTypeGroup encoding, byte[] payload)
{
using var stream = new MemoryStream(payload);
using IDecoder decoder = CreateDecoder(
encoding.EncoderType,
encoding.UseXmlParser,
Context,
stream,
typeof(DataValue));
return decoder.ReadDataValue("DataValue");
}
private static byte[] ReplaceDimensions(
EncodingType encodingType,
byte[] payload,
int[] dimensions)
{
return encodingType switch
{
EncodingType.Binary => ReplaceBinaryDimensions(payload, dimensions),
EncodingType.Json => ReplaceJsonDimensions(payload, dimensions),
EncodingType.Xml => ReplaceXmlDimensions(payload, dimensions),
_ => throw new ArgumentOutOfRangeException(nameof(encodingType), encodingType, null)
};
}
private static byte[] ReplaceBinaryDimensions(byte[] payload, int[] dimensions)
{
const int originalDimensionCount = 2;
int originalDimensionBytes = sizeof(int) * (originalDimensionCount + 1);
int prefixLength = payload.Length - originalDimensionBytes;
Assert.That(
BinaryPrimitives.ReadInt32LittleEndian(payload.AsSpan(prefixLength, sizeof(int))),
Is.EqualTo(originalDimensionCount));
byte[] result = new byte[prefixLength + (sizeof(int) * (dimensions.Length + 1))];
payload.AsSpan(0, prefixLength).CopyTo(result);
Span<byte> destination = result.AsSpan(prefixLength);
BinaryPrimitives.WriteInt32LittleEndian(destination, dimensions.Length);
for (int i = 0; i < dimensions.Length; i++)
{
BinaryPrimitives.WriteInt32LittleEndian(
destination[((i + 1) * sizeof(int))..],
dimensions[i]);
}
return result;
}
private static byte[] ReplaceJsonDimensions(byte[] payload, int[] dimensions)
{
JsonNode root = JsonNode.Parse(payload)
?? throw new InvalidOperationException("The encoded JSON payload is empty.");
JsonObject owner = FindJsonDimensionsOwner(root)
?? throw new InvalidOperationException("The encoded JSON payload has no Dimensions property.");
var replacement = new JsonArray();
for (int i = 0; i < dimensions.Length; i++)
{
replacement.Add(dimensions[i]);
}
owner["Dimensions"] = replacement;
return JsonSerializer.SerializeToUtf8Bytes(root);
}
private static JsonObject FindJsonDimensionsOwner(JsonNode node)
{
if (node is JsonObject jsonObject)
{
if (jsonObject.ContainsKey("Dimensions"))
{
return jsonObject;
}
foreach (KeyValuePair<string, JsonNode> property in jsonObject)
{
JsonNode child = property.Value;
if (child != null && FindJsonDimensionsOwner(child) is JsonObject owner)
{
return owner;
}
}
}
else if (node is JsonArray jsonArray)
{
for (int i = 0; i < jsonArray.Count; i++)
{
if (jsonArray[i] != null &&
FindJsonDimensionsOwner(jsonArray[i]) is JsonObject owner)
{
return owner;
}
}
}
return null;
}
private static byte[] ReplaceXmlDimensions(byte[] payload, int[] dimensions)
{
using var stream = new MemoryStream(payload);
XDocument document = XDocument.Load(stream);
XElement dimensionsElement = document.Root?
.DescendantsAndSelf()
.FirstOrDefault(element => element.Name.LocalName == "Dimensions")
?? throw new InvalidOperationException("The encoded XML payload has no Dimensions element.");
XNamespace ns = dimensionsElement.Name.Namespace;
var elements = new XElement[dimensions.Length];
for (int i = 0; i < dimensions.Length; i++)
{
elements[i] = new XElement(ns + "Int32", dimensions[i]);
}
dimensionsElement.ReplaceNodes(elements);
return Encoding.UTF8.GetBytes(document.ToString(SaveOptions.DisableFormatting));
}
}
}