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; /// /// Folder event items must see a deployment, a grant removal or a moved alarm on the very next event (Gitea #826). /// The SDK memoizes the verdict of whatever travels its own per-notifier channel per (item, event type, /// SourceNode), and #826 kept those verdicts fresh with a reflective call into a private SDK method. Gitea #878 /// removed that: condition events are decided per item, uncached, by the node manager's own delivery (#855), and /// under enforcement everything that still rides the channel is denied outright, so no cached verdict depends on a /// grant. These pin the properties that made the invalidation exist — an ORPHANED subscription, a replaced /// instance's late event, a revoked grant — plus the channel itself (a condition's method audit, a plain event). /// public sealed class FolderEventVerdictFreshnessTests { private const NodePermissions All = NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.AlarmRead | NodePermissions.AlarmAcknowledge | NodePermissions.AlarmConfirm | NodePermissions.AlarmShelve | NodePermissions.MethodCall; private const string EqA = ServerEventRig.EqA; private const string EqB = ServerEventRig.EqB; /// /// A subscription orphaned by its session (closed without deleting its subscriptions) keeps its items on the /// folder fan-out with NO session. A deployment that drops EqA's reference must still reach those items: the /// native alarm's events raised while orphaned are not queued to the EqA item, so a TransferSubscriptions to a /// new session delivers them only through EqB (positive control on the same subscription and event). /// /// /// Since #855 this holds because the decision is per event and uncached, not because a cache is invalidated /// (#878: it passed with the invalidation disabled). It is the per-item decision this pins: with the /// live-notifier check skipped in DeliverToNotifierItems, t2/t3 reach the EqA item. /// [Fact] public async Task An_orphaned_folder_item_does_not_keep_a_stale_allow_across_a_deploy_and_a_transfer() { var ct = TestContext.Current.CancellationToken; await using var rig = await ServerEventRig.StartAsync("Folder826Orphan", encrypted: false, ct); rig.KeyRoles = []; rig.MaterialiseNative(); NodeAcl[] grants = [KeyEquipmentGrant(EqA), KeyEquipmentGrant(EqB)]; var state = rig.Enforce(true, [ServerEventRig.Key()], grants); using var first = await rig.ConnectAsync(anonymous: false, ct); var events = new RecordedEvents(); var subscription = await RecordedEvents.SubscribeAsync(first, events, ct); var onA = RecordedEvents.AddEventItem(subscription, ServerEventRig.UnsNodeId(first, EqA)); var onB = RecordedEvents.AddEventItem(subscription, ServerEventRig.UnsNodeId(first, EqB)); await subscription.ApplyChangesAsync(ct); onA.Status.Error.ShouldBeNull(); // An ALLOW for the EqA item (before #855 the SDK cached it). rig.Project(native: true, State("t1")); await RecordedEvents.WaitUntilAsync(() => events.Has(onA, "t1") && events.Has(onB, "t1")); // Orphan the subscription: a raw CloseSession with deleteSubscriptions=false, so the subscription and its items // stay on the server with no session. The raw service leaves the client session OBJECT alive (Session.CloseAsync // would dispose it), as a reconnecting client's is, so the high-level transfer can take the subscription over. var orphaned = new SubscriptionCollection(first.Subscriptions); await ((ISessionClient)first).CloseSessionAsync(null, deleteSubscriptions: false, ct); // The deployment drops EqA's reference; the alarm reports while the subscription has no session. Deploy(state, "rev-2", grants, EqB); rig.Project(native: true, State("t2")); using var second = await rig.ConnectAsync(anonymous: false, ct); (await second.TransferSubscriptionsAsync(orphaned, sendInitialValues: false, ct)).ShouldBeTrue(); rig.Project(native: true, State("t3")); await RecordedEvents.WaitUntilAsync(() => events.Has(onB, "t2") && events.Has(onB, "t3")); await Task.Delay(TimeSpan.FromMilliseconds(700), ct); events.Has(onA, "t2").ShouldBeFalse("an event raised while orphaned must not be queued on a stale allow"); events.Has(onA, "t3").ShouldBeFalse(); await second.CloseAsync(ct); } /// /// A surgical replace of a scripted condition under the SAME NodeId (remove + re-materialise, the folder item /// survives), with the old instance's last event still waiting in the SDK's per-folder queue when the apply's /// invalidation runs. That late event must not leave a cached DENY the new instance inherits: the new /// instance's events keep reaching the folder subscriber. /// [Fact] public async Task A_replaced_instances_late_event_does_not_leave_a_cached_deny_for_the_new_instance() { var ct = TestContext.Current.CancellationToken; await using var rig = await ServerEventRig.StartAsync("Folder826Replace", encrypted: false, ct); rig.KeyRoles = []; rig.MaterialiseScripted(); rig.MaterialiseNative(); // also wired into EqA: its event is the deterministic blocker below rig.Enforce(true, [], ServerEventRig.EquipmentGrant(EqA, NodePermissions.ReadOnly)); using var session = await rig.ConnectAsync(anonymous: true, ct); var events = new RecordedEvents(); var subscription = await RecordedEvents.SubscribeAsync(session, events, ct); var onA = RecordedEvents.AddEventItem(subscription, ServerEventRig.UnsNodeId(session, EqA)); await subscription.ApplyChangesAsync(ct); onA.Status.Error.ShouldBeNull(); rig.Project(native: false, State("s1")); await RecordedEvents.WaitUntilAsync(() => events.Has(onA, "s1")); // Before Gitea #855, holding the node-manager lock parked EqA's SDK delivery task, so the OLD scripted instance's // "s2" was decided (and its verdict cached) only after the replace and the apply's invalidation. Condition events // are now decided per item at report time, uncached (#878 removed the invalidation) — "s2" is delivered while its // instance is live — and this pins that the replace still leaves the new instance's events flowing either way. lock (rig.Nm.Lock) { rig.Nm.CompleteAddressSpaceApply(); rig.Project(native: true, State("n1")); rig.Project(native: false, State("s2")); ((ISurgicalAddressSpaceSink)rig.Sink) .RemoveAlarmConditionNode(ServerEventRig.ScriptedAlarmId, AddressSpaceRealm.Uns).ShouldBeTrue(); rig.MaterialiseScripted(); rig.Nm.CompleteAddressSpaceApply(); } rig.Project(native: false, State("s3")); // The new instance's event arrives (a cached deny would withhold it until the next invalidation), and so does // the old instance's late one — decided on the live instance, exactly as the live one's would be. await RecordedEvents.WaitUntilAsync(() => events.Has(onA, "s3") && events.Has(onA, "s2")); await session.CloseAsync(ct); } /// /// The real security property behind the removed invalidation (Gitea #878): a deployment that revokes a session's /// grant stops every condition event to it on the very next event — at its folder item AND its Server-object item — /// while a session whose grant stays keeps receiving the same events (positive control). /// /// /// Mutation-checked on #878. The folder leg has two defences, and fails only when both are removed: the /// revalidation stand-down (#562, the item loses its folder grant and is detached) and the per-event grant check /// in DeliverToNotifierItems. The Server-object leg fails when OnConditionReportEvent skips its /// grant check. /// [Fact] public async Task A_revoked_grant_stops_condition_events_to_that_session_while_a_granted_session_keeps_them() { var ct = TestContext.Current.CancellationToken; await using var rig = await ServerEventRig.StartAsync("Folder878Revoke", encrypted: false, ct); rig.KeyRoles = []; rig.MaterialiseNative(); var anonymousGrant = ServerEventRig.EquipmentGrant(EqA, NodePermissions.ReadOnly); var state = rig.Enforce(true, [ServerEventRig.Key()], KeyEquipmentGrant(EqA), anonymousGrant); using var revoked = await rig.ConnectAsync(anonymous: false, ct); var revokedEvents = new RecordedEvents(); var revokedSubscription = await RecordedEvents.SubscribeAsync(revoked, revokedEvents, ct); var revokedFolder = RecordedEvents.AddEventItem(revokedSubscription, ServerEventRig.UnsNodeId(revoked, EqA)); var revokedServer = RecordedEvents.AddEventItem(revokedSubscription, ObjectIds.Server); await revokedSubscription.ApplyChangesAsync(ct); revokedFolder.Status.Error.ShouldBeNull(); using var kept = await rig.ConnectAsync(anonymous: true, ct); var keptEvents = new RecordedEvents(); var keptSubscription = await RecordedEvents.SubscribeAsync(kept, keptEvents, ct); var keptFolder = RecordedEvents.AddEventItem(keptSubscription, ServerEventRig.UnsNodeId(kept, EqA)); var keptServer = RecordedEvents.AddEventItem(keptSubscription, ObjectIds.Server); await keptSubscription.ApplyChangesAsync(ct); keptFolder.Status.Error.ShouldBeNull(); rig.Project(native: true, State("t1")); await RecordedEvents.WaitUntilAsync(() => revokedEvents.Has(revokedFolder, "t1") && revokedEvents.Has(revokedServer, "t1") && keptEvents.Has(keptFolder, "t1") && keptEvents.Has(keptServer, "t1")); // The key's grant is gone; the anonymous one stays, and both folders still reference the alarm. Deploy(state, "rev-2", [anonymousGrant], EqA, EqB); rig.Project(native: true, State("t2")); await RecordedEvents.WaitUntilAsync(() => keptEvents.Has(keptFolder, "t2") && keptEvents.Has(keptServer, "t2")); await Task.Delay(TimeSpan.FromMilliseconds(700), ct); revokedEvents.Has(revokedFolder, "t2").ShouldBeFalse("a revoked grant must stop the folder item's next event"); revokedEvents.Has(revokedServer, "t2").ShouldBeFalse("a revoked grant must stop the Server-object item's next event"); await kept.CloseAsync(ct); await revoked.CloseAsync(ct); } /// /// What still rides the SDK's own per-notifier channel — and so its MEMOIZED verdict — is a condition's SDK-raised /// Part 9 method audit (Gitea #878). It needs auditing on, SignAndEncrypt, and the one role the SDK's type check /// admits to audit types, SecurityAdmin, which this test maps to every authenticated session (nothing in /// the product does). With enforcement ON the folder item never receives it — the channel's verdict is a flat /// deny, so no grant-dependent verdict is cached and nothing needs invalidating. Positive controls: the same /// audit reaches the same session's Server-object item (decided per event there), and with enforcement OFF it /// reaches the folder item, so the channel really does carry it. /// [Theory] [InlineData(true)] [InlineData(false)] public async Task A_condition_method_audit_reaches_a_folder_item_only_with_enforcement_off(bool enforced) { var ct = TestContext.Current.CancellationToken; await using var rig = await ServerEventRig.StartAsync($"Folder878Audit{enforced}", encrypted: true, ct); rig.SetAuditing(true); ServiceResult.IsGood(rig.Server.CurrentInstance.RoleManager.AddIdentity( ObjectIds.WellKnownRole_SecurityAdmin, new IdentityMappingRuleType { CriteriaType = IdentityCriteriaType.AuthenticatedUser })) .ShouldBeTrue(); rig.Enforce(enforced, [ServerEventRig.Key()], ServerEventRig.ClusterGrant(ServerEventRig.KeyPrincipal, "acl-key", All)); rig.MaterialiseScripted(); var current = State("base"); ConditionMethodEventTests.WireSimulatedOwners(rig, native: false, () => current, s => current = s); using var session = await rig.ConnectAsync(anonymous: false, ct, encrypted: true); var events = new RecordedEvents(); var subscription = await RecordedEvents.SubscribeAsync(session, events, ct); var folderItem = RecordedEvents.AddEventItem(subscription, ServerEventRig.UnsNodeId(session, EqA)); var serverItem = RecordedEvents.AddEventItem(subscription, ObjectIds.Server); await subscription.ApplyChangesAsync(ct); folderItem.Status.Error.ShouldBeNull(); rig.Project(native: false, current); await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, "base") && events.Has(serverItem, "base")); await ConditionMethodEventTests.CallAsync( session, ServerEventRig.ConditionNodeId(session, native: false), "AddComment", events.LatestEventId(folderItem, "base"), ct); await rig.DrainProjectionsAsync(); await RecordedEvents.WaitUntilAsync(() => events.HasType(serverItem, ObjectTypeIds.AuditConditionCommentEventType)); if (!enforced) { await RecordedEvents.WaitUntilAsync(() => events.HasType(folderItem, ObjectTypeIds.AuditConditionCommentEventType)); } else { current = current with { Message = "sentinel" }; rig.Project(native: false, current); await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, "sentinel")); await Task.Delay(TimeSpan.FromMilliseconds(700), ct); events.HasType(folderItem, ObjectTypeIds.AuditConditionCommentEventType).ShouldBeFalse( "under enforcement nothing on the SDK channel reaches a folder item of ours"); } await session.CloseAsync(ct); } /// /// Gitea #838 item 3: an event on a folder that names no live condition (none is produced today — model-change and /// rejected-write audits go to the Server object) rides the SDK channel and is WITHHELD under enforcement, fail /// closed, and delivered with the flag off (the SDK's own type + SourceNode check only). Positive controls: the /// same event reaches the same item shape with enforcement off, and a condition event reaches it with enforcement /// on. Each mode runs on a fresh server: a verdict the SDK cached while enforcement was off would survive a switch /// on (#878 — the residual documented on INodeManager3.ValidateEventRolePermissions, which no event the /// product raises can reach). /// [Theory] [InlineData(true)] [InlineData(false)] public async Task A_non_condition_event_on_a_folder_is_withheld_under_enforcement_and_delivered_without_it(bool enforced) { var ct = TestContext.Current.CancellationToken; await using var rig = await ServerEventRig.StartAsync($"Folder838NonCondition{enforced}", encrypted: false, ct); rig.KeyRoles = []; rig.MaterialiseNative(); rig.Enforce(enforced, [], ServerEventRig.EquipmentGrant(EqA, NodePermissions.ReadOnly)); using var session = await rig.ConnectAsync(anonymous: true, ct); var events = new RecordedEvents(); var subscription = await RecordedEvents.SubscribeAsync(session, events, ct); var onA = RecordedEvents.AddEventItem(subscription, ServerEventRig.UnsNodeId(session, EqA)); await subscription.ApplyChangesAsync(ct); onA.Status.Error.ShouldBeNull(); var folder = rig.Nm.FindPredefinedNode(ServerEventRig.UnsNodeId(session, EqA)).ShouldNotBeNull(); var plain = new BaseEventState(null); lock (rig.Nm.Lock) { plain.Initialize(rig.Nm.SystemContext, folder, EventSeverity.Low, new LocalizedText("plain")); folder.ReportEvent(rig.Nm.SystemContext, plain); } rig.Project(native: true, State("cond")); await RecordedEvents.WaitUntilAsync(() => events.Has(onA, "cond")); if (enforced) { await Task.Delay(TimeSpan.FromMilliseconds(700), ct); events.Has(onA, "plain").ShouldBeFalse("a non-condition event on a folder fails closed under enforcement"); } else { await RecordedEvents.WaitUntilAsync(() => events.Has(onA, "plain")); } await session.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, }; }