From c5915700bd5bb9444e0119e64709694b0b8e33c9 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 18 May 2026 09:31:30 -0400 Subject: [PATCH] feat(server): route OPC UA Part 9 shelve methods to ScriptedAlarmEngine (#24) OneShotShelve / TimedShelve / Unshelve now reach the ScriptedAlarmEngine. Scripted-alarm condition nodes get a ShelvedStateMachine subtree created before alarm.Create so the stack wires each shelve method's dispatch handler; AlarmConditionState.OnShelve / OnTimedUnshelve route to the engine and mirror the result onto the OPC UA node via SetShelvingState. The three per-instance shelve method NodeIds are indexed so the Call gate resolves them to OpcUaOperation.AlarmShelve instead of falling through to generic Call. Engine dispatch is split into the node-free InvokeEngineShelve so the routing decision is unit-testable. Adds 9 unit tests; updates phase-7-status.md Gap 1 (only AddComment remains unwired) and the #24 entry in looseends.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/v2/phase-7-status.md | 4 +- looseends.md | 50 ++++ .../OpcUa/DriverNodeManager.cs | 228 ++++++++++++++++-- .../CallGatingTests.cs | 63 +++++ .../DeferredGateHardeningTests.cs | 39 +-- .../Phase7/ScriptedAlarmMethodRoutingTests.cs | 70 ++++++ 6 files changed, 421 insertions(+), 33 deletions(-) create mode 100644 looseends.md diff --git a/docs/v2/phase-7-status.md b/docs/v2/phase-7-status.md index 9a05547..e5e7a46 100644 --- a/docs/v2/phase-7-status.md +++ b/docs/v2/phase-7-status.md @@ -138,9 +138,9 @@ All three are verified closed in the 2026-04-23 exit-gate audit: These are real open items, not issues with the plan reconciliation. -### Gap 1 — OPC UA method-call dispatch for scripted alarm Ack/Confirm/Shelve (Stream G / C.6) +### Gap 1 — OPC UA method-call dispatch for scripted alarm AddComment (Stream G / C.6) -`DriverNodeManager.MethodCall` does not route OPC UA `Acknowledge` / `Confirm` / `OneShotShelve` / `TimedShelve` / `Unshelve` / `AddComment` method invocations to the `ScriptedAlarmEngine`. Operators can acknowledge scripted alarms through the Admin UI today; OPC UA HMI clients expecting to use Part 9 method nodes directly cannot. Explicit in `phase-7-e2e-smoke.md` §"Known limitations". +`Acknowledge` / `Confirm` route to the `ScriptedAlarmEngine` via `DriverNodeManager.RouteScriptedAlarmMethodCalls` (task #24). `OneShotShelve` / `TimedShelve` / `Unshelve` route via the native `AlarmConditionState.OnShelve` / `OnTimedUnshelve` hooks wired in `MarkAsAlarmCondition` (task #24 follow-up); the per-instance shelve method NodeIds are indexed so the Call gate resolves them to `OpcUaOperation.AlarmShelve`. Only `AddComment` is still not wired to the OPC UA method path — the engine has `AddCommentAsync` but no Part 9 `AddComment` method node is dispatched to it. ### Gap 2 — Admin UI: no `/virtual-tags` tab or form (Stream F.2) diff --git a/looseends.md b/looseends.md new file mode 100644 index 0000000..7cc05ad --- /dev/null +++ b/looseends.md @@ -0,0 +1,50 @@ +# Loose ends + +State as of 2026-05-18, after the #9–#29 task-list run. Everything on the +formal task list is shipped except #20; the items below are what genuinely +remains, plus follow-ups surfaced during the run. + +## Open task + +- **#20 — D.1 dev-rig rollout smoke.** A full 3-service deployment + (gateway + worker + server + Wonderware historian sidecar): deploy the + refreshed binaries, run `scripts/install/Refresh-Services.ps1`, exercise + alarms end-to-end, and capture the rollout artifact. The code blockers + were cleared by #18; the act itself needs the physical AVEVA dev rig and + cannot be produced from a dev box. Runbook context in + `docs/plans/alarms-worker-wiring-plan.md`. + +## Follow-ups surfaced during the run + +- **C.1 live SDK binding.** `SdkAlarmHistorianWriteBackend.WriteBatchAsync` + (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/`) is + still a placeholder returning `RetryPlease` for every event, so queued + alarm events are retained rather than written. Pinning the real + `aahClientManaged` alarm-write entry point is rig-gated — pairs with #20. + +- **~~#24 Shelve-method routing.~~** DONE. Acknowledge / Confirm already + routed; OneShotShelve / TimedShelve / Unshelve now route via the native + `AlarmConditionState.OnShelve` / `OnTimedUnshelve` hooks wired in + `DriverNodeManager.MarkAsAlarmCondition` (scripted alarms get a shelvable + `ShelvedStateMachine` subtree created before `alarm.Create`). The three + per-instance shelve method NodeIds are indexed so the Call gate resolves + them to `OpcUaOperation.AlarmShelve`. Remaining: address-space + materialisation of the shelve method nodes is best confirmed by a live + OPC UA browse (pairs with the G6 / D.1 rig steps). `AddComment` is still + not wired to an OPC UA method node — see `phase-7-status.md` Gap 1. + +- **mxaccessgw alarm epic branch.** The alarm subsystem work (A.2/A.3/A.4 + + the two production-gap fixes from #18) lives on the mxaccessgw branch + `docs/alarm-client-wm-app-finding`. It is NOT merged to mxaccessgw's main. + Whether/when to merge the alarm epic to main is an open release decision. + +- **#15 operator/lab GA gates.** Two v2 GA gates are manual lab steps, not + automatable here: the OPC UA CTT (Compliance Test Tool) pass and the + deployment-checklist signoff. Documented in + `docs/plans/v2-ga-lab-gates-plan.md`. + +## Done — for reference + +The 5 Phase 7 gaps discovered mid-run (#24–#28) were all completed and +merged; no Phase 7 gaps remain open. Add any new follow-ups above as they +are spun out. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs index ec01dff..b3e5c86 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs @@ -125,6 +125,16 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder private readonly Dictionary _scriptedAlarmIdByConditionNodeId = new(StringComparer.OrdinalIgnoreCase); + // Task #24 follow-up — NodeIds of the OneShotShelve / TimedShelve / Unshelve method + // nodes created on scripted-alarm ShelvedStateMachine subtrees. Those methods carry + // per-instance NodeIds (not well-known type MethodIds), so the Call gate can't + // constant-match them; it consults this set instead to map a shelve invocation to + // OpcUaOperation.AlarmShelve. Routing itself is handled by the native + // AlarmConditionState.OnShelve hook wired in MarkAsAlarmCondition — no Call-override + // interception is needed because the stack dispatches the method to that delegate. + // Populated during the address-space build; read-only once clients are served. + private readonly HashSet _scriptedAlarmShelveMethodNodeIds = new(); + public DriverNodeManager(IServerInternal server, ApplicationConfiguration configuration, IDriver driver, CapabilityInvoker invoker, ILogger logger, AuthorizationGate? authzGate = null, NodeScopeResolver? scopeResolver = null, @@ -621,7 +631,8 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder IList results, IList errors) { - GateCallMethodRequests(methodsToCall, errors, context.UserIdentity, _authzGate, _scopeResolver); + GateCallMethodRequests(methodsToCall, errors, context.UserIdentity, _authzGate, _scopeResolver, + _scriptedAlarmShelveMethodNodeIds); // Task #24 — Phase 7 Gap 1: route Part 9 Acknowledge / Confirm calls that target // scripted alarm condition nodes directly to the ScriptedAlarmEngine. The engine @@ -674,8 +685,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder ScriptedAlarmEngine engine, IReadOnlyDictionary conditionIdToAlarmId) { - var user = userIdentity?.DisplayName; - if (string.IsNullOrWhiteSpace(user)) user = "opcua-client"; + var user = ResolveCallUser(userIdentity); for (var i = 0; i < methodsToCall.Count; i++) { @@ -732,6 +742,114 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder } } + /// + /// Resolves the audit identity for an OPC UA method call. Authenticated LDAP + /// sessions populate during + /// OtOpcUaServer.OnImpersonateUser; anonymous sessions fall back to + /// "opcua-client" so every audit entry carries an identity. + /// + internal static string ResolveCallUser(IUserIdentity? userIdentity) + { + var user = userIdentity?.DisplayName; + return string.IsNullOrWhiteSpace(user) ? "opcua-client" : user; + } + + /// + /// Task #24 follow-up — native AlarmConditionState.OnShelve handler for a + /// scripted alarm. The OPC UA stack dispatches OneShotShelve / TimedShelve / + /// Unshelve method calls here after validating the Part 9 state transition. The + /// handler advances the with the authenticated + /// principal, then mirrors the new shelving state onto the OPC UA node via + /// SetShelvingState. A failed engine call returns a Bad status so the stack + /// leaves the node's ShelvedStateMachine unchanged. + /// + internal static ServiceResult RouteScriptedAlarmShelve( + ISystemContext context, + OpcAlarmConditionState alarm, + bool shelving, + bool oneShot, + double shelvingTime, + ScriptedAlarmEngine engine, + string alarmId, + ILogger? logger) + { + var user = ResolveCallUser(context?.UserIdentity); + var engineResult = InvokeEngineShelve(engine, alarmId, user, shelving, oneShot, shelvingTime, logger); + if (ServiceResult.IsBad(engineResult)) return engineResult; + + // Mirror the engine's new state onto the OPC UA ShelvedStateMachine. The stack + // expects the OnShelve handler to advance the node — it does not do so itself. + alarm?.SetShelvingState(context, shelving, oneShot, shelvingTime); + return ServiceResult.Good; + } + + /// + /// Task #24 follow-up — native AlarmConditionState.OnTimedUnshelve handler: + /// the stack's timed-shelve countdown has expired, so unshelve the alarm in the + /// engine and mirror the Unshelved state onto the OPC UA node. + /// + internal static ServiceResult RouteScriptedAlarmTimedUnshelve( + ISystemContext context, + OpcAlarmConditionState alarm, + ScriptedAlarmEngine engine, + string alarmId, + ILogger? logger) + { + // The expiry is a server-side timer, not an operator action — attribute the + // audit entry to the subsystem rather than a user principal. + var engineResult = InvokeEngineShelve( + engine, alarmId, "timed-unshelve", shelving: false, oneShot: false, shelvingTime: 0, logger); + if (ServiceResult.IsBad(engineResult)) return engineResult; + + alarm?.SetShelvingState(context, false, false, 0); + return ServiceResult.Good; + } + + /// + /// Dispatches a shelve transition to the . Extracted + /// as a pure function (no OPC UA node dependency) so the engine-routing decision — + /// including the -shaped status mapping — is unit-testable. + /// / follow the OPC UA + /// OnShelve contract: (false, *) = Unshelve, (true, true) = + /// OneShotShelve, (true, false) = TimedShelve for + /// milliseconds. + /// + internal static ServiceResult InvokeEngineShelve( + ScriptedAlarmEngine engine, + string alarmId, + string user, + bool shelving, + bool oneShot, + double shelvingTime, + ILogger? logger) + { + try + { + if (!shelving) + engine.UnshelveAsync(alarmId, user, CancellationToken.None).GetAwaiter().GetResult(); + else if (oneShot) + engine.OneShotShelveAsync(alarmId, user, CancellationToken.None).GetAwaiter().GetResult(); + else + engine.TimedShelveAsync( + alarmId, user, DateTime.UtcNow.AddMilliseconds(shelvingTime), CancellationToken.None) + .GetAwaiter().GetResult(); + return ServiceResult.Good; + } + catch (ArgumentException ex) + { + // Unknown alarmId or an invalid Part 9 transition — surface as BadInvalidArgument + // so the OPC UA client sees a meaningful status. + logger?.LogInformation( + "Scripted-alarm shelve rejected for {AlarmId}: {Message}", alarmId, ex.Message); + return new ServiceResult(StatusCodes.BadInvalidArgument, ex.Message, ex.Message); + } + catch (Exception ex) + { + logger?.LogError(ex, "Scripted-alarm shelve failed for {AlarmId}", alarmId); + return new ServiceResult(StatusCodes.BadInternalError, ex.Message, ex.Message); + } + } + /// /// Pure-function gate for a batch of . Pre-populates /// slots with @@ -742,7 +860,8 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder IList errors, IUserIdentity? userIdentity, AuthorizationGate? gate, - NodeScopeResolver? scopeResolver) + NodeScopeResolver? scopeResolver, + IReadOnlySet? shelveMethodIds = null) { if (gate is null || scopeResolver is null) return; if (methodsToCall.Count == 0) return; @@ -755,7 +874,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder if (request.ObjectId.Identifier is not string fullRef) continue; var scope = scopeResolver.Resolve(fullRef); - var operation = MapCallOperation(request.MethodId); + var operation = MapCallOperation(request.MethodId, shelveMethodIds); if (!gate.IsAllowed(userIdentity, operation, scope)) errors[i] = new ServiceResult(StatusCodes.BadUserAccessDenied); } @@ -767,20 +886,28 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder /// operator-UI grants can distinguish acknowledge/confirm/shelve; everything else /// falls through to generic . /// - internal static OpcUaOperation MapCallOperation(NodeId methodId) + /// The of the method being invoked. + /// + /// The set of per-instance OneShotShelve / TimedShelve / Unshelve method NodeIds + /// indexed during the address-space build (see + /// _scriptedAlarmShelveMethodNodeIds). Shelve methods carry per-instance + /// NodeIds rather than well-known type NodeIds, so they can't be constant-matched + /// like Acknowledge / Confirm; a membership test against this set is how they + /// resolve to . When null (no + /// scripted alarms) shelve methods fall through to . + /// + internal static OpcUaOperation MapCallOperation(NodeId methodId, IReadOnlySet? shelveMethodIds = null) { // Standard Part 9 method ids on AcknowledgeableConditionType. The stack models these - // as ns=0 numeric ids; comparisons are value-based. Shelve is dispatched on the - // ShelvedStateMachine instance's methods — those arrive with per-instance NodeIds - // rather than well-known type NodeIds, so we can't reliably constant-match them - // here. Shelve falls through to OpcUaOperation.Call; the caller can still set a - // permissive Call grant for operators who are allowed to shelve alarms, and - // finer-grained AlarmShelve gating is a follow-up when the method-invocation path - // also carries a "method-role" annotation. + // as ns=0 numeric ids; comparisons are value-based. if (methodId == MethodIds.AcknowledgeableConditionType_Acknowledge) return OpcUaOperation.AlarmAcknowledge; if (methodId == MethodIds.AcknowledgeableConditionType_Confirm) return OpcUaOperation.AlarmConfirm; + // Shelve methods live on each alarm's own ShelvedStateMachine subtree, so they're + // matched by NodeId membership rather than a constant comparison. + if (methodId is not null && shelveMethodIds is not null && shelveMethodIds.Contains(methodId)) + return OpcUaOperation.AlarmShelve; return OpcUaOperation.Call; } @@ -910,6 +1037,31 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder BrowseName = new QualifiedName(_variable.BrowseName.Name + "_Condition", _owner.NamespaceIndex), DisplayName = new LocalizedText(info.SourceName), }; + + // Task #24 follow-up — scripted alarms expose a shelvable ShelvingState + // subtree so OPC UA Part 9 OneShotShelve / TimedShelve / Unshelve method + // calls have method nodes to target. The optional ShelvingState is NOT + // created by AlarmConditionState.Create; it must be attached *before* + // Create so the stack's AlarmConditionState.OnAfterCreate wires each shelve + // method's OnCallMethod handler to the ShelvedStateMachine. Non-scripted + // alarms (Galaxy etc.) have no engine to route to, so they stay unshelvable. + var isScriptedAlarm = + _owner._scriptedAlarmEngine is not null + && _owner._sourceByFullRef.TryGetValue(FullReference, out var conditionVarSource) + && conditionVarSource == NodeSourceKind.ScriptedAlarm; + if (isScriptedAlarm) + { + alarm.ShelvingState = new ShelvedStateMachineState(alarm); + alarm.ShelvingState.Create( + _owner.SystemContext, null, + new QualifiedName(BrowseNames.ShelvingState), + new LocalizedText(BrowseNames.ShelvingState), false); + // UnshelveTime carries the timed-shelve countdown; it is optional and + // not materialised by ShelvedStateMachineState.Create — create it so + // the stack's timed-unshelve timer has a node to write. + alarm.ShelvingState.UnshelveTime ??= new PropertyState(alarm.ShelvingState); + } + // assignNodeIds=true makes the stack allocate NodeIds for every inherited // AlarmConditionState child (Severity / Message / ActiveState / AckedState / // EnabledState / …). Without this the children keep Foundation (ns=0) type- @@ -955,13 +1107,16 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder // The condition's string identifier is "{FullReference}.Condition"; the engine // addresses alarms by ScriptedAlarmId (= FullReference for scripted alarms, // because EquipmentNodeWalker sets FullName = ScriptedAlarmId on the attr). - if (_owner._scriptedAlarmEngine is not null - && _owner._sourceByFullRef.TryGetValue(FullReference, out var varSource) - && varSource == NodeSourceKind.ScriptedAlarm) + if (isScriptedAlarm) { var conditionKey = alarm.NodeId.Identifier?.ToString(); if (!string.IsNullOrEmpty(conditionKey)) _owner._scriptedAlarmIdByConditionNodeId[conditionKey!] = FullReference; + + // Task #24 follow-up — wire the shelve methods created above to the + // engine and index their NodeIds for the Call gate. + if (alarm.ShelvingState is not null) + WireScriptedAlarmShelving(alarm, FullReference); } // PR 2.3 — when the server-level alarm-condition service is wired, register @@ -1024,6 +1179,47 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder AssignSymbolicDescendantIds(child, child.NodeId, namespaceIndex); } } + + /// + /// Task #24 follow-up — connects a scripted alarm's ShelvingState subtree to + /// the . The stack dispatches OneShotShelve / + /// TimedShelve / Unshelve method calls to the OnShelve delegate and the + /// expiry of a timed shelve to OnTimedUnshelve; both routes advance the + /// engine state machine and mirror the result onto the OPC UA node. The three + /// shelve method NodeIds are indexed so the Call gate can resolve them to + /// . + /// + private void WireScriptedAlarmShelving(OpcAlarmConditionState alarm, string alarmId) + { + var shelving = alarm.ShelvingState!; + var engine = _owner._scriptedAlarmEngine!; + var logger = _owner._logger; + + // How often the timed-unshelve countdown ticks toward expiry (milliseconds). + alarm.UnshelveTimeUpdateRate = 1000; + + alarm.OnShelve = (context, a, isShelving, oneShot, shelvingTime) => + RouteScriptedAlarmShelve(context, a, isShelving, oneShot, shelvingTime, engine, alarmId, logger); + alarm.OnTimedUnshelve = (context, a) => + RouteScriptedAlarmTimedUnshelve(context, a, engine, alarmId, logger); + + CollectShelveMethodNodeIds(shelving, _owner._scriptedAlarmShelveMethodNodeIds); + } + + /// + /// Adds the NodeIds of the ShelvedStateMachine's method children + /// (OneShotShelve / TimedShelve / Unshelve) to . + /// has already given each a stable + /// NodeId in the node manager's namespace by the time this runs. + /// + private static void CollectShelveMethodNodeIds(ShelvedStateMachineState shelving, HashSet sink) + { + var children = new List(); + shelving.GetChildren(null!, children); + foreach (var child in children) + if (child is MethodState method && !NodeId.IsNull(method.NodeId)) + sink.Add(method.NodeId); + } } private sealed class ConditionSink(DriverNodeManager owner, OpcAlarmConditionState alarm) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/CallGatingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/CallGatingTests.cs index 2b9ae9b..d142a98 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/CallGatingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/CallGatingTests.cs @@ -41,6 +41,69 @@ public sealed class CallGatingTests .ShouldBe(OpcUaOperation.Call); } + [Fact] + public void MapCallOperation_shelve_method_in_index_maps_to_AlarmShelve() + { + // Shelve methods carry per-instance NodeIds; membership in the indexed set + // (built during the address-space build) is how they resolve to AlarmShelve. + var shelveMethodId = new NodeId("al-1.Condition.ShelvingState.OneShotShelve", 2); + var index = new HashSet { shelveMethodId }; + + DriverNodeManager.MapCallOperation(shelveMethodId, index) + .ShouldBe(OpcUaOperation.AlarmShelve); + } + + [Fact] + public void MapCallOperation_shelve_method_not_in_index_falls_through_to_Call() + { + // A shelve-shaped NodeId that wasn't indexed (e.g. no scripted alarms) is + // indistinguishable from a generic method node and gates as Call. + var shelveMethodId = new NodeId("al-1.Condition.ShelvingState.OneShotShelve", 2); + + DriverNodeManager.MapCallOperation(shelveMethodId, new HashSet()) + .ShouldBe(OpcUaOperation.Call); + DriverNodeManager.MapCallOperation(shelveMethodId, shelveMethodIds: null) + .ShouldBe(OpcUaOperation.Call); + } + + [Fact] + public void Denied_shelve_call_gets_BadUserAccessDenied() + { + var shelveMethodId = new NodeId("c1/area/line/eq/alarm1.Condition.ShelvingState.OneShotShelve", 2); + var calls = new List + { + NewCall("c1/area/line/eq/alarm1", shelveMethodId), + }; + var errors = new List { (ServiceResult)null! }; + // Operator has AlarmAcknowledge but NOT AlarmShelve — shelve must be denied. + var gate = MakeGate(strict: true, rows: [Row("grp-ops", NodePermissions.AlarmAcknowledge)]); + + DriverNodeManager.GateCallMethodRequests( + calls, errors, NewIdentity("alice", "grp-ops"), gate, new NodeScopeResolver("c1"), + shelveMethodIds: new HashSet { shelveMethodId }); + + ServiceResult.IsBad(errors[0]).ShouldBeTrue(); + errors[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadUserAccessDenied); + } + + [Fact] + public void Allowed_shelve_call_passes_through() + { + var shelveMethodId = new NodeId("c1/area/line/eq/alarm1.Condition.ShelvingState.OneShotShelve", 2); + var calls = new List + { + NewCall("c1/area/line/eq/alarm1", shelveMethodId), + }; + var errors = new List { (ServiceResult)null! }; + var gate = MakeGate(strict: true, rows: [Row("grp-eng", NodePermissions.AlarmShelve)]); + + DriverNodeManager.GateCallMethodRequests( + calls, errors, NewIdentity("alice", "grp-eng"), gate, new NodeScopeResolver("c1"), + shelveMethodIds: new HashSet { shelveMethodId }); + + errors[0].ShouldBeNull("AlarmShelve grant allows the shelve call"); + } + [Fact] public void Gate_null_leaves_errors_untouched() { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/DeferredGateHardeningTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/DeferredGateHardeningTests.cs index a98fc3b..35cccf5 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/DeferredGateHardeningTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/DeferredGateHardeningTests.cs @@ -21,8 +21,8 @@ namespace ZB.MOM.WW.OtOpcUa.Server.Tests; /// Lax-mode fall-through for all four deferred gates /// Permission-bit isolation — Subscribe-only grant denies Read; HistoryRead-only /// grant denies Read (Phase 6.2 compliance item "HistoryRead uses its own flag") -/// AlarmShelve intentional fall-through to Call (documents the ShelvedStateMachine -/// per-instance NodeId limitation noted in the MapCallOperation implementation) +/// AlarmShelve resolves via the indexed shelve-method NodeId set (Task #24 +/// follow-up); an unindexed shelve-shaped NodeId still falls through to Call /// Complete OpcUaOperation → NodePermissions mapping coverage for deferred ops /// /// @@ -203,29 +203,38 @@ public sealed class DeferredGateHardeningTests } // ====================================================================== - // 5. AlarmShelve falls through to Call in MapCallOperation - // Documents the ShelvedStateMachine per-instance NodeId limitation. + // 5. AlarmShelve resolution in MapCallOperation (Task #24 follow-up) + // Shelve methods carry per-instance NodeIds, so they resolve to AlarmShelve + // via membership in the indexed shelve-method set rather than a constant match. // ====================================================================== [Fact] - public void MapCallOperation_AlarmShelve_falls_through_to_Call() + public void MapCallOperation_indexed_shelve_method_maps_to_AlarmShelve() { - // AlarmShelve methods on ShelvedStateMachine arrive with per-instance NodeIds - // (not well-known type NodeIds), so they can't be reliably constant-matched. - // MapCallOperation returns OpcUaOperation.Call for any unrecognised method NodeId; - // operators who can Shelve must therefore have NodePermissions.MethodCall granted. - // (This is an intentional design decision documented in the MapCallOperation - // implementation remarks — finer-grained AlarmShelve gating is deferred until - // the method-invocation path also carries a "method-role" annotation.) + // The address-space build indexes each scripted alarm's three ShelvedStateMachine + // method NodeIds. A call whose MethodId is in that set gates as AlarmShelve, so + // operators can be granted shelve rights independently of generic MethodCall. + var shelveMethodId = new NodeId("al-1.Condition.ShelvingState.OneShotShelve", 2); + var index = new HashSet { shelveMethodId }; + + DriverNodeManager.MapCallOperation(shelveMethodId, index).ShouldBe(OpcUaOperation.AlarmShelve); + } + + [Fact] + public void MapCallOperation_unindexed_shelve_method_falls_through_to_Call() + { + // Without the index (e.g. a deployment with no scripted alarms) a shelve-shaped + // NodeId is indistinguishable from a generic driver method and gates as Call. var shelveMethodId = new NodeId("ShelvedStateMachine.OneShotShelve", namespaceIndex: 0); DriverNodeManager.MapCallOperation(shelveMethodId).ShouldBe(OpcUaOperation.Call); } [Fact] - public void MethodCall_grant_allows_generic_Call_including_shelve_path() + public void MethodCall_grant_allows_generic_Call() { - // Users with MethodCall permission can invoke shelve methods because the gate - // maps AlarmShelve back to Call (see MapCallOperation_AlarmShelve_falls_through_to_Call). + // Users with MethodCall permission can invoke generic (non-alarm) driver methods. + // Shelve methods now gate as AlarmShelve when indexed (see + // MapCallOperation_indexed_shelve_method_maps_to_AlarmShelve). var gate = MakeGate(strict: true, rows: [ Row("grp-eng", NodePermissions.MethodCall), diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/Phase7/ScriptedAlarmMethodRoutingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/Phase7/ScriptedAlarmMethodRoutingTests.cs index ddcf95c..40f46a0 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/Phase7/ScriptedAlarmMethodRoutingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/Phase7/ScriptedAlarmMethodRoutingTests.cs @@ -410,6 +410,76 @@ public sealed class ScriptedAlarmMethodRoutingTests errors[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadInvalidArgument); } + // ---- Shelve routing (Task #24 follow-up) ------------------------------- + + [Fact] + public void InvokeEngineShelve_oneshot_shelves_engine_state() + { + using var engine = BuildEngine("al-1"); + + var result = DriverNodeManager.InvokeEngineShelve( + engine, "al-1", "ops-user", shelving: true, oneShot: true, shelvingTime: 0, logger: null); + + ServiceResult.IsBad(result).ShouldBeFalse("OneShotShelve succeeds"); + engine.GetState("al-1")!.Shelving.Kind.ShouldBe(ShelvingKind.OneShot); + } + + [Fact] + public void InvokeEngineShelve_timed_shelves_engine_state() + { + using var engine = BuildEngine("al-1"); + + // shelvingTime is a Duration in ms — InvokeEngineShelve adds it to UtcNow. + var result = DriverNodeManager.InvokeEngineShelve( + engine, "al-1", "ops-user", shelving: true, oneShot: false, shelvingTime: 60_000, logger: null); + + ServiceResult.IsBad(result).ShouldBeFalse("TimedShelve succeeds"); + var state = engine.GetState("al-1")!; + state.Shelving.Kind.ShouldBe(ShelvingKind.Timed); + state.Shelving.UnshelveAtUtc.ShouldNotBeNull(); + } + + [Fact] + public void InvokeEngineShelve_unshelve_clears_engine_state() + { + using var engine = BuildEngine("al-1"); + DriverNodeManager.InvokeEngineShelve( + engine, "al-1", "ops-user", shelving: true, oneShot: true, shelvingTime: 0, logger: null); + engine.GetState("al-1")!.Shelving.Kind.ShouldBe(ShelvingKind.OneShot); + + var result = DriverNodeManager.InvokeEngineShelve( + engine, "al-1", "ops-user", shelving: false, oneShot: false, shelvingTime: 0, logger: null); + + ServiceResult.IsBad(result).ShouldBeFalse("Unshelve succeeds"); + engine.GetState("al-1")!.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved); + } + + [Fact] + public void InvokeEngineShelve_timed_with_non_positive_duration_returns_BadInvalidArgument() + { + using var engine = BuildEngine("al-1"); + + // A TimedShelve resolving to an unshelve time at-or-before now is rejected by the + // engine's Part 9 state machine (ArgumentOutOfRangeException → BadInvalidArgument). + var result = DriverNodeManager.InvokeEngineShelve( + engine, "al-1", "ops-user", shelving: true, oneShot: false, shelvingTime: 0, logger: null); + + ServiceResult.IsBad(result).ShouldBeTrue(); + result.StatusCode.ShouldBe((StatusCode)StatusCodes.BadInvalidArgument); + } + + [Fact] + public void InvokeEngineShelve_unknown_alarm_returns_BadInvalidArgument() + { + using var engine = BuildEngine("al-1"); + + var result = DriverNodeManager.InvokeEngineShelve( + engine, "not-an-alarm", "ops-user", shelving: true, oneShot: true, shelvingTime: 0, logger: null); + + ServiceResult.IsBad(result).ShouldBeTrue("unknown alarm id → error result"); + result.StatusCode.ShouldBe((StatusCode)StatusCodes.BadInvalidArgument); + } + // ---- Phase7ComposedSources helpers ------------------------------------- private static Script ScriptRow(string id, string source) => new()