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; } }