From 63289d377c515e5494a8a4a44bb9d21785f93a85 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 11 Jun 2026 06:05:39 -0400 Subject: [PATCH] feat(alarms): route inbound Part 9 alarm methods through AlarmAck gate (T18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the materialised AlarmConditionState method handlers so a client calling Acknowledge/Confirm/Shelve/AddComment is gated on the AlarmAck data-plane role and, when allowed, routed back to the scripted-alarm engine via a new `alarm-commands` DistributedPubSub topic. - Commons: new AlarmCommand DTO (AlarmId/Operation/User/Comment/UnshelveAtUtc). - ScriptedAlarmHostActor: add AlarmCommandsTopic const. - OtOpcUaNodeManager: settable AlarmCommandRouter + wire OnAcknowledge/OnConfirm/ OnAddComment/OnShelve/OnTimedUnshelve. Each resolves the principal off ISessionOperationContext.UserIdentity as RoleCarryingUserIdentity, fails closed (BadUserAccessDenied) when the AlarmAck role is absent or no identity, else maps + routes an AlarmCommand and returns Good. OnShelve discriminates OneShotShelve/ TimedShelve/Unshelve from the SDK flags; TimedShelve expiry = UtcNow + ms. No Akka/IActorRef handle — only the Action delegate. T20 de-dup note left; WriteAlarmCondition untouched. - OpcUaServer.Security: OpcUaDataPlaneRoles.AlarmAck shared const (the role was a bare string everywhere; introduced one symbol for the gate + tests). - OtOpcUaSdkServer: SetAlarmCommandRouter pass-through. - Host: boot wiring publishes each command via mediator.Tell(Publish(...)) using a lazy ActorSystem accessor (mirrors DpsScriptLogPublisher). - Tests: 11 new gate + mapping tests (OpcUaServer.Tests 88->99, all green). --- .../OpcUa/AlarmCommand.cs | 37 ++ .../OpcUa/OtOpcUaServerHostedService.cs | 33 ++ src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 4 + .../OtOpcUaNodeManager.cs | 93 +++++ .../OtOpcUaSdkServer.cs | 19 + .../Security/OpcUaDataPlaneRoles.cs | 25 ++ .../ScriptedAlarms/ScriptedAlarmHostActor.cs | 6 + .../AlarmCommandRouterTests.cs | 367 ++++++++++++++++++ 8 files changed, 584 insertions(+) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmCommand.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/OpcUaDataPlaneRoles.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmCommand.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmCommand.cs new file mode 100644 index 00000000..34994e2a --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmCommand.cs @@ -0,0 +1,37 @@ +namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa; + +/// +/// Commons-level command carried from an inbound OPC UA Part 9 alarm method call +/// (Acknowledge / Confirm / Shelve / AddComment …) back to the scripted-alarm engine. The SDK +/// node manager builds one of these in its condition method-handler delegates after the +/// AlarmAck role gate passes, then the host routes it onto the cluster +/// alarm-commands DistributedPubSub topic; T19's engine-side subscriber consumes it and +/// drives the matching Part9StateMachine.Apply* transition. This is a pure DTO — it makes +/// no auth decision and holds no SDK/Akka handle. +/// +/// +/// The alarm's ScriptedAlarmId — equal to the materialised condition node's NodeId identifier +/// (T14 aligned the condition NodeId to the ScriptedAlarmId). The engine keys its domain state by +/// this id. +/// +/// +/// The Part 9 operation, one of: Acknowledge, Confirm, OneShotShelve, +/// TimedShelve, Unshelve, Enable, Disable, AddComment. These map +/// 1:1 onto the engine's Part9StateMachine.Apply* calls on the consuming side (T19). +/// +/// The acting user — the authenticated session identity's display/name. +/// +/// The free-text comment supplied with the call (the OPC UA LocalizedText payload's text), +/// or null when none was provided. +/// +/// +/// For TimedShelve, the absolute UTC instant the shelve auto-expires +/// (DateTime.UtcNow + shelvingTime, where the OPC UA Duration shelvingTime is +/// in milliseconds); null for every other operation. +/// +public sealed record AlarmCommand( + string AlarmId, + string Operation, + string User, + string? Comment, + DateTime? UnshelveAtUtc); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs index 32506e50..d0639780 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs @@ -1,9 +1,12 @@ +using Akka.Actor; +using Akka.Cluster.Tools.PublishSubscribe; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; +using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms; namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa; @@ -24,6 +27,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl private readonly DeferredAddressSpaceSink _deferredSink; private readonly DeferredServiceLevelPublisher _deferredServiceLevel; private readonly IOpcUaUserAuthenticator _userAuthenticator; + private readonly Func _actorSystemAccessor; private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; @@ -37,18 +41,23 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl /// The deferred address space sink that receives the real sink once the server is ready. /// The deferred service level publisher that receives the real publisher once the server is ready. /// The OPC UA user authenticator. + /// Lazy accessor for the running , used to + /// resolve the DistributedPubSub mediator the inbound alarm-command router publishes through. Resolved + /// lazily (mirroring DpsScriptLogPublisher) so construction never races Akka startup. /// The logger factory for creating loggers. public OtOpcUaServerHostedService( IOptions options, DeferredAddressSpaceSink deferredSink, DeferredServiceLevelPublisher deferredServiceLevel, IOpcUaUserAuthenticator userAuthenticator, + Func actorSystemAccessor, ILoggerFactory loggerFactory) { _options = options.Value; _deferredSink = deferredSink; _deferredServiceLevel = deferredServiceLevel; _userAuthenticator = userAuthenticator; + _actorSystemAccessor = actorSystemAccessor; _loggerFactory = loggerFactory; _logger = loggerFactory.CreateLogger(); } @@ -88,6 +97,30 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl _deferredSink.SetSink(new SdkAddressSpaceSink(_server.NodeManager)); + // Wire the reverse-path inbound-alarm-command router: a client Acknowledge/Confirm/Shelve that + // passes the node manager's AlarmAck gate publishes the mapped AlarmCommand onto the cluster + // `alarm-commands` topic (same DistributedPubSub mediator the `alerts`/`script-logs` topics use). + // The Tell is fire-and-forget so the handler — which runs under the SDK's Lock — never blocks. + // The mediator is resolved per-publish via the lazy ActorSystem accessor so a transient cluster + // condition is tolerated and construction never raced Akka startup. + _server.SetAlarmCommandRouter(cmd => + { + try + { + var mediator = DistributedPubSub.Get(_actorSystemAccessor()).Mediator; + mediator.Tell(new Publish(ScriptedAlarmHostActor.AlarmCommandsTopic, cmd)); + } + catch (Exception ex) + { + // The router runs under the SDK Lock on a server thread; a cluster hiccup must not + // escape into the SDK's Call path. Log + drop — the client still gets Good for the + // node-state change; the missed command surfaces as a non-applied engine transition. + _logger.LogWarning(ex, + "OtOpcUaServerHostedService: failed to route inbound alarm command {Operation} for {AlarmId}", + cmd.Operation, cmd.AlarmId); + } + }); + // ServiceLevel publisher needs IServerInternal — only available after Start. if (_server.CurrentInstance is { } serverInternal) { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index f2a5109e..2eb9aa82 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -147,6 +147,10 @@ if (hasDriver) builder.Services.AddValidatedOptions( builder.Configuration, "OpcUa"); + // Lazy ActorSystem accessor so OtOpcUaServerHostedService can resolve the DistributedPubSub + // mediator (for the inbound alarm-command router) without racing Akka startup — same pattern the + // DpsScriptLogPublisher above uses. TryAdd so a fused admin+driver node registers it exactly once. + builder.Services.TryAddSingleton>(sp => () => sp.GetRequiredService()); builder.Services.AddHostedService(); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index de2c51bb..c61295b2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using Opc.Ua; using Opc.Ua.Server; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; @@ -52,6 +53,23 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// Gets the count of real Part 9 nodes currently managed. public int AlarmConditionCount => _alarmConditions.Count; + /// + /// Reverse-path sink for inbound OPC UA Part 9 alarm method calls. When a client invokes a + /// materialised condition's Acknowledge / Confirm / Shelve / AddComment method, the condition's + /// handler (wired in ) gates on the caller's + /// AlarmAck role and, when allowed, builds an and invokes this + /// delegate. The host sets it at boot to a non-blocking mediator.Tell onto the + /// alarm-commands DistributedPubSub topic; T19's engine-side subscriber consumes it. + /// + /// This is the ONLY reverse coupling out of the node manager — by design it is a plain + /// (no Akka / IActorRef / DI handle). The handler + /// delegates run under the manager's Lock; the invoked action MUST be non-blocking + /// (a fire-and-forget Tell) so there is no deadlock. Null (the default) makes every + /// handler a safe no-op — it still gates + returns, just routes nowhere. + /// + /// + public Action? AlarmCommandRouter { get; set; } + /// Look up a materialised Part 9 alarm-condition node by its alarm node id (the /// ScriptedAlarmId), or null if not yet materialised. Exposed for tests + diagnostics. /// The alarm node identifier (== ScriptedAlarmId). @@ -316,6 +334,38 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 alarm.Message.Value = new LocalizedText(displayName); if (alarm.ConditionName is not null) alarm.ConditionName.Value = displayName; + // T18 — inbound Part 9 method handlers. Create() materialised the Acknowledge/Confirm/ + // AddComment/Shelve/Unshelve method nodes and the condition types wired their built-in OnCall + // routing; these delegates are the veto/permission seam the SDK invokes BEFORE applying the + // state change. Each gates on the caller's AlarmAck role (fails closed) and, when allowed, + // routes a mapped AlarmCommand to the engine via AlarmCommandRouter, then returns Good so the + // SDK applies its node state + auto-fires its own event. + // T20: the engine re-projects that same logical transition through WriteAlarmCondition, which + // also fires — the resulting double-emit is de-duped in a later task (T20), NOT here. + alarm.OnAcknowledge = (context, condition, _, comment) => + HandleAlarmCommand(context, condition, "Acknowledge", comment, unshelveAt: null); + alarm.OnConfirm = (context, condition, _, comment) => + HandleAlarmCommand(context, condition, "Confirm", comment, unshelveAt: null); + alarm.OnAddComment = (context, condition, _, comment) => + HandleAlarmCommand(context, condition, "AddComment", comment, unshelveAt: null); + alarm.OnShelve = (context, condition, shelving, oneShot, shelvingTime) => + { + // SDK invocation shapes (verified against the decompiled AlarmConditionState): + // OneShotShelve → (shelving:true, oneShot:true, 0.0) ⇒ OneShotShelve, no expiry + // TimedShelve → (shelving:true, oneShot:false, ms) ⇒ TimedShelve, expiry = UtcNow + ms + // Unshelve → (shelving:false, oneShot:false, 0.0) ⇒ Unshelve, no expiry + // shelvingTime is an OPC UA Duration (milliseconds). + var (operation, unshelveAt) = + !shelving ? ("Unshelve", (DateTime?)null) + : oneShot ? ("OneShotShelve", null) + : ("TimedShelve", DateTime.UtcNow + TimeSpan.FromMilliseconds(shelvingTime)); + return HandleAlarmCommand(context, condition, operation, comment: null, unshelveAt); + }; + // The auto-unshelve timer firing is an unshelve transition driven by the SDK (no client user); + // route it as Unshelve so the engine clears its shelve state. Same AlarmAck gate applies. + alarm.OnTimedUnshelve = (context, condition) => + HandleAlarmCommand(context, condition, "Unshelve", comment: null, unshelveAt: null); + parent.AddChild(alarm); // Promote the equipment folder to an event notifier + register it as a root notifier so @@ -328,6 +378,49 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } } + /// + /// Shared body for every inbound Part 9 alarm method handler (T18). Resolves the calling + /// principal off the SDK , applies the AlarmAck role gate + /// (fails closed: a missing identity or a missing role is denied), and on success builds a + /// mapped and routes it through . + /// + /// The SDK context the handler delegate was invoked with — a + /// ServerSystemContext (an ) carrying the session + /// identity. T17 attached the LDAP roles as a . + /// The condition the method targets; its NodeId identifier is the + /// ScriptedAlarmId (T14 aligned them), which becomes . + /// The Part 9 operation name (e.g. Acknowledge, TimedShelve). + /// The call's comment text, or null when none was supplied. + /// For TimedShelve, the computed UTC expiry; otherwise null. + /// ServiceResult.Good when allowed (the SDK then applies state + auto-fires its + /// event); BadUserAccessDenied when the gate vetoes (no route, no state mutation). + private ServiceResult HandleAlarmCommand( + ISystemContext context, ConditionState condition, string operation, LocalizedText? comment, DateTime? unshelveAt) + { + // Resolve the principal the SAME way the SDK's own GetCurrentUserId does, then narrow to the + // role-carrying identity T17 attached. Anonymous / non-role-carrying identities ⇒ null ⇒ denied. + var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity; + if (identity is null || !identity.Roles.Contains(OpcUaDataPlaneRoles.AlarmAck, StringComparer.OrdinalIgnoreCase)) + { + // Fail closed: no role / no identity ⇒ veto. Returning a bad ServiceResult aborts the SDK's + // state change and surfaces the status to the client; we never route or mutate. + return new ServiceResult(StatusCodes.BadUserAccessDenied); + } + + var cmd = new AlarmCommand( + AlarmId: condition.NodeId.Identifier?.ToString() ?? string.Empty, + Operation: operation, + User: identity.DisplayName ?? string.Empty, + Comment: comment?.Text, + UnshelveAtUtc: unshelveAt); + + // Non-blocking by contract (host wires a fire-and-forget mediator.Tell); safe to call under Lock. + AlarmCommandRouter?.Invoke(cmd); + + // Good ⇒ the SDK applies the node-state change + auto-fires its own condition event. + return ServiceResult.Good; + } + /// Map our domain AlarmType string to the matching SDK condition subtype. Script /// alarms have no OPC limit/setpoint values, so limit-style types fall back to the base /// (see remarks). diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs index aa628562..adce251f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs @@ -1,5 +1,6 @@ using Opc.Ua; using Opc.Ua.Server; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; @@ -17,6 +18,24 @@ public sealed class OtOpcUaSdkServer : StandardServer /// . Null until the SDK has bootstrapped. public OtOpcUaNodeManager? NodeManager => _otOpcUaNodeManager; + /// + /// Wire the reverse-path sink for inbound Part 9 alarm method calls onto the created + /// . The host calls this after start with a non-blocking + /// mediator.Tell that publishes each onto the + /// alarm-commands DistributedPubSub topic. No-op (returns false) when the node + /// manager has not been created yet, so the caller can detect a too-early call. + /// + /// The router invoked by the condition handlers once the AlarmAck + /// gate passes; may be null to clear it. + /// true when the router was set on a live node manager; false when no node + /// manager exists yet. + public bool SetAlarmCommandRouter(Action? router) + { + if (_otOpcUaNodeManager is null) return false; + _otOpcUaNodeManager.AlarmCommandRouter = router; + return true; + } + /// protected override MasterNodeManager CreateMasterNodeManager( IServerInternal server, ApplicationConfiguration configuration) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/OpcUaDataPlaneRoles.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/OpcUaDataPlaneRoles.cs new file mode 100644 index 00000000..0b81841d --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/OpcUaDataPlaneRoles.cs @@ -0,0 +1,25 @@ +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; + +/// +/// Canonical string constants for the OPC UA data-plane roles the LDAP group→role map +/// produces and carries onto the session identity. +/// These are distinct from the control-plane AdminRole enum (Admin UI capabilities) — the +/// two planes share zero runtime code path by design. +/// +/// Across the codebase these data-plane roles (ReadOnly, WriteOperate, +/// WriteTune, WriteConfigure, AlarmAck, …) are used as bare strings +/// (they originate as LDAP group names mapped through RoleMapper). T18 introduced this +/// single shared const for the one role the inbound alarm-method gate reads, so the gate and +/// its tests reference one symbol instead of a re-typed literal. Comparison is case-insensitive +/// (the role set is built with ), so the +/// gate matches with that comparer too. +/// +/// +public static class OpcUaDataPlaneRoles +{ + /// The role that grants OPC UA Part 9 alarm acknowledge / confirm / shelve / comment + /// authority. A session must carry this role for the inbound alarm-condition method handlers to + /// route the command to the engine; absent it, the call is denied with + /// BadUserAccessDenied. + public const string AlarmAck = "AlarmAck"; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs index 6ff420dc..d9872016 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs @@ -58,6 +58,12 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor /// the constant the (retired) ScriptedAlarmActor used so subscribers stay wired. public const string AlertsTopic = "alerts"; + /// The cluster DistributedPubSub topic inbound OPC UA Part 9 alarm method calls + /// (Acknowledge / Confirm / Shelve / AddComment) are routed onto as s. + /// The OPC UA node manager's condition handlers build the command (after the AlarmAck role + /// gate); the host's boot wiring publishes it here; T19's engine-side subscriber consumes it. + public const string AlarmCommandsTopic = "alarm-commands"; + /// Reconcile the loaded alarm set to exactly the enabled subset of : /// builds s (skipping disabled plans), reloads the engine, and /// re-registers mux interest for the union of dependency refs. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs new file mode 100644 index 00000000..60bdfbf6 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs @@ -0,0 +1,367 @@ +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 with AlarmAck maps to the Unshelve operation (the timer fired, the alarm + /// auto-unshelves). + [Fact] + public async Task OnTimedUnshelve_with_AlarmAck_routes_unshelve_operation() + { + 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(); + + var ctx = SessionContext(server, "ivan", OpcUaDataPlaneRoles.AlarmAck); + var result = condition.OnTimedUnshelve!(ctx, condition); + + result.ShouldBe(ServiceResult.Good); + captured.Count.ShouldBe(1); + captured[0].Operation.ShouldBe("Unshelve"); + + 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 */ } + } + } +}