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