From 328bd1b9ee18e5f98e25140ba97ef06f5fdaf191 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 15 Jun 2026 14:24:19 -0400 Subject: [PATCH] =?UTF-8?q?feat(alarms):=20wire=20OnEnableDisable=20over?= =?UTF-8?q?=20OPC=20UA=20(AlarmAck-gated;=20native=E2=86=92BadNotSupported?= =?UTF-8?q?)=20[H4]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OtOpcUaNodeManager.cs | 13 +++ .../AlarmCommandRouterTests.cs | 109 ++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 3327bf40..d10bafd7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -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 diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs index 395cdc04..047f17a8 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs @@ -302,6 +302,115 @@ public sealed class AlarmCommandRouterTests : IDisposable await host.DisposeAsync(); } + /// H4 — OnEnableDisable with enabling:false and the AlarmAck role on a SCRIPTED + /// condition returns Good and routes exactly one with Operation == "Disable" + /// and User == the caller's DisplayName. + [Fact] + public async Task OnEnableDisable_disabling_with_AlarmAck_routes_Disable() + { + var (host, server) = await BootAsync(); + var nm = server.NodeManager!; + var captured = new List(); + 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(); + } + + /// H4 — OnEnableDisable with enabling:true maps to the Enable operation. + [Fact] + public async Task OnEnableDisable_enabling_routes_Enable() + { + var (host, server) = await BootAsync(); + var nm = server.NodeManager!; + var captured = new List(); + 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(); + } + + /// H4 — OnEnableDisable from an anonymous / role-less identity is vetoed + /// (BadUserAccessDenied) and the router is NOT invoked — the gate fails closed. + [Fact] + public async Task OnEnableDisable_anonymous_is_denied() + { + var (host, server) = await BootAsync(); + var nm = server.NodeManager!; + var captured = new List(); + 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(); + } + + /// 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. + [Fact] + public async Task OnEnableDisable_on_native_condition_is_BadNotSupported() + { + var (host, server) = await BootAsync(); + var nm = server.NodeManager!; + var captured = new List(); + 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(); + } + /// A null router is a safe no-op: handler still gates + returns Good, just routes nowhere. [Fact] public async Task OnAcknowledge_with_null_router_is_safe_noop_and_returns_good()