feat(alarms): wire OnEnableDisable over OPC UA (AlarmAck-gated; native→BadNotSupported) [H4]
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user