Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Sdk2EquipmentVariableTests.cs
T

363 lines
18 KiB
C#

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<OpcUaApplicationHost>.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<double>().ShouldBe(3.25d);
SdkValueBoundary.ToClr(node.Value).ShouldBeOfType<double>();
}
[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<short[]>().ShouldBe([1, 2]);
SdkValueBoundary.ToClr(node.Value).ShouldBeOfType<short[]>().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<int>)[1, 32768]),
"fraction" => Variant.From((ArrayOf<double>)[1d, 2.5d]),
"null" => Variant.Null,
"matrix" => Variant.From((MatrixOf<short>)new short[,] { { 1, 2 }, { 3, 4 } }),
"length" => Variant.From((ArrayOf<short>)[1]),
_ => Variant.From((ArrayOf<short>)[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<byte>(), new int[] { 1, 2 }, Array.Empty<int>(), 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<byte[]>().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<byte[]>().ShouldBe([1, 2]);
var readback = SdkValueBoundary.ToClr(node.Value).ShouldBeOfType<byte[]>();
readback[0] = 77;
SdkValueBoundary.ToClr(node.Value).ShouldBeOfType<byte[]>().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<short[]>().SequenceEqual(new short[] { 7, 8 }))
await Task.Delay(10, Ct);
SdkValueBoundary.ToClr(node.Value).ShouldBeOfType<short[]>().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<NodeWriteOutcome>(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<NodeWriteOutcome>(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<short[]>().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<NodeWriteOutcome>(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<short[]>().SequenceEqual(new short[] { 7, 8 }))
await Task.Delay(10, Ct);
SdkValueBoundary.ToClr(node.Value).ShouldBeOfType<short[]>().ShouldBe([7, 8]);
}
private sealed class OutcomeLogger : ILogger<OtOpcUaNodeManager>
{
internal TaskCompletionSource Ignored { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel level) => true;
public void Log<TState>(LogLevel level, EventId id, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
if (formatter(state, exception).Contains("ignored superseded write outcome", StringComparison.Ordinal)) Ignored.TrySetResult();
}
}
private sealed class OutcomeGateway(Func<object?, Task<NodeWriteOutcome>> outcome) : IOpcUaNodeWriteGateway
{
public Task<NodeWriteOutcome> 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<object?> Values { get; } = [];
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct)
{
Values.Add(value);
return Task.FromResult(new NodeWriteOutcome(true, null));
}
}
}