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.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// /// Gitea #797 over the wire — a real activated apikey session with zero application-wide roles, /// holding only an Equipment grant, drives the Part 9 methods on a scripted condition whose id is a /// production-valid opaque ScriptedAlarmId, under a production-shaped UNS tree (every folder an opaque /// entity id, grant + owner paths resolved by from the NAMES). /// /// /// /// Observation is through a SEPARATE anonymous session that holds a Cluster grant carrying only /// Browse/Read/Subscribe/AlarmRead — no alarm-command flag — so it sees every condition event while its /// own presence can never be what authorizes a command. Every count is read after a later sentinel /// transition on the same item, so a late duplicate would already be queued ahead of it. /// /// /// The rig's NodeIds stay exactly what production publishes: the condition is s=scripted-eqa-hi in /// the UNS namespace and every routed is that id unchanged — the /// ScadaBridge-visible wire contract. /// /// public sealed class ScriptedAlarmOwnerScopeWireTests { private const string AreaId = "UA-0000000001"; private const string LineId = "UL-0000000001"; private const string Pump = "EQ-00000000a1b2"; private const string Fan = "EQ-00000000c3d4"; private const string Alarm = ServerEventRig.ScriptedAlarmId; // "scripted-eqa-hi" private const string FanAlarm = "scripted-fan-hi"; private const NodePermissions Commands = NodePermissions.ReadOnly | NodePermissions.AlarmAcknowledge | NodePermissions.AlarmConfirm | NodePermissions.AlarmShelve | NodePermissions.MethodCall; private const NodePermissions ObserveOnly = NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.AlarmRead; /// /// Every gated condition method, authorized by the narrow owner grant alone: routes exactly once with the /// opaque id unchanged and reports exactly one condition event — while the same method on the SIBLING /// equipment's scripted alarm, on the same session, is denied, routes nothing and reports nothing. /// [Theory] [InlineData("Acknowledge")] [InlineData("Confirm")] [InlineData("AddComment")] [InlineData("OneShotShelve")] [InlineData("TimedShelve")] [InlineData("Unshelve")] [InlineData("Disable")] [InlineData("Enable")] public async Task A_narrow_equipment_grant_authorizes_each_method_once_and_its_sibling_is_denied(string method) { var ct = TestContext.Current.CancellationToken; await using var rig = await ServerEventRig.StartAsync($"Owner797{method}", encrypted: false, ct); rig.KeyRoles = []; Topology(rig); Install(rig, new Clock(), [ServerEventRig.Key()], OwnerGrant(Pump), Observer()); var routed = Route(rig); using var session = await rig.ConnectAsync(anonymous: false, ct); AssertNoRoles(rig); var (events, observer, item) = await ObserveAsync(rig, ct); var pre = ConditionMethodEventTests.PreState(method, native: false); Project(rig, Alarm, pre with { Message = "base" }); Project(rig, FanAlarm, pre with { Message = "fan-base" }); await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base") && events.Has(item, "fan-base")); await ConditionMethodEventTests.CallAsync( session, Uns(session, Alarm), method, events.LatestEventId(item, "base"), ct); var denied = await Should.ThrowAsync(ConditionMethodEventTests.CallAsync( session, Uns(session, FanAlarm), method, events.LatestEventId(item, "fan-base"), ct)); denied.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); Project(rig, Alarm, pre with { Message = "sentinel" }); Project(rig, FanAlarm, pre with { Message = "fan-sentinel" }); await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel") && events.Has(item, "fan-sentinel")); var mine = events.ConditionEvents(item, "base"); mine.Count.ShouldBe(2, $"the base transition plus exactly one {method} event"); ConditionMethodEventTests.AssertNewState(method, native: false, mine[1]); events.ConditionEvents(item, "fan-base").Count.ShouldBe(1, "the denied sibling call reports nothing"); routed.Select(c => (c.AlarmId, c.Operation)).ShouldBe([(Alarm, method)]); await observer.CloseAsync(ct); await session.CloseAsync(ct); } /// /// Initial and repeated Acknowledge / Confirm on the narrow grant: every call is authorized and routed (the /// engine owns the retry decision), and the condition events are exactly the documented contract — the ack, /// the COMMENTED re-ack (its comment changed), the confirm, and nothing for the re-confirm (no delta). /// [Fact] public async Task Initial_and_repeated_ack_and_confirm_route_every_call_and_report_exact_events() { var ct = TestContext.Current.CancellationToken; await using var rig = await ServerEventRig.StartAsync("Owner797Repeat", encrypted: false, ct); rig.KeyRoles = []; Topology(rig); Install(rig, new Clock(), [ServerEventRig.Key()], OwnerGrant(Pump), Observer()); var routed = Route(rig); using var session = await rig.ConnectAsync(anonymous: false, ct); AssertNoRoles(rig); var (events, observer, item) = await ObserveAsync(rig, ct); var pre = new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base"); Project(rig, Alarm, pre); await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base")); var condition = Uns(session, Alarm); await ConditionMethodEventTests.CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct, ackComment: "ack"); await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 2); await ConditionMethodEventTests.CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct, ackComment: "again"); await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 3); await ConditionMethodEventTests.CallAsync(session, condition, "Confirm", events.LatestEventId(item, "base"), ct); await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 4); await ConditionMethodEventTests.CallAsync(session, condition, "Confirm", events.LatestEventId(item, "base"), ct); Project(rig, Alarm, pre with { Acknowledged = true, Confirmed = true, Message = "sentinel" }); await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel")); events.ConditionEvents(item, "base").Count.ShouldBe(4, "base + ack + commented re-ack + confirm; the re-confirm is silent"); routed.Select(c => (c.AlarmId, c.Operation)).ShouldBe( [(Alarm, "Acknowledge"), (Alarm, "Acknowledge"), (Alarm, "Confirm"), (Alarm, "Confirm")]); await observer.CloseAsync(ct); await session.CloseAsync(ct); } /// /// The grant, the key and the clock are re-read at every call on the open session: revoking any of them /// denies the next command (no route, no event, no state change); restoring the authority restores the /// allow, on the same session, without reconnecting. /// [Theory] [InlineData("grant")] [InlineData("key")] [InlineData("expiry")] public async Task A_revoked_or_expired_authority_denies_the_next_command_on_the_open_session(string change) { var ct = TestContext.Current.CancellationToken; await using var rig = await ServerEventRig.StartAsync($"Owner797Revoke{change}", encrypted: false, ct); rig.KeyRoles = []; Topology(rig); var clock = new Clock(); var key = ServerEventRig.Key(); key.ExpiresUtc = clock.GetUtcNow().UtcDateTime.AddMinutes(10); var state = Install(rig, clock, [key], OwnerGrant(Pump), Observer()); var routed = Route(rig); using var session = await rig.ConnectAsync(anonymous: false, ct); AssertNoRoles(rig); var (events, observer, item) = await ObserveAsync(rig, ct); var pre = new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base"); Project(rig, Alarm, pre); await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base")); var condition = Uns(session, Alarm); await ConditionMethodEventTests.CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct); await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 2); switch (change) { case "grant": Reapply(state, "rev-2", [key], Observer()); break; case "key": var disabled = ServerEventRig.Key(); disabled.Enabled = false; Reapply(state, "rev-2", [disabled], OwnerGrant(Pump), Observer()); break; case "expiry": clock.Advance(TimeSpan.FromMinutes(20)); break; } var condState = rig.Nm.TryGetAlarmCondition(Alarm)!; var commentBefore = condState.Comment!.Value; foreach (var method in new[] { "AddComment", "Confirm", "OneShotShelve" }) { var error = await Should.ThrowAsync(ConditionMethodEventTests.CallAsync( session, condition, method, events.LatestEventId(item, "base"), ct)); error.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied, method); } condState.Comment!.Value.ShouldBe(commentBefore); routed.Count.ShouldBe(1); clock.Reset(); Reapply(state, "rev-3", [key], OwnerGrant(Pump), Observer()); await ConditionMethodEventTests.CallAsync(session, condition, "AddComment", events.LatestEventId(item, "base"), ct); Project(rig, Alarm, pre with { Acknowledged = true, Message = "sentinel" }); await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel")); events.ConditionEvents(item, "base").Count.ShouldBe(3, "base + ack + the restored AddComment; the denied calls report nothing"); routed.Select(c => c.Operation).ShouldBe(["Acknowledge", "AddComment"]); await observer.CloseAsync(ct); await session.CloseAsync(ct); } /// /// Delete + recreate of the SAME opaque id under ANOTHER equipment: the old equipment's grant is denied on /// the recreated condition and the new equipment's grant authorizes it; the routed id never changes. An id /// with no live condition is refused by the server before any gate, and routes nothing. /// [Fact] public async Task A_recreated_alarm_follows_its_new_owner_and_an_unknown_id_routes_nothing() { var ct = TestContext.Current.CancellationToken; await using var rig = await ServerEventRig.StartAsync("Owner797Recreate", encrypted: false, ct); rig.KeyRoles = []; Topology(rig); var state = Install(rig, new Clock(), [ServerEventRig.Key()], OwnerGrant(Pump), Observer()); var routed = Route(rig); using var session = await rig.ConnectAsync(anonymous: false, ct); AssertNoRoles(rig); var (events, observer, item) = await ObserveAsync(rig, ct); var pre = new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base"); Project(rig, Alarm, pre); await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base")); await ConditionMethodEventTests.CallAsync(session, Uns(session, Alarm), "Acknowledge", events.LatestEventId(item, "base"), ct); ((ISurgicalAddressSpaceSink)rig.Sink).RemoveAlarmConditionNode(Alarm, AddressSpaceRealm.Uns).ShouldBeTrue(); rig.Sink.MaterialiseAlarmCondition(Alarm, Fan, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns); rig.Nm.ScriptedAlarmOwnerOf(Alarm).ShouldBe(Fan); Project(rig, Alarm, pre with { Message = "recreated" }); // Gitea #808 — deliberate change: while the live binding (Fan) disagrees with the generation (Pump), the // condition is unplaceable for EVERY grant, so even the Cluster-granted observer does not receive its event // (it used to: the opaque id split to [uns, scripted-eqa-hi], which only a Cluster grant reached). Positive // control: a later event on the correctly placed sibling arrives, so "recreated" would have arrived first. Project(rig, FanAlarm, pre with { Message = "fan-control" }); await RecordedEvents.WaitUntilAsync(() => events.Has(item, "fan-control")); events.Has(item, "recreated").ShouldBeFalse("a moved alarm is invisible until the generation agrees"); var staleEventId = rig.Nm.TryGetAlarmCondition(Alarm)!.EventId!.Value.ToArray(); var oldGrant = await Should.ThrowAsync(ConditionMethodEventTests.CallAsync( session, Uns(session, Alarm), "Acknowledge", staleEventId, ct)); oldGrant.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); routed.Count.ShouldBe(1); // The old grant was refused above with the generation STILL deploying the alarm under Pump (binding ≠ // generation); now the deploy that moved it lands, granting the new owner — and placing it again for the // observer, which sees its next event. Reapply(state, "rev-fan", Deployed(alarmOwner: Fan), [ServerEventRig.Key()], OwnerGrant(Fan), Observer()); Project(rig, Alarm, pre with { Message = "moved" }); await RecordedEvents.WaitUntilAsync(() => events.Has(item, "moved")); await ConditionMethodEventTests.CallAsync( session, Uns(session, Alarm), "Acknowledge", events.LatestEventId(item, "moved"), ct); var unknown = await Should.ThrowAsync(ConditionMethodEventTests.CallAsync( session, Uns(session, "scripted-never-deployed"), "Acknowledge", [1, 2, 3], ct)); unknown.StatusCode.ShouldNotBe(StatusCodes.Good); Project(rig, Alarm, pre with { Acknowledged = true, Message = "sentinel" }); await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel")); events.ConditionEvents(item, "moved").Count.ShouldBe(2, "the re-placed base plus the new owner's one ack"); routed.Select(c => (c.AlarmId, c.Operation)).ShouldBe([(Alarm, "Acknowledge"), (Alarm, "Acknowledge")]); await observer.CloseAsync(ct); await session.CloseAsync(ct); } // ───────────────────────────── helpers ───────────────────────────── /// Area1 / Line1 / {Pump, Fan} under opaque entity ids, one scripted alarm under each equipment. private static void Topology(ServerEventRig rig) { rig.Sink.EnsureFolder(AreaId, parentNodeId: null, displayName: "Area1", realm: AddressSpaceRealm.Uns); rig.Sink.EnsureFolder(LineId, parentNodeId: AreaId, displayName: "Line1", realm: AddressSpaceRealm.Uns); rig.Sink.EnsureFolder(Pump, parentNodeId: LineId, displayName: "Pump", realm: AddressSpaceRealm.Uns); rig.Sink.EnsureFolder(Fan, parentNodeId: LineId, displayName: "Fan", realm: AddressSpaceRealm.Uns); rig.Sink.MaterialiseAlarmCondition(Alarm, Pump, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns); rig.Sink.MaterialiseAlarmCondition(FanAlarm, Fan, "fan hi", "AlarmCondition", 500, AddressSpaceRealm.Uns); } private static AclScopePathResolver Resolver() => new( new Dictionary(), new Dictionary(), new Dictionary(), new Dictionary(), new Dictionary(), new Dictionary { [AreaId] = "Area1" }, new Dictionary { [LineId] = (AreaId, "Line1") }, new Dictionary { [Pump] = (LineId, "Pump"), [Fan] = (LineId, "Fan") }, []); /// /// The topology's UNS folder placements (Gitea #808), from the same resolver: a scripted condition is placed /// only while its owner's live folder ancestry matches the generation's. /// private static Dictionary FolderScopes() { var resolver = Resolver(); NodeAclPath One(NodeAclScopeKind kind, string id) => resolver.ResolveOne(kind, id).ShouldHaveSingleItem(); return new Dictionary { [AreaId] = new(One(NodeAclScopeKind.UnsArea, AreaId), [AreaId]), [LineId] = new(One(NodeAclScopeKind.UnsLine, LineId), [AreaId, LineId]), [Pump] = new(One(NodeAclScopeKind.Equipment, Pump), [AreaId, LineId, Pump]), [Fan] = new(One(NodeAclScopeKind.Equipment, Fan), [AreaId, LineId, Fan]), }; } private static Dictionary OwnerPaths() { var resolver = Resolver(); return new Dictionary { [Pump] = resolver.ResolveOne(NodeAclScopeKind.Equipment, Pump).ShouldHaveSingleItem(), [Fan] = resolver.ResolveOne(NodeAclScopeKind.Equipment, Fan).ShouldHaveSingleItem(), }; } private static DataPlaneAuthorityState Install(ServerEventRig rig, TimeProvider clock, ApiKey[] keys, params NodeAcl[] grants) { var state = new DataPlaneAuthorityState(); Reapply(state, "rev-1", keys, grants); rig.Nm.DataPlaneAuthority = state; rig.Nm.Authorizer = new DataPlaneAuthorizer( state, new TriePermissionEvaluator(state.Tries), true, NullLogger.Instance, clock); return state; } private static void Reapply(DataPlaneAuthorityState state, string revision, ApiKey[] keys, params NodeAcl[] grants) => Reapply(state, revision, Deployed(), keys, grants); private static void Reapply( DataPlaneAuthorityState state, string revision, IReadOnlyDictionary deployedOwners, ApiKey[] keys, params NodeAcl[] grants) => state.Apply(ServerEventRig.Cluster, revision, keys, grants, Resolver().Resolve(grants), OwnerPaths(), deployedOwners, FolderScopes()); /// The generation's deployed scripted-alarm owners; moves the Pump alarm. private static Dictionary Deployed(string alarmOwner = Pump) => new() { [Alarm] = alarmOwner, [FanAlarm] = Fan }; /// The key's ONLY alarm authority: an Equipment grant on . private static NodeAcl OwnerGrant(string equipmentId) => new() { NodeAclId = $"owner-{equipmentId}", ClusterId = ServerEventRig.Cluster, Principal = ServerEventRig.KeyPrincipal, ScopeKind = NodeAclScopeKind.Equipment, ScopeId = equipmentId, PermissionFlags = Commands, }; /// The anonymous observer: sees everything, may command nothing. private static NodeAcl Observer() => new() { NodeAclId = "observer", ClusterId = ServerEventRig.Cluster, Principal = OpcUaPrincipals.Anonymous, ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = ObserveOnly, }; private static List Route(ServerEventRig rig) { var routed = new List(); rig.Nm.AlarmCommandRouter = cmd => { lock (routed) routed.Add(cmd); }; return routed; } private static void AssertNoRoles(ServerEventRig rig) => rig.Server.CurrentInstance.SessionManager.GetSessions() .Select(s => s.EffectiveIdentity).OfType() .Single(i => i.ApiKeyId == ServerEventRig.KeyId) .Roles.ShouldBeEmpty("the narrow grant alone must authorize — the session holds no server-wide role"); private static async Task<(RecordedEvents Events, ISession Observer, MonitoredItem Item)> ObserveAsync( ServerEventRig rig, CancellationToken ct) { var observer = await rig.ConnectAsync(anonymous: true, ct); var events = new RecordedEvents(); var subscription = await RecordedEvents.SubscribeAsync(observer, events, ct); var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server); await subscription.ApplyChangesAsync(ct); item.Status.Error.ShouldBeNull(); return (events, observer, item); } private static void Project(ServerEventRig rig, string alarmId, AlarmConditionSnapshot state) => rig.Sink.WriteAlarmCondition(alarmId, state, DateTime.UtcNow, AddressSpaceRealm.Uns); private static NodeId Uns(ISession session, string id) => ServerEventRig.UnsNodeId(session, id); private sealed class Clock : TimeProvider { private readonly DateTimeOffset _initial = DateTimeOffset.UtcNow; private TimeSpan _offset; public override DateTimeOffset GetUtcNow() => _initial + _offset; public void Advance(TimeSpan by) => _offset += by; public void Reset() => _offset = TimeSpan.Zero; } }