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();
}
}