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(); } /// 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(); } /// 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 */ } } } }