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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<NodeId> { 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<NodeId>())
|
||||
.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<CallMethodRequest>
|
||||
{
|
||||
NewCall("c1/area/line/eq/alarm1", shelveMethodId),
|
||||
};
|
||||
var errors = new List<ServiceResult> { (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<NodeId> { 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<CallMethodRequest>
|
||||
{
|
||||
NewCall("c1/area/line/eq/alarm1", shelveMethodId),
|
||||
};
|
||||
var errors = new List<ServiceResult> { (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<NodeId> { shelveMethodId });
|
||||
|
||||
errors[0].ShouldBeNull("AlarmShelve grant allows the shelve call");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate_null_leaves_errors_untouched()
|
||||
{
|
||||
|
||||
@@ -21,8 +21,8 @@ namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
/// <item>Lax-mode fall-through for all four deferred gates</item>
|
||||
/// <item>Permission-bit isolation — Subscribe-only grant denies Read; HistoryRead-only
|
||||
/// grant denies Read (Phase 6.2 compliance item "HistoryRead uses its own flag")</item>
|
||||
/// <item>AlarmShelve intentional fall-through to Call (documents the ShelvedStateMachine
|
||||
/// per-instance NodeId limitation noted in the MapCallOperation implementation)</item>
|
||||
/// <item>AlarmShelve resolves via the indexed shelve-method NodeId set (Task #24
|
||||
/// follow-up); an unindexed shelve-shaped NodeId still falls through to Call</item>
|
||||
/// <item>Complete OpcUaOperation → NodePermissions mapping coverage for deferred ops</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
@@ -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<NodeId> { 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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user