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