diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/DeferredGateHardeningTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/DeferredGateHardeningTests.cs
new file mode 100644
index 0000000..a98fc3b
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/DeferredGateHardeningTests.cs
@@ -0,0 +1,402 @@
+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;
+
+///
+/// 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 (, ,
+/// ):
+///
+/// - 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)
+/// - Complete OpcUaOperation → NodePermissions mapping coverage for deferred ops
+///
+///
+[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 { NewMonitorRequest("c1/area/line/eq/tag1") };
+ var errors = new List { (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 { NewMonitorRequest("c1/area/line/eq/tag1") };
+ var errors = new List { (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
+ {
+ NewCall("c1/area/line/eq/alarm1", MethodIds.AcknowledgeableConditionType_Acknowledge),
+ };
+ var errors = new List { (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
+ {
+ NewCall("c1/area/line/eq/alarm1", MethodIds.AcknowledgeableConditionType_Acknowledge),
+ };
+ var errors = new List { (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 falls through to Call in MapCallOperation
+ // Documents the ShelvedStateMachine per-instance NodeId limitation.
+ // ======================================================================
+
+ [Fact]
+ public void MapCallOperation_AlarmShelve_falls_through_to_Call()
+ {
+ // 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.)
+ 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()
+ {
+ // Users with MethodCall permission can invoke shelve methods because the gate
+ // maps AlarmShelve back to Call (see MapCallOperation_AlarmShelve_falls_through_to_Call).
+ 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 { 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
+ {
+ 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 groups)
+ {
+ DisplayName = name;
+ LdapGroups = groups;
+ }
+ public new string DisplayName { get; }
+ public IReadOnlyList LdapGroups { get; }
+ }
+}