using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Opc.Ua.Server; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// T18 — inbound OPC UA Part 9 alarm method handlers. Boots a real , /// materialises a condition, sets a capturing , /// then drives the condition's public SDK handler delegates (OnAcknowledge/OnShelve) /// directly. Verifies (a) the AlarmAck role gate fails closed and (b) the routed /// is mapped correctly. /// public sealed class AlarmCommandRouterTests : IDisposable { private static CancellationToken Ct => TestContext.Current.CancellationToken; private readonly string _pkiRoot = Path.Combine( Path.GetTempPath(), $"otopcua-alarmcmd-{Guid.NewGuid():N}"); /// With the AlarmAck role present, OnAcknowledge returns Good and routes exactly one /// correctly-mapped AlarmCommand (AlarmId = ScriptedAlarmId, Operation, User, Comment). [Fact] public async Task OnAcknowledge_with_AlarmAck_returns_good_and_routes_mapped_command() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; var captured = new List(); nm.AlarmCommandRouter = captured.Add; nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700); var condition = nm.TryGetAlarmCondition("alm-1"); condition.ShouldNotBeNull(); condition!.OnAcknowledge.ShouldNotBeNull(); var ctx = SessionContext(server, "alice", "ReadOnly", OpcUaDataPlaneRoles.AlarmAck); var result = condition.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("ack note")); result.ShouldBe(ServiceResult.Good); captured.Count.ShouldBe(1); var cmd = captured[0]; cmd.AlarmId.ShouldBe("alm-1"); // == ScriptedAlarmId (condition NodeId identifier) cmd.Operation.ShouldBe("Acknowledge"); cmd.User.ShouldBe("alice"); cmd.Comment.ShouldBe("ack note"); cmd.UnshelveAtUtc.ShouldBeNull(); await host.DisposeAsync(); } /// Without the AlarmAck role, OnAcknowledge is vetoed (BadUserAccessDenied) and the /// router is NOT invoked — the gate fails closed and never mutates state. [Fact] public async Task OnAcknowledge_without_AlarmAck_returns_denied_and_does_not_route() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; var captured = new List(); nm.AlarmCommandRouter = captured.Add; nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2"); nm.MaterialiseAlarmCondition("alm-2", "eq-2", "HighTemp", "OffNormalAlarm", severity: 700); var condition = nm.TryGetAlarmCondition("alm-2"); condition.ShouldNotBeNull(); var ctx = SessionContext(server, "bob", "ReadOnly"); // no AlarmAck var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("nope")); result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); captured.ShouldBeEmpty(); await host.DisposeAsync(); } /// A null identity (anonymous / no role-carrying identity on the context) is denied and does /// not route — the gate fails closed on a missing principal. [Fact] public async Task OnAcknowledge_with_null_identity_returns_denied_and_does_not_route() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; var captured = new List(); nm.AlarmCommandRouter = captured.Add; nm.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3"); nm.MaterialiseAlarmCondition("alm-3", "eq-3", "HighTemp", "OffNormalAlarm", severity: 700); var condition = nm.TryGetAlarmCondition("alm-3"); condition.ShouldNotBeNull(); // ServerSystemContext with no UserIdentity set ⇒ (context as ISessionOperationContext).UserIdentity is null. var ctx = new ServerSystemContext(server.CurrentInstance); var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("x")); result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); captured.ShouldBeEmpty(); await host.DisposeAsync(); } /// OnConfirm with AlarmAck maps to the Confirm operation. [Fact] public async Task OnConfirm_with_AlarmAck_routes_confirm_operation() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; var captured = new List(); nm.AlarmCommandRouter = captured.Add; nm.EnsureFolder("eq-c", parentNodeId: null, displayName: "Equipment C"); nm.MaterialiseAlarmCondition("alm-c", "eq-c", "HighTemp", "OffNormalAlarm", severity: 700); var condition = nm.TryGetAlarmCondition("alm-c"); condition.ShouldNotBeNull(); condition!.OnConfirm.ShouldNotBeNull(); var ctx = SessionContext(server, "carol", OpcUaDataPlaneRoles.AlarmAck); var result = condition.OnConfirm!(ctx, condition, EventIdBytes(), comment: null); result.ShouldBe(ServiceResult.Good); captured.Count.ShouldBe(1); captured[0].Operation.ShouldBe("Confirm"); captured[0].Comment.ShouldBeNull(); // null LocalizedText ⇒ null Comment captured[0].User.ShouldBe("carol"); await host.DisposeAsync(); } /// OnAddComment with AlarmAck maps to the AddComment operation, carrying the comment text. [Fact] public async Task OnAddComment_with_AlarmAck_routes_add_comment_operation() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; var captured = new List(); nm.AlarmCommandRouter = captured.Add; nm.EnsureFolder("eq-ac", parentNodeId: null, displayName: "Equipment AC"); nm.MaterialiseAlarmCondition("alm-ac", "eq-ac", "HighTemp", "OffNormalAlarm", severity: 700); var condition = nm.TryGetAlarmCondition("alm-ac"); condition.ShouldNotBeNull(); condition!.OnAddComment.ShouldNotBeNull(); var ctx = SessionContext(server, "dave", OpcUaDataPlaneRoles.AlarmAck); var result = condition.OnAddComment!(ctx, condition, EventIdBytes(), new LocalizedText("look here")); result.ShouldBe(ServiceResult.Good); captured.Count.ShouldBe(1); captured[0].Operation.ShouldBe("AddComment"); captured[0].Comment.ShouldBe("look here"); await host.DisposeAsync(); } /// OnShelve with oneShot=true maps to OneShotShelve with no UnshelveAtUtc. [Fact] public async Task OnShelve_oneshot_routes_one_shot_shelve_with_no_expiry() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; var captured = new List(); nm.AlarmCommandRouter = captured.Add; nm.EnsureFolder("eq-s1", parentNodeId: null, displayName: "Equipment S1"); nm.MaterialiseAlarmCondition("alm-s1", "eq-s1", "HighTemp", "OffNormalAlarm", severity: 700); var condition = nm.TryGetAlarmCondition("alm-s1"); condition.ShouldNotBeNull(); condition!.OnShelve.ShouldNotBeNull(); var ctx = SessionContext(server, "erin", OpcUaDataPlaneRoles.AlarmAck); // SDK OneShotShelve invokes OnShelve(ctx, alarm, shelving:true, oneShot:true, 0.0). var result = condition.OnShelve!(ctx, condition, shelving: true, oneShot: true, shelvingTime: 0.0); result.ShouldBe(ServiceResult.Good); captured.Count.ShouldBe(1); captured[0].Operation.ShouldBe("OneShotShelve"); captured[0].UnshelveAtUtc.ShouldBeNull(); await host.DisposeAsync(); } /// OnShelve with oneShot=false and shelving=true maps to TimedShelve with UnshelveAtUtc /// derived from the millisecond Duration (UtcNow + ms). [Fact] public async Task OnShelve_timed_routes_timed_shelve_with_ms_derived_expiry() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; var captured = new List(); nm.AlarmCommandRouter = captured.Add; nm.EnsureFolder("eq-s2", parentNodeId: null, displayName: "Equipment S2"); nm.MaterialiseAlarmCondition("alm-s2", "eq-s2", "HighTemp", "OffNormalAlarm", severity: 700); var condition = nm.TryGetAlarmCondition("alm-s2"); condition.ShouldNotBeNull(); var ctx = SessionContext(server, "frank", OpcUaDataPlaneRoles.AlarmAck); const double shelvingTimeMs = 60_000.0; // OPC UA Duration = milliseconds var before = DateTime.UtcNow; // SDK TimedShelve invokes OnShelve(ctx, alarm, shelving:true, oneShot:false, shelvingTime). var result = condition!.OnShelve!(ctx, condition, shelving: true, oneShot: false, shelvingTime: shelvingTimeMs); var after = DateTime.UtcNow; result.ShouldBe(ServiceResult.Good); captured.Count.ShouldBe(1); captured[0].Operation.ShouldBe("TimedShelve"); captured[0].UnshelveAtUtc.ShouldNotBeNull(); // UtcNow + 60s, bracketed by the before/after capture instants. captured[0].UnshelveAtUtc!.Value.ShouldBeGreaterThanOrEqualTo(before.AddMilliseconds(shelvingTimeMs)); captured[0].UnshelveAtUtc!.Value.ShouldBeLessThanOrEqualTo(after.AddMilliseconds(shelvingTimeMs)); await host.DisposeAsync(); } /// OnShelve with shelving=false maps to Unshelve with no UnshelveAtUtc. [Fact] public async Task OnShelve_unshelve_routes_unshelve_operation() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; var captured = new List(); nm.AlarmCommandRouter = captured.Add; nm.EnsureFolder("eq-s3", parentNodeId: null, displayName: "Equipment S3"); nm.MaterialiseAlarmCondition("alm-s3", "eq-s3", "HighTemp", "OffNormalAlarm", severity: 700); var condition = nm.TryGetAlarmCondition("alm-s3"); condition.ShouldNotBeNull(); var ctx = SessionContext(server, "grace", OpcUaDataPlaneRoles.AlarmAck); // SDK Unshelve invokes OnShelve(ctx, alarm, shelving:false, oneShot:false, 0.0). var result = condition!.OnShelve!(ctx, condition, shelving: false, oneShot: false, shelvingTime: 0.0); result.ShouldBe(ServiceResult.Good); captured.Count.ShouldBe(1); captured[0].Operation.ShouldBe("Unshelve"); captured[0].UnshelveAtUtc.ShouldBeNull(); await host.DisposeAsync(); } /// OnShelve without AlarmAck is vetoed and does not route. [Fact] public async Task OnShelve_without_AlarmAck_returns_denied_and_does_not_route() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; var captured = new List(); nm.AlarmCommandRouter = captured.Add; nm.EnsureFolder("eq-s4", parentNodeId: null, displayName: "Equipment S4"); nm.MaterialiseAlarmCondition("alm-s4", "eq-s4", "HighTemp", "OffNormalAlarm", severity: 700); var condition = nm.TryGetAlarmCondition("alm-s4"); condition.ShouldNotBeNull(); var ctx = SessionContext(server, "heidi", "ReadOnly"); // no AlarmAck var result = condition!.OnShelve!(ctx, condition, shelving: true, oneShot: true, shelvingTime: 0.0); result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); captured.ShouldBeEmpty(); await host.DisposeAsync(); } /// OnTimedUnshelve fires with the SDK's system context (no session, no user identity) — /// the real SDK path when a TimedShelve duration expires. The gate must NOT veto: the result must be /// Good, the router must be invoked exactly once with Operation == "Unshelve" and User == empty, /// and no UnshelveAtUtc is carried. [Fact] public async Task OnTimedUnshelve_with_system_context_returns_good_and_routes_unshelve() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; var captured = new List(); nm.AlarmCommandRouter = captured.Add; nm.EnsureFolder("eq-tu", parentNodeId: null, displayName: "Equipment TU"); nm.MaterialiseAlarmCondition("alm-tu", "eq-tu", "HighTemp", "OffNormalAlarm", severity: 700); var condition = nm.TryGetAlarmCondition("alm-tu"); condition.ShouldNotBeNull(); condition!.OnTimedUnshelve.ShouldNotBeNull(); // Reproduce the real SDK path: system context has no session and no UserIdentity — exactly what // the SDK's internal timer fires the callback with when a TimedShelve duration expires. var ctx = new ServerSystemContext(server.CurrentInstance); // no UserIdentity set var result = condition.OnTimedUnshelve!(ctx, condition); result.ShouldBe(ServiceResult.Good); captured.Count.ShouldBe(1); var cmd = captured[0]; cmd.AlarmId.ShouldBe("alm-tu"); // == ScriptedAlarmId / condition NodeId identifier cmd.Operation.ShouldBe("Unshelve"); cmd.User.ShouldBe(string.Empty); // no client principal — system-initiated cmd.UnshelveAtUtc.ShouldBeNull(); await host.DisposeAsync(); } /// H4 — OnEnableDisable with enabling:false and the AlarmAck role on a SCRIPTED /// condition returns Good and routes exactly one with Operation == "Disable" /// and User == the caller's DisplayName. [Fact] public async Task OnEnableDisable_disabling_with_AlarmAck_routes_Disable() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; var captured = new List(); nm.AlarmCommandRouter = captured.Add; nm.EnsureFolder("eq-ed1", parentNodeId: null, displayName: "Equipment ED1"); nm.MaterialiseAlarmCondition("alm-ed1", "eq-ed1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false); var condition = nm.TryGetAlarmCondition("alm-ed1"); condition.ShouldNotBeNull(); condition!.OnEnableDisable.ShouldNotBeNull(); var ctx = SessionContext(server, "ivan", OpcUaDataPlaneRoles.AlarmAck); // SDK Disable invokes OnEnableDisable(ctx, condition, enabling:false). var result = condition.OnEnableDisable!(ctx, condition, enabling: false); result.ShouldBe(ServiceResult.Good); captured.Count.ShouldBe(1); captured[0].AlarmId.ShouldBe("alm-ed1"); captured[0].Operation.ShouldBe("Disable"); captured[0].User.ShouldBe("ivan"); captured[0].UnshelveAtUtc.ShouldBeNull(); await host.DisposeAsync(); } /// H4 — OnEnableDisable with enabling:true maps to the Enable operation. [Fact] public async Task OnEnableDisable_enabling_routes_Enable() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; var captured = new List(); nm.AlarmCommandRouter = captured.Add; nm.EnsureFolder("eq-ed2", parentNodeId: null, displayName: "Equipment ED2"); nm.MaterialiseAlarmCondition("alm-ed2", "eq-ed2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false); var condition = nm.TryGetAlarmCondition("alm-ed2"); condition.ShouldNotBeNull(); condition!.OnEnableDisable.ShouldNotBeNull(); var ctx = SessionContext(server, "jane", OpcUaDataPlaneRoles.AlarmAck); // SDK Enable invokes OnEnableDisable(ctx, condition, enabling:true). var result = condition.OnEnableDisable!(ctx, condition, enabling: true); result.ShouldBe(ServiceResult.Good); captured.Count.ShouldBe(1); captured[0].Operation.ShouldBe("Enable"); captured[0].User.ShouldBe("jane"); await host.DisposeAsync(); } /// H4 — OnEnableDisable from an anonymous / role-less identity is vetoed /// (BadUserAccessDenied) and the router is NOT invoked — the gate fails closed. [Fact] public async Task OnEnableDisable_anonymous_is_denied() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; var captured = new List(); nm.AlarmCommandRouter = captured.Add; nm.EnsureFolder("eq-ed3", parentNodeId: null, displayName: "Equipment ED3"); nm.MaterialiseAlarmCondition("alm-ed3", "eq-ed3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false); var condition = nm.TryGetAlarmCondition("alm-ed3"); condition.ShouldNotBeNull(); // ServerSystemContext with no UserIdentity ⇒ anonymous / no role-carrying identity. var ctx = new ServerSystemContext(server.CurrentInstance); var result = condition!.OnEnableDisable!(ctx, condition, enabling: false); result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); captured.ShouldBeEmpty(); await host.DisposeAsync(); } /// H4 — OnEnableDisable on a NATIVE (driver-fed) condition returns BadNotSupported and does /// NOT route — native conditions have no engine enable/disable surface (Phase 3 decision #2). The gate /// short-circuits on the native flag before the role check, so even an AlarmAck caller gets BadNotSupported. [Fact] public async Task OnEnableDisable_on_native_condition_is_BadNotSupported() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; var captured = new List(); nm.AlarmCommandRouter = captured.Add; nm.EnsureFolder("eq-ed4", parentNodeId: null, displayName: "Equipment ED4"); nm.MaterialiseAlarmCondition("alm-ed4", "eq-ed4", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true); var condition = nm.TryGetAlarmCondition("alm-ed4"); condition.ShouldNotBeNull(); condition!.OnEnableDisable.ShouldNotBeNull(); var ctx = SessionContext(server, "kara", OpcUaDataPlaneRoles.AlarmAck); var result = condition.OnEnableDisable!(ctx, condition, enabling: false); result.StatusCode.Code.ShouldBe(StatusCodes.BadNotSupported); captured.ShouldBeEmpty(); await host.DisposeAsync(); } /// H6c — a NATIVE (driver-fed) condition's Acknowledge routes to (the driver-bound seam), NOT the scripted /// . With the AlarmAck role present, OnAcknowledge /// returns Good, the captured carries the condition NodeId, the operator /// DisplayName, and the comment text, and the scripted router is NOT invoked. [Fact] public async Task Native_OnAcknowledge_routes_to_NativeAlarmAckRouter_not_scripted() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; var scripted = new List(); var native = new List(); nm.AlarmCommandRouter = scripted.Add; nm.NativeAlarmAckRouter = native.Add; nm.EnsureFolder("eq-nak1", parentNodeId: null, displayName: "Equipment NAK1"); nm.MaterialiseAlarmCondition("alm-nak1", "eq-nak1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true); var condition = nm.TryGetAlarmCondition("alm-nak1"); condition.ShouldNotBeNull(); condition!.OnAcknowledge.ShouldNotBeNull(); var ctx = SessionContext(server, "nora", OpcUaDataPlaneRoles.AlarmAck); var result = condition.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("native ack")); result.ShouldBe(ServiceResult.Good); native.Count.ShouldBe(1); native[0].ConditionNodeId.ShouldBe("alm-nak1"); // folder-scoped condition NodeId identifier native[0].OperatorUser.ShouldBe("nora"); native[0].Comment.ShouldBe("native ack"); scripted.ShouldBeEmpty(); // scripted engine NOT involved await host.DisposeAsync(); } /// H6c — a SCRIPTED condition's Acknowledge still routes through the scripted /// (Operation == "Acknowledge"); the native /// router is NOT invoked. [Fact] public async Task Scripted_OnAcknowledge_still_uses_AlarmCommandRouter() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; var scripted = new List(); var native = new List(); nm.AlarmCommandRouter = scripted.Add; nm.NativeAlarmAckRouter = native.Add; nm.EnsureFolder("eq-nak2", parentNodeId: null, displayName: "Equipment NAK2"); nm.MaterialiseAlarmCondition("alm-nak2", "eq-nak2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false); var condition = nm.TryGetAlarmCondition("alm-nak2"); condition.ShouldNotBeNull(); condition!.OnAcknowledge.ShouldNotBeNull(); var ctx = SessionContext(server, "owen", OpcUaDataPlaneRoles.AlarmAck); var result = condition.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("scripted ack")); result.ShouldBe(ServiceResult.Good); scripted.Count.ShouldBe(1); scripted[0].AlarmId.ShouldBe("alm-nak2"); scripted[0].Operation.ShouldBe("Acknowledge"); scripted[0].User.ShouldBe("owen"); scripted[0].Comment.ShouldBe("scripted ack"); native.ShouldBeEmpty(); // native seam NOT involved await host.DisposeAsync(); } /// H6c — a NATIVE condition's Acknowledge from a non-null identity that lacks the /// AlarmAck role is vetoed (BadUserAccessDenied) and NEITHER router is invoked — the gate /// fails closed before any route, exactly as for scripted conditions. [Fact] public async Task Native_OnAcknowledge_without_AlarmAck_returns_denied_and_routes_nothing() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; var scripted = new List(); var native = new List(); nm.AlarmCommandRouter = scripted.Add; nm.NativeAlarmAckRouter = native.Add; nm.EnsureFolder("eq-nak4", parentNodeId: null, displayName: "Equipment NAK4"); nm.MaterialiseAlarmCondition("alm-nak4", "eq-nak4", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true); var condition = nm.TryGetAlarmCondition("alm-nak4"); condition.ShouldNotBeNull(); var ctx = SessionContext(server, "pete", "ReadOnly"); // non-null identity, but no AlarmAck var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("nope")); result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); native.ShouldBeEmpty(); scripted.ShouldBeEmpty(); await host.DisposeAsync(); } /// H6c — a NATIVE condition's Acknowledge from an anonymous / role-less identity is vetoed /// (BadUserAccessDenied) and NEITHER router is invoked — the gate fails closed before any route. [Fact] public async Task Native_OnAcknowledge_anonymous_is_denied() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; var scripted = new List(); var native = new List(); nm.AlarmCommandRouter = scripted.Add; nm.NativeAlarmAckRouter = native.Add; nm.EnsureFolder("eq-nak3", parentNodeId: null, displayName: "Equipment NAK3"); nm.MaterialiseAlarmCondition("alm-nak3", "eq-nak3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true); var condition = nm.TryGetAlarmCondition("alm-nak3"); condition.ShouldNotBeNull(); // ServerSystemContext with no UserIdentity ⇒ anonymous / no role-carrying identity. var ctx = new ServerSystemContext(server.CurrentInstance); var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("x")); result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); native.ShouldBeEmpty(); scripted.ShouldBeEmpty(); await host.DisposeAsync(); } /// A null router is a safe no-op: handler still gates + returns Good, just routes nowhere. [Fact] public async Task OnAcknowledge_with_null_router_is_safe_noop_and_returns_good() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; // No router set (default null). nm.EnsureFolder("eq-nr", parentNodeId: null, displayName: "Equipment NR"); nm.MaterialiseAlarmCondition("alm-nr", "eq-nr", "HighTemp", "OffNormalAlarm", severity: 700); var condition = nm.TryGetAlarmCondition("alm-nr"); condition.ShouldNotBeNull(); var ctx = SessionContext(server, "judy", OpcUaDataPlaneRoles.AlarmAck); var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("ok")); result.ShouldBe(ServiceResult.Good); await host.DisposeAsync(); } /// H6a — a condition materialised with isNative:true is tracked so later inbound-ack /// routing can dispatch its Acknowledge to the driver rather than the scripted engine. [Fact] public async Task Native_materialise_is_tracked_as_native() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment"); nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true); nm.IsNativeAlarmNode("a1").ShouldBeTrue(); await host.DisposeAsync(); } /// H6a — a scripted condition (the default, isNative:false) is NOT tracked as native. [Fact] public async Task Scripted_materialise_is_not_native() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment"); nm.MaterialiseAlarmCondition("a2", "eq", "d", "OffNormalAlarm", 700, isNative: false); nm.IsNativeAlarmNode("a2").ShouldBeFalse(); await host.DisposeAsync(); } /// H6a lifecycle — the native flag is NOT sticky across a rebuild + kind flip. Materialise an /// id as native (flag true), (which clears the /// folder + condition + native-flag sets), re-ensure the equipment folder, then re-materialise the /// SAME id as scripted (isNative:false). The flag must read false — the rebuild dropped it and /// the scripted re-materialise did NOT re-add it. Guards against a stale-native leak that would route a /// now-scripted alarm's inbound ack to the driver. [Fact] public async Task Native_flag_clears_on_rebuild_then_kind_flip() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment"); nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true); nm.IsNativeAlarmNode("a1").ShouldBeTrue(); // RebuildAddressSpace clears the folder set too, so the equipment folder must be re-ensured // before the same id can be re-materialised (ResolveParentFolder needs the parent back). nm.RebuildAddressSpace(); nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment"); nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: false); nm.IsNativeAlarmNode("a1").ShouldBeFalse(); await host.DisposeAsync(); } /// H6a lifecycle (converse) — a scripted condition can flip TO native across a rebuild. /// Materialise an id as scripted (flag false), rebuild, re-ensure the folder, re-materialise the SAME /// id as native (isNative:true). The flag must read true — the native re-materialise re-adds it /// cleanly after the rebuild cleared the slate. [Fact] public async Task Scripted_flag_can_flip_to_native_across_rebuild() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment"); nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: false); nm.IsNativeAlarmNode("a1").ShouldBeFalse(); nm.RebuildAddressSpace(); nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment"); nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true); nm.IsNativeAlarmNode("a1").ShouldBeTrue(); await host.DisposeAsync(); } /// Builds a (an ) /// carrying a with the given name + roles — the exact seam the /// gate reads via (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity. private static ServerSystemContext SessionContext(OtOpcUaSdkServer server, string user, params string[] roles) { var identity = new RoleCarryingUserIdentity( new UserNameIdentityToken { UserName = user }, roles); return new ServerSystemContext(server.CurrentInstance) { UserIdentity = identity }; } private static byte[] EventIdBytes() => Guid.NewGuid().ToByteArray(); private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() { var host = new OpcUaApplicationHost( new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.AlarmCmdTest", ApplicationUri = $"urn:OtOpcUa.AlarmCmdTest:{Guid.NewGuid():N}", OpcUaPort = AllocateFreePort(), PublicHostname = "localhost", PkiStoreRoot = _pkiRoot, }, NullLogger.Instance); var server = new OtOpcUaSdkServer(); await host.StartAsync(server, Ct); return (host, server); } private static int AllocateFreePort() { using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); listener.Start(); var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; listener.Stop(); return port; } /// Cleans up the PKI root directory. public void Dispose() { if (Directory.Exists(_pkiRoot)) { try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort cleanup */ } } } }