mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-28 22:55:49 +08:00
# 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>
293 lines
11 KiB
C#
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));
|
|
}
|
|
}
|
|
}
|