From 47a29dee2b5425d3663bd9a763a3ba0377e1d230 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 10 Nov 2026 12:50:19 -0500 Subject: [PATCH] test: prove peer provenance and repeated alarm authority with baseline scope oracle --- .../ScriptedScopeProbe.cs | 44 ++++++ .../Sdk2OpaqueScriptedScopeBaselineTests.cs | 44 ++++++ .../Sdk2RepeatedAlarmAuthorityWireTests.cs | 126 ++++++++++++++++++ .../Sdk2TrustedChannelTests.cs | 68 ++++++++-- 4 files changed, 273 insertions(+), 9 deletions(-) create mode 100644 scripts/validation/opcua-scripted-alarm-baseline/ScriptedScopeProbe.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2OpaqueScriptedScopeBaselineTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2RepeatedAlarmAuthorityWireTests.cs diff --git a/scripts/validation/opcua-scripted-alarm-baseline/ScriptedScopeProbe.cs b/scripts/validation/opcua-scripted-alarm-baseline/ScriptedScopeProbe.cs new file mode 100644 index 000000000..cc2ecfc37 --- /dev/null +++ b/scripts/validation/opcua-scripted-alarm-baseline/ScriptedScopeProbe.cs @@ -0,0 +1,44 @@ +// Deliberately compiled only in a temporary SDK1.5 baseline integration checkout; never in the candidate solution. +using Opc.Ua; +using Opc.Ua.Client; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; + +public sealed class BaselineScriptedScopeProbe +{ + [Fact] + public async Task Opaque_scripted_ID_is_denied_by_its_equipment_grant_but_allowed_by_a_cluster_grant() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await StartAsync("BaselineScriptedScope", encrypted: false, ct); + rig.KeyRoles = []; + const NodePermissions permissions = NodePermissions.ReadOnly | NodePermissions.AlarmAcknowledge | NodePermissions.MethodCall; + rig.Enforce(true, [Key()], new NodeAcl + { + NodeAclId = "narrow", ClusterId = ServerEventRig.Cluster, Principal = KeyPrincipal, + ScopeKind = NodeAclScopeKind.Equipment, ScopeId = $"eq-{EqA}", PermissionFlags = permissions, + }); + rig.MaterialiseScripted(); + var routed = 0; + rig.Nm.AlarmCommandRouter = _ => routed++; + using var session = await rig.ConnectAsync(anonymous: false, ct); + rig.Project(false, new AlarmConditionSnapshot(true, true, true, true, AlarmShelvingKind.Unshelved, 500, "scope")); + var condition = rig.Nm.TryGetAlarmCondition(ScriptedAlarmId, AddressSpaceRealm.Uns).ShouldNotBeNull(); + var eventId = condition.EventId.ShouldNotBeNull().Value; + var denied = await Should.ThrowAsync(() => session.CallAsync(condition.NodeId, + MethodIds.AcknowledgeableConditionType_Acknowledge, ct, new object[] { eventId, new LocalizedText("denied") })); + denied.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); + routed.ShouldBe(0); + rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "cluster-positive", permissions)); + await session.CallAsync(condition.NodeId, MethodIds.AcknowledgeableConditionType_Acknowledge, ct, + new object[] { eventId, new LocalizedText("allowed") }); + routed.ShouldBe(1); + await session.CloseAsync(ct); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2OpaqueScriptedScopeBaselineTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2OpaqueScriptedScopeBaselineTests.cs new file mode 100644 index 000000000..bedb008fb --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2OpaqueScriptedScopeBaselineTests.cs @@ -0,0 +1,44 @@ +// The same application-authored opaque ID and two grant outcomes are independently exercised on SDK1.5. +using Opc.Ua; +using Opc.Ua.Client; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; + +public sealed class Sdk2OpaqueScriptedScopeBaselineTests +{ + [Fact] + public async Task Opaque_scripted_ID_is_denied_by_its_equipment_grant_but_allowed_by_a_cluster_grant() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await StartAsync("BaselineScriptedScope", encrypted: false, ct); + rig.KeyRoles = []; + const NodePermissions permissions = NodePermissions.ReadOnly | NodePermissions.AlarmAcknowledge | NodePermissions.MethodCall; + rig.Enforce(true, [Key()], new NodeAcl + { + NodeAclId = "narrow", ClusterId = ServerEventRig.Cluster, Principal = KeyPrincipal, + ScopeKind = NodeAclScopeKind.Equipment, ScopeId = $"eq-{EqA}", PermissionFlags = permissions, + }); + rig.MaterialiseScripted(); + var routed = 0; + rig.Nm.AlarmCommandRouter = _ => routed++; + using var session = await rig.ConnectAsync(anonymous: false, ct); + rig.Project(false, new AlarmConditionSnapshot(true, true, true, true, AlarmShelvingKind.Unshelved, 500, "scope")); + var condition = rig.Nm.TryGetAlarmCondition(ScriptedAlarmId, AddressSpaceRealm.Uns).ShouldNotBeNull(); + var eventId = condition.EventId.ShouldNotBeNull().Value; + var denied = await Should.ThrowAsync(() => session.CallAsync(condition.NodeId, + MethodIds.AcknowledgeableConditionType_Acknowledge, ct, new Variant[] { Variant.From(eventId), Variant.From(new LocalizedText("denied")) })); + denied.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); + routed.ShouldBe(0); + rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "cluster-positive", permissions)); + await session.CallAsync(condition.NodeId, MethodIds.AcknowledgeableConditionType_Acknowledge, ct, + new Variant[] { Variant.From(eventId), Variant.From(new LocalizedText("allowed")) }); + routed.ShouldBe(1); + await session.CloseAsync(ct); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2RepeatedAlarmAuthorityWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2RepeatedAlarmAuthorityWireTests.cs new file mode 100644 index 000000000..8ab1dad75 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2RepeatedAlarmAuthorityWireTests.cs @@ -0,0 +1,126 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Client; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; +using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; +using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; + +/// The SDK2 already-acknowledged/confirmed compatibility path must still consult the current application authority. +public sealed class Sdk2RepeatedAlarmAuthorityWireTests +{ + public static TheoryData Cases => new( + from operation in new[] { (Native: true, Method: "Acknowledge"), (Native: false, Method: "Acknowledge"), (Native: false, Method: "Confirm") } + from change in new[] { "grant", "key", "expiry" } + select (operation.Native, operation.Method, change)); + + [Theory] + [MemberData(nameof(Cases))] + public async Task A_current_trunk_retry_uses_its_application_grant_and_current_key(bool native, string method, string change) + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await StartAsync("RepeatAlarmAuthority", encrypted: true, ct); + rig.KeyRoles = []; + var clock = new Clock(); + var key = Key(); key.ExpiresUtc = clock.GetUtcNow().UtcDateTime.AddMinutes(10); + var permissions = NodePermissions.ReadOnly | NodePermissions.AlarmAcknowledge | NodePermissions.AlarmConfirm | NodePermissions.MethodCall; + var grant = new NodeAcl + { + NodeAclId = native ? "narrow-native-alarm" : "cluster-scripted-alarm", ClusterId = ServerEventRig.Cluster, Principal = KeyPrincipal, + ScopeKind = native ? NodeAclScopeKind.RawDevice : NodeAclScopeKind.Cluster, + ScopeId = native ? "alarm-owner" : null, PermissionFlags = permissions, + }; + IReadOnlyDictionary> scopes = + new Dictionary> + { + [new(grant.ScopeKind, "alarm-owner")] = native + ? [new([NodeAclRealms.Raw, "Plant", "Modbus", "dev1"])] + : [], + }; + var state = rig.Enforce(true, [key], scopes, grant); + rig.Nm.Authorizer = new DataPlaneAuthorizer(state, new TriePermissionEvaluator(state.Tries), true, NullLogger.Instance, clock); + if (native) rig.MaterialiseNative(); else rig.MaterialiseScripted(); + var realm = native ? AddressSpaceRealm.Raw : AddressSpaceRealm.Uns; + var path = native ? RawAlarmPath : ScriptedAlarmId; + var routed = 0; + rig.Nm.NativeAlarmAckRouter = _ => Interlocked.Increment(ref routed); + rig.Nm.AlarmCommandRouter = _ => Interlocked.Increment(ref routed); + + using var session = await rig.ConnectAsync(anonymous: false, ct, encrypted: true); + var identity = rig.Server.CurrentInstance.SessionManager.GetSessions().ShouldHaveSingleItem().EffectiveIdentity + .ShouldBeOfType(); + identity.Roles.ShouldBeEmpty("the positive retry must be authorized by the application grant alone"); + // Native: narrow raw-device scope. Scripted: preserve the existing cluster-scope contract; + // opaque scripted IDs are not under their equipment ACL path (a separately tracked baseline defect). + identity.ApiKeyId.ShouldBe(KeyId); + var events = new RecordedEvents(); + var subscription = await RecordedEvents.SubscribeAsync(session, events, ct); + var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server); + await subscription.ApplyChangesAsync(ct); + item.Status.Error.ShouldBeNull(); + rig.Project(native, new AlarmConditionSnapshot(true, true, true, true, AlarmShelvingKind.Unshelved, 500, "retry")); + await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "retry").Count == 1); + var condition = rig.Nm.TryGetAlarmCondition(path, realm).ShouldNotBeNull(); + var methodId = method == "Acknowledge" ? MethodIds.AcknowledgeableConditionType_Acknowledge : MethodIds.AcknowledgeableConditionType_Confirm; + var eventIdNode = condition.EventId.ShouldNotBeNull(); + var commentNode = condition.Comment.ShouldNotBeNull(); + var ackNode = condition.AckedState.ShouldNotBeNull().Id.ShouldNotBeNull(); + var confirmNode = condition.ConfirmedState.ShouldNotBeNull().Id.ShouldNotBeNull(); + var initialId = eventIdNode.Value; + await session.CallAsync(condition.NodeId, methodId, ct, + Variant.From(initialId), Variant.From(new LocalizedText("allowed retry"))); + // A commented re-ack reports its comment. An already-confirmed retry has no state delta + // and remains silent under the existing Confirm reporting contract. + var expectedEvents = method == "Confirm" ? 1 : 2; + if (expectedEvents == 2) + await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "retry").Count == expectedEvents); + routed.ShouldBe(1, "each authorized call reaches its owner once; the scripted owner decides whether a retry changes state"); + var currentId = eventIdNode.Value; + currentId.IsEmpty.ShouldBeFalse(); + var beforeEvents = events.Of(item).Count; + var beforeRoutes = Volatile.Read(ref routed); + var beforeComment = commentNode.Value; + var beforeAck = ackNode.Value; + var beforeConfirm = confirmNode.Value; + + var keys = new[] { key }; + var grants = new[] { grant }; + if (change == "grant") grants = []; + if (change == "key") { var disabled = Key(); disabled.Enabled = false; keys = [disabled]; } + if (change == "expiry") clock.Advance(TimeSpan.FromMinutes(20)); + else state.Apply(ServerEventRig.Cluster, "revoked", keys, grants, scopes); + + var error = await Should.ThrowAsync(() => session.CallAsync(condition.NodeId, methodId, ct, + Variant.From(currentId), Variant.From(new LocalizedText("must not be recorded")))); + error.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); + Volatile.Read(ref routed).ShouldBe(beforeRoutes); + eventIdNode.Value.ShouldBe(currentId); + commentNode.Value.ShouldBe(beforeComment); + ackNode.Value.ShouldBe(beforeAck); + confirmNode.Value.ShouldBe(beforeConfirm); + // A subsequent authorized same-item sentinel drains any queued method event before the assertion. + clock.Reset(); + state.Apply(ServerEventRig.Cluster, "restored", [key], [grant], scopes); + rig.Project(native, new AlarmConditionSnapshot(true, true, true, true, AlarmShelvingKind.Unshelved, 500, "sentinel")); + await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel")); + events.Of(item).Count.ShouldBe(beforeEvents + 1, "the denied retry emits nothing before the sentinel"); + events.ConditionEvents(item, "retry").Count.ShouldBe(expectedEvents); + await subscription.DeleteAsync(true, ct); + await session.CloseAsync(ct); + } + + private sealed class Clock : TimeProvider + { + private readonly DateTimeOffset _initial = DateTimeOffset.UtcNow; + private TimeSpan _offset; + public override DateTimeOffset GetUtcNow() => _initial + _offset; + public void Advance(TimeSpan time) => _offset += time; + public void Reset() => _offset = TimeSpan.Zero; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2TrustedChannelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2TrustedChannelTests.cs index f2695e818..e3851669c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2TrustedChannelTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2TrustedChannelTests.cs @@ -52,6 +52,44 @@ public sealed class Sdk2TrustedChannelTests rig.Writes.ShouldBe(1, "revocation must be checked before a cached narrow grant or dispatch"); } + [Theory] + [InlineData(OpcUaSecurityProfile.Basic256Sha256Sign)] + [InlineData(OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt)] + public async Task Concurrent_IPv4_and_IPv6_activations_keep_their_own_transport_peer(OpcUaSecurityProfile profile) + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await TrustedRig.StartAsync(profile, ct); + rig.HoldPeerActivations = true; + // Distinct test attempt markers share one key identity and trusted certificate. Both auth flows await the barrier. + var firstConnection = rig.ConnectAsync("contract-key-ipv4", ct, transportHost: "127.0.0.1"); + var secondConnection = rig.ConnectAsync("contract-key-ipv6", ct, transportHost: "[::1]"); + ISession[] connections; + try { connections = await Task.WhenAll(firstConnection, secondConnection); } + catch + { + if (firstConnection.IsCompletedSuccessfully) (await firstConnection).Dispose(); + if (secondConnection.IsCompletedSuccessfully) (await secondConnection).Dispose(); + throw; + } + using var ipv4 = connections[0]; + using var ipv6 = connections[1]; + rig.Peers.Count.ShouldBe(2); + var first4 = rig.PeerObservations.Single(x => x.Attempt == "IPv4"); + var first6 = rig.PeerObservations.Single(x => x.Attempt == "IPv6"); + first4.Peer.ShouldNotBeNull().Address.ShouldBe(IPAddress.Loopback); + first6.Peer.ShouldNotBeNull().Address.ShouldBe(IPAddress.IPv6Loopback); + first4.AfterAwaitPeer.ShouldBe(first4.Peer.Address); + first6.AfterAwaitPeer.ShouldBe(first6.Peer.Address); + foreach (var session in connections) + (await session.ReadValueAsync(rig.Node(session), ct)).StatusCode.ShouldBe(StatusCodes.Good); + await ipv4.ReconnectAsync(null, null, ct); + rig.Peers.Count.ShouldBe(3); + rig.Peers.Last().ShouldNotBeNull().Address.ShouldBe(IPAddress.Loopback, + "reactivation cannot inherit the concurrent IPv6 activation's peer"); + await ipv6.CloseAsync(ct); + await ipv4.CloseAsync(ct); + } + [Theory] [InlineData(OpcUaSecurityProfile.Basic256Sha256Sign)] [InlineData(OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt)] @@ -85,14 +123,19 @@ public sealed class Sdk2TrustedChannelTests private const string Cluster = "SDK2"; private const string PathId = "Plant/Device/Value"; private readonly string _root = Path.Combine(Path.GetTempPath(), $"otua-trusted-{Guid.NewGuid():N}"); - private readonly Dictionary _applications = []; + private readonly ConcurrentDictionary _applications = new(); private readonly DataPlaneAuthorityState _authority = new(); private readonly OpcUaApplicationHostOptions _options; private OpcUaApplicationHost _host = null!; private int _port; private int _writes; private int _authCalls; - internal ConcurrentQueue Peers { get; } = new(); + internal sealed record PeerObservation(string Attempt, IPEndPoint? Peer, IPAddress? AfterAwaitPeer); + internal ConcurrentQueue PeerObservations { get; } = new(); + internal IReadOnlyList Peers => PeerObservations.Select(x => x.Peer).ToArray(); + internal bool HoldPeerActivations { get; set; } + private readonly TaskCompletionSource _peerBarrier = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _peerBarrierArrivals; internal OtOpcUaSdkServer Server { get; private set; } = null!; internal int Writes => Volatile.Read(ref _writes); internal int AuthCalls => Volatile.Read(ref _authCalls); @@ -173,7 +216,7 @@ public sealed class Sdk2TrustedChannelTests { await config.ValidateAsync(ApplicationType.Client, ct); await app.CheckApplicationInstanceCertificatesAsync(true, lifeTimeInMonths: 12, ct); - _applications.Add(config, app); + _applications[config] = app; return config; } catch { await app.DisposeAsync(); throw; } @@ -181,10 +224,10 @@ public sealed class Sdk2TrustedChannelTests private async Task ReleaseConfigurationAsync(ApplicationConfiguration config) { - if (_applications.Remove(config, out var app)) await app.DisposeAsync(); + if (_applications.TryRemove(config, out var app)) await app.DisposeAsync(); } - internal async Task ConnectAsync(string credential, CancellationToken ct, bool untrustedClient = false) + internal async Task ConnectAsync(string credential, CancellationToken ct, bool untrustedClient = false, string? transportHost = null) { var pki = untrustedClient ? System.IO.Path.Combine(_root, $"untrusted-{Guid.NewGuid():N}") : ClientPki; var config = await ConfigurationAsync(pki, ct); @@ -198,6 +241,7 @@ public sealed class Sdk2TrustedChannelTests var telemetry = DefaultTelemetry.Create(_ => { }); var endpoint = await CoreClientUtils.SelectEndpointAsync(config, $"opc.tcp://localhost:{_port}/OtOpcUa", true, telemetry, ct) ?? throw new InvalidOperationException("No secure endpoint."); + if (transportHost is not null) endpoint.EndpointUrl = $"opc.tcp://{transportHost}:{_port}/OtOpcUa"; return await new DefaultSessionFactory(telemetry).CreateAsync(config, new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(config)), false, true, "Sdk2TrustedContract", 60_000, new UserIdentity("apikey", Encoding.UTF8.GetBytes(credential)), default, ct); @@ -217,16 +261,22 @@ public sealed class Sdk2TrustedChannelTests new Dictionary> { [new(NodeAclScopeKind.RawDevice, "device")] = [new([NodeAclRealms.Raw, "Plant", "Device"])] }); - public Task AuthenticateUserNameAsync(string username, string password, IPEndPoint? peer, CancellationToken ct) + public async Task AuthenticateUserNameAsync(string username, string password, IPEndPoint? peer, CancellationToken ct) { - Peers.Enqueue(peer is null ? null : new IPEndPoint(peer.Address, peer.Port)); - return AuthenticateUserNameAsync(username, password, ct); + var attempt = password switch { "contract-key-ipv4" => "IPv4", "contract-key-ipv6" => "IPv6", _ => "Other" }; + if (HoldPeerActivations && attempt != "Other") + { + if (Interlocked.Increment(ref _peerBarrierArrivals) == 2) _peerBarrier.TrySetResult(); + await _peerBarrier.Task.WaitAsync(TimeSpan.FromSeconds(15), ct); + } + PeerObservations.Enqueue(new(attempt, peer is null ? null : new IPEndPoint(peer.Address, peer.Port), Server.ActivationPeerAddress)); + return await AuthenticateUserNameAsync(username, password, ct); } public Task AuthenticateUserNameAsync(string username, string password, CancellationToken ct) { Interlocked.Increment(ref _authCalls); - return Task.FromResult(username == "apikey" && password == "contract-key" + return Task.FromResult(username == "apikey" && (password is "contract-key" or "contract-key-ipv4" or "contract-key-ipv6") ? OpcUaUserAuthResult.AllowKey(Principal, [], KeyId) : OpcUaUserAuthResult.Deny("Invalid credential.")); } public Task WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct)