using System.Net; using System.Net.Sockets; using System.Text; 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.Host.OpcUa; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// /// Gitea #562 over the wire — an alarm (event) subscription is re-authorized by both revalidation /// triggers, exactly as a data-change subscription is: a deployment that removes its grant, and the host's /// clock-driven once the session's key expires. /// /// /// /// Before #562 an event monitored item was gated at creation and never again, so an alarm feed /// survived the revocation of the authority it was admitted under until the client disconnected. /// /// /// Over the wire for the reason AclSubscriptionEnforcementTests gives: whether an event item /// can recover the identity of the session that created it, and whether detaching it from the SDK's /// event fan-out really stops delivery (and re-attaching really resumes it), are assumptions about SDK /// behaviour that fail silently if wrong. /// /// /// Every absence assertion is paired with a positive control on the same event: one condition is /// wired to two equipment notifiers (or watched by two sessions), so a single transition must reach the /// still-authorized item before the denied one's silence counts for anything. /// /// public sealed class AclEventSubscriptionEnforcementTests { private const string Cluster = "SITE-A"; private const string RawDeviceFolder = "Plant/Modbus/dev1"; private const string RawAlarmPath = "Plant/Modbus/dev1/temp_hi"; private const string ScriptedAlarmId = "scripted-eqa-hi"; private const string EqA = "EqA"; private const string EqB = "EqB"; private const string KeyPrincipal = "expiry-probe"; private const string KeyId = "k-event-expiry"; // [0]=EventId, [1]=Message — the message is each transition's discriminator; [2]=EventType. private const int MessageFieldIndex = 1; private const int EventTypeFieldIndex = 2; private static readonly DateTime Start = new(2026, 10, 7, 12, 0, 0, DateTimeKind.Utc); /// /// The deployment axis, end to end: revoke → the alarm feed stops (while the still-granted control /// item receives the same event) → restore → the feed resumes with no client action. /// /// /// The restore leg is the reason the stand-down is a detach rather than /// MonitoringMode.Disabled: a disabled item can only be re-enabled by the client, and a machine /// consumer never will. /// [Fact] public async Task Revoking_then_restoring_a_grant_stops_then_resumes_a_live_alarm_subscription() { var ct = TestContext.Current.CancellationToken; var rig = await Rig.StartAsync("AclEventRevoke", ct); try { var state = rig.Enforce(EquipmentGrant(EqA), EquipmentGrant(EqB)); rig.Materialise(); using var session = await rig.ConnectAsync(anonymous: true, ct); var collector = new EventCollector(); var subscription = await CreateSubscriptionAsync(session, collector, ct); var onA = AddEventItem(subscription, rig.UnsNodeId(session, EqA)); var onB = AddEventItem(subscription, rig.UnsNodeId(session, EqB)); await subscription.ApplyChangesAsync(ct); onA.Status.Error.ShouldBeNull(); onB.Status.Error.ShouldBeNull(); // Baseline: both items receive the transition. rig.Transition("t1", active: true); await WaitUntilAsync(() => collector.Has(onA, "t1") && collector.Has(onB, "t1")); // The next deployment carries only EqB's grant. state.Apply(Cluster, "rev-2", [], [EquipmentGrant(EqB)], ScopePaths()); rig.AttachedEventItems.ShouldBe(1); rig.Transition("t2", active: false); // Positive control: the same event reached the still-granted item, so it was fired and published… await WaitUntilAsync(() => collector.Has(onB, "t2")); // …and settle past a publishing interval so a late copy for EqA would have arrived too. await Task.Delay(TimeSpan.FromMilliseconds(700), ct); collector.Has(onA, "t2").ShouldBeFalse("the revoked item must not receive events after the deployment"); // A deployment that restores the grant brings the feed back on its own. state.Apply(Cluster, "rev-3", [], [EquipmentGrant(EqA), EquipmentGrant(EqB)], ScopePaths()); rig.AttachedEventItems.ShouldBe(2); rig.Transition("t3", active: true); await WaitUntilAsync(() => collector.Has(onA, "t3") && collector.Has(onB, "t3")); collector.Has(onA, "t2").ShouldBeFalse("nothing withheld while stood down is replayed on restore"); await subscription.DeleteAsync(true, ct); } finally { await rig.DisposeAsync(); } } /// /// A client may delete an item while it is stood down: the delete succeeds, and a later deployment /// that would have restored it does not resurrect it onto the notifier's fan-out. /// /// /// The SDK's own unsubscribe would try to remove an item that is no longer attached, so the node /// manager short-circuits it. Asserted over the wire because the failure — an exception inside the /// SDK's delete path, or a ghost item still receiving events — is only observable there. /// [Fact] public async Task A_stood_down_alarm_item_can_be_deleted_and_is_not_resurrected() { var ct = TestContext.Current.CancellationToken; var rig = await Rig.StartAsync("AclEventDelete", ct); try { var state = rig.Enforce(EquipmentGrant(EqA), EquipmentGrant(EqB)); rig.Materialise(); using var session = await rig.ConnectAsync(anonymous: true, ct); var collector = new EventCollector(); var subscription = await CreateSubscriptionAsync(session, collector, ct); var onA = AddEventItem(subscription, rig.UnsNodeId(session, EqA)); var onB = AddEventItem(subscription, rig.UnsNodeId(session, EqB)); await subscription.ApplyChangesAsync(ct); rig.Transition("t1", active: true); await WaitUntilAsync(() => collector.Has(onA, "t1") && collector.Has(onB, "t1")); rig.AttachedEventItems.ShouldBe(2); state.Apply(Cluster, "rev-2", [], [EquipmentGrant(EqB)], ScopePaths()); rig.AttachedEventItems.ShouldBe(1); subscription.RemoveItem(onA); await subscription.ApplyChangesAsync(ct); onA.Status.Error.ShouldBeNull(); // A restoring deployment must not re-attach the deleted item. The client could never see that // happen — it has deleted the item — so the server-side fan-out is what is asserted. state.Apply(Cluster, "rev-3", [], [EquipmentGrant(EqA), EquipmentGrant(EqB)], ScopePaths()); rig.AttachedEventItems.ShouldBe(1, "the deleted item must not be resurrected onto EqA's fan-out"); rig.Transition("t2", active: false); await WaitUntilAsync(() => collector.Has(onB, "t2")); await Task.Delay(TimeSpan.FromMilliseconds(700), ct); collector.Has(onA, "t2").ShouldBeFalse(); await subscription.DeleteAsync(true, ct); } finally { await rig.DisposeAsync(); } } /// /// The clock axis: the key behind a session's alarm subscription expires, one revalidation tick runs /// through the host's own , and that session stops receiving alarms /// — with no deployment anywhere in the run — while an independent session on the same notifier keeps /// receiving the same event. /// [Fact] public async Task An_expired_key_stops_a_live_alarm_subscription_with_no_deploy() { var ct = TestContext.Current.CancellationToken; var rig = await Rig.StartAsync("AclEventExpiry", ct); try { var key = new ApiKey { ApiKeyId = KeyId, Name = KeyId, SecretHash = "hash", Principal = KeyPrincipal, Enabled = true, CreatedUtc = Start.AddDays(-1), ExpiresUtc = Start.AddMinutes(10), }; var state = rig.Enforce( [key], ClusterGrant(KeyPrincipal, "acl-key"), ClusterGrant(OpcUaPrincipals.Anonymous, "acl-anon")); rig.Materialise(); using var keySession = await rig.ConnectAsync(anonymous: false, ct); using var controlSession = await rig.ConnectAsync(anonymous: true, ct); var keyEvents = new EventCollector(); var keySubscription = await CreateSubscriptionAsync(keySession, keyEvents, ct); var keyItem = AddEventItem(keySubscription, rig.UnsNodeId(keySession, EqA)); await keySubscription.ApplyChangesAsync(ct); keyItem.Status.Error.ShouldBeNull(); var controlEvents = new EventCollector(); var controlSubscription = await CreateSubscriptionAsync(controlSession, controlEvents, ct); var controlItem = AddEventItem(controlSubscription, rig.UnsNodeId(controlSession, EqA)); await controlSubscription.ApplyChangesAsync(ct); rig.Transition("t1", active: true); await WaitUntilAsync(() => keyEvents.Has(keyItem, "t1") && controlEvents.Has(controlItem, "t1")); // Only the clock moves. rig.Advance(TimeSpan.FromMinutes(20)); rig.TickRevalidation(); rig.Transition("t2", active: false); await WaitUntilAsync(() => controlEvents.Has(controlItem, "t2")); await Task.Delay(TimeSpan.FromMilliseconds(700), ct); keyEvents.Has(keyItem, "t2").ShouldBeFalse("the expired key's alarm feed must stop on the tick"); state.Current.GenerationId.ShouldBe(1); await controlSubscription.DeleteAsync(true, ct); await keySubscription.DeleteAsync(true, ct); } finally { await rig.DisposeAsync(); } } /// /// The negative control for the clock axis: the same run with the clock advanced but not past /// ExpiresUtc keeps the alarm feed live across a tick. /// /// /// Without it the test above also passes on a build whose tick stands down every event item it sees, /// which would silence every alarm consumer in the fleet once a minute. /// [Fact] public async Task An_unexpired_key_keeps_its_alarm_subscription_across_a_tick() { var ct = TestContext.Current.CancellationToken; var rig = await Rig.StartAsync("AclEventLive", ct); try { var key = new ApiKey { ApiKeyId = KeyId, Name = KeyId, SecretHash = "hash", Principal = KeyPrincipal, Enabled = true, CreatedUtc = Start.AddDays(-1), ExpiresUtc = Start.AddHours(4), }; rig.Enforce([key], ClusterGrant(KeyPrincipal, "acl-key")); rig.Materialise(); using var session = await rig.ConnectAsync(anonymous: false, ct); var events = new EventCollector(); var subscription = await CreateSubscriptionAsync(session, events, ct); var item = AddEventItem(subscription, rig.UnsNodeId(session, EqA)); await subscription.ApplyChangesAsync(ct); rig.Transition("t1", active: true); await WaitUntilAsync(() => events.Has(item, "t1")); rig.Advance(TimeSpan.FromMinutes(20)); rig.TickRevalidation(); rig.Transition("t2", active: false); await WaitUntilAsync(() => events.Has(item, "t2")); await subscription.DeleteAsync(true, ct); } finally { await rig.DisposeAsync(); } } // ─────────────────── Server-object (i=2253) subscriptions — Gitea #586 ─────────────────── /// /// A session holding no grant receives nothing through a Server-object event item, while a granted /// session's Server-object item receives the same transition. /// /// /// The Server object carries every alarm in the server — each condition reports once and its one /// copy reaches every all-events item — so before #586 this was the one feed that ignored the ACL /// entirely. The granted item is the positive control: it proves the transition was fired and /// published before the denied item's silence is read as anything. /// [Fact] public async Task A_server_object_subscription_receives_only_the_alarms_its_session_is_granted() { var ct = TestContext.Current.CancellationToken; var rig = await Rig.StartAsync("AclServerEvents", ct); try { rig.Enforce([UnexpiringKey()], ClusterGrant(KeyPrincipal, "acl-key")); rig.Materialise(); using var granted = await rig.ConnectAsync(anonymous: false, ct); using var ungranted = await rig.ConnectAsync(anonymous: true, ct); var grantedEvents = new EventCollector(); var grantedSubscription = await CreateSubscriptionAsync(granted, grantedEvents, ct); var grantedItem = AddEventItem(grantedSubscription, ObjectIds.Server); await grantedSubscription.ApplyChangesAsync(ct); grantedItem.Status.Error.ShouldBeNull(); var ungrantedEvents = new EventCollector(); var ungrantedSubscription = await CreateSubscriptionAsync(ungranted, ungrantedEvents, ct); var ungrantedItem = AddEventItem(ungrantedSubscription, ObjectIds.Server); await ungrantedSubscription.ApplyChangesAsync(ct); // Creation is not refused: an all-events item is legitimate for a session with partial grants, // so what is withheld is each event the session may not see, not the item. ungrantedItem.Status.Error.ShouldBeNull(); rig.Transition("t1", active: true); await WaitUntilAsync(() => grantedEvents.Has(grantedItem, "t1")); await Task.Delay(TimeSpan.FromMilliseconds(700), ct); ungrantedEvents.Has(ungrantedItem, "t1").ShouldBeFalse( "a session with no grant must not receive alarms through the Server object"); grantedEvents.Count(grantedItem, "t1").ShouldBe(1, "the condition still reaches the Server object exactly once"); await ungrantedSubscription.DeleteAsync(true, ct); await grantedSubscription.DeleteAsync(true, ct); } finally { await rig.DisposeAsync(); } } /// /// A Server-object item created before the alarm is deployed is gated the same way, and still /// receives exactly one copy when granted. /// /// /// The ordering a long-lived consumer actually sees — it subscribes once, deployments come later. The /// SDK never attaches such an item to a root notifier registered after it (its AddRootNotifier /// subscribes existing all-events items with unsubscribe: true), so before #586 these items were /// served only by the root notifier's forward to the core Server object — a path this manager never /// saw, and the one now dropped. This proves the replacement delivery covers it. /// [Fact] public async Task A_server_object_subscription_created_before_the_deployment_is_gated_and_served() { var ct = TestContext.Current.CancellationToken; var rig = await Rig.StartAsync("AclServerEventsEarly", ct); try { rig.Enforce([UnexpiringKey()], ClusterGrant(KeyPrincipal, "acl-key")); using var granted = await rig.ConnectAsync(anonymous: false, ct); using var ungranted = await rig.ConnectAsync(anonymous: true, ct); var grantedEvents = new EventCollector(); var grantedSubscription = await CreateSubscriptionAsync(granted, grantedEvents, ct); var grantedItem = AddEventItem(grantedSubscription, ObjectIds.Server); await grantedSubscription.ApplyChangesAsync(ct); var ungrantedEvents = new EventCollector(); var ungrantedSubscription = await CreateSubscriptionAsync(ungranted, ungrantedEvents, ct); var ungrantedItem = AddEventItem(ungrantedSubscription, ObjectIds.Server); await ungrantedSubscription.ApplyChangesAsync(ct); rig.Materialise(); rig.Transition("t1", active: true); await WaitUntilAsync(() => grantedEvents.Has(grantedItem, "t1")); await Task.Delay(TimeSpan.FromMilliseconds(700), ct); ungrantedEvents.Has(ungrantedItem, "t1").ShouldBeFalse(); grantedEvents.Count(grantedItem, "t1").ShouldBe(1); await ungrantedSubscription.DeleteAsync(true, ct); await grantedSubscription.DeleteAsync(true, ct); } finally { await rig.DisposeAsync(); } } /// /// The deployment axis on a Server-object item: an equipment grant admits the alarm, a deployment that /// revokes it stops the feed, and one that restores it resumes the feed with no client action. /// /// /// The grant is an Equipment grant on a UNS folder the condition is wired to, not a grant on the /// raw condition itself — the decision has to match the per-folder path, where that grant admits the /// same alarm through the equipment's notifier. A cluster-granted key session is the positive control. /// [Fact] public async Task Revoking_then_restoring_a_grant_stops_then_resumes_a_server_object_alarm_feed() { var ct = TestContext.Current.CancellationToken; var rig = await Rig.StartAsync("AclServerEventsRevoke", ct); try { var key = UnexpiringKey(); var state = rig.Enforce([key], EquipmentGrant(EqA), ClusterGrant(KeyPrincipal, "acl-key")); rig.Materialise(); using var control = await rig.ConnectAsync(anonymous: false, ct); using var session = await rig.ConnectAsync(anonymous: true, ct); var controlEvents = new EventCollector(); var controlSubscription = await CreateSubscriptionAsync(control, controlEvents, ct); var controlItem = AddEventItem(controlSubscription, ObjectIds.Server); await controlSubscription.ApplyChangesAsync(ct); var events = new EventCollector(); var subscription = await CreateSubscriptionAsync(session, events, ct); var item = AddEventItem(subscription, ObjectIds.Server); await subscription.ApplyChangesAsync(ct); rig.Transition("t1", active: true); await WaitUntilAsync(() => events.Has(item, "t1") && controlEvents.Has(controlItem, "t1")); state.Apply(Cluster, "rev-2", [key], [ClusterGrant(KeyPrincipal, "acl-key")], ScopePaths()); rig.Transition("t2", active: false); await WaitUntilAsync(() => controlEvents.Has(controlItem, "t2")); await Task.Delay(TimeSpan.FromMilliseconds(700), ct); events.Has(item, "t2").ShouldBeFalse("the revoked session must not receive alarms after the deployment"); state.Apply(Cluster, "rev-3", [key], [EquipmentGrant(EqA), ClusterGrant(KeyPrincipal, "acl-key")], ScopePaths()); rig.Transition("t3", active: true); await WaitUntilAsync(() => events.Has(item, "t3") && controlEvents.Has(controlItem, "t3")); events.Has(item, "t2").ShouldBeFalse("nothing withheld while revoked is replayed on restore"); await subscription.DeleteAsync(true, ct); await controlSubscription.DeleteAsync(true, ct); } finally { await rig.DisposeAsync(); } } /// /// The clock axis on a Server-object item: once the session's key expires the alarm feed stops on the /// very next event — with no deployment and no revalidation tick — while an independent session keeps /// receiving the same event. /// /// /// Server-object events are decided per event at delivery, and the authorizer re-checks key expiry on /// every call, so revalidation is implicit rather than driven by either trigger. Deliberately no /// TickRevalidation() here: a build that only stood items down on the tick would pass a test /// that ticked. /// [Fact] public async Task An_expired_key_stops_a_server_object_alarm_feed_on_the_next_event() { var ct = TestContext.Current.CancellationToken; var rig = await Rig.StartAsync("AclServerEventsExpiry", ct); try { var key = UnexpiringKey(expiresUtc: Start.AddMinutes(10)); var state = rig.Enforce( [key], ClusterGrant(KeyPrincipal, "acl-key"), ClusterGrant(OpcUaPrincipals.Anonymous, "acl-anon")); rig.Materialise(); using var keySession = await rig.ConnectAsync(anonymous: false, ct); using var controlSession = await rig.ConnectAsync(anonymous: true, ct); var keyEvents = new EventCollector(); var keySubscription = await CreateSubscriptionAsync(keySession, keyEvents, ct); var keyItem = AddEventItem(keySubscription, ObjectIds.Server); await keySubscription.ApplyChangesAsync(ct); var controlEvents = new EventCollector(); var controlSubscription = await CreateSubscriptionAsync(controlSession, controlEvents, ct); var controlItem = AddEventItem(controlSubscription, ObjectIds.Server); await controlSubscription.ApplyChangesAsync(ct); rig.Transition("t1", active: true); await WaitUntilAsync(() => keyEvents.Has(keyItem, "t1") && controlEvents.Has(controlItem, "t1")); rig.Advance(TimeSpan.FromMinutes(20)); rig.Transition("t2", active: false); await WaitUntilAsync(() => controlEvents.Has(controlItem, "t2")); await Task.Delay(TimeSpan.FromMilliseconds(700), ct); keyEvents.Has(keyItem, "t2").ShouldBeFalse("the expired key's Server-object feed must stop"); state.Current.GenerationId.ShouldBe(1); await controlSubscription.DeleteAsync(true, ct); await keySubscription.DeleteAsync(true, ct); } finally { await rig.DisposeAsync(); } } /// /// With Security:Acl:Enabled false the Server object stays ungated: a session with no grant /// receives the alarm exactly as a granted one does. /// /// /// Without this the tests above also pass on a build that withholds every Server-object event from /// every session — the flag-off posture leaves reads and subscriptions ungated, and the alarm consumers /// of every fleet that has not opted in depend on it. /// [Fact] public async Task With_acl_enforcement_off_a_server_object_subscription_receives_every_alarm() { var ct = TestContext.Current.CancellationToken; var rig = await Rig.StartAsync("AclServerEventsOff", ct); try { rig.Enforce(enabled: false, [UnexpiringKey()], ClusterGrant(KeyPrincipal, "acl-key")); rig.Materialise(); using var granted = await rig.ConnectAsync(anonymous: false, ct); using var ungranted = await rig.ConnectAsync(anonymous: true, ct); var grantedEvents = new EventCollector(); var grantedSubscription = await CreateSubscriptionAsync(granted, grantedEvents, ct); var grantedItem = AddEventItem(grantedSubscription, ObjectIds.Server); await grantedSubscription.ApplyChangesAsync(ct); var ungrantedEvents = new EventCollector(); var ungrantedSubscription = await CreateSubscriptionAsync(ungranted, ungrantedEvents, ct); var ungrantedItem = AddEventItem(ungrantedSubscription, ObjectIds.Server); await ungrantedSubscription.ApplyChangesAsync(ct); rig.Transition("t1", active: true); await WaitUntilAsync(() => grantedEvents.Has(grantedItem, "t1") && ungrantedEvents.Has(ungrantedItem, "t1")); await Task.Delay(TimeSpan.FromMilliseconds(300), ct); ungrantedEvents.Count(ungrantedItem, "t1").ShouldBe(1); await ungrantedSubscription.DeleteAsync(true, ct); await grantedSubscription.DeleteAsync(true, ct); } finally { await rig.DisposeAsync(); } } /// /// A ConditionRefresh on a Server-object subscription replays only the conditions the session is /// granted: the granted session gets the current state, the ungranted one gets the refresh brackets /// and nothing between them. /// /// /// Refresh is the other way a Server-object item learns about a condition — it is how a client /// connecting after a transition gets the current alarm picture — and it walks every condition in the /// server. The ungranted session's RefreshEndEvent is the positive control on its own item: it /// proves the refresh ran for that subscription and completed, so the missing condition is withheld, /// not late. /// [Fact] public async Task A_condition_refresh_on_the_server_object_replays_only_granted_conditions() { var ct = TestContext.Current.CancellationToken; var rig = await Rig.StartAsync("AclServerRefresh", ct); try { rig.Enforce([UnexpiringKey()], ClusterGrant(KeyPrincipal, "acl-key")); rig.Materialise(); // The transition precedes every subscription, so only a refresh can deliver it. rig.Transition("before", active: true); using var granted = await rig.ConnectAsync(anonymous: false, ct); using var ungranted = await rig.ConnectAsync(anonymous: true, ct); var grantedEvents = new EventCollector(); var grantedSubscription = await CreateSubscriptionAsync(granted, grantedEvents, ct); var grantedItem = AddEventItem(grantedSubscription, ObjectIds.Server); await grantedSubscription.ApplyChangesAsync(ct); var ungrantedEvents = new EventCollector(); var ungrantedSubscription = await CreateSubscriptionAsync(ungranted, ungrantedEvents, ct); var ungrantedItem = AddEventItem(ungrantedSubscription, ObjectIds.Server); await ungrantedSubscription.ApplyChangesAsync(ct); await RefreshAsync(granted, grantedSubscription, ct); await RefreshAsync(ungranted, ungrantedSubscription, ct); await WaitUntilAsync(() => grantedEvents.Has(grantedItem, "before") && ungrantedEvents.HasType(ungrantedItem, ObjectTypeIds.RefreshEndEventType)); await Task.Delay(TimeSpan.FromMilliseconds(300), ct); ungrantedEvents.Has(ungrantedItem, "before").ShouldBeFalse( "a refresh must not replay a condition the session is not granted"); await ungrantedSubscription.DeleteAsync(true, ct); await grantedSubscription.DeleteAsync(true, ct); } finally { await rig.DisposeAsync(); } } // ──────── Server-object delivery with the flag OFF — #586 review: no regression vs. the SDK path ──────── /// /// Part 9 method calls (Acknowledge, AddComment) on a condition put no audit event in front of a /// Server-object item while the server is not auditing — with and without a folder co-subscriber — /// and the item still receives the condition's own transitions exactly once. /// /// /// /// A condition reports its method audit events (AuditConditionAcknowledgeEventType, /// AuditConditionCommentEventType) through its own ReportEvent, which since #586 is /// where Server-object delivery happens. The SDK path it replaced dropped them unless auditing was /// on over an encrypted channel; the replacement must too. Found in review: the first cut queued /// i=8944 to every Server-object item. /// /// /// Both shapes matter. With a folder co-subscriber the condition counts as monitored through the /// folder; with none, only the Server-object item makes it so — the shape the second review /// finding was about, where nothing at all was reported before. /// /// /// The positive control is the next transition: the method calls queue synchronously, so an audit /// event would be ahead of t2 in the same item's queue. /// /// [Theory] [InlineData(true)] [InlineData(false)] public async Task Method_audit_events_do_not_reach_a_server_object_item_while_not_auditing(bool folderCoSubscriber) { var ct = TestContext.Current.CancellationToken; var rig = await Rig.StartAsync($"AclServerAudit{folderCoSubscriber}", ct); try { rig.Materialise(); using var session = await rig.ConnectAsync(anonymous: true, ct); var events = new EventCollector(); var subscription = await CreateSubscriptionAsync(session, events, ct); var serverItem = AddEventItem(subscription, ObjectIds.Server); var folderItem = folderCoSubscriber ? AddEventItem(subscription, rig.UnsNodeId(session, EqA)) : null; await subscription.ApplyChangesAsync(ct); rig.Transition("t1", active: true); await WaitUntilAsync(() => events.Has(serverItem, "t1")); var eventId = events.EventIdOf(serverItem, "t1"); var condition = rig.RawConditionNodeId(session); await CallIgnoringStatusAsync( session, condition, MethodIds.AcknowledgeableConditionType_Acknowledge, ct, eventId, new LocalizedText("ack")); await CallIgnoringStatusAsync( session, condition, MethodIds.ConditionType_AddComment, ct, eventId, new LocalizedText("note")); rig.Transition("t2", active: false); await WaitUntilAsync(() => events.Has(serverItem, "t2")); await Task.Delay(TimeSpan.FromMilliseconds(300), ct); events.HasType(serverItem, ObjectTypeIds.AuditConditionAcknowledgeEventType).ShouldBeFalse(); events.HasType(serverItem, ObjectTypeIds.AuditConditionCommentEventType).ShouldBeFalse(); events.Count(serverItem, "t1").ShouldBe(1); events.Count(serverItem, "t2").ShouldBe(1); if (folderItem is not null) { events.HasType(folderItem, ObjectTypeIds.AuditConditionAcknowledgeEventType).ShouldBeFalse(); events.Count(folderItem, "t2").ShouldBe(1); } await subscription.DeleteAsync(true, ct); } finally { await rig.DisposeAsync(); } } /// /// A Server-object item alone marks every condition as monitored, and deleting it un-marks them — /// including a condition rebuilt by a redeploy while the item was live. /// /// /// ConditionState.EventsMonitored() gates whether the SDK reports a condition's method audit /// events. The SDK base raised it by attaching the item to every root notifier; #586 /// attaches to nothing, so the first cut left it false on a server whose only subscribers are /// Server-object items — the machine-consumer default. Asserted server-side: method-driven state-change /// events no longer depend on it (Gitea #587, ConditionMethodEventTests), and audit events only /// leave the server while it is auditing. /// [Fact] public async Task A_server_object_item_alone_marks_conditions_monitored_until_it_is_deleted() { var ct = TestContext.Current.CancellationToken; var rig = await Rig.StartAsync("AclServerMonitored", ct); try { rig.Materialise(); rig.NativeConditionEventsMonitored.ShouldBeFalse("baseline: nobody is listening yet"); using var session = await rig.ConnectAsync(anonymous: true, ct); var events = new EventCollector(); var subscription = await CreateSubscriptionAsync(session, events, ct); AddEventItem(subscription, ObjectIds.Server); await subscription.ApplyChangesAsync(ct); rig.NativeConditionEventsMonitored.ShouldBeTrue(); rig.Rebuild(); rig.Materialise(); rig.NativeConditionEventsMonitored.ShouldBeTrue("a condition rebuilt under a live item is monitored too"); await subscription.DeleteAsync(true, ct); rig.NativeConditionEventsMonitored.ShouldBeFalse("the last item's delete must give the unit back"); } finally { await rig.DisposeAsync(); } } /// /// A Server-object item outlives a full address-space rebuild and receives exactly one copy of each /// transition on the rebuilt condition. /// /// /// Nothing is attached to the discarded folders, so there is no fan-out to repair; this pins that the /// rebuilt condition's delivery is wired, and that the rebuild did not leave a second path behind. /// [Fact] public async Task A_server_object_item_survives_a_rebuild_with_one_copy_per_event() { var ct = TestContext.Current.CancellationToken; var rig = await Rig.StartAsync("AclServerRebuild", ct); try { rig.Materialise(); using var session = await rig.ConnectAsync(anonymous: true, ct); var events = new EventCollector(); var subscription = await CreateSubscriptionAsync(session, events, ct); var item = AddEventItem(subscription, ObjectIds.Server); await subscription.ApplyChangesAsync(ct); rig.Transition("t1", active: true); await WaitUntilAsync(() => events.Has(item, "t1")); rig.Rebuild(); rig.Materialise(); rig.Transition("t2", active: false); await WaitUntilAsync(() => events.Has(item, "t2")); await Task.Delay(TimeSpan.FromMilliseconds(300), ct); events.Count(item, "t1").ShouldBe(1); events.Count(item, "t2").ShouldBe(1); await subscription.DeleteAsync(true, ct); } finally { await rig.DisposeAsync(); } } /// /// With several conditions — a native one wired to two equipment folders and a scripted one under a /// UNS equipment folder — a Server-object item receives each condition's transition exactly once. /// [Fact] public async Task A_server_object_item_receives_native_and_scripted_conditions_once_each() { var ct = TestContext.Current.CancellationToken; var rig = await Rig.StartAsync("AclServerMulti", ct); try { rig.Materialise(); rig.MaterialiseScripted(); using var session = await rig.ConnectAsync(anonymous: true, ct); var events = new EventCollector(); var subscription = await CreateSubscriptionAsync(session, events, ct); var item = AddEventItem(subscription, ObjectIds.Server); await subscription.ApplyChangesAsync(ct); rig.Transition("n1", active: true); rig.TransitionScripted("s1", active: true); await WaitUntilAsync(() => events.Has(item, "n1") && events.Has(item, "s1")); await Task.Delay(TimeSpan.FromMilliseconds(300), ct); events.Count(item, "n1").ShouldBe(1); events.Count(item, "s1").ShouldBe(1); await subscription.DeleteAsync(true, ct); } finally { await rig.DisposeAsync(); } } /// /// With enforcement on, the decision follows the notifier a condition reports through: an equipment /// grant on admits the native condition wired to it and withholds the scripted /// condition under ; a cluster-granted session receives both (the positive control). /// /// /// The scripted condition's NodeId is its alarm id, not a path under its equipment, so it is admitted /// only through its parent notifier folder — the branch of the check this pins. /// [Fact] public async Task Server_object_delivery_follows_the_notifier_folders_for_native_and_scripted_conditions() { var ct = TestContext.Current.CancellationToken; var rig = await Rig.StartAsync("AclServerScripted", ct); try { rig.Enforce([UnexpiringKey()], EquipmentGrant(EqB), ClusterGrant(KeyPrincipal, "acl-key")); rig.Materialise(); rig.MaterialiseScripted(); using var control = await rig.ConnectAsync(anonymous: false, ct); using var session = await rig.ConnectAsync(anonymous: true, ct); var controlEvents = new EventCollector(); var controlSubscription = await CreateSubscriptionAsync(control, controlEvents, ct); var controlItem = AddEventItem(controlSubscription, ObjectIds.Server); await controlSubscription.ApplyChangesAsync(ct); var events = new EventCollector(); var subscription = await CreateSubscriptionAsync(session, events, ct); var item = AddEventItem(subscription, ObjectIds.Server); await subscription.ApplyChangesAsync(ct); rig.Transition("n1", active: true); rig.TransitionScripted("s1", active: true); await WaitUntilAsync(() => controlEvents.Has(controlItem, "n1") && controlEvents.Has(controlItem, "s1") && events.Has(item, "n1")); await Task.Delay(TimeSpan.FromMilliseconds(500), ct); events.Has(item, "s1").ShouldBeFalse("EqB's grant does not reach the scripted condition under EqA"); await subscription.DeleteAsync(true, ct); await controlSubscription.DeleteAsync(true, ct); } finally { await rig.DisposeAsync(); } } /// /// With the flag off, a ConditionRefresh on a Server-object item replays each condition exactly /// once — not once per notifier it is wired to — bracketed by RefreshStart and RefreshEnd. /// [Fact] public async Task A_condition_refresh_on_the_server_object_replays_each_condition_once() { var ct = TestContext.Current.CancellationToken; var rig = await Rig.StartAsync("AclServerRefreshOff", ct); try { rig.Materialise(); rig.MaterialiseScripted(); rig.Transition("native-before", active: true); rig.TransitionScripted("scripted-before", active: true); using var session = await rig.ConnectAsync(anonymous: true, ct); var events = new EventCollector(); var subscription = await CreateSubscriptionAsync(session, events, ct); var item = AddEventItem(subscription, ObjectIds.Server); await subscription.ApplyChangesAsync(ct); await RefreshAsync(session, subscription, ct); await WaitUntilAsync(() => events.HasType(item, ObjectTypeIds.RefreshEndEventType)); await Task.Delay(TimeSpan.FromMilliseconds(300), ct); events.HasType(item, ObjectTypeIds.RefreshStartEventType).ShouldBeTrue(); events.Count(item, "native-before").ShouldBe(1, "the native condition is wired to three notifiers"); events.Count(item, "scripted-before").ShouldBe(1); await subscription.DeleteAsync(true, ct); } finally { await rig.DisposeAsync(); } } // ───────────────────────────── helpers ───────────────────────────── /// /// Calls a condition method, ignoring its outcome: the test asserts what the call puts on the event /// stream, which the SDK reports whether or not the call itself is accepted. /// private static async Task CallIgnoringStatusAsync( ISession session, NodeId objectId, NodeId methodId, CancellationToken ct, params object[] args) { try { await session.CallAsync(objectId, methodId, ct, args); } catch (ServiceResultException) { /* the outcome is not under test */ } } private static ApiKey UnexpiringKey(DateTime? expiresUtc = null) => new() { ApiKeyId = KeyId, Name = KeyId, SecretHash = "hash", Principal = KeyPrincipal, Enabled = true, CreatedUtc = Start.AddDays(-1), ExpiresUtc = expiresUtc, }; private static async Task RefreshAsync(ISession session, Subscription subscription, CancellationToken ct) => await session.CallAsync( ObjectTypeIds.ConditionType, MethodIds.ConditionType_ConditionRefresh, ct, subscription.Id); private static NodeAcl EquipmentGrant(string equipment) => new() { NodeAclId = $"acl-{equipment}", ClusterId = Cluster, Principal = OpcUaPrincipals.Anonymous, ScopeKind = NodeAclScopeKind.Equipment, ScopeId = $"eq-{equipment}", PermissionFlags = NodePermissions.Read | NodePermissions.Browse | NodePermissions.Subscribe, }; private static NodeAcl ClusterGrant(string principal, string id) => new() { NodeAclId = id, ClusterId = Cluster, Principal = principal, ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = NodePermissions.Read | NodePermissions.Browse | NodePermissions.Subscribe, }; private static IReadOnlyDictionary> ScopePaths() => new Dictionary> { [new AclScopeRef(NodeAclScopeKind.Equipment, $"eq-{EqA}")] = [new NodeAclPath([NodeAclRealms.Uns, EqA])], [new AclScopeRef(NodeAclScopeKind.Equipment, $"eq-{EqB}")] = [new NodeAclPath([NodeAclRealms.Uns, EqB])], }; private static async Task CreateSubscriptionAsync( ISession session, EventCollector collector, CancellationToken ct) { var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 }; subscription.FastEventCallback = collector.OnEvents; session.AddSubscription(subscription); await subscription.CreateAsync(ct); return subscription; } private static MonitoredItem AddEventItem(Subscription subscription, NodeId source) { var filter = new EventFilter(); filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventId); filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message); filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventType); var item = new MonitoredItem(subscription.DefaultItem) { StartNodeId = source, AttributeId = Attributes.EventNotifier, Filter = filter, QueueSize = 100, SamplingInterval = 0, }; subscription.AddItem(item); return item; } private static async Task WaitUntilAsync(Func condition) { var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); while (DateTime.UtcNow < deadline) { if (condition()) return; await Task.Delay(50); } condition().ShouldBeTrue("condition not met within timeout"); } /// Records which transition messages, and which event types, reached which monitored item. private sealed class EventCollector { private readonly object _gate = new(); private readonly List<(uint Handle, string Message)> _seen = []; private readonly Dictionary<(uint Handle, string Message), byte[]> _eventIds = []; private readonly HashSet<(uint Handle, NodeId Type)> _types = []; public void OnEvents(Subscription subscription, EventNotificationList notification, IList stringTable) { lock (_gate) { foreach (var e in notification.Events) { if (e.EventFields.Count > MessageFieldIndex && e.EventFields[MessageFieldIndex].Value is LocalizedText { Text: { } text }) { _seen.Add((e.ClientHandle, text)); if (e.EventFields[0].Value is byte[] eventId) _eventIds[(e.ClientHandle, text)] = eventId; } if (e.EventFields.Count > EventTypeFieldIndex && e.EventFields[EventTypeFieldIndex].Value is NodeId type) { _types.Add((e.ClientHandle, type)); } } } } public bool Has(MonitoredItem item, string message) => Count(item, message) > 0; public int Count(MonitoredItem item, string message) { lock (_gate) return _seen.Count(s => s.Handle == item.ClientHandle && s.Message == message); } public bool HasType(MonitoredItem item, NodeId type) { lock (_gate) return _types.Contains((item.ClientHandle, type)); } public byte[] EventIdOf(MonitoredItem item, string message) { lock (_gate) return _eventIds[(item.ClientHandle, message)]; } } /// A booted server with ACL enforcement, one native alarm wired to two equipment notifiers. private sealed class Rig : IAsyncDisposable { private readonly string _pkiRoot; private readonly FakeClock _clock = new(Start); private OpcUaApplicationHost _host = null!; private OtOpcUaSdkServer _server = null!; private SdkAddressSpaceSink _sink = null!; private AclRevalidationTimer? _timer; private int _port; private Rig(string pkiRoot) => _pkiRoot = pkiRoot; private OtOpcUaNodeManager Nm => _server.NodeManager!; public static async Task StartAsync(string name, CancellationToken ct) { var rig = new Rig(Path.Combine(Path.GetTempPath(), $"otopcua-{name}-{Guid.NewGuid():N}")); var applicationUri = $"urn:OtOpcUa.{name}.{Guid.NewGuid():N}"; (rig._host, rig._server) = await OpcUaHostStart.StartAsync( port => { rig._port = port; return new OpcUaApplicationHost( new OpcUaApplicationHostOptions { ApplicationName = $"OtOpcUa.{name}", ApplicationUri = applicationUri, OpcUaPort = port, PublicHostname = "127.0.0.1", PkiStoreRoot = rig._pkiRoot, EnabledSecurityProfiles = [OpcUaSecurityProfile.None], AutoAcceptUntrustedClientCertificates = true, }, NullLogger.Instance, new KeyPresentingAuthenticator()); }, () => new OtOpcUaSdkServer(), ct); rig._sink = new SdkAddressSpaceSink(rig.Nm); return rig; } /// Switches enforcement on with the given grants and no keys. public DataPlaneAuthorityState Enforce(params NodeAcl[] grants) => Enforce([], grants); /// /// Switches enforcement on, wiring the authorizer, the authority source (the deployment trigger) /// and the host's revalidation timer (the clock trigger) over one hand-advanced clock. /// public DataPlaneAuthorityState Enforce(ApiKey[] keys, params NodeAcl[] grants) => Enforce(true, keys, grants); /// /// As , with standing in for /// Security:Acl:Enabled — false wires the same grants with enforcement switched off. /// public DataPlaneAuthorityState Enforce(bool enabled, ApiKey[] keys, params NodeAcl[] grants) { var state = new DataPlaneAuthorityState(); state.Apply(Cluster, "rev-1", keys, grants, ScopePaths()); Nm.DataPlaneAuthority = state; Nm.Authorizer = new DataPlaneAuthorizer( state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance, _clock); // A long interval: the tick is driven by hand through the same component the host arms. _timer = new AclRevalidationTimer( () => _server.NodeManager?.RevalidateMonitoredItems(), TimeSpan.FromHours(1), TimeProvider.System, NullLogger.Instance); return state; } /// One native condition on a raw device, wired as an event source of two equipment folders. public void Materialise() { _sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw); _sink.MaterialiseAlarmCondition( RawAlarmPath, RawDeviceFolder, "temp_hi", "OffNormalAlarm", 700, AddressSpaceRealm.Raw, isNative: true); _sink.EnsureFolder(EqA, parentNodeId: null, displayName: "A", realm: AddressSpaceRealm.Uns); _sink.EnsureFolder(EqB, parentNodeId: null, displayName: "B", realm: AddressSpaceRealm.Uns); _sink.WireAlarmNotifiers(RawAlarmPath, AddressSpaceRealm.Raw, [EqA, EqB], AddressSpaceRealm.Uns); } /// A scripted condition under equipment folder (UNS realm, no extra notifiers). public void MaterialiseScripted() => _sink.MaterialiseAlarmCondition( ScriptedAlarmId, EqA, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns, isNative: false); /// Fires one transition on the scripted condition, carrying . public void TransitionScripted(string message, bool active) => _sink.WriteAlarmCondition( ScriptedAlarmId, new AlarmConditionSnapshot( Active: active, Acknowledged: !active, Confirmed: true, Enabled: true, Shelving: AlarmShelvingKind.Unshelved, Severity: 500, Message: message), DateTime.UtcNow, AddressSpaceRealm.Uns); /// A full address-space rebuild, as a redeploy that is not surgical performs. public void Rebuild() => _sink.RebuildAddressSpace(); /// Whether the native condition reports itself monitored (ConditionState.EventsMonitored()). public bool NativeConditionEventsMonitored => Nm.ConditionEventsMonitored(RawAlarmPath, AddressSpaceRealm.Raw); /// The native condition's NodeId in the session's raw namespace. public NodeId RawConditionNodeId(ISession session) => new(RawAlarmPath, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri)); /// Fires one genuine condition transition, carrying . public void Transition(string message, bool active) => _sink.WriteAlarmCondition( RawAlarmPath, new AlarmConditionSnapshot( Active: active, Acknowledged: !active, Confirmed: true, Enabled: true, Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: message), DateTime.UtcNow, AddressSpaceRealm.Raw); public NodeId UnsNodeId(ISession session, string id) { var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri); ns.ShouldBeGreaterThan((ushort)0); return new NodeId(id, ns); } /// Event items attached to the fan-out of the manager's own notifiers. public int AttachedEventItems => Nm.AttachedEventItemCount; public void Advance(TimeSpan by) => _clock.Advance(by); public void TickRevalidation() => _timer!.Tick(); public async Task ConnectAsync(bool anonymous, CancellationToken ct) { var appConfig = new ApplicationConfiguration { ApplicationName = "OtOpcUa.AclEventClient", ApplicationUri = $"urn:OtOpcUa.AclEventClient.{Guid.NewGuid():N}", ApplicationType = ApplicationType.Client, SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()), ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 }, }; await appConfig.ValidateAsync(ApplicationType.Client, ct); appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; var endpoint = await CoreClientUtils.SelectEndpointAsync( appConfig, $"opc.tcp://127.0.0.1:{_port}/OtOpcUa", false, DefaultTelemetry.Create(_ => { }), ct); var identity = anonymous ? new UserIdentity(new AnonymousIdentityToken()) : new UserIdentity(new UserNameIdentityToken { UserName = "apikey", // 1.5.378 carries the UserName token password as raw bytes; the host decodes UTF-8. DecryptedPassword = Encoding.UTF8.GetBytes($"otua_{KeyId}_secret"), }); return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync( appConfig, new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(appConfig)), updateBeforeConnect: false, checkDomain: false, sessionName: "AclEventSubscriptionEnforcementTests", sessionTimeout: 60_000, identity: identity, preferredLocales: null, ct: ct); } public async ValueTask DisposeAsync() { _timer?.Dispose(); await _host.DisposeAsync(); if (Directory.Exists(_pkiRoot)) { try { Directory.Delete(_pkiRoot, recursive: true); } catch (IOException) { /* best effort */ } } } /// /// Admits the fixed apikey username as carrying /// ; anonymous falls through to the interface default (the /// anonymous principal). Credential verification itself is covered in the Host suite. /// private sealed class KeyPresentingAuthenticator : IOpcUaUserAuthenticator { /// public Task AuthenticateUserNameAsync( string username, string password, CancellationToken ct) => Task.FromResult( string.Equals(username, "apikey", StringComparison.Ordinal) && password.StartsWith($"otua_{KeyId}_", StringComparison.Ordinal) ? OpcUaUserAuthResult.AllowKey(KeyPrincipal, [], KeyId) : OpcUaUserAuthResult.Deny("Invalid API key.")); } /// A hand-advanced clock; the authorizer reads it on every decision. private sealed class FakeClock(DateTime utcNow) : TimeProvider { private DateTimeOffset _now = new(utcNow, TimeSpan.Zero); /// public override DateTimeOffset GetUtcNow() => _now; public void Advance(TimeSpan by) => _now = _now.Add(by); } } }