Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Acls/AclScopeServiceTests.cs
T
Joseph Doherty b211911f98
v2-ci / unit-tests (push) Failing after 13m22s
test: migrate AdminUI/ControlPlane/Runtime/Host tests to xunit.v3 (#756)
Akka.TestKit.Xunit2 -> Akka.TestKit.Xunit 1.5.62 (xunit.v3 TestKit; needs xunit.v3 3.2.2, set via
VersionOverride on the four projects). Removes the xunit 2.9.3 pin. IAsyncLifetime returns ValueTask,
SqlServerFactAttribute gains the source-info ctor, and xUnit1051 call sites pass
TestContext.Current.CancellationToken. Test counts unchanged.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-10 08:57:33 -05:00

380 lines
17 KiB
C#

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;
/// <summary>
/// #520 stage 7 — the ACL scope picker's trees and the authoring-time checks behind them.
/// </summary>
/// <remarks>
/// 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
/// <em>rejection</em> — and each is paired with the neighbouring case that must still be accepted, or
/// a service that rejected everything would pass the lot.
/// </remarks>
public sealed class AclScopeServiceTests
{
// --- The picker's trees ------------------------------------------------------------------------
/// <summary>
/// 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.
/// </summary>
[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, TestContext.Current.CancellationToken);
var cluster = roots.Raw.ShouldHaveSingleItem();
cluster.ScopeKind.ShouldBe(NodeAclScopeKind.Cluster);
var folder = (await svc.LoadChildrenAsync(cluster, TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
folder.ScopeKind.ShouldBe(NodeAclScopeKind.RawFolder);
folder.ScopeId.ShouldBe(AclScopeTestDb.FolderId);
var driver = (await svc.LoadChildrenAsync(folder, TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
driver.ScopeKind.ShouldBe(NodeAclScopeKind.RawDriver);
driver.ScopeId.ShouldBe(AclScopeTestDb.DriverId);
var device = (await svc.LoadChildrenAsync(driver, TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
device.ScopeKind.ShouldBe(NodeAclScopeKind.RawDevice);
device.ScopeId.ShouldBe(AclScopeTestDb.DeviceId);
var group = (await svc.LoadChildrenAsync(device, TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
group.ScopeKind.ShouldBe(NodeAclScopeKind.RawTagGroup);
group.ScopeId.ShouldBe(AclScopeTestDb.TagGroupId);
var tag = (await svc.LoadChildrenAsync(group, TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
tag.ScopeKind.ShouldBe(NodeAclScopeKind.Tag);
tag.ScopeId.ShouldBe(AclScopeTestDb.TagId);
}
/// <summary>
/// A cluster row produces NO scope id. The deploy gate rejects a Cluster grant that carries one
/// (<c>NodeAclScopeIdUnexpected</c>), so a picker that set the cluster id there would author a row
/// that cannot be deployed.
/// </summary>
[Fact]
public async Task The_cluster_row_produces_no_scope_id()
{
var svc = AclScopeTestDb.NewService();
var cluster = (await svc.LoadRootsAsync(AclScopeTestDb.ClusterId, TestContext.Current.CancellationToken)).Raw.ShouldHaveSingleItem();
cluster.ScopeId.ShouldBeNull();
(await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, cluster.ScopeKind!.Value, cluster.ScopeId, TestContext.Current.CancellationToken))
.Ok.ShouldBeTrue();
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public async Task The_picker_cannot_reach_another_clusters_nodes()
{
var svc = AclScopeTestDb.NewService();
var roots = await svc.LoadRootsAsync(AclScopeTestDb.ClusterId, TestContext.Current.CancellationToken);
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, TestContext.Current.CancellationToken);
(await FlattenAsync(svc, side)).ShouldContain(AclScopeTestDb.OtherDriverId);
}
/// <summary>The UNS side offers area, line and equipment.</summary>
[Fact]
public async Task The_uns_tree_offers_area_line_and_equipment()
{
var svc = AclScopeTestDb.NewService();
var area = (await svc.LoadRootsAsync(AclScopeTestDb.ClusterId, TestContext.Current.CancellationToken)).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);
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public async Task An_unknown_cluster_yields_empty_trees()
{
var svc = AclScopeTestDb.NewService();
var roots = await svc.LoadRootsAsync("NO-SUCH-CLUSTER", TestContext.Current.CancellationToken);
roots.Raw.ShouldBeEmpty();
roots.Uns.ShouldBeEmpty();
}
// --- The authoring-time scope check ------------------------------------------------------------
/// <summary>
/// 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.
/// </summary>
[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, TestContext.Current.CancellationToken))
.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, TestContext.Current.CancellationToken))
.Ok.ShouldBeFalse($"'{id}' is not a {neighbour}");
}
/// <summary>An id that names nothing is rejected, and the message says so.</summary>
[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", TestContext.Current.CancellationToken);
check.Ok.ShouldBeFalse();
check.Message.ShouldContain("DEV-typo");
}
/// <summary>
/// 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.
/// </summary>
[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, TestContext.Current.CancellationToken);
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, TestContext.Current.CancellationToken))
.Ok.ShouldBeTrue();
}
/// <summary>
/// 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.
/// </summary>
[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, TestContext.Current.CancellationToken))
.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, TestContext.Current.CancellationToken))
.Ok.ShouldBeTrue();
}
/// <summary>
/// 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.
/// </summary>
[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, TestContext.Current.CancellationToken))
.Ok.ShouldBeFalse();
(await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, NodeAclScopeKind.Cluster, null, TestContext.Current.CancellationToken))
.Ok.ShouldBeTrue();
}
/// <summary>Every kind but Cluster needs a target.</summary>
[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, TestContext.Current.CancellationToken))
.Ok.ShouldBeFalse();
(await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, NodeAclScopeKind.RawDevice, " ", TestContext.Current.CancellationToken))
.Ok.ShouldBeFalse();
}
/// <summary>
/// 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.
/// </summary>
[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, TestContext.Current.CancellationToken);
check.Ok.ShouldBeFalse();
check.Message.ShouldContain("RawFolder");
}
/// <summary>A Namespace grant takes a realm token, in either spelling, and nothing else.</summary>
[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, TestContext.Current.CancellationToken))
.Ok.ShouldBe(expected);
}
/// <summary>An ordinal outside the enum comes from a hand-edited row; nothing below it is meaningful.</summary>
[Fact]
public async Task An_undefined_scope_kind_is_rejected()
{
var svc = AclScopeTestDb.NewService();
(await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, (NodeAclScopeKind)999, "anything", TestContext.Current.CancellationToken))
.Ok.ShouldBeFalse();
}
// --- Principals ---------------------------------------------------------------------------------
/// <summary>The reserved anonymous principal always reaches something — it has no key to count.</summary>
[Fact]
public async Task The_anonymous_principal_is_recognised_without_a_key()
{
var svc = AclScopeTestDb.NewService();
var usage = await svc.CheckPrincipalAsync(OpcUaPrincipals.Anonymous, TestContext.Current.CancellationToken);
usage.IsAnonymous.ShouldBeTrue();
usage.ReachesSomething.ShouldBeTrue();
}
/// <summary>
/// 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.
/// </summary>
[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, TestContext.Current.CancellationToken);
live.EnabledKeys.ShouldBe(2);
live.DisabledKeys.ShouldBe(1);
live.ReachesSomething.ShouldBeTrue();
var stale = await svc.CheckPrincipalAsync(AclScopeTestDb.StalePrincipal, TestContext.Current.CancellationToken);
stale.EnabledKeys.ShouldBe(0, "its only key expired yesterday");
stale.DisabledKeys.ShouldBe(1);
stale.ReachesSomething.ShouldBeFalse();
}
/// <summary>A principal nothing uses reaches nobody — the typo case the hint exists to surface.</summary>
[Fact]
public async Task An_unknown_principal_reaches_nobody()
{
var svc = AclScopeTestDb.NewService();
var usage = await svc.CheckPrincipalAsync("scadabridge-dcI", TestContext.Current.CancellationToken); // capital i, not lowercase L
usage.EnabledKeys.ShouldBe(0);
usage.ReachesSomething.ShouldBeFalse();
}
/// <summary>The suggestion list covers key principals and the reserved anonymous one.</summary>
[Fact]
public async Task Known_principals_include_every_key_principal_and_anonymous()
{
var svc = AclScopeTestDb.NewService();
var known = await svc.LoadKnownPrincipalsAsync(TestContext.Current.CancellationToken);
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 ------------------------------------------------------------------------------------
/// <summary>
/// A clock pinned to the fixture's <c>Now</c>. 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.
/// </summary>
private sealed class FixedClock(DateTime utcNow) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => new(utcNow, TimeSpan.Zero);
}
/// <summary>Every scope id reachable through the picker for one cluster, both trees, fully expanded.</summary>
private static async Task<List<string>> FlattenAsync(IAclScopeService svc, AclScopeRoots roots)
{
var ids = new List<string>();
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;
}
}