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; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// /// Gitea #826, native variant, over the wire: a native alarm materialises once at its raw tag and is wired as an /// extra event notifier into every referencing equipment folder. A deployment that drops one equipment's reference /// installs its generation BEFORE the rebuild that unwires the folder (and an apply that fails keeps the old /// wiring), so the folder's subscribers used to keep receiving the alarm — EventId included — through the SDK's /// per-folder fan-out, which no ACL decision reached, and through the Server object, whose scopes admitted every /// wired folder. Both now ask the CURRENT generation whether it still projects the raw tag into that equipment. /// public sealed class NativeAlarmFolderEventAclWireTests { private const string EqA = ServerEventRig.EqA; private const string EqB = ServerEventRig.EqB; /// /// Anonymous holds EqA, the key holds EqB, the alarm is wired into both. The generation drops EqA's reference /// with the wiring still in place: EqA's folder item and the anonymous Server-object item stop receiving the /// alarm (live AND ConditionRefresh), while the key's EqB folder and Server-object items receive the same events /// (positive control). Restoring the reference restores EqA's feed with no client action — the SDK's memoized /// per-event verdict is invalidated by the generation change. /// [Fact] public async Task A_native_alarm_stops_reaching_a_folder_whose_reference_the_generation_dropped() { var ct = TestContext.Current.CancellationToken; await using var rig = await ServerEventRig.StartAsync("Folder826Native", encrypted: false, ct); rig.KeyRoles = []; rig.MaterialiseNative(); NodeAcl[] grants = [ServerEventRig.EquipmentGrant(EqA, NodePermissions.ReadOnly), KeyEquipmentGrant(EqB)]; var state = rig.Enforce(true, [ServerEventRig.Key()], grants); using var anonymous = await rig.ConnectAsync(anonymous: true, ct); using var keyed = await rig.ConnectAsync(anonymous: false, ct); var anonEvents = new RecordedEvents(); var anonSub = await RecordedEvents.SubscribeAsync(anonymous, anonEvents, ct); var onA = RecordedEvents.AddEventItem(anonSub, ServerEventRig.UnsNodeId(anonymous, EqA)); var anonServer = RecordedEvents.AddEventItem(anonSub, ObjectIds.Server); await anonSub.ApplyChangesAsync(ct); onA.Status.Error.ShouldBeNull(); var keyEvents = new RecordedEvents(); var keySub = await RecordedEvents.SubscribeAsync(keyed, keyEvents, ct); var onB = RecordedEvents.AddEventItem(keySub, ServerEventRig.UnsNodeId(keyed, EqB)); var keyServer = RecordedEvents.AddEventItem(keySub, ObjectIds.Server); await keySub.ApplyChangesAsync(ct); onB.Status.Error.ShouldBeNull(); // Baseline: every item receives the alarm while both references are deployed. rig.Project(native: true, State("t1")); await RecordedEvents.WaitUntilAsync(() => anonEvents.Has(onA, "t1") && anonEvents.Has(anonServer, "t1") && keyEvents.Has(onB, "t1") && keyEvents.Has(keyServer, "t1")); // The next generation drops EqA's reference; the address space still wires the alarm into EqA. Deploy(state, "rev-2", grants, EqB); rig.Project(native: true, State("t2")); await RecordedEvents.WaitUntilAsync(() => keyEvents.Has(onB, "t2") && keyEvents.Has(keyServer, "t2")); await Task.Delay(TimeSpan.FromMilliseconds(700), ct); anonEvents.Has(onA, "t2").ShouldBeFalse("the folder the generation dropped must not receive the alarm"); anonEvents.Has(anonServer, "t2").ShouldBeFalse("nor may that folder's grant admit it through the Server object"); // ConditionRefresh on the stale folder respects the same rule; the still-referenced folder replays it. await RefreshAsync(anonymous, anonSub, ct); await RefreshAsync(keyed, keySub, ct); await RecordedEvents.WaitUntilAsync(() => anonEvents.HasType(onA, ObjectTypeIds.RefreshEndEventType) && keyEvents.ConditionEvents(onB, "t2").Count == 2); await Task.Delay(TimeSpan.FromMilliseconds(300), ct); anonEvents.Has(onA, "t2").ShouldBeFalse("a refresh must not replay the alarm to the dropped folder"); anonEvents.Has(anonServer, "t2").ShouldBeFalse(); // Restoring the reference restores the feed on the next event — no client action, no stale cached deny. Deploy(state, "rev-3", grants, EqA, EqB); rig.Project(native: true, State("t3")); await RecordedEvents.WaitUntilAsync(() => anonEvents.Has(onA, "t3") && anonEvents.Has(anonServer, "t3") && keyEvents.Has(onB, "t3")); await anonymous.CloseAsync(ct); await keyed.CloseAsync(ct); } private static AlarmConditionSnapshot State(string message) => new(true, false, false, true, AlarmShelvingKind.Unshelved, 700, message); private static void Deploy(DataPlaneAuthorityState state, string revision, NodeAcl[] grants, params string[] referencing) => state.Apply( ServerEventRig.Cluster, revision, [ServerEventRig.Key()], grants, ServerEventRig.ScopePaths(), ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes(), new Dictionary> { [ServerEventRig.RawAlarmPath] = referencing.ToHashSet() }); private static NodeAcl KeyEquipmentGrant(string equipment) => new() { NodeAclId = $"acl-key-{equipment}", ClusterId = ServerEventRig.Cluster, Principal = ServerEventRig.KeyPrincipal, ScopeKind = NodeAclScopeKind.Equipment, ScopeId = $"eq-{equipment}", PermissionFlags = NodePermissions.ReadOnly, }; private static async Task RefreshAsync(ISession session, Subscription subscription, CancellationToken ct) => await session.CallAsync( ObjectTypeIds.ConditionType, MethodIds.ConditionType_ConditionRefresh, ct, subscription.Id); }