Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/DeferredGateHardeningTests.cs
Joseph Doherty c5915700bd 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>
2026-05-18 09:31:30 -04:00

412 lines
18 KiB
C#

using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
/// <summary>
/// Task #12 hardening tests for the Phase 6.2 deferred authorization gates —
/// Browse, Subscribe (CreateMonitoredItems), Alarm-acknowledge, and Call.
///
/// Fills the compliance-checklist gaps not covered by the existing per-gate unit
/// tests (<see cref="BrowseGatingTests"/>, <see cref="MonitoredItemGatingTests"/>,
/// <see cref="CallGatingTests"/>):
/// <list type="bullet">
/// <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 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>
[Trait("Category", "Unit")]
public sealed class DeferredGateHardeningTests
{
private const string Cluster = "c1";
// ======================================================================
// 1. Lax-mode fall-through — deferred gates
// ======================================================================
[Fact]
public void Subscribe_gate_lax_mode_null_identity_keeps_items()
{
// In lax mode a session without LDAP groups must NOT be denied —
// the pre-Phase-6.2 default path runs unchanged.
var items = new List<MonitoredItemCreateRequest> { NewMonitorRequest("c1/area/line/eq/tag1") };
var errors = new List<ServiceResult> { (ServiceResult)null! };
var gate = MakeGate(strict: false, rows: []); // lax, no grants
DriverNodeManager.GateMonitoredItemCreateRequests(items, errors, userIdentity: null, gate, new NodeScopeResolver(Cluster));
errors[0].ShouldBeNull("lax mode keeps pre-Phase-6.2 behaviour — no denial for unauthenticated sessions");
}
[Fact]
public void Subscribe_gate_lax_mode_identity_without_ldap_groups_keeps_items()
{
var items = new List<MonitoredItemCreateRequest> { NewMonitorRequest("c1/area/line/eq/tag1") };
var errors = new List<ServiceResult> { (ServiceResult)null! };
var gate = MakeGate(strict: false, rows: []);
// UserIdentity with no LDAP groups — lax gate should not deny
DriverNodeManager.GateMonitoredItemCreateRequests(items, errors, new UserIdentity(), gate, new NodeScopeResolver(Cluster));
errors[0].ShouldBeNull("lax mode allows sessions without LDAP groups");
}
[Fact]
public void Call_gate_lax_mode_null_identity_keeps_calls()
{
var calls = new List<CallMethodRequest>
{
NewCall("c1/area/line/eq/alarm1", MethodIds.AcknowledgeableConditionType_Acknowledge),
};
var errors = new List<ServiceResult> { (ServiceResult)null! };
var gate = MakeGate(strict: false, rows: []);
DriverNodeManager.GateCallMethodRequests(calls, errors, userIdentity: null, gate, new NodeScopeResolver(Cluster));
errors[0].ShouldBeNull("lax mode keeps pre-Phase-6.2 behaviour for null identity");
}
[Fact]
public void Call_gate_lax_mode_identity_without_ldap_groups_keeps_calls()
{
var calls = new List<CallMethodRequest>
{
NewCall("c1/area/line/eq/alarm1", MethodIds.AcknowledgeableConditionType_Acknowledge),
};
var errors = new List<ServiceResult> { (ServiceResult)null! };
var gate = MakeGate(strict: false, rows: []);
DriverNodeManager.GateCallMethodRequests(calls, errors, new UserIdentity(), gate, new NodeScopeResolver(Cluster));
errors[0].ShouldBeNull("lax mode allows sessions without LDAP groups");
}
// ======================================================================
// 2. Flag isolation — Subscribe vs Read
// ======================================================================
[Fact]
public void Subscribe_grant_does_not_imply_Read()
{
// Phase 6.2 compliance: Subscribe and Read are independent flags. A session
// granted only Subscribe should NOT be able to read the current value.
var gate = MakeGate(strict: true, rows:
[
Row("grp-subs", NodePermissions.Subscribe),
]);
var identity = NewIdentity("alice", "grp-subs");
var scope = Scope();
gate.IsAllowed(identity, OpcUaOperation.CreateMonitoredItems, scope).ShouldBeTrue("Subscribe grant allows CreateMonitoredItems");
gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeFalse("Subscribe grant alone does NOT allow Read");
}
[Fact]
public void Read_grant_does_not_imply_Subscribe()
{
// Read-only sessions can read current values but must not be allowed to subscribe.
// This is a deliberate restriction: a data-centre operator monitoring a dashboard
// via an OPC UA subscription is a different grant tier than "read once on demand".
var gate = MakeGate(strict: true, rows:
[
Row("grp-readonly", NodePermissions.Read),
]);
var identity = NewIdentity("alice", "grp-readonly");
var scope = Scope();
gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeTrue("Read grant allows Read");
gate.IsAllowed(identity, OpcUaOperation.CreateMonitoredItems, scope).ShouldBeFalse("Read grant alone does NOT allow Subscribe");
}
// ======================================================================
// 3. Flag isolation — HistoryRead vs Read
// "HistoryRead uses its own flag" from Phase 6.2 Compliance Checklist
// ======================================================================
[Fact]
public void Read_grant_without_HistoryRead_denies_history_access()
{
// Phase 6.2 Compliance Checklist: "user with Read but not HistoryRead can read live
// values but gets BadUserAccessDenied on HistoryRead."
var gate = MakeGate(strict: true, rows:
[
Row("grp-read", NodePermissions.Read), // no HistoryRead bit
]);
var identity = NewIdentity("bob", "grp-read");
var scope = Scope();
gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeTrue("Read granted for current values");
gate.IsAllowed(identity, OpcUaOperation.HistoryRead, scope).ShouldBeFalse("HistoryRead NOT granted — own flag required");
}
[Fact]
public void HistoryRead_grant_without_Read_denies_current_value_read()
{
// Verify flag isolation in the other direction too — history archivers that can
// pull history should not implicitly get live-read access.
var gate = MakeGate(strict: true, rows:
[
Row("grp-hist", NodePermissions.HistoryRead), // no Read bit
]);
var identity = NewIdentity("carol", "grp-hist");
var scope = Scope();
gate.IsAllowed(identity, OpcUaOperation.HistoryRead, scope).ShouldBeTrue("HistoryRead granted for historical values");
gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeFalse("Read NOT granted — own flag required");
}
// ======================================================================
// 4. Flag isolation — Alarm bits
// ======================================================================
[Fact]
public void AlarmAcknowledge_grant_does_not_imply_AlarmConfirm()
{
// Each alarm-action bit is distinct — operators can acknowledge without also
// having confirm authority.
var gate = MakeGate(strict: true, rows:
[
Row("grp-ack", NodePermissions.AlarmAcknowledge),
]);
var identity = NewIdentity("dave", "grp-ack");
var scope = Scope();
gate.IsAllowed(identity, OpcUaOperation.AlarmAcknowledge, scope).ShouldBeTrue();
gate.IsAllowed(identity, OpcUaOperation.AlarmConfirm, scope).ShouldBeFalse("Confirm requires its own flag");
gate.IsAllowed(identity, OpcUaOperation.AlarmShelve, scope).ShouldBeFalse("Shelve requires its own flag");
}
[Fact]
public void Browse_grant_does_not_grant_AlarmAcknowledge()
{
// Browse is granted for hierarchy navigation; it must not cascade to alarm actions.
var gate = MakeGate(strict: true, rows:
[
Row("grp-browse", NodePermissions.Browse),
]);
var identity = NewIdentity("eve", "grp-browse");
var scope = Scope();
gate.IsAllowed(identity, OpcUaOperation.Browse, scope).ShouldBeTrue();
gate.IsAllowed(identity, OpcUaOperation.AlarmAcknowledge, scope).ShouldBeFalse();
}
// ======================================================================
// 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_indexed_shelve_method_maps_to_AlarmShelve()
{
// 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()
{
// 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),
]);
var identity = NewIdentity("frank", "grp-eng");
var scope = Scope();
gate.IsAllowed(identity, OpcUaOperation.Call, scope).ShouldBeTrue("MethodCall grant covers generic Call");
}
// ======================================================================
// 6. OpcUaOperation → NodePermissions mapping completeness (deferred ops)
// Ensures the TriePermissionEvaluator maps all deferred operations correctly.
// ======================================================================
[Theory]
[InlineData(OpcUaOperation.Browse, NodePermissions.Browse)]
[InlineData(OpcUaOperation.CreateMonitoredItems, NodePermissions.Subscribe)]
[InlineData(OpcUaOperation.TransferSubscriptions,NodePermissions.Subscribe)]
[InlineData(OpcUaOperation.Call, NodePermissions.MethodCall)]
[InlineData(OpcUaOperation.AlarmAcknowledge, NodePermissions.AlarmAcknowledge)]
[InlineData(OpcUaOperation.AlarmConfirm, NodePermissions.AlarmConfirm)]
[InlineData(OpcUaOperation.AlarmShelve, NodePermissions.AlarmShelve)]
public void Deferred_operation_maps_to_expected_permission_bit(OpcUaOperation op, NodePermissions required)
{
// Phase 6.2 Stream C compliance — every deferred gate operation must map to the
// correct NodePermissions bit in TriePermissionEvaluator. Verifies the full
// round-trip: grant exactly the required bit → IsAllowed returns true; no grant
// → false.
var gate = MakeGate(strict: true, rows: [Row("grp-test", required)]);
var identity = NewIdentity("tester", "grp-test");
var scope = Scope();
gate.IsAllowed(identity, op, scope).ShouldBeTrue(
$"operation {op} should be allowed when {required} bit is granted");
}
[Theory]
[InlineData(OpcUaOperation.Browse, NodePermissions.Read)] // wrong bit
[InlineData(OpcUaOperation.CreateMonitoredItems, NodePermissions.Read)] // wrong bit
[InlineData(OpcUaOperation.Call, NodePermissions.Browse)] // wrong bit
[InlineData(OpcUaOperation.AlarmAcknowledge, NodePermissions.Browse)] // wrong bit
[InlineData(OpcUaOperation.AlarmConfirm, NodePermissions.Browse)] // wrong bit
[InlineData(OpcUaOperation.AlarmShelve, NodePermissions.Browse)] // wrong bit
public void Deferred_operation_denied_when_wrong_permission_bit_granted(OpcUaOperation op, NodePermissions wrongBit)
{
var gate = MakeGate(strict: true, rows: [Row("grp-wrong", wrongBit)]);
var identity = NewIdentity("tester", "grp-wrong");
var scope = Scope();
gate.IsAllowed(identity, op, scope).ShouldBeFalse(
$"operation {op} must NOT be allowed by the {wrongBit} bit");
}
// ======================================================================
// 7. Mixed multi-group union for deferred gates
// ======================================================================
[Fact]
public void Multi_group_union_for_deferred_gates()
{
// A session belonging to both grp-browse (Browse only) and grp-ack (AlarmAck only)
// should be allowed both Browse and AlarmAcknowledge but not Read or Call.
var gate = MakeGate(strict: true, rows:
[
Row("grp-browse", NodePermissions.Browse),
Row("grp-ack", NodePermissions.AlarmAcknowledge),
]);
var identity = NewIdentity("grace", "grp-browse", "grp-ack");
var scope = Scope();
gate.IsAllowed(identity, OpcUaOperation.Browse, scope).ShouldBeTrue("Browse from first group");
gate.IsAllowed(identity, OpcUaOperation.AlarmAcknowledge, scope).ShouldBeTrue("AlarmAcknowledge from second group");
gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeFalse("Read not granted by either group");
gate.IsAllowed(identity, OpcUaOperation.Call, scope).ShouldBeFalse("Call not granted by either group");
}
// ======================================================================
// 8. Strict vs lax for Browse gate (parity with existing BrowseGatingTests)
// ======================================================================
[Fact]
public void Browse_gate_strict_mode_denies_identity_with_ldap_groups_but_no_grant()
{
var refs = new List<ReferenceDescription> { NewRef("c1/area/line/eq/tag1") };
// Identity has groups but no Browse ACL → strict mode must deny
var gate = MakeGate(strict: true, rows: [Row("grp-other", NodePermissions.Read)]);
var resolver = new NodeScopeResolver(Cluster);
DriverNodeManager.FilterBrowseReferences(refs, NewIdentity("alice", "grp-ops"), gate, resolver);
refs.Count.ShouldBe(0, "strict mode: no Browse grant → reference removed");
}
[Fact]
public void Browse_gate_strict_mode_allows_with_Browse_grant()
{
var refs = new List<ReferenceDescription>
{
NewRef("c1/area/line/eq/tag1"),
NewRef("c1/area/line/eq/tag2"),
};
var gate = MakeGate(strict: true, rows: [Row("grp-ops", NodePermissions.Browse)]);
var resolver = new NodeScopeResolver(Cluster);
DriverNodeManager.FilterBrowseReferences(refs, NewIdentity("alice", "grp-ops"), gate, resolver);
refs.Count.ShouldBe(2, "strict mode: Browse grant → both references pass through");
}
// ---- helpers -----------------------------------------------------------
private static NodeScope Scope() => new()
{
ClusterId = Cluster,
NamespaceId = "ns",
UnsAreaId = "area",
UnsLineId = "line",
EquipmentId = "eq",
TagId = "tag1",
Kind = NodeHierarchyKind.Equipment,
};
private static NodeAcl Row(string group, NodePermissions flags) => new()
{
NodeAclRowId = Guid.NewGuid(),
NodeAclId = Guid.NewGuid().ToString(),
GenerationId = 1,
ClusterId = Cluster,
LdapGroup = group,
ScopeKind = NodeAclScopeKind.Cluster,
ScopeId = null,
PermissionFlags = flags,
};
private static AuthorizationGate MakeGate(bool strict, NodeAcl[] rows)
{
var cache = new PermissionTrieCache();
cache.Install(PermissionTrieBuilder.Build(Cluster, 1, rows));
var evaluator = new TriePermissionEvaluator(cache);
return new AuthorizationGate(evaluator, strictMode: strict);
}
private static IUserIdentity NewIdentity(string name, params string[] groups) => new FakeIdentity(name, groups);
private static MonitoredItemCreateRequest NewMonitorRequest(string fullRef) => new()
{
ItemToMonitor = new ReadValueId { NodeId = new NodeId(fullRef, 2) },
};
private static CallMethodRequest NewCall(string objectFullRef, NodeId methodId) => new()
{
ObjectId = new NodeId(objectFullRef, 2),
MethodId = methodId,
};
private static ReferenceDescription NewRef(string fullRef) => new()
{
NodeId = new NodeId(fullRef, 2),
BrowseName = new QualifiedName("browse"),
DisplayName = new LocalizedText("display"),
};
private sealed class FakeIdentity : UserIdentity, ILdapGroupsBearer
{
public FakeIdentity(string name, IReadOnlyList<string> groups)
{
DisplayName = name;
LdapGroups = groups;
}
public new string DisplayName { get; }
public IReadOnlyList<string> LdapGroups { get; }
}
}