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; /// /// Unit tests for — Phase 6.2 /// Stream C Browse gating. Verifies that references to nodes the session isn't /// allowed to browse are removed silently, while allowed references pass through. /// [Trait("Category", "Unit")] public sealed class BrowseGatingTests { [Fact] public void Gate_null_leaves_references_untouched() { var refs = new List { NewRef("c1/area/line/eq/tag1"), NewRef("c1/area/line/eq/tag2"), }; DriverNodeManager.FilterBrowseReferences(refs, new UserIdentity(), gate: null, scopeResolver: null); refs.Count.ShouldBe(2); } [Fact] public void Empty_reference_list_is_a_no_op() { var refs = new List(); var gate = MakeGate(strict: true, rows: []); var resolver = new NodeScopeResolver("c1"); DriverNodeManager.FilterBrowseReferences(refs, new UserIdentity(), gate, resolver); refs.Count.ShouldBe(0); } [Fact] public void Denied_references_are_removed() { var refs = new List { NewRef("c1/area/line/eq/tag1"), NewRef("c1/area/line/eq/tag2"), }; // Strict mode with no ACL rows → everyone is denied. var gate = MakeGate(strict: true, rows: []); var resolver = new NodeScopeResolver("c1"); DriverNodeManager.FilterBrowseReferences(refs, NewIdentity("alice", "grp-ops"), gate, resolver); refs.Count.ShouldBe(0); } [Fact] public void Allowed_references_remain() { 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("c1"); DriverNodeManager.FilterBrowseReferences(refs, NewIdentity("alice", "grp-ops"), gate, resolver); refs.Count.ShouldBe(2); } [Fact] public void Non_string_identifiers_bypass_the_gate() { // A numeric-identifier reference (stack-synthesized standard type) must not be // filtered — only driver-materialized (string-id) nodes are subject to the authz trie. var refs = new List { new() { NodeId = new NodeId(62u) }, // VariableTypeIds.BaseVariableType or similar NewRef("c1/area/line/eq/tag1"), }; // Strict + no grants → would deny everything, but the numeric ref bypasses. var gate = MakeGate(strict: true, rows: []); var resolver = new NodeScopeResolver("c1"); DriverNodeManager.FilterBrowseReferences(refs, NewIdentity("alice", "grp-ops"), gate, resolver); refs.Count.ShouldBe(1); refs[0].NodeId.Identifier.ShouldBe(62u); } [Fact] public void Lax_mode_null_identity_keeps_references() { var refs = new List { NewRef("c1/area/line/eq/tag1") }; var gate = MakeGate(strict: false, rows: []); var resolver = new NodeScopeResolver("c1"); DriverNodeManager.FilterBrowseReferences(refs, userIdentity: null, gate, resolver); refs.Count.ShouldBe(1, "lax mode keeps the pre-Phase-6.2 behaviour — everything visible"); } // ---- helpers ----------------------------------------------------------- private static ReferenceDescription NewRef(string fullRef) => new() { NodeId = new NodeId(fullRef, 2), BrowseName = new QualifiedName("browse"), DisplayName = new LocalizedText("display"), }; private static NodeAcl Row(string group, NodePermissions flags) => new() { NodeAclRowId = Guid.NewGuid(), NodeAclId = Guid.NewGuid().ToString(), GenerationId = 1, ClusterId = "c1", 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("c1", 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 sealed class FakeIdentity : UserIdentity, ILdapGroupsBearer { public FakeIdentity(string name, IReadOnlyList groups) { DisplayName = name; LdapGroups = groups; } public new string DisplayName { get; } public IReadOnlyList LdapGroups { get; } } }