From 1784eedd3f7bd5428e243647ba9d09fe460a8280 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 11 Jun 2026 06:16:30 -0400 Subject: [PATCH] fix(opcua): exempt OnTimedUnshelve from the client AlarmAck gate (system-initiated) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK fires OnTimedUnshelve with the node manager's system context (no session, no user identity) when a TimedShelve duration expires. Routing through the shared HandleAlarmCommand hit the AlarmAck gate and returned BadUserAccessDenied, leaving the alarm permanently shelved. Replace the delegated HandleAlarmCommand call with an inline lambda that bypasses the client gate, extracts the AlarmId the same way, and routes an Unshelve command so the engine clears its shelve state. The manual-client Unshelve path via OnShelve(shelving:false) remains gated. Update the AlarmCommandRouterTests OnTimedUnshelve test to use a real system context (no UserIdentity) — reproducing the actual SDK invocation path — and assert Good, AlarmId, Operation==Unshelve, User==empty. Add a doc note to AlarmCommand.Operation that Enable/Disable are in the vocabulary but not yet wired at the node-manager seam. --- .../OpcUa/AlarmCommand.cs | 3 +++ .../OtOpcUaNodeManager.cs | 14 +++++++++++--- .../AlarmCommandRouterTests.cs | 18 +++++++++++++----- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmCommand.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmCommand.cs index 34994e2a..7def14ed 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmCommand.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmCommand.cs @@ -18,6 +18,9 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa; /// 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). +/// Note: Enable and Disable are part of the vocabulary but are not yet wired at the +/// node-manager seam (T18 wired Acknowledge/Confirm/AddComment/Shelve/TimedUnshelve only); +/// OnEnableDisable delegate wiring is a future task. /// /// The acting user — the authenticated session identity's display/name. /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index c61295b2..9261dd2b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -361,10 +361,18 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 : ("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. + // The auto-unshelve timer callback is SDK-initiated (the TimedShelve duration expired); the SDK + // fires it with the node manager's system context — there is NO session and NO user identity. + // Routing through HandleAlarmCommand would hit the AlarmAck gate and return BadUserAccessDenied, + // leaving the alarm permanently shelved. Instead, bypass the client gate, extract the AlarmId the + // same way HandleAlarmCommand does, and route an Unshelve command so the engine clears its shelve + // state. The manual-client Unshelve path goes through OnShelve(shelving:false) and stays gated. alarm.OnTimedUnshelve = (context, condition) => - HandleAlarmCommand(context, condition, "Unshelve", comment: null, unshelveAt: null); + { + var alarmId = condition.NodeId.Identifier?.ToString() ?? string.Empty; + AlarmCommandRouter?.Invoke(new AlarmCommand(alarmId, "Unshelve", string.Empty, null, null)); + return ServiceResult.Good; + }; parent.AddChild(alarm); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs index 60bdfbf6..1594c2c1 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs @@ -268,10 +268,12 @@ public sealed class AlarmCommandRouterTests : IDisposable await host.DisposeAsync(); } - /// OnTimedUnshelve with AlarmAck maps to the Unshelve operation (the timer fired, the alarm - /// auto-unshelves). + /// 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_AlarmAck_routes_unshelve_operation() + public async Task OnTimedUnshelve_with_system_context_returns_good_and_routes_unshelve() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; @@ -284,12 +286,18 @@ public sealed class AlarmCommandRouterTests : IDisposable condition.ShouldNotBeNull(); condition!.OnTimedUnshelve.ShouldNotBeNull(); - var ctx = SessionContext(server, "ivan", OpcUaDataPlaneRoles.AlarmAck); + // 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); - captured[0].Operation.ShouldBe("Unshelve"); + 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(); }