fix(opcua): exempt OnTimedUnshelve from the client AlarmAck gate (system-initiated)

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.
This commit is contained in:
Joseph Doherty
2026-06-11 06:16:30 -04:00
parent 63289d377c
commit 1784eedd3f
3 changed files with 27 additions and 8 deletions
@@ -18,6 +18,9 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
/// The Part 9 operation, one of: <c>Acknowledge</c>, <c>Confirm</c>, <c>OneShotShelve</c>,
/// <c>TimedShelve</c>, <c>Unshelve</c>, <c>Enable</c>, <c>Disable</c>, <c>AddComment</c>. These map
/// 1:1 onto the engine's <c>Part9StateMachine.Apply*</c> calls on the consuming side (T19).
/// Note: <c>Enable</c> and <c>Disable</c> are part of the vocabulary but are not yet wired at the
/// node-manager seam (T18 wired Acknowledge/Confirm/AddComment/Shelve/TimedUnshelve only);
/// <c>OnEnableDisable</c> delegate wiring is a future task.
/// </param>
/// <param name="User">The acting user — the authenticated session identity's display/name.</param>
/// <param name="Comment">
@@ -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);
@@ -268,10 +268,12 @@ public sealed class AlarmCommandRouterTests : IDisposable
await host.DisposeAsync();
}
/// <summary>OnTimedUnshelve with AlarmAck maps to the Unshelve operation (the timer fired, the alarm
/// auto-unshelves).</summary>
/// <summary>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.</summary>
[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();
}