using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Configuration.Validation; namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests; /// /// The #520 stage-3 deploy gate for scopes. An unresolvable scope is dropped by /// the permission-trie builder and the deploy still seals green, so the row reads as a live grant in the /// AdminUI while authorizing nothing — these pin that it fails the deploy instead. /// [Trait("Category", "Unit")] public sealed class DraftValidatorNodeAclScopeTests { /// The cluster every ACL row under test is authored in. private const string Own = "c"; /// A second, fully-populated cluster in the same snapshot — the cross-cluster counterparty. private const string Other = "other"; private const string OwnEquipId = "EQ-000000000000"; private const string OtherEquipId = "EQ-0000000b0000"; private static NodeAcl Acl(NodeAclScopeKind kind, string? scopeId, string id = "acl-1") => new() { NodeAclId = id, ClusterId = Own, Principal = "svc-line3", ScopeKind = kind, ScopeId = scopeId, PermissionFlags = NodePermissions.Read, }; /// /// A draft carrying one of each scoped entity in and a mirrored set in /// . Two clusters, not one, so that a cross-cluster id is genuinely present in the /// snapshot: if the rule's lookups were cluster-filtered such an id would present as merely /// unresolved and the containment check could never fire. /// private static DraftSnapshot Draft(params NodeAcl[] acls) => new() { GenerationId = 1, ClusterId = Own, NodeAcls = acls, RawFolders = [ new RawFolder { RawFolderId = "rf-1", ClusterId = Own, Name = "plant" }, new RawFolder { RawFolderId = "rf-2", ClusterId = Other, Name = "plant2" }, ], DriverInstances = [ new DriverInstance { DriverInstanceId = "di-1", ClusterId = Own, Name = "modbus", DriverType = "Modbus", DriverConfig = "{}", RawFolderId = "rf-1", }, new DriverInstance { DriverInstanceId = "di-2", ClusterId = Other, Name = "modbus2", DriverType = "Modbus", DriverConfig = "{}", RawFolderId = "rf-2", }, ], Devices = [ new Device { DeviceId = "dev-1", DriverInstanceId = "di-1", Name = "dev1", DeviceConfig = "{}" }, new Device { DeviceId = "dev-2", DriverInstanceId = "di-2", Name = "dev2", DeviceConfig = "{}" }, ], TagGroups = [ new TagGroup { TagGroupId = "tg-1", DeviceId = "dev-1", Name = "group1" }, new TagGroup { TagGroupId = "tg-2", DeviceId = "dev-2", Name = "group2" }, ], UnsAreas = [ new UnsArea { UnsAreaId = "area-1", ClusterId = Own, Name = "north" }, new UnsArea { UnsAreaId = "area-2", ClusterId = Other, Name = "south" }, ], UnsLines = [ new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line3" }, new UnsLine { UnsLineId = "line-2", UnsAreaId = "area-2", Name = "line4" }, ], Equipment = [ new Equipment { EquipmentUuid = Guid.Parse("00000000-0000-0000-0000-0000000000aa"), EquipmentId = OwnEquipId, Name = "filler", UnsLineId = "line-1", MachineCode = "m", }, new Equipment { EquipmentUuid = Guid.Parse("0000000b-0000-0000-0000-000000000000"), EquipmentId = OtherEquipId, Name = "packer", UnsLineId = "line-2", MachineCode = "m", }, ], Tags = [ new Tag { TagId = "tag-1", DeviceId = "dev-1", Name = "speed", DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{}", }, new Tag { TagId = "tag-2", DeviceId = "dev-2", Name = "speed2", DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{}", }, ], }; /// /// The fixture itself must trip no rule, or a test asserting on the whole error list is really /// asserting on unrelated noise. This also pins both hardcoded EquipmentIds against the canonical /// derivation, which a later edit to the fixture's UUIDs would otherwise silently break. /// [Fact] public void The_fixture_with_no_acl_rows_is_clean() { DraftValidator.Validate(Draft()).ShouldBeEmpty(); DraftValidator.DeriveEquipmentId(Guid.Parse("00000000-0000-0000-0000-0000000000aa")).ShouldBe(OwnEquipId); DraftValidator.DeriveEquipmentId(Guid.Parse("0000000b-0000-0000-0000-000000000000")).ShouldBe(OtherEquipId); } /// /// The row shape the trie builder drops without a word: a narrower kind with nothing to narrow to. /// [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] public void A_non_Cluster_kind_without_a_ScopeId_is_a_deploy_error(string? scopeId) { var errors = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.Equipment, scopeId))); errors.ShouldContain(e => e.Code == "NodeAclScopeIdMissing" && e.Context == "acl-1"); } /// /// A Cluster grant ignores ScopeId, so a populated one means the author picked the wrong kind and the /// grant is broader than intended — the direction that must never pass silently. /// [Fact] public void A_Cluster_kind_carrying_a_ScopeId_is_a_deploy_error() { var errors = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.Cluster, "EQ-000000000000"))); errors.ShouldContain(e => e.Code == "NodeAclScopeIdUnexpected" && e.Context == "acl-1"); } [Fact] public void A_Cluster_kind_with_no_ScopeId_passes() { DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.Cluster, null))) .ShouldNotContain(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal)); } /// /// One case per scoped kind: a well-formed id that names no such entity must fail. Kept exhaustive /// because the kind→collection mapping is hand-written, and a kind wired to the wrong collection /// would still "work" for every id that happens to exist in both. /// [Theory] [InlineData(NodeAclScopeKind.UnsArea)] [InlineData(NodeAclScopeKind.UnsLine)] [InlineData(NodeAclScopeKind.Equipment)] [InlineData(NodeAclScopeKind.Tag)] [InlineData(NodeAclScopeKind.RawFolder)] [InlineData(NodeAclScopeKind.RawDriver)] [InlineData(NodeAclScopeKind.RawDevice)] [InlineData(NodeAclScopeKind.RawTagGroup)] public void A_ScopeId_naming_no_such_entity_is_a_deploy_error(NodeAclScopeKind kind) { var errors = DraftValidator.Validate(Draft(Acl(kind, "does-not-exist"))); errors.ShouldContain(e => e.Code == "NodeAclScopeUnresolved" && e.Context == "acl-1"); } /// /// The counterpart to the theory above: each kind pointed at the id from its OWN collection must /// pass. Without this the rule could reject everything and still look correct. /// [Theory] [InlineData(NodeAclScopeKind.UnsArea, "area-1")] [InlineData(NodeAclScopeKind.UnsLine, "line-1")] [InlineData(NodeAclScopeKind.Equipment, OwnEquipId)] [InlineData(NodeAclScopeKind.Tag, "tag-1")] [InlineData(NodeAclScopeKind.RawFolder, "rf-1")] [InlineData(NodeAclScopeKind.RawDriver, "di-1")] [InlineData(NodeAclScopeKind.RawDevice, "dev-1")] [InlineData(NodeAclScopeKind.RawTagGroup, "tg-1")] public void A_ScopeId_naming_a_real_entity_of_its_own_kind_passes(NodeAclScopeKind kind, string scopeId) { DraftValidator.Validate(Draft(Acl(kind, scopeId))) .ShouldNotContain(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal)); } /// /// The containment check, one case per kind. Each id is real and resolves to a real trie path — it /// just belongs to another cluster, so the grant would confer authority across a cluster boundary. /// Three of these kinds carry ClusterId directly; the other five are only reachable by walking a /// parent chain, which is why every kind is covered rather than a representative one. /// [Theory] [InlineData(NodeAclScopeKind.UnsArea, "area-2")] [InlineData(NodeAclScopeKind.UnsLine, "line-2")] [InlineData(NodeAclScopeKind.Equipment, OtherEquipId)] [InlineData(NodeAclScopeKind.Tag, "tag-2")] [InlineData(NodeAclScopeKind.RawFolder, "rf-2")] [InlineData(NodeAclScopeKind.RawDriver, "di-2")] [InlineData(NodeAclScopeKind.RawDevice, "dev-2")] [InlineData(NodeAclScopeKind.RawTagGroup, "tg-2")] public void A_ScopeId_naming_an_entity_in_another_cluster_is_a_deploy_error( NodeAclScopeKind kind, string scopeId) { var errors = DraftValidator.Validate(Draft(Acl(kind, scopeId))); errors.ShouldContain(e => e.Code == "NodeAclScopeCrossCluster" && e.Context == "acl-1"); } /// /// The trap this check exists to avoid: if the lookups were cluster-filtered, a cross-cluster id /// would present as unresolved, the operator would be told to fix a typo in an id that is not a typo, /// and the containment rule would never fire at all. The two codes must be mutually exclusive. /// [Theory] [InlineData(NodeAclScopeKind.UnsArea, "area-2")] [InlineData(NodeAclScopeKind.UnsLine, "line-2")] [InlineData(NodeAclScopeKind.Equipment, OtherEquipId)] [InlineData(NodeAclScopeKind.Tag, "tag-2")] [InlineData(NodeAclScopeKind.RawFolder, "rf-2")] [InlineData(NodeAclScopeKind.RawDriver, "di-2")] [InlineData(NodeAclScopeKind.RawDevice, "dev-2")] [InlineData(NodeAclScopeKind.RawTagGroup, "tg-2")] public void A_cross_cluster_scope_is_never_also_reported_as_unresolved( NodeAclScopeKind kind, string scopeId) { var errors = DraftValidator.Validate(Draft(Acl(kind, scopeId))); errors.ShouldNotContain(e => e.Code == "NodeAclScopeUnresolved"); errors.Where(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal)).ShouldHaveSingleItem(); } /// /// Gitea #533 — the gate's own identifier comparisons follow the same rule as the resolver's: /// identifiers compare OrdinalIgnoreCase. Both were ordinal, which put the gate and the /// runtime into disagreement in the direction that costs the most: the deploy gate would reject a /// whole configuration as cross-cluster over a ClusterId case difference that /// PermissionTrieBuilder — which has always matched case-insensitively — would have accepted. /// [Fact] public void A_case_drifted_ClusterId_is_not_reported_as_cross_cluster() { var acl = Acl(NodeAclScopeKind.RawDevice, "dev-1"); acl.ClusterId = Own.ToUpperInvariant(); var errors = DraftValidator.Validate(Draft(acl)); errors.ShouldNotContain(e => e.Code == "NodeAclScopeCrossCluster"); errors.ShouldNotContain(e => e.Code == "NodeAclScopeUnresolved"); } /// /// The same rule applied to the ScopeId lookups, so a row this gate passes is a row /// AclScopePathResolver resolves — the property the gate's whole value rests on. A gate /// stricter than the resolver blocks deploys that would have worked; a gate looser than it lets /// through the silently-dropped grants it exists to catch. /// [Fact] public void A_case_drifted_ScopeId_resolves_at_the_gate_too() { DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.RawDevice, "DEV-1"))) .ShouldNotContain(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal)); } /// Negative control: relaxing case must not stop a genuinely foreign cluster from being caught. [Fact] public void A_genuinely_different_ClusterId_is_still_cross_cluster() { DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.RawDevice, "dev-2"))) .ShouldContain(e => e.Code == "NodeAclScopeCrossCluster"); } /// The error must name both clusters — which one the grant is in and which one owns the /// entity — because "re-author under the other cluster" and "repoint at the local entity" are different /// fixes and the operator picks between them from this message alone. [Fact] public void The_cross_cluster_message_names_both_clusters() { var error = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.RawDevice, "dev-2"))) .First(e => e.Code == "NodeAclScopeCrossCluster"); error.Message.ShouldContain($"'{Own}'"); error.Message.ShouldContain($"'{Other}'"); } /// /// An entity whose parent link dangles belongs to no cluster, so containment is unknowable — and it /// resolves to no scope path either, which is the unresolved case. It must report that, once, rather /// than a cross-cluster error built on a null owner. /// [Fact] public void A_device_whose_driver_is_missing_reports_unresolved_only() { var draft = new DraftSnapshot { GenerationId = 1, ClusterId = Own, NodeAcls = [Acl(NodeAclScopeKind.RawDevice, "dev-orphan")], Devices = [ new Device { DeviceId = "dev-orphan", DriverInstanceId = "di-gone", Name = "orphan", DeviceConfig = "{}", }, ], }; var errors = DraftValidator.Validate(draft); errors.ShouldContain(e => e.Code == "NodeAclScopeUnresolved" && e.Context == "acl-1"); errors.ShouldNotContain(e => e.Code == "NodeAclScopeCrossCluster"); } /// The UNS counterpart, and a two-hop break rather than one: Equipment reaches its cluster /// through UnsLine and only then UnsArea, so a missing line strands it just as a missing driver strands /// a device. [Fact] public void An_equipment_whose_line_is_missing_reports_unresolved_only() { var uuid = Guid.Parse("00000000-0000-0000-0000-0000000000aa"); var draft = new DraftSnapshot { GenerationId = 1, ClusterId = Own, NodeAcls = [Acl(NodeAclScopeKind.Equipment, OwnEquipId)], Equipment = [ new Equipment { EquipmentUuid = uuid, EquipmentId = DraftValidator.DeriveEquipmentId(uuid), Name = "filler", UnsLineId = "line-gone", MachineCode = "m", }, ], }; var errors = DraftValidator.Validate(draft); errors.ShouldContain(e => e.Code == "NodeAclScopeUnresolved" && e.Context == "acl-1"); errors.ShouldNotContain(e => e.Code == "NodeAclScopeCrossCluster"); } /// /// ScopeId is a bare string with no foreign key, so a real id of the wrong kind is exactly /// the mistake that would otherwise hand a grant another entity's subtree. /// [Fact] public void A_real_id_of_the_wrong_kind_is_still_unresolved() { var errors = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.UnsArea, "dev-1"))); errors.ShouldContain(e => e.Code == "NodeAclScopeUnresolved" && e.Context == "acl-1"); } /// Both realm tokens and both full namespace URIs are accepted, in any case — matching /// NodeAclRealms.TryCanonicalize, which is what actually resolves the row at build time. [Theory] [InlineData("raw")] [InlineData("uns")] [InlineData("RAW")] [InlineData("Uns")] [InlineData(V3NodeIds.RawNamespaceUri)] [InlineData(V3NodeIds.UnsNamespaceUri)] [InlineData(" raw ")] public void A_Namespace_scope_naming_a_realm_passes(string scopeId) { DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.Namespace, scopeId))) .ShouldNotContain(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal)); } /// The server has exactly two namespaces; anything else names none of them. [Theory] [InlineData("ns=2")] [InlineData("https://zb.com/otopcua/ns")] // the retired v2 single namespace [InlineData("equipment")] public void A_Namespace_scope_naming_no_realm_is_a_deploy_error(string scopeId) { var errors = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.Namespace, scopeId))); errors.ShouldContain(e => e.Code == "NodeAclNamespaceInvalid" && e.Context == "acl-1"); } /// /// FolderSegment survives in the enum only so later ordinals do not shift; it has no v3 entity and /// can never resolve, so the error has to name the replacement rather than just say "unresolved". /// [Fact] public void The_retired_FolderSegment_kind_is_a_deploy_error_naming_RawFolder() { var error = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.FolderSegment, "some-folder"))) .Where(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal)) .ShouldHaveSingleItem(); error.Code.ShouldBe("NodeAclScopeKindRetired"); error.Context.ShouldBe("acl-1"); error.Message.ShouldContain("RawFolder"); } /// /// A FolderSegment row with no ScopeId is both retired and missing an id. The retirement is the /// actionable fact — the kind can never resolve whatever its ScopeId — so telling the operator to /// supply one would send them to fix the wrong thing. /// [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] public void FolderSegment_retirement_wins_over_the_missing_ScopeId_error(string? scopeId) { var error = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.FolderSegment, scopeId))) .Where(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal)) .ShouldHaveSingleItem(); error.Code.ShouldBe("NodeAclScopeKindRetired"); } /// /// An ordinal outside the enum comes from a hand-edited row or a stale schema. Every other check /// switches on the kind, so it must be rejected up front rather than falling into a default branch. /// [Fact] public void An_undefined_ScopeKind_ordinal_is_a_deploy_error() { var errors = DraftValidator.Validate(Draft(Acl((NodeAclScopeKind)99, "anything"))); errors.ShouldContain(e => e.Code == "NodeAclScopeKindUndefined" && e.Context == "acl-1"); } /// Every bad row is reported in one pass — the validator's stated contract, and what lets an /// operator fix a batch of ACLs in one edit instead of one deploy attempt each. [Fact] public void All_offending_rows_are_reported_in_one_pass() { var errors = DraftValidator.Validate(Draft( Acl(NodeAclScopeKind.Equipment, null, "acl-a"), Acl(NodeAclScopeKind.Cluster, OwnEquipId, "acl-b"), Acl(NodeAclScopeKind.RawDevice, "nope", "acl-c"), Acl(NodeAclScopeKind.RawDevice, "dev-2", "acl-d"))); errors.Where(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal)) .Select(e => e.Context) .ShouldBe(["acl-a", "acl-b", "acl-c", "acl-d"], ignoreOrder: true); } }