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:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user