d542ab5b7c
The last stage of #520. Enforcement landed in stages 5-6; this closes the authoring end, where a grant could be saved, deployed and sealed green while resolving to nothing. Scope kind and scope target are now chosen TOGETHER from a picker over the cluster's raw and UNS trees (AclScopePickerModal -> AclScopeTree, backed by IAclScopeService). They were previously an unrelated dropdown and a free-text box, so "RawDevice + a TagId" was a well-formed row. Because the trees are rooted at the grant's own cluster and each node supplies its own kind, NodeAclScopeCrossCluster, NodeAclScopeIdUnexpected, NodeAclScopeIdMissing and NodeAclScopeKindRetired all become unauthorable by construction. CheckScopeAsync mirrors DraftValidator.ValidateNodeAclScopes rule for rule and blocks the save when a scope resolves to nothing - re-checked on submit, not trusted from form load, because a row whose entity was deleted since is exactly where the operator never touches the picker. The list page marks such rows unresolved with the reason. The principal field suggests known principals and reports how many live keys reach the one typed; reaching nobody warns rather than blocks, since authoring ahead of minting is legitimate. Deliberate departures from the plan line, all recorded in the design doc: - The banners are reshaped, not removed. "Not enforced" stopped being true at stage 6, but enforcement is still a per-node flag the AdminUI cannot see, so a narrow grant really does confer nothing on a default-configured node. - A Cluster row produces a NULL ScopeId. The deploy gate rejects one that carries an id, so filling in the cluster id would author undeployable rows. - AclScopeTree is a new component rather than a third mode on RawTree, which already carries the /raw editing tree and the equipment reference picker. - UNS tags are not offered: a UNS signal is a UnsTagReference, and a Tag grant already covers the raw node and every UNS projection of it. Also fixed: the ACL editor had a DataAnnotationsValidator and no ValidationSummary, so an invalid NodeAclId made Create a silent no-op. Tests: 30 in AdminUI.Tests/Acls, falsified with six production mutations - each turned exactly the intended tests red and no others. Live gate PASSED on docker-dev, 11 legs; both central containers asserted on the same image by container id. The pair that matters is legs 8 and 11: the page and the deploy gate reject the same row with the same reason. Legs 5 and 9 are the positive controls - without them the gate passes against a page that refuses everything. Not covered: UNS-realm scope selection (SITE-A has no UNS areas). Rig restored to zero ACL rows. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
380 lines
16 KiB
C#
380 lines
16 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);
|
|
|
|
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);
|
|
}
|
|
|
|
/// <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)).Raw.ShouldHaveSingleItem();
|
|
|
|
cluster.ScopeId.ShouldBeNull();
|
|
(await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, cluster.ScopeKind!.Value, cluster.ScopeId))
|
|
.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);
|
|
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);
|
|
}
|
|
|
|
/// <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)).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");
|
|
|
|
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))
|
|
.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}");
|
|
}
|
|
|
|
/// <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");
|
|
|
|
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);
|
|
|
|
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();
|
|
}
|
|
|
|
/// <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))
|
|
.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();
|
|
}
|
|
|
|
/// <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))
|
|
.Ok.ShouldBeFalse();
|
|
(await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, NodeAclScopeKind.Cluster, null))
|
|
.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))
|
|
.Ok.ShouldBeFalse();
|
|
(await svc.CheckScopeAsync(AclScopeTestDb.ClusterId, NodeAclScopeKind.RawDevice, " "))
|
|
.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);
|
|
|
|
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))
|
|
.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"))
|
|
.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);
|
|
|
|
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);
|
|
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();
|
|
}
|
|
|
|
/// <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"); // 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();
|
|
|
|
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;
|
|
}
|
|
}
|