feat(alarms): wire OnEnableDisable over OPC UA (AlarmAck-gated; native→BadNotSupported) [H4]

This commit is contained in:
Joseph Doherty
2026-06-15 14:24:19 -04:00
parent 226587d817
commit 328bd1b9ee
2 changed files with 122 additions and 0 deletions
@@ -634,6 +634,19 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
return ServiceResult.Good;
};
// H4 — inbound Part 9 Enable/Disable (the condition type's built-in Enable/Disable methods route
// here via this delegate). The engine handles Enable/Disable for SCRIPTED alarms
// (ScriptedAlarmEngine.EnableAsync/DisableAsync, dispatched by ScriptedAlarmHostActor on the
// "Enable"/"Disable" AlarmCommand operations), so a scripted condition routes through the same
// AlarmAck-gated HandleAlarmCommand as the other handlers. NATIVE (driver-fed) conditions have no
// engine enable/disable surface (Phase 3 decision #2) — they short-circuit to BadNotSupported.
alarm.OnEnableDisable = (context, condition, enabling) =>
{
if (_nativeAlarmNodeIds.Contains(alarmNodeId))
return new ServiceResult(StatusCodes.BadNotSupported);
return HandleAlarmCommand(context, condition, enabling ? "Enable" : "Disable", comment: null, unshelveAt: null);
};
parent.AddChild(alarm);
// Promote the equipment folder to an event notifier + register it as a root notifier so
@@ -302,6 +302,115 @@ public sealed class AlarmCommandRouterTests : IDisposable
await host.DisposeAsync();
}
/// <summary>H4 — OnEnableDisable with <c>enabling:false</c> and the <c>AlarmAck</c> role on a SCRIPTED
/// condition returns Good and routes exactly one <see cref="AlarmCommand"/> with Operation == "Disable"
/// and User == the caller's DisplayName.</summary>
[Fact]
public async Task OnEnableDisable_disabling_with_AlarmAck_routes_Disable()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-ed1", parentNodeId: null, displayName: "Equipment ED1");
nm.MaterialiseAlarmCondition("alm-ed1", "eq-ed1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false);
var condition = nm.TryGetAlarmCondition("alm-ed1");
condition.ShouldNotBeNull();
condition!.OnEnableDisable.ShouldNotBeNull();
var ctx = SessionContext(server, "ivan", OpcUaDataPlaneRoles.AlarmAck);
// SDK Disable invokes OnEnableDisable(ctx, condition, enabling:false).
var result = condition.OnEnableDisable!(ctx, condition, enabling: false);
result.ShouldBe(ServiceResult.Good);
captured.Count.ShouldBe(1);
captured[0].AlarmId.ShouldBe("alm-ed1");
captured[0].Operation.ShouldBe("Disable");
captured[0].User.ShouldBe("ivan");
captured[0].UnshelveAtUtc.ShouldBeNull();
await host.DisposeAsync();
}
/// <summary>H4 — OnEnableDisable with <c>enabling:true</c> maps to the Enable operation.</summary>
[Fact]
public async Task OnEnableDisable_enabling_routes_Enable()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-ed2", parentNodeId: null, displayName: "Equipment ED2");
nm.MaterialiseAlarmCondition("alm-ed2", "eq-ed2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false);
var condition = nm.TryGetAlarmCondition("alm-ed2");
condition.ShouldNotBeNull();
condition!.OnEnableDisable.ShouldNotBeNull();
var ctx = SessionContext(server, "jane", OpcUaDataPlaneRoles.AlarmAck);
// SDK Enable invokes OnEnableDisable(ctx, condition, enabling:true).
var result = condition.OnEnableDisable!(ctx, condition, enabling: true);
result.ShouldBe(ServiceResult.Good);
captured.Count.ShouldBe(1);
captured[0].Operation.ShouldBe("Enable");
captured[0].User.ShouldBe("jane");
await host.DisposeAsync();
}
/// <summary>H4 — OnEnableDisable from an anonymous / role-less identity is vetoed
/// (BadUserAccessDenied) and the router is NOT invoked — the gate fails closed.</summary>
[Fact]
public async Task OnEnableDisable_anonymous_is_denied()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-ed3", parentNodeId: null, displayName: "Equipment ED3");
nm.MaterialiseAlarmCondition("alm-ed3", "eq-ed3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false);
var condition = nm.TryGetAlarmCondition("alm-ed3");
condition.ShouldNotBeNull();
// ServerSystemContext with no UserIdentity ⇒ anonymous / no role-carrying identity.
var ctx = new ServerSystemContext(server.CurrentInstance);
var result = condition!.OnEnableDisable!(ctx, condition, enabling: false);
result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
captured.ShouldBeEmpty();
await host.DisposeAsync();
}
/// <summary>H4 — OnEnableDisable on a NATIVE (driver-fed) condition returns BadNotSupported and does
/// NOT route — native conditions have no engine enable/disable surface (Phase 3 decision #2). The gate
/// short-circuits on the native flag before the role check, so even an AlarmAck caller gets BadNotSupported.</summary>
[Fact]
public async Task OnEnableDisable_on_native_condition_is_BadNotSupported()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-ed4", parentNodeId: null, displayName: "Equipment ED4");
nm.MaterialiseAlarmCondition("alm-ed4", "eq-ed4", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true);
var condition = nm.TryGetAlarmCondition("alm-ed4");
condition.ShouldNotBeNull();
condition!.OnEnableDisable.ShouldNotBeNull();
var ctx = SessionContext(server, "kara", OpcUaDataPlaneRoles.AlarmAck);
var result = condition.OnEnableDisable!(ctx, condition, enabling: false);
result.StatusCode.Code.ShouldBe(StatusCodes.BadNotSupported);
captured.ShouldBeEmpty();
await host.DisposeAsync();
}
/// <summary>A null router is a safe no-op: handler still gates + returns Good, just routes nowhere.</summary>
[Fact]
public async Task OnAcknowledge_with_null_router_is_safe_noop_and_returns_good()