363 lines
18 KiB
C#
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));
|
|
}
|
|
}
|
|
}
|