using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Opc.Ua.Server; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; public sealed class Sdk2EquipmentVariableTests : IAsyncLifetime { private readonly string _pki = Path.Combine(Path.GetTempPath(), $"otua-sdk2-values-{Guid.NewGuid():N}"); private OpcUaApplicationHost _host = null!; private OtOpcUaSdkServer _server = null!; private OtOpcUaNodeManager _manager = null!; private readonly RecordingGateway _gateway = new(); private readonly OutcomeLogger _outcomeLogger = new(); private static CancellationToken Ct => TestContext.Current.CancellationToken; public async ValueTask InitializeAsync() { var started = await OpcUaHostStart.StartAsync(port => new OpcUaApplicationHost( new OpcUaApplicationHostOptions { ApplicationName = "SDK2.ValueContracts", ApplicationUri = "urn:SDK2.ValueContracts", PublicHostname = "localhost", OpcUaPort = port, PkiStoreRoot = _pki, }, NullLogger.Instance), () => new OtOpcUaSdkServer(_outcomeLogger), Ct); _host = started.Host; _server = started.Server; _manager = _server.NodeManager!; _manager.NodeWriteGateway = _gateway; } public async ValueTask DisposeAsync() { await _host.DisposeAsync(); if (Directory.Exists(_pki)) Directory.Delete(_pki, true); } [Fact] public void Double_to_Float_retains_the_scalar_driver_contract() { var node = Node("Float"); Write(node, 3.25d).ShouldBe(ServiceResult.Good); _gateway.Values.ShouldHaveSingleItem().ShouldBeOfType().ShouldBe(3.25d); SdkValueBoundary.ToClr(node.Value).ShouldBeOfType(); } [Fact] public async Task Checked_arrays_are_converted_before_the_async_node_write_dispatches() { var node = Node("Int16", array: true); var result = await node.WriteAttributeAsync(Context(), Attributes.Value, default, new DataValue(SdkValueBoundary.FromClr(new[] { 1, 2 })), Ct); result.ShouldBe(ServiceResult.Good); _gateway.Values.ShouldHaveSingleItem().ShouldBeOfType().ShouldBe([1, 2]); SdkValueBoundary.ToClr(node.Value).ShouldBeOfType().ShouldBe([1, 2]); } [Theory] [InlineData(false)] [InlineData(true)] public void Scalar_shaped_arrays_follow_the_existing_driver_capability(bool refuse) { var node = Node("Int16", refuse: refuse); var result = Write(node, new short[] { 1, 2 }); result.StatusCode.ShouldBe(refuse ? StatusCodes.BadTypeMismatch : StatusCodes.Good); _gateway.Values.Count.ShouldBe(refuse ? 0 : 1); } [Theory] [InlineData("overflow")] [InlineData("fraction")] [InlineData("null")] [InlineData("matrix")] [InlineData("length")] [InlineData("range")] public void Invalid_writes_never_dispatch_or_mutate_the_tuple(string fault) { var node = Node("Int16", array: true); _manager.WriteValue("v", new short[] { 7, 8 }, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); var oldValue = node.Value; var oldStatus = node.StatusCode; var oldTime = node.Timestamp; var payload = fault switch { "overflow" => Variant.From((ArrayOf)[1, 32768]), "fraction" => Variant.From((ArrayOf)[1d, 2.5d]), "null" => Variant.Null, "matrix" => Variant.From((MatrixOf)new short[,] { { 1, 2 }, { 3, 4 } }), "length" => Variant.From((ArrayOf)[1]), _ => Variant.From((ArrayOf)[1, 2]), }; var result = node.WriteAttribute(Context(), Attributes.Value, fault == "range" ? new NumericRange(0, 1) : default, new DataValue(payload)); result.StatusCode.ShouldBe(fault == "range" ? StatusCodes.BadWriteNotSupported : StatusCodes.BadTypeMismatch); _gateway.Values.ShouldBeEmpty(); node.Value.ShouldBe(oldValue); node.StatusCode.ShouldBe(oldStatus); node.Timestamp.ShouldBe(oldTime); } [Theory] [InlineData(AddressSpaceRealm.Raw)] [InlineData(AddressSpaceRealm.Uns)] public void A_denied_identity_with_bad_shape_stays_denied(AddressSpaceRealm realm) { var node = Node("Int16", array: true, realm: realm); Write(node, null, authorized: false).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); _gateway.Values.ShouldBeEmpty(); } [Fact] public void Readonly_to_writable_to_readonly_uses_the_same_adapter() { var node = Node("Float", writable: false); Write(node, 1d).StatusCode.ShouldBe(StatusCodes.BadNotWritable); _manager.UpdateTagAttributes("v", true, null, "Float", false, null, AddressSpaceRealm.Raw); Write(node, 2d).ShouldBe(ServiceResult.Good); _manager.UpdateTagAttributes("v", false, null, "Float", false, null, AddressSpaceRealm.Raw); Write(node, 3d).StatusCode.ShouldBe(StatusCodes.BadNotWritable); _gateway.Values.ShouldHaveSingleItem().ShouldBe(2d); } [Fact] public void SDK_metadata_access_checks_stay_in_force() { var node = Node("Int32"); var infoBits = new StatusCode(0x00004000u); node.WriteAttribute(Context(), Attributes.Value, default, new DataValue(Variant.From(1), infoBits)) .ShouldBe(ServiceResult.Good); foreach (var status in new[] { StatusCodes.GoodClamped, StatusCodes.BadNoData }) node.WriteAttribute(Context(), Attributes.Value, default, new DataValue(Variant.From(2), status)) .StatusCode.ShouldBe(StatusCodes.BadWriteNotSupported); node.WriteAttribute(Context(), Attributes.Value, default, new DataValue(Variant.From(3), StatusCodes.Good, DateTime.UtcNow)).StatusCode.ShouldBe(StatusCodes.BadWriteNotSupported); node.WriteAttribute(Context(), Attributes.Value, default, new DataValue(Variant.From(3), StatusCodes.Good, default, DateTime.UtcNow)).StatusCode.ShouldBe(StatusCodes.BadWriteNotSupported); node.AccessLevel |= AccessLevels.StatusWrite | AccessLevels.TimestampWrite; node.UserAccessLevel = AccessLevels.CurrentRead | AccessLevels.CurrentWrite; node.WriteAttribute(Context(), Attributes.Value, default, new DataValue(Variant.From(4), StatusCodes.BadNoData)) .StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); node.WriteAttribute(Context(), Attributes.Value, default, new DataValue(Variant.From(4), StatusCodes.Good, DateTime.UtcNow)).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); node.OnReadUserAccessLevel = (ISystemContext _, NodeState _, ref byte access) => { access = AccessLevels.CurrentRead; return ServiceResult.Good; }; Write(node, 5).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); _gateway.Values.ShouldHaveSingleItem(); } [Fact] public async Task A_direct_async_sampler_waits_for_the_complete_published_tuple() { var node = Node("Int32"); var stamp = new DateTime(2026, 11, 10, 10, 0, 0, DateTimeKind.Utc); using var started = new ManualResetEventSlim(); Task<(ServiceResult Result, DataValue Value)> read; lock (_manager.Lock) { node.Value = Variant.From(42); read = Task.Run(async () => { started.Set(); return await node.ReadAttributeAsync(Context(), Attributes.Value, default, default, default, Ct); }, Ct); started.Wait(Ct); read.IsCompleted.ShouldBeFalse(); node.StatusCode = StatusCodes.UncertainLastUsableValue; node.Timestamp = stamp; } var result = await read; SdkValueBoundary.ToClr(result.Value.WrappedValue).ShouldBe(42); result.Value.StatusCode.ShouldBe(StatusCodes.UncertainLastUsableValue); result.Value.SourceTimestamp.ToDateTime().ShouldBe(stamp); } [Fact] public void Published_values_retain_existing_CLR_types_and_null_semantics() { object?[] cases = [true, (sbyte)-1, (byte)1, (short)-2, (ushort)2, -3, 3u, -4L, 4UL, 1.25f, 2.5d, "", "text", null, Guid.NewGuid(), DateTime.UtcNow, new byte[] { 1, 2 }, Array.Empty(), new int[] { 1, 2 }, Array.Empty(), new string[] { "a", "" }]; for (var i = 0; i < cases.Length; i++) { var id = $"payload/{i}"; _manager.EnsureVariable(id, null, id, "BaseDataType", false, AddressSpaceRealm.Raw); _manager.WriteValue(id, cases[i], OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); var actual = SdkValueBoundary.ToClr(_manager.TryGetVariable(id, AddressSpaceRealm.Raw)!.Value); if (cases[i] is { } expected) actual!.GetType().ShouldBe(expected.GetType()); actual.ShouldBe(cases[i]); } SdkValueBoundary.ToClr(Variant.From(default(ByteString))).ShouldBeNull(); SdkValueBoundary.ToClr(Variant.From(ByteString.Empty)).ShouldBeOfType().ShouldBeEmpty(); } [Fact] public void Publications_detach_buffers_and_preserve_byte_array_wire_rank() { var node = Node("Byte", array: true); var bytes = new byte[] { 1, 2 }; _manager.WriteValue("v", bytes, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); bytes[0] = 99; node.Value.TypeInfo.BuiltInType.ShouldBe(BuiltInType.Byte); node.Value.TypeInfo.ValueRank.ShouldBe(ValueRanks.OneDimension); SdkValueBoundary.ToClr(node.Value).ShouldBeOfType().ShouldBe([1, 2]); var readback = SdkValueBoundary.ToClr(node.Value).ShouldBeOfType(); readback[0] = 77; SdkValueBoundary.ToClr(node.Value).ShouldBeOfType().ShouldBe([1, 2]); var scalarBytes = SdkValueBoundary.FromClr(new byte[] { 1, 2 }); scalarBytes.TypeInfo.BuiltInType.ShouldBe(BuiltInType.ByteString); scalarBytes.TypeInfo.ValueRank.ShouldBe(ValueRanks.Scalar); } [Theory] [InlineData(AddressSpaceRealm.Raw)] [InlineData(AddressSpaceRealm.Uns)] public async Task An_immediate_failed_array_outcome_reverts_an_actual_node_write(AddressSpaceRealm realm) { var node = Node("Int16", array: true, realm: realm); _manager.WriteValue("v", new short[] { 7, 8 }, OpcUaStatus.Good, DateTime.UtcNow, realm); _manager.NodeWriteGateway = new OutcomeGateway(_ => Task.FromResult(new NodeWriteOutcome(false, "refused"))); Write(node, new short[] { 1, 2 }).ShouldBe(ServiceResult.Good); var deadline = DateTime.UtcNow.AddSeconds(2); while (DateTime.UtcNow < deadline && !SdkValueBoundary.ToClr(node.Value).ShouldBeOfType().SequenceEqual(new short[] { 7, 8 })) await Task.Delay(10, Ct); SdkValueBoundary.ToClr(node.Value).ShouldBeOfType().ShouldBe([7, 8]); node.StatusCode.ShouldBe(StatusCodes.Good); } [Theory] [InlineData("equal-client-write")] [InlineData("equal-driver-poll")] [InlineData("status-only")] [InlineData("same-id-recreated")] [InlineData("shape-update")] public async Task An_old_failed_outcome_cannot_replace_a_newer_actual_node_tuple(string change) { var node = Node("Int16", array: true); _manager.WriteValue("v", new short[] { 7, 8 }, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); var first = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var calls = 0; _manager.NodeWriteGateway = new OutcomeGateway(_ => ++calls == 1 ? first.Task : Task.FromResult(new NodeWriteOutcome(true, null))); Write(node, new short[] { 1, 2 }).ShouldBe(ServiceResult.Good); var stamp = new DateTime(2026, 11, 10, 12, 0, 0, DateTimeKind.Utc); switch (change) { case "equal-client-write": Write(node, new short[] { 1, 2 }).ShouldBe(ServiceResult.Good); break; case "equal-driver-poll": _manager.WriteValue("v", new short[] { 1, 2 }, StatusCodes.UncertainLastUsableValue.Code, stamp, AddressSpaceRealm.Raw); break; case "status-only": _manager.WriteStatuses([new StatusWrite("v", StatusCodes.BadNoCommunication.Code, stamp, AddressSpaceRealm.Raw)]); break; case "same-id-recreated": _manager.RemoveVariableNode("v", AddressSpaceRealm.Raw).ShouldBeTrue(); node = Node("Int16", array: true); Write(node, new short[] { 1, 2 }).ShouldBe(ServiceResult.Good); break; case "shape-update": _manager.UpdateTagAttributes("v", true, null, "Int32", true, 2, AddressSpaceRealm.Raw).ShouldBeTrue(); break; } var expected = node.Value; var status = node.StatusCode; var time = node.Timestamp; first.SetResult(new NodeWriteOutcome(false, "older write failed")); await _outcomeLogger.Ignored.Task.WaitAsync(TimeSpan.FromSeconds(5), Ct); node.Value.ShouldBe(expected); node.StatusCode.ShouldBe(status); node.Timestamp.ShouldBe(time); } [Fact] public async Task A_reentrant_driver_publication_wins_over_the_callback_optimistic_commit() { var node = Node("Int16", array: true); _manager.WriteValue("v", new short[] { 7, 8 }, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); var first = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var stamp = new DateTime(2026, 11, 10, 12, 0, 0, DateTimeKind.Utc); _manager.NodeWriteGateway = new OutcomeGateway(_ => { _manager.WriteValue("v", new short[] { 1, 2 }, StatusCodes.UncertainLastUsableValue.Code, stamp, AddressSpaceRealm.Raw); return first.Task; }); Write(node, new short[] { 1, 2 }).ShouldBe(ServiceResult.Good); node.StatusCode.ShouldBe(StatusCodes.UncertainLastUsableValue); node.Timestamp.ToDateTime().ShouldBe(stamp); first.SetResult(new NodeWriteOutcome(false, "old write failed")); await _outcomeLogger.Ignored.Task.WaitAsync(TimeSpan.FromSeconds(5), Ct); SdkValueBoundary.ToClr(node.Value).ShouldBeOfType().ShouldBe([1, 2]); node.StatusCode.ShouldBe(StatusCodes.UncertainLastUsableValue); node.Timestamp.ToDateTime().ShouldBe(stamp); } [Theory] [InlineData(false)] [InlineData(true)] public async Task A_refused_attempt_does_not_disown_an_older_committed_write(bool synchronousThrow) { var node = Node("Int16", array: true); _manager.WriteValue("v", new short[] { 7, 8 }, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); var first = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _manager.NodeWriteGateway = new OutcomeGateway(_ => first.Task); Write(node, new short[] { 1, 2 }).ShouldBe(ServiceResult.Good); if (synchronousThrow) { _manager.NodeWriteGateway = new OutcomeGateway(_ => throw new InvalidOperationException("gateway threw")); Write(node, new short[] { 3, 4 }).StatusCode.ShouldBe(StatusCodes.BadDeviceFailure); } else Write(node, new short[] { 3, 4 }, authorized: false).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); first.SetResult(new NodeWriteOutcome(false, "first write failed")); var deadline = DateTime.UtcNow.AddSeconds(2); while (DateTime.UtcNow < deadline && !SdkValueBoundary.ToClr(node.Value).ShouldBeOfType().SequenceEqual(new short[] { 7, 8 })) await Task.Delay(10, Ct); SdkValueBoundary.ToClr(node.Value).ShouldBeOfType().ShouldBe([7, 8]); } private sealed class OutcomeLogger : ILogger { internal TaskCompletionSource Ignored { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel level) => true; public void Log(LogLevel level, EventId id, TState state, Exception? exception, Func formatter) { if (formatter(state, exception).Contains("ignored superseded write outcome", StringComparison.Ordinal)) Ignored.TrySetResult(); } } private sealed class OutcomeGateway(Func> outcome) : IOpcUaNodeWriteGateway { public Task WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) => outcome(value); } private BaseDataVariableState Node(string type, bool array = false, bool refuse = false, bool writable = true, AddressSpaceRealm realm = AddressSpaceRealm.Raw) { _manager.EnsureVariable("v", null, "Value", type, writable, realm, isArray: array, arrayLength: array ? 2u : null, refuseArrayOnScalar: refuse); return _manager.TryGetVariable("v", realm)!; } private ServerSystemContext Context(bool authorized = true) => new(_server.CurrentInstance) { UserIdentity = new RoleCarryingUserIdentity(new AnonymousIdentityToken(), authorized ? [OpcUaDataPlaneRoles.WriteOperate] : [], "operator", null), }; private ServiceResult Write(BaseDataVariableState node, object? value, bool authorized = true) => node.WriteAttribute(Context(authorized), Attributes.Value, default, new DataValue(SdkValueBoundary.FromClr(value))); private sealed class RecordingGateway : IOpcUaNodeWriteGateway { internal List Values { get; } = []; public Task WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) { Values.Add(value); return Task.FromResult(new NodeWriteOutcome(true, null)); } } }