mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-21 09:06:37 +08:00
7212607f16
# Description Fixes the High and Medium findings of an OPC UA Part 3 (OPC 10000-3, Address Space Model) compliance audit. Each finding was checked against the full spec text (Parts 3, 4, 5, 6, 14 and 18) before it was fixed. Every fix comes with regression tests that fail without it. ### Role permissions (Part 3 §8.55, Part 4 §5.12.2.2) - The type-hierarchy exemption in `MasterNodeManager.ValidateRolePermissions` / `ValidateAccessRestrictions` now covers only Browse, Read and ReadRolePermissions. Type nodes stay visible to generic clients, which was the intent of #3705; every other permission bit is now enforced on them. - `ReceiveEvents` is enforced on the EventType node. The standard NodeSet grants it on `AuditEventType` and its subtypes only to SecurityAdmin, so only SecurityAdmin sessions now receive audit events. - Call checks the `Call` permission and AccessRestrictions on the Object as well as on the Method. - When a Call's `methodId` is the Method declaration of the ObjectType, the Object's Method with the same BrowseName is permission-checked and invoked. The declaration is used only if the Object has no such Method. This applies to `AsyncCustomNodeManager`, `CustomNodeManager2` and the sample node manager. ### Write service (Part 3 §5.6.2, §8.40, §8.57; Part 4 §5.11.4) - The synchronous `OnWriteValue` path (fluent `OnWrite`) checks DataType and ValueRank first (`Bad_TypeMismatch`). After an IndexRange write it merges the slice into the cached value instead of replacing it. - StatusWrite / TimestampWrite are enforced (`Bad_WriteNotSupported`). - Undefined enumeration values and invalid OptionSet writes return `Bad_OutOfRange`. OptionSet writes merge only the valid bits into the stored value. - New `ReportPropertyValueChanged` / `ReportSemanticChange` let server code raise SemanticChangeEvents for changes it makes itself. - Required follow-up (Part 14 §6.2.11.1): if an external target rejects a timestamp, the PubSub subscriber's TargetVariables writer retries without it. ### NodeManagement and Call arguments (Part 3 §5.5.2, §5.7.3, §6.4.2; Part 4 §5.8, §5.12.2) - AddNodes creates the Mandatory InstanceDeclarations of the TypeDefinition and rejects abstract or Interface types (`Bad_TypeDefinitionInvalid`). - AddReferences, DeleteReferences and DeleteNodes bump NodeVersion and raise GeneralModelChangeEvents. - Clients may omit trailing optional input arguments (`HasOptionalInputArgumentDescription`). ### NodeSet DataTypes (Part 3 §5.8.3, §8.48; Part 6 Annex F.12) - `UANodeSet.Import` completes structure definitions with the base type's fields (base fields first). It creates definitions for structures without fields and sets `DefaultEncodingId`. `StructureDefinition.FirstExplicitFieldIndex` marks the subtype's own fields. - `DataTypeState` no longer mutates the stored definition on read. ### Locales (Part 3 §5.2.5, §8.5; Part 4 §5.4) - Read, Browse and data change notifications return the translation that best matches the session's LocaleIds, or all translations for `mul`. The selection lives in the server dispatcher and is built on `ResourceManager`: the new `TranslateValue` calls the overridable `Translate`. - Locale ids are matched case-insensitively, and fallback translations carry their real locale. ### Client complex types (Part 6 §5.1.7) - The Reflection.Emit builder encodes only `IsOptional` subtyped fields as ExtensionObject. It also supports OptionSet subtypes: an unsupported type is skipped instead of aborting the whole load. - `TypeInfo.GetXmlName` uses the data type's XML name for dynamic structures and OptionSets (the XML body element was `<Structure>`). Both builders now produce identical XML. ## Behaviour changes All are documented in `docs/MigrationGuide.md`. - **Audit events:** only SecurityAdmin receives them. The CTT "Auditing Connections" unit needs a SecurityAdmin user (e.g. sysadmin); `AuditingOperationTests` was updated. - **Write timestamps and status:** clients writing a SourceTimestamp or a non-Good StatusCode get `Bad_WriteNotSupported` unless the Variable has StatusWrite/TimestampWrite. No variable in `src/` sets these bits. - **AddNodes:** it rejects abstract types (including `BaseVariableType` and `BaseEventType`), and new instances now come with child nodes. - **Call:** the Object needs the Call permission. The Object's own Method runs even when the call names the type declaration. ## Not included - Low-severity findings: missing validation on optional services and author-side NodeSet rules. - LocalizedText nested inside structures or Variant arrays, and in HistoryRead or Call outputs. ## Testing net10.0, Debug, one suite at a time on the merged branch. All suites below pass: | Suite | Passed | |---|---| | Types | 11430 | | Core.Encoders | 2947 | | Core.Schema | 179 | | Core.Security | 558 | | Client | 2761 | | Server | 8210 | | InformationModel | 1080 | | Features | 145 | | History | 536 | | Di | 455 | | Machinery | 167 | | Robotics | 661 | | Robotics.Intent | 22 | | Pumps | 75 | | Scales | 122 | | Positioning | 69 | | ISA95 | 144 | | Vision | 484 | | PubSub | 1490 | | PubSub.Adapter | 151 | | PubSub.Server | 177 | | Redundancy.Server | 856 | | Lds | 173 | | Configuration | 241 | | WotCon | 1496 | - **Client.ComplexTypes:** one test wrote a SourceTimestamp to a variable without TimestampWrite. It is fixed in this PR, and the suite has not been re-run since. - **Core:** 6068 passed, 1 failure in `ActivityTraceFlags_Metrics_ShouldRecordMetricsOnly`. That telemetry test passed when re-run alone, so it looks flaky. - **Still running:** Sessions, Subscriptions (Classic, Durable, standard) and Interop. Results will follow on this PR. - `UA.slnx` builds with 0 errors for net10.0 and net48, with no new analyzer warnings in changed files. ## Related Issues - Part 3 compliance audit (no tracking issue) ## Checklist - [x] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
297 lines
14 KiB
C#
297 lines
14 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.Threading;
|
|
using System.Threading.Tasks;
|
|
using Moq;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.PubSub.Adapter.Session;
|
|
using Opc.Ua.PubSub.Adapter.Subscriber;
|
|
|
|
namespace Opc.Ua.PubSub.Adapter.Tests
|
|
{
|
|
/// <summary>
|
|
/// Unit tests for <see cref="ServerTargetVariableWriter"/>: it
|
|
/// builds a single WriteValue, returns the server status, and never throws
|
|
/// on a service fault.
|
|
/// </summary>
|
|
[TestFixture]
|
|
public sealed class ServerTargetVariableWriterTests
|
|
{
|
|
[Test]
|
|
public void ConstructorNullSessionThrows()
|
|
{
|
|
Assert.That(
|
|
() => new ServerTargetVariableWriter(null!, AdapterTestHelpers.Telemetry()),
|
|
Throws.ArgumentNullException.With.Property("ParamName").EqualTo("session"));
|
|
}
|
|
|
|
[Test]
|
|
public async Task WriteAsyncBuildsWriteValueAndReturnsSessionStatusAsync()
|
|
{
|
|
Mock<IServerSession> session = AdapterTestHelpers.ConnectedSession();
|
|
ArrayOf<WriteValue> captured = default;
|
|
session
|
|
.Setup(s => s.WriteAsync(
|
|
It.IsAny<ArrayOf<WriteValue>>(), It.IsAny<CancellationToken>()))
|
|
.Callback<ArrayOf<WriteValue>, CancellationToken>((w, _) => captured = w)
|
|
.Returns(new ValueTask<ArrayOf<StatusCode>>(
|
|
new[] { StatusCodes.Good }.ToArrayOf()));
|
|
var writer = new ServerTargetVariableWriter(
|
|
session.Object, AdapterTestHelpers.Telemetry());
|
|
|
|
var node = new NodeId(7u);
|
|
var value = new DataValue(new Variant(42.0));
|
|
StatusCode status = await writer
|
|
.WriteAsync(node, Attributes.Value, "1:2", value)
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(StatusCode.IsGood(status), Is.True);
|
|
Assert.That(captured.Count, Is.EqualTo(1));
|
|
Assert.That(captured[0].NodeId, Is.EqualTo(node));
|
|
Assert.That(captured[0].AttributeId, Is.EqualTo(Attributes.Value));
|
|
Assert.That(captured[0].IndexRange, Is.EqualTo("1:2"));
|
|
Assert.That(captured[0].Value.WrappedValue, Is.EqualTo(new Variant(42.0)));
|
|
}
|
|
|
|
[Test]
|
|
public async Task WriteAsyncDropsTimestampsWhenTargetRejectsThemAsync()
|
|
{
|
|
// Part 14 6.2.11.1: a received timestamp is not used when the target
|
|
// Variable does not allow writing timestamps.
|
|
Mock<IServerSession> session = AdapterTestHelpers.ConnectedSession();
|
|
var written = new System.Collections.Generic.List<DataValue>();
|
|
session
|
|
.Setup(s => s.WriteAsync(
|
|
It.IsAny<ArrayOf<WriteValue>>(), It.IsAny<CancellationToken>()))
|
|
.Returns<ArrayOf<WriteValue>, CancellationToken>((w, _) =>
|
|
{
|
|
DataValue value = w[0].Value;
|
|
written.Add(value);
|
|
StatusCode status = value.SourceTimestamp == DateTimeUtc.MinValue
|
|
? StatusCodes.Good
|
|
: StatusCodes.BadWriteNotSupported;
|
|
return new ValueTask<ArrayOf<StatusCode>>(new[] { status }.ToArrayOf());
|
|
});
|
|
var writer = new ServerTargetVariableWriter(
|
|
session.Object, AdapterTestHelpers.Telemetry());
|
|
var node = new NodeId(7u);
|
|
var timestamp = new DateTimeUtc(new DateTime(2026, 10, 5, 0, 0, 0, DateTimeKind.Utc));
|
|
|
|
StatusCode first = await writer
|
|
.WriteAsync(node, Attributes.Value, null,
|
|
new DataValue(new Variant(1.0), StatusCodes.Good, timestamp))
|
|
.ConfigureAwait(false);
|
|
StatusCode second = await writer
|
|
.WriteAsync(node, Attributes.Value, null,
|
|
new DataValue(new Variant(2.0), StatusCodes.Good, timestamp))
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(StatusCode.IsGood(first), Is.True);
|
|
Assert.That(StatusCode.IsGood(second), Is.True);
|
|
// first write is retried without timestamps; the second goes
|
|
// straight to the timestamp-free write.
|
|
Assert.That(written, Has.Count.EqualTo(3));
|
|
Assert.That(written[0].SourceTimestamp, Is.EqualTo(timestamp));
|
|
Assert.That(written[1].SourceTimestamp, Is.EqualTo(DateTimeUtc.MinValue));
|
|
Assert.That(written[1].WrappedValue, Is.EqualTo(new Variant(1.0)));
|
|
Assert.That(written[2].SourceTimestamp, Is.EqualTo(DateTimeUtc.MinValue));
|
|
Assert.That(written[2].WrappedValue, Is.EqualTo(new Variant(2.0)));
|
|
}
|
|
|
|
[Test]
|
|
public async Task WriteAsyncRejectedServerTimestampKeepsSourceTimestampAsync()
|
|
{
|
|
// the target accepts SourceTimestamps but no ServerTimestamp: dropping the
|
|
// ServerTimestamp must not suppress the SourceTimestamp of later writes.
|
|
Mock<IServerSession> session = AdapterTestHelpers.ConnectedSession();
|
|
var written = new System.Collections.Generic.List<DataValue>();
|
|
session
|
|
.Setup(s => s.WriteAsync(
|
|
It.IsAny<ArrayOf<WriteValue>>(), It.IsAny<CancellationToken>()))
|
|
.Returns<ArrayOf<WriteValue>, CancellationToken>((w, _) =>
|
|
{
|
|
DataValue value = w[0].Value;
|
|
written.Add(value);
|
|
StatusCode status = value.ServerTimestamp == DateTimeUtc.MinValue
|
|
? StatusCodes.Good
|
|
: StatusCodes.BadWriteNotSupported;
|
|
return new ValueTask<ArrayOf<StatusCode>>(new[] { status }.ToArrayOf());
|
|
});
|
|
var writer = new ServerTargetVariableWriter(
|
|
session.Object, AdapterTestHelpers.Telemetry());
|
|
var node = new NodeId(7u);
|
|
var source = new DateTimeUtc(new DateTime(2026, 10, 5, 0, 0, 0, DateTimeKind.Utc));
|
|
var server = new DateTimeUtc(new DateTime(2026, 10, 5, 0, 0, 1, DateTimeKind.Utc));
|
|
|
|
StatusCode first = await writer
|
|
.WriteAsync(node, Attributes.Value, null,
|
|
new DataValue(new Variant(1.0), StatusCodes.Good, source, server))
|
|
.ConfigureAwait(false);
|
|
StatusCode second = await writer
|
|
.WriteAsync(node, Attributes.Value, null,
|
|
new DataValue(new Variant(2.0), StatusCodes.Good, source, server))
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(StatusCode.IsGood(first), Is.True);
|
|
Assert.That(StatusCode.IsGood(second), Is.True);
|
|
Assert.That(written, Has.Count.EqualTo(3));
|
|
Assert.That(written[1].ServerTimestamp, Is.EqualTo(DateTimeUtc.MinValue));
|
|
Assert.That(written[1].SourceTimestamp, Is.EqualTo(source));
|
|
Assert.That(written[2].ServerTimestamp, Is.EqualTo(DateTimeUtc.MinValue));
|
|
Assert.That(written[2].SourceTimestamp, Is.EqualTo(source),
|
|
"Only the rejected ServerTimestamp is remembered.");
|
|
}
|
|
|
|
[Test]
|
|
public async Task WriteAsyncKeepsStatusCodeWhenDroppingTimestampsAsync()
|
|
{
|
|
Mock<IServerSession> session = AdapterTestHelpers.ConnectedSession();
|
|
var written = new System.Collections.Generic.List<DataValue>();
|
|
session
|
|
.Setup(s => s.WriteAsync(
|
|
It.IsAny<ArrayOf<WriteValue>>(), It.IsAny<CancellationToken>()))
|
|
.Returns<ArrayOf<WriteValue>, CancellationToken>((w, _) =>
|
|
{
|
|
written.Add(w[0].Value);
|
|
return new ValueTask<ArrayOf<StatusCode>>(
|
|
new[] { (StatusCode)StatusCodes.BadWriteNotSupported }.ToArrayOf());
|
|
});
|
|
var writer = new ServerTargetVariableWriter(
|
|
session.Object, AdapterTestHelpers.Telemetry());
|
|
var timestamp = new DateTimeUtc(new DateTime(2026, 10, 5, 0, 0, 0, DateTimeKind.Utc));
|
|
|
|
StatusCode status = await writer
|
|
.WriteAsync(new NodeId(7u), Attributes.Value, null,
|
|
new DataValue(new Variant(1.0), StatusCodes.UncertainLastUsableValue, timestamp))
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(status.Code, Is.EqualTo(StatusCodes.BadWriteNotSupported));
|
|
Assert.That(written, Has.Count.EqualTo(2));
|
|
Assert.That(written[1].StatusCode.Code, Is.EqualTo(StatusCodes.UncertainLastUsableValue));
|
|
Assert.That(written[1].SourceTimestamp, Is.EqualTo(DateTimeUtc.MinValue));
|
|
}
|
|
|
|
[Test]
|
|
public async Task WriteAsyncEmptyResultsReturnsBadAsync()
|
|
{
|
|
Mock<IServerSession> session = AdapterTestHelpers.ConnectedSession();
|
|
session
|
|
.Setup(s => s.WriteAsync(
|
|
It.IsAny<ArrayOf<WriteValue>>(), It.IsAny<CancellationToken>()))
|
|
.Returns(new ValueTask<ArrayOf<StatusCode>>([]));
|
|
var writer = new ServerTargetVariableWriter(
|
|
session.Object, AdapterTestHelpers.Telemetry());
|
|
|
|
StatusCode status = await writer
|
|
.WriteAsync(new NodeId(1u), Attributes.Value, null, new DataValue(new Variant(1)))
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(status.Code, Is.EqualTo(StatusCodes.BadCommunicationError));
|
|
}
|
|
|
|
[Test]
|
|
public async Task WriteAsyncServiceFaultReturnsFaultStatusAsync()
|
|
{
|
|
Mock<IServerSession> session = AdapterTestHelpers.ConnectedSession();
|
|
session
|
|
.Setup(s => s.WriteAsync(
|
|
It.IsAny<ArrayOf<WriteValue>>(), It.IsAny<CancellationToken>()))
|
|
.Throws(ServiceResultException.Create(StatusCodes.BadNodeIdUnknown, "x"));
|
|
var writer = new ServerTargetVariableWriter(
|
|
session.Object, AdapterTestHelpers.Telemetry());
|
|
|
|
StatusCode status = await writer
|
|
.WriteAsync(new NodeId(1u), Attributes.Value, null, new DataValue(new Variant(1)))
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(status.Code, Is.EqualTo(StatusCodes.BadNodeIdUnknown));
|
|
}
|
|
|
|
[Test]
|
|
public async Task WriteAsyncUnexpectedFaultReturnsBadCommunicationErrorAsync()
|
|
{
|
|
Mock<IServerSession> session = AdapterTestHelpers.ConnectedSession();
|
|
session
|
|
.Setup(s => s.WriteAsync(
|
|
It.IsAny<ArrayOf<WriteValue>>(), It.IsAny<CancellationToken>()))
|
|
.Throws(new InvalidOperationException("transport"));
|
|
var writer = new ServerTargetVariableWriter(
|
|
session.Object, AdapterTestHelpers.Telemetry());
|
|
|
|
StatusCode status = await writer
|
|
.WriteAsync(new NodeId(1u), Attributes.Value, null, new DataValue(new Variant(1)))
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(status.Code, Is.EqualTo(StatusCodes.BadCommunicationError));
|
|
}
|
|
|
|
[Test]
|
|
public async Task WriteAsyncConnectsWhenDisconnectedAsync()
|
|
{
|
|
var session = new Mock<IServerSession>();
|
|
session.SetupGet(s => s.IsConnected).Returns(false);
|
|
session.Setup(s => s.ConnectAsync(It.IsAny<CancellationToken>()))
|
|
.Returns(default(ValueTask));
|
|
session
|
|
.Setup(s => s.WriteAsync(
|
|
It.IsAny<ArrayOf<WriteValue>>(), It.IsAny<CancellationToken>()))
|
|
.Returns(new ValueTask<ArrayOf<StatusCode>>(
|
|
new[] { StatusCodes.Good }.ToArrayOf()));
|
|
var writer = new ServerTargetVariableWriter(
|
|
session.Object, AdapterTestHelpers.Telemetry());
|
|
|
|
await writer
|
|
.WriteAsync(new NodeId(1u), Attributes.Value, null, new DataValue(new Variant(1)))
|
|
.ConfigureAwait(false);
|
|
|
|
session.Verify(s => s.ConnectAsync(It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
[Test]
|
|
public void WriteAsyncCancellationPropagates()
|
|
{
|
|
Mock<IServerSession> session = AdapterTestHelpers.ConnectedSession();
|
|
var writer = new ServerTargetVariableWriter(
|
|
session.Object, AdapterTestHelpers.Telemetry());
|
|
using var cts = new CancellationTokenSource();
|
|
cts.Cancel();
|
|
|
|
Assert.That(
|
|
async () => await writer
|
|
.WriteAsync(
|
|
new NodeId(1u), Attributes.Value, null,
|
|
new DataValue(new Variant(1)), cts.Token)
|
|
.ConfigureAwait(false),
|
|
Throws.InstanceOf<OperationCanceledException>());
|
|
}
|
|
}
|
|
}
|