using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.Acls; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Core.Authorization; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Acls; /// /// #520 stage 7 — the ACL scope picker's trees and the authoring-time checks behind them. /// /// /// The defect this stage closes is not a crash: it is that a grant could be authored, saved, deployed /// and sealed green while resolving to nothing. So the tests that matter are the ones asserting a /// rejection — and each is paired with the neighbouring case that must still be accepted, or /// a service that rejected everything would pass the lot. /// public sealed class AclScopeServiceTests { // --- The picker's trees ------------------------------------------------------------------------ /// /// The raw tree is rooted at the cluster and every level offers the scope kind that level grants /// at. Without the kinds the picker would be a browser, not a picker. /// [Fact] public async Task The_raw_tree_carries_a_scope_kind_at_every_level() { var svc = AclScopeTestDb.NewService(); var roots = await svc.LoadRootsAsync(AclScopeTestDb.ClusterId); var cluster = roots.Raw.ShouldHaveSingleItem(); cluster.ScopeKind.ShouldBe(NodeAclScopeKind.Cluster); var folder = (await svc.LoadChildrenAsync(cluster)).ShouldHaveSingleItem(); folder.ScopeKind.ShouldBe(NodeAclScopeKind.RawFolder); folder.ScopeId.ShouldBe(AclScopeTestDb.FolderId); var driver = (await svc.LoadChildrenAsync(folder)).ShouldHaveSingleItem(); driver.ScopeKind.ShouldBe(NodeAclScopeKind.RawDriver); driver.ScopeId.ShouldBe(AclScopeTestDb.DriverId); var device = (await svc.LoadChildrenAsync(driver)).ShouldHaveSingleItem(); device.ScopeKind.ShouldBe(NodeAclScopeKind.RawDevice); device.ScopeId.ShouldBe(AclScopeTestDb.DeviceId); var group = (await svc.LoadChildrenAsync(device)).ShouldHaveSingleItem(); group.ScopeKind.ShouldBe(NodeAclScopeKind.RawTagGroup); group.ScopeId.ShouldBe(AclScopeTestDb.TagGroupId); var tag = (await svc.LoadChildrenAsync(group)).ShouldHaveSingleItem(); tag.ScopeKind.ShouldBe(NodeAclScopeKind.Tag); tag.ScopeId.ShouldBe(AclScopeTestDb.TagId); } /// /// A cluster row produces NO scope id. The deploy gate rejects a Cluster grant that carries one /// (NodeAclScopeIdUnexpected), so a picker that set the cluster id there would author a row /// that cannot be deployed. /// [Fact] public async Task The_cluster_row_produces_no_scope_id() { var svc = AclScopeTestDb.NewService(); var cluster = (await svc.LoadRootsAsync(AclScopeTestDb.ClusterId)).Raw.ShouldHaveSingleItem(); cluster.ScopeId.ShouldBeNull(); (await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, cluster.ScopeKind!.Value, cluster.ScopeId)) .Ok.ShouldBeTrue(); } /// /// Another cluster's nodes are structurally unreachable through the picker — the counterpart of the /// deploy gate's cross-cluster rule. Reachability, not just discouragement, is the point: the SIDE /// driver exists and resolves, so only the rooting keeps it out. /// [Fact] public async Task The_picker_cannot_reach_another_clusters_nodes() { var svc = AclScopeTestDb.NewService(); var roots = await svc.LoadRootsAsync(AclScopeTestDb.ClusterId); var reachable = await FlattenAsync(svc, roots); reachable.ShouldNotContain(AclScopeTestDb.OtherDriverId); // …and it is a real driver, so the absence is the rooting rather than the id being unknown. var side = await svc.LoadRootsAsync(AclScopeTestDb.OtherClusterId); (await FlattenAsync(svc, side)).ShouldContain(AclScopeTestDb.OtherDriverId); } /// The UNS side offers area, line and equipment. [Fact] public async Task The_uns_tree_offers_area_line_and_equipment() { var svc = AclScopeTestDb.NewService(); var area = (await svc.LoadRootsAsync(AclScopeTestDb.ClusterId)).Uns.ShouldHaveSingleItem(); area.ScopeKind.ShouldBe(NodeAclScopeKind.UnsArea); area.ScopeId.ShouldBe(AclScopeTestDb.AreaId); var line = area.Children.ShouldHaveSingleItem(); line.ScopeKind.ShouldBe(NodeAclScopeKind.UnsLine); line.ScopeId.ShouldBe(AclScopeTestDb.LineId); var equipment = line.Children.ShouldHaveSingleItem(); equipment.ScopeKind.ShouldBe(NodeAclScopeKind.Equipment); equipment.ScopeId.ShouldBe(AclScopeTestDb.EquipmentId); } /// /// An unknown cluster yields empty trees rather than the whole fleet. A picker that fell back to /// everything would hand an operator another cluster's nodes at the one moment they are least /// likely to notice. /// [Fact] public async Task An_unknown_cluster_yields_empty_trees() { var svc = AclScopeTestDb.NewService(); var roots = await svc.LoadRootsAsync("NO-SUCH-CLUSTER"); roots.Raw.ShouldBeEmpty(); roots.Uns.ShouldBeEmpty(); } // --- The authoring-time scope check ------------------------------------------------------------ /// /// THE test for this stage. Every scope kind resolves when pointed at its own entity type, and the /// SAME id under a neighbouring kind does not — which is precisely the row the old free-text field /// could produce, and which saved, deployed and authorized nobody. /// [Theory] [InlineData(NodeAclScopeKind.RawFolder, AclScopeTestDb.FolderId)] [InlineData(NodeAclScopeKind.RawDriver, AclScopeTestDb.DriverId)] [InlineData(NodeAclScopeKind.RawDevice, AclScopeTestDb.DeviceId)] [InlineData(NodeAclScopeKind.RawTagGroup, AclScopeTestDb.TagGroupId)] [InlineData(NodeAclScopeKind.Tag, AclScopeTestDb.TagId)] [InlineData(NodeAclScopeKind.UnsArea, AclScopeTestDb.AreaId)] [InlineData(NodeAclScopeKind.UnsLine, AclScopeTestDb.LineId)] [InlineData(NodeAclScopeKind.Equipment, AclScopeTestDb.EquipmentId)] public async Task A_kind_resolves_its_own_entity_and_nothing_else(NodeAclScopeKind kind, string id) { var svc = AclScopeTestDb.NewService(); (await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, kind, id)) .Ok.ShouldBeTrue($"{kind} '{id}' names a real entity of that type"); // The same id under a different kind must not resolve. RawDevice is the neighbour for every raw // kind and UnsArea for every UNS one; both are real kinds with real rows, so a service that // ignored the kind entirely would pass the line above and fail here. var neighbour = kind is NodeAclScopeKind.UnsArea or NodeAclScopeKind.UnsLine or NodeAclScopeKind.Equipment ? (kind == NodeAclScopeKind.UnsArea ? NodeAclScopeKind.UnsLine : NodeAclScopeKind.UnsArea) : (kind == NodeAclScopeKind.RawDevice ? NodeAclScopeKind.RawDriver : NodeAclScopeKind.RawDevice); (await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, neighbour, id)) .Ok.ShouldBeFalse($"'{id}' is not a {neighbour}"); } /// An id that names nothing is rejected, and the message says so. [Fact] public async Task A_scope_id_that_names_nothing_is_rejected() { var svc = AclScopeTestDb.NewService(); var check = await svc.CheckScopeAsync( AclScopeTestDb.ClusterId, NodeAclScopeKind.RawDevice, "DEV-typo"); check.Ok.ShouldBeFalse(); check.Message.ShouldContain("DEV-typo"); } /// /// A real id in ANOTHER cluster is rejected, and the message names the cluster that owns it. This /// is the one rejection whose id exists, so a check that only asked "does a row with this id /// exist?" would accept it and confer authority across a cluster boundary. /// [Fact] public async Task A_scope_in_another_cluster_is_rejected_and_names_its_owner() { var svc = AclScopeTestDb.NewService(); var check = await svc.CheckScopeAsync( AclScopeTestDb.ClusterId, NodeAclScopeKind.RawDriver, AclScopeTestDb.OtherDriverId); check.Ok.ShouldBeFalse(); check.Message.ShouldContain(AclScopeTestDb.OtherClusterId); // Authored under its own cluster, the same row is fine — so the rejection is the boundary, not // the driver. (await svc.CheckScopeAsync( AclScopeTestDb.OtherClusterId, NodeAclScopeKind.RawDriver, AclScopeTestDb.OtherDriverId)) .Ok.ShouldBeTrue(); } /// /// A row whose parent chain is broken exists but is owned by no cluster, so it resolves to no path. /// Checked separately from a missing row because the two reach the rejection by different routes. /// [Fact] public async Task An_entity_whose_parent_chain_is_broken_is_rejected() { var svc = AclScopeTestDb.NewService(); (await svc.CheckScopeAsync( AclScopeTestDb.ClusterId, NodeAclScopeKind.Equipment, AclScopeTestDb.OrphanEquipmentId)) .Ok.ShouldBeFalse(); // The healthy equipment in the same table passes, so the rejection is the broken line and not the // Equipment arm being wired to nothing. (await svc.CheckScopeAsync( AclScopeTestDb.ClusterId, NodeAclScopeKind.Equipment, AclScopeTestDb.EquipmentId)) .Ok.ShouldBeTrue(); } /// /// A Cluster grant covers everything and carries no id; one that carries an id means the author /// picked the wrong kind and the grant is BROADER than intended — the direction that must never /// pass silently. /// [Fact] public async Task A_cluster_grant_carrying_a_scope_id_is_rejected() { var svc = AclScopeTestDb.NewService(); (await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, NodeAclScopeKind.Cluster, AclScopeTestDb.DeviceId)) .Ok.ShouldBeFalse(); (await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, NodeAclScopeKind.Cluster, null)) .Ok.ShouldBeTrue(); } /// Every kind but Cluster needs a target. [Fact] public async Task A_narrower_kind_without_a_scope_id_is_rejected() { var svc = AclScopeTestDb.NewService(); (await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, NodeAclScopeKind.RawDevice, null)) .Ok.ShouldBeFalse(); (await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, NodeAclScopeKind.RawDevice, " ")) .Ok.ShouldBeFalse(); } /// /// The retired v2 kind is rejected whatever its target — including a target that would resolve /// under the kind that replaced it, which is the shape an operator migrating a v2 row produces. /// [Fact] public async Task The_retired_FolderSegment_kind_is_rejected() { var svc = AclScopeTestDb.NewService(); var check = await svc.CheckScopeAsync( AclScopeTestDb.ClusterId, NodeAclScopeKind.FolderSegment, AclScopeTestDb.FolderId); check.Ok.ShouldBeFalse(); check.Message.ShouldContain("RawFolder"); } /// A Namespace grant takes a realm token, in either spelling, and nothing else. [Theory] [InlineData("raw", true)] [InlineData("uns", true)] [InlineData("RAW", true)] [InlineData("https://zb.com/otopcua/uns", true)] [InlineData("ns=2", false)] [InlineData("MAIN", false)] public async Task A_namespace_grant_accepts_only_a_realm(string scopeId, bool expected) { var svc = AclScopeTestDb.NewService(); (await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, NodeAclScopeKind.Namespace, scopeId)) .Ok.ShouldBe(expected); } /// An ordinal outside the enum comes from a hand-edited row; nothing below it is meaningful. [Fact] public async Task An_undefined_scope_kind_is_rejected() { var svc = AclScopeTestDb.NewService(); (await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, (NodeAclScopeKind)999, "anything")) .Ok.ShouldBeFalse(); } // --- Principals --------------------------------------------------------------------------------- /// The reserved anonymous principal always reaches something — it has no key to count. [Fact] public async Task The_anonymous_principal_is_recognised_without_a_key() { var svc = AclScopeTestDb.NewService(); var usage = await svc.CheckPrincipalAsync(OpcUaPrincipals.Anonymous); usage.IsAnonymous.ShouldBeTrue(); usage.ReachesSomething.ShouldBeTrue(); } /// /// Only enabled, unexpired keys count as reaching the principal. An expired key is the case a /// count of rows would get wrong, so the fixture's stale principal has one and nothing else. /// [Fact] public async Task Only_live_keys_count_towards_a_principal() { var svc = AclScopeTestDb.NewService(new FixedClock(AclScopeTestDb.Now)); var live = await svc.CheckPrincipalAsync(AclScopeTestDb.LivePrincipal); live.EnabledKeys.ShouldBe(2); live.DisabledKeys.ShouldBe(1); live.ReachesSomething.ShouldBeTrue(); var stale = await svc.CheckPrincipalAsync(AclScopeTestDb.StalePrincipal); stale.EnabledKeys.ShouldBe(0, "its only key expired yesterday"); stale.DisabledKeys.ShouldBe(1); stale.ReachesSomething.ShouldBeFalse(); } /// A principal nothing uses reaches nobody — the typo case the hint exists to surface. [Fact] public async Task An_unknown_principal_reaches_nobody() { var svc = AclScopeTestDb.NewService(); var usage = await svc.CheckPrincipalAsync("scadabridge-dcI"); // capital i, not lowercase L usage.EnabledKeys.ShouldBe(0); usage.ReachesSomething.ShouldBeFalse(); } /// The suggestion list covers key principals and the reserved anonymous one. [Fact] public async Task Known_principals_include_every_key_principal_and_anonymous() { var svc = AclScopeTestDb.NewService(); var known = await svc.LoadKnownPrincipalsAsync(); known.ShouldContain(AclScopeTestDb.LivePrincipal); known.ShouldContain(AclScopeTestDb.StalePrincipal); known.ShouldContain(OpcUaPrincipals.Anonymous); known.Count.ShouldBe(known.Distinct(StringComparer.OrdinalIgnoreCase).Count(), "three keys share a principal"); } // --- Helpers ------------------------------------------------------------------------------------ /// /// A clock pinned to the fixture's Now. Key expiry is the one thing here that moves with the /// wall clock, and a test that let it would go red on its own a day after the fixture was written. /// private sealed class FixedClock(DateTime utcNow) : TimeProvider { public override DateTimeOffset GetUtcNow() => new(utcNow, TimeSpan.Zero); } /// Every scope id reachable through the picker for one cluster, both trees, fully expanded. private static async Task> FlattenAsync(IAclScopeService svc, AclScopeRoots roots) { var ids = new List(); async Task WalkAsync(AclScopeNode node) { if (node.ScopeId is not null) ids.Add(node.ScopeId); var children = node.HasLazyChildren ? await svc.LoadChildrenAsync(node) : node.Children; foreach (var child in children) await WalkAsync(child); } foreach (var node in roots.Raw.Concat(roots.Uns)) await WalkAsync(node); return ids; } }