using System.Text; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// /// End-to-end coverage of the ACL scope path as it actually runs on a node (#520 stage 3): /// artifact bytes → → scope paths → /// → . /// /// /// /// This is the coverage the design doc called out as missing, and it was missing in a specific /// way worth remembering: the pre-stage-3 tests all hand-built the scopePaths dictionary in /// the test body with exactly the segments the walker expected, so the walker was pinned against /// the test's idea of the format rather than against any producer. Nothing proved a real artifact /// could yield a matching grant, and in fact nothing could have — there was no producer at all. /// /// /// So the artifact JSON here is deliberately hand-written rather than composed: it is standing in /// for the wire format, and the parser must agree with it. The composer→parser agreement /// is pinned separately, in ControlPlane.Tests/DataPlaneAuthorityArtifactTests. /// /// public sealed class DeploymentArtifactScopePathTests { private const string NodeId = "node-a:4840"; // Plant/Line1 → ModbusDrv → dev1 → Fast → Speed, projected into equipment West/L1/Mixer as "MixerSpeed". private const string Artifact = """ { "RawFolders": [ {"RawFolderId":"RF-plant","ClusterId":"c1","ParentRawFolderId":null,"Name":"Plant"}, {"RawFolderId":"RF-line1","ClusterId":"c1","ParentRawFolderId":"RF-plant","Name":"Line1"} ], "DriverInstances": [ {"DriverInstanceId":"DI-modbus","ClusterId":"c1","RawFolderId":"RF-line1","Name":"ModbusDrv"} ], "Devices": [{"DeviceId":"DV-1","DriverInstanceId":"DI-modbus","Name":"dev1"}], "TagGroups": [{"TagGroupId":"TG-fast","DeviceId":"DV-1","ParentTagGroupId":null,"Name":"Fast"}], "Tags": [{"TagId":"TAG-speed","DeviceId":"DV-1","TagGroupId":"TG-fast","Name":"Speed"}], "UnsAreas": [{"UnsAreaId":"UA-w","ClusterId":"c1","Name":"West"}], "UnsLines": [{"UnsLineId":"UL-1","UnsAreaId":"UA-w","Name":"L1"}], "Equipment": [{"EquipmentId":"EQ-mixer","UnsLineId":"UL-1","Name":"Mixer"}], "UnsTagReferences": [ {"UnsTagReferenceId":"REF-1","EquipmentId":"EQ-mixer","TagId":"TAG-speed","DisplayNameOverride":"MixerSpeed"} ], "NodeAcls": [ {"NodeAclId":"acl-dev","ClusterId":"c1","Principal":"p","ScopeKind":9,"ScopeId":"DV-1","PermissionFlags":2}, {"NodeAclId":"acl-eq","ClusterId":"c1","Principal":"p","ScopeKind":4,"ScopeId":"EQ-mixer","PermissionFlags":16}, {"NodeAclId":"acl-dangling","ClusterId":"c1","Principal":"p","ScopeKind":4,"ScopeId":"EQ-gone","PermissionFlags":16} ] } """; private static (PermissionTrie Trie, List Diagnostics) BuildFromArtifact() { var projection = DeploymentArtifact.ParseDataPlaneAuthority(Encoding.UTF8.GetBytes(Artifact), NodeId); var diagnostics = new List(); var trie = PermissionTrieBuilder.Build("c1", 1, projection.Grants, projection.ScopePaths, diagnostics.Add); return (trie, diagnostics); } [Fact] public void A_raw_device_grant_from_the_artifact_matches_a_tag_beneath_that_device() { var (trie, _) = BuildFromArtifact(); var matches = trie.CollectMatches( new NodeScope { ClusterId = "c1", Kind = NodeHierarchyKind.Raw, Segments = ["Plant", "Line1", "ModbusDrv", "dev1", "Fast", "Speed"], }, ["p"]); var match = matches.ShouldHaveSingleItem(); match.PermissionFlags.ShouldBe(NodePermissions.Read); // Provenance is the authored kind, not the depth the grant happens to sit at. match.Scope.ShouldBe(NodeAclScopeKind.RawDevice); } [Fact] public void A_tag_under_a_DIFFERENT_device_does_not_match_that_grant() { // The negative control. Without it the test above passes for any trie that returns every grant it // holds regardless of path — which is exactly what a broken walk would do. var (trie, _) = BuildFromArtifact(); trie.CollectMatches( new NodeScope { ClusterId = "c1", Kind = NodeHierarchyKind.Raw, Segments = ["Plant", "Line1", "ModbusDrv", "otherdev", "Fast", "Speed"], }, ["p"]).ShouldBeEmpty(); } [Fact] public void The_same_grant_set_matches_through_the_UNS_realm_too() { // The equipment grant is authored once and must be reachable at the UNS NodeId, which is a // completely different path from the raw one for the same underlying value. var (trie, _) = BuildFromArtifact(); var matches = trie.CollectMatches( new NodeScope { ClusterId = "c1", Kind = NodeHierarchyKind.Uns, Segments = ["West", "L1", "Mixer", "MixerSpeed"], }, ["p"]); matches.ShouldHaveSingleItem().PermissionFlags.ShouldBe(NodePermissions.WriteOperate); } [Fact] public void A_raw_scope_does_not_pick_up_a_UNS_grant_at_the_same_depth() { // Realm isolation. "West/L1/Mixer" and a three-segment raw path are the same depth, so without the // realm gate at level 1 a UNS grant would match a raw node whose folder/driver/device happened to // be named the same. The two trees are authored independently, so that collision is not exotic. var (trie, _) = BuildFromArtifact(); trie.CollectMatches( new NodeScope { ClusterId = "c1", Kind = NodeHierarchyKind.Raw, Segments = ["West", "L1", "Mixer", "MixerSpeed"], }, ["p"]).ShouldBeEmpty(); } [Fact] public void A_dangling_scope_is_dropped_with_a_diagnostic_rather_than_planted() { var (_, diagnostics) = BuildFromArtifact(); var diagnostic = diagnostics.ShouldHaveSingleItem(); diagnostic.NodeAclId.ShouldBe("acl-dangling"); diagnostic.Reason.ShouldBe(PermissionTrieBuildDiagnosticReason.UnresolvedScope); } [Fact] public void Case_drift_in_a_path_segment_grants_nothing() { // Raw sibling names are unique under an ordinal comparer, so "Speed" and "speed" can both exist // under one device. The trie compared OrdinalIgnoreCase before stage 3, which would have let a // grant on either match both. This goes red if the comparer is reverted. var (trie, _) = BuildFromArtifact(); trie.CollectMatches( new NodeScope { ClusterId = "c1", Kind = NodeHierarchyKind.Raw, Segments = ["plant", "Line1", "ModbusDrv", "dev1", "Fast", "Speed"], }, ["p"]).ShouldBeEmpty(); } [Fact] public void An_artifact_with_no_topology_resolves_no_scopes() { var projection = DeploymentArtifact.ParseDataPlaneAuthority( Encoding.UTF8.GetBytes(""" {"NodeAcls":[{"NodeAclId":"a","ClusterId":"c1","Principal":"p","ScopeKind":4,"ScopeId":"EQ-x","PermissionFlags":1}]} """), NodeId); projection.Grants.ShouldHaveSingleItem(); projection.ScopePaths.ShouldBeEmpty(); } /// /// Gitea #797 — the artifact also yields every equipment's OWN path (the scripted-alarm owner lookup), and /// it is identical to the path the same artifact plants an Equipment grant at. That equality is what makes /// a narrow equipment grant authorize the equipment's scripted alarms: if the two were produced by /// different rules, the owner binding would silently authorize at a node no grant can reach. /// [Fact] public void Every_equipment_owner_path_equals_the_path_its_equipment_grant_is_planted_at() { var projection = DeploymentArtifact.ParseDataPlaneAuthority(Encoding.UTF8.GetBytes(Artifact), NodeId); projection.EquipmentScopePaths.ShouldContainKey("EQ-mixer"); var owner = projection.EquipmentScopePaths["EQ-mixer"]; owner.Segments.ShouldBe(["uns", "West", "L1", "Mixer"]); projection.ScopePaths[new AclScopeRef(NodeAclScopeKind.Equipment, "EQ-mixer")] .ShouldHaveSingleItem().Segments.ShouldBe(owner.Segments); // Keyed by identifier (OrdinalIgnoreCase), like every other id lookup on the authorization path. projection.EquipmentScopePaths.ContainsKey("eq-MIXER").ShouldBeTrue(); // A grant naming an equipment the artifact does not carry yields no owner path either. projection.EquipmentScopePaths.ContainsKey("EQ-gone").ShouldBeFalse(); var snapshot = new DataPlaneAuthorityState().Apply( "c1", "rev-1", projection.Keys, projection.Grants, projection.ScopePaths, projection.EquipmentScopePaths); snapshot.TryGetEquipmentUnsSegments("EQ-mixer", out var segments).ShouldBeTrue(); segments.ShouldBe(["West", "L1", "Mixer"]); } /// An equipment whose UNS chain does not resolve (dangling line) has no owner path. [Fact] public void An_equipment_with_a_dangling_line_has_no_owner_path() { var projection = DeploymentArtifact.ParseDataPlaneAuthority( Encoding.UTF8.GetBytes(""" { "UnsAreas": [{"UnsAreaId":"UA-w","ClusterId":"c1","Name":"West"}], "Equipment": [{"EquipmentId":"EQ-orphan","UnsLineId":"UL-missing","Name":"Orphan"}], "NodeAcls":[{"NodeAclId":"a","ClusterId":"c1","Principal":"p","ScopeKind":0,"PermissionFlags":1}] } """), NodeId); projection.EquipmentScopePaths.ShouldBeEmpty(); } /// /// #797 review — the generation also carries each scripted alarm's deployed owner, decoded from the same /// ScriptedAlarms rows the address space is composed from, keyed ordinally by the alarm id. /// [Fact] public void The_projection_carries_each_scripted_alarms_deployed_owner() { var projection = DeploymentArtifact.ParseDataPlaneAuthority( Encoding.UTF8.GetBytes(""" { "ScriptedAlarms": [ {"ScriptedAlarmId":"scripted-eqa-hi","EquipmentId":"EQ-mixer","Name":"hi"}, {"ScriptedAlarmId":"scripted-no-owner","Name":"x"} ], "NodeAcls":[{"NodeAclId":"a","ClusterId":"c1","Principal":"p","ScopeKind":0,"PermissionFlags":1}] } """), NodeId); projection.ScriptedAlarmOwners.ShouldBe(new Dictionary { ["scripted-eqa-hi"] = "EQ-mixer" }); projection.ScriptedAlarmOwners.ContainsKey("SCRIPTED-EQA-HI").ShouldBeFalse("alarm ids are NodeId identifiers: ordinal"); var snapshot = new DataPlaneAuthorityState().Apply("c1", "rev-1", projection.Keys, projection.Grants, projection.ScopePaths, projection.EquipmentScopePaths, projection.ScriptedAlarmOwners); snapshot.TryGetScriptedAlarmOwner("scripted-eqa-hi", out var owner).ShouldBeTrue(); owner.ShouldBe("EQ-mixer"); snapshot.TryGetScriptedAlarmOwner("scripted-no-owner", out _).ShouldBeFalse(); } /// /// Gitea #808 — the artifact yields every UNS Area / Line / Equipment folder's placement: the SAME path a grant /// on that entity is planted at, plus the folder-id chain the address space nests it under. The path equality /// is what lets a narrow grant reach the opaque-id folder it names; the chain is what the authorizer compares /// the live folder against. /// [Fact] public void Every_uns_folder_scope_equals_its_entity_grant_path_and_carries_its_id_chain() { var projection = DeploymentArtifact.ParseDataPlaneAuthority(Encoding.UTF8.GetBytes(Artifact), NodeId); var folders = projection.UnsFolderScopes; folders.Keys.Order(StringComparer.Ordinal).ShouldBe(["EQ-mixer", "UA-w", "UL-1"]); folders["UA-w"].Path.Segments.ShouldBe(["uns", "West"]); folders["UA-w"].IdChain.ShouldBe(["UA-w"]); folders["UL-1"].Path.Segments.ShouldBe(["uns", "West", "L1"]); folders["UL-1"].IdChain.ShouldBe(["UA-w", "UL-1"]); folders["EQ-mixer"].IdChain.ShouldBe(["UA-w", "UL-1", "EQ-mixer"]); folders["EQ-mixer"].Path.Segments.ShouldBe( projection.ScopePaths[new AclScopeRef(NodeAclScopeKind.Equipment, "EQ-mixer")].ShouldHaveSingleItem().Segments); var snapshot = new DataPlaneAuthorityState().Apply( "c1", "rev-1", projection.Keys, projection.Grants, projection.ScopePaths, projection.EquipmentScopePaths, projection.ScriptedAlarmOwners, projection.UnsFolderScopes); snapshot.TryGetUnsFolderSegments("EQ-mixer", ["UA-w", "UL-1", "EQ-mixer"], out var segments).ShouldBeTrue(); segments.ShouldBe(["West", "L1", "Mixer"]); // A live folder nested differently from what the generation deploys (moved, not yet rebuilt) is not placed // at all — neither at the generation's path nor at its old one. snapshot.TryGetUnsFolderSegments("EQ-mixer", ["UA-w", "UL-2", "EQ-mixer"], out _).ShouldBeFalse(); snapshot.TryGetUnsFolderSegments("EQ-mixer", ["EQ-mixer"], out _).ShouldBeFalse(); snapshot.TryGetUnsFolderSegments("EQ-gone", ["UA-w", "UL-1", "EQ-gone"], out _).ShouldBeFalse(); } /// /// Gitea #808 — an id two UNS entity kinds share (the address space can hold only one folder for it) is dropped /// rather than placed by whichever kind came first; an orphaned line or a line-less equipment has no scope. /// [Fact] public void An_ambiguous_or_orphaned_uns_folder_has_no_scope() { var projection = DeploymentArtifact.ParseDataPlaneAuthority( Encoding.UTF8.GetBytes(""" { "UnsAreas": [{"UnsAreaId":"X-1","ClusterId":"c1","Name":"West"}], "UnsLines": [ {"UnsLineId":"X-1","UnsAreaId":"X-1","Name":"Clash"}, {"UnsLineId":"UL-orphan","UnsAreaId":"UA-missing","Name":"Orphan"}, {"UnsLineId":"UL-ok","UnsAreaId":"X-1","Name":"L1"} ], "Equipment": [{"EquipmentId":"EQ-dangling","UnsLineId":"UL-missing","Name":"E"}], "NodeAcls":[{"NodeAclId":"a","ClusterId":"c1","Principal":"p","ScopeKind":0,"PermissionFlags":1}] } """), NodeId); projection.UnsFolderScopes.Keys.ShouldBe(["UL-ok"]); } /// /// Gitea #826 — the projection says which equipment each ALARM-BEARING raw tag is projected into, keyed on the /// RawPath the same resolver builds (a native alarm's ConditionId), so the node manager can tell a native /// alarm's still-referenced equipment folders from one whose reference the generation dropped. Plain value tags /// are never looked up and are left out (review: memory). /// [Fact] public void The_projection_carries_each_alarm_bearing_raw_tags_referencing_equipment_under_its_raw_path() { var projection = DeploymentArtifact.ParseDataPlaneAuthority(Encoding.UTF8.GetBytes(AlarmArtifact), NodeId); const string hot = "Plant/Line1/ModbusDrv/dev1/Fast/Hot"; projection.TagReferencingEquipment.Keys.ShouldBe([hot], "the plain Speed tag is not alarm-bearing"); projection.TagReferencingEquipment[hot].Order(StringComparer.Ordinal).ShouldBe(["EQ-mixer", "EQ-oven"]); var snapshot = new DataPlaneAuthorityState().Apply( "c1", "rev-1", projection.Keys, projection.Grants, projection.ScopePaths, projection.EquipmentScopePaths, projection.ScriptedAlarmOwners, projection.UnsFolderScopes, projection.TagReferencingEquipment); snapshot.IsTagReferencedBy(hot, "EQ-oven").ShouldBeTrue(); snapshot.IsTagReferencedBy(hot, "eq-MIXER").ShouldBeTrue("equipment ids compare as identifiers"); snapshot.IsTagReferencedBy(hot, "EQ-other").ShouldBeFalse(); snapshot.IsTagReferencedBy("Plant/Line1/ModbusDrv/dev1/Fast/Speed", "EQ-mixer").ShouldBeFalse(); new DataPlaneAuthorityState().Apply("c1", "rev-1", [], [], projection.ScopePaths) .IsTagReferencedBy(hot, "EQ-mixer").ShouldBeFalse("an absent map references nothing (fail closed)"); } // The fixture's topology plus an alarm-bearing Hot tag referenced by two equipment; Speed stays a plain value. private const string AlarmArtifact = """ { "RawFolders": [ {"RawFolderId":"RF-plant","ClusterId":"c1","ParentRawFolderId":null,"Name":"Plant"}, {"RawFolderId":"RF-line1","ClusterId":"c1","ParentRawFolderId":"RF-plant","Name":"Line1"} ], "DriverInstances": [ {"DriverInstanceId":"DI-modbus","ClusterId":"c1","RawFolderId":"RF-line1","Name":"ModbusDrv"} ], "Devices": [{"DeviceId":"DV-1","DriverInstanceId":"DI-modbus","Name":"dev1"}], "TagGroups": [{"TagGroupId":"TG-fast","DeviceId":"DV-1","ParentTagGroupId":null,"Name":"Fast"}], "Tags": [ {"TagId":"TAG-speed","DeviceId":"DV-1","TagGroupId":"TG-fast","Name":"Speed"}, {"TagId":"TAG-hot","DeviceId":"DV-1","TagGroupId":"TG-fast","Name":"Hot", "TagConfig":"{\"alarm\":{\"alarmType\":\"OffNormalAlarm\"}}"} ], "UnsAreas": [{"UnsAreaId":"UA-w","ClusterId":"c1","Name":"West"}], "UnsLines": [{"UnsLineId":"UL-1","UnsAreaId":"UA-w","Name":"L1"}], "Equipment": [ {"EquipmentId":"EQ-mixer","UnsLineId":"UL-1","Name":"Mixer"}, {"EquipmentId":"EQ-oven","UnsLineId":"UL-1","Name":"Oven"} ], "UnsTagReferences": [ {"UnsTagReferenceId":"REF-1","EquipmentId":"EQ-mixer","TagId":"TAG-speed"}, {"UnsTagReferenceId":"REF-2","EquipmentId":"EQ-mixer","TagId":"TAG-hot"}, {"UnsTagReferenceId":"REF-3","EquipmentId":"EQ-oven","TagId":"TAG-hot"} ], "NodeAcls": [ {"NodeAclId":"acl-eq","ClusterId":"c1","Principal":"p","ScopeKind":4,"ScopeId":"EQ-mixer","PermissionFlags":16} ] } """; }