Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/ArtifactApiKeyStoreTests.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

151 lines
6.4 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
/// <summary>
/// The key source for a driver-only node (#520 stage 4) — the one with no ConfigDb, which until this
/// stage denied every UserName token.
/// </summary>
public sealed class ArtifactApiKeyStoreTests
{
private static readonly DateTime Now = new(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc);
/// <summary>
/// No test here exercises a narrower-than-Cluster scope, so the resolved scope paths are empty.
/// </summary>
private static readonly IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> NoScopePaths =
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
[Fact]
public async Task A_node_that_has_not_been_deployed_to_authenticates_nothing()
{
var sut = new ArtifactApiKeyStore(new DataPlaneAuthorityState());
(await sut.FindAsync("abc", Now, TestContext.Current.CancellationToken)).ShouldBeNull();
}
[Fact]
public async Task A_deployed_key_resolves_with_its_principals_cluster_grants()
{
var state = new DataPlaneAuthorityState();
state.Apply("cluster-a", "rev-1",
[Key("abc", "scadabridge-dcl")],
[Acl("acl-1", "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)], NoScopePaths);
var found = await new ArtifactApiKeyStore(state).FindAsync("abc", Now, TestContext.Current.CancellationToken);
found.ShouldNotBeNull();
found!.Key.Principal.ShouldBe("scadabridge-dcl");
found.Grants.ShouldHaveSingleItem().PermissionFlags.ShouldBe(NodePermissions.WriteOperate);
}
/// <summary>
/// The FULL grant set is returned, narrow scopes included (#520 stage 5). It used to narrow to
/// <c>Cluster</c> here, which was redundant — <see cref="ApiKeyInterimRoleMapper"/> applies exactly
/// that filter itself — and became a trap once scope was enforced, because a caller reading
/// <c>ApiKeyLookup.Grants</c> as "this principal's grants" got a silently truncated set.
/// </summary>
/// <remarks>
/// Cluster confinement still happens, just not here: <c>ParseDataPlaneAuthority</c> already kept
/// only this node's cluster's rows, which is where this store differs from
/// <c>ConfigDbApiKeyStore</c> (that one reads the fleet-wide database and confines it itself; see
/// <c>ConfigDbApiKeyStoreTests</c>).
/// </remarks>
[Fact]
public async Task Grants_narrower_than_cluster_are_returned_too()
{
var state = new DataPlaneAuthorityState();
state.Apply("cluster-a", "rev-1",
[Key("abc", "p")],
[
Acl("acl-cluster", "p", NodeAclScopeKind.Cluster, NodePermissions.Read),
Acl("acl-equipment", "p", NodeAclScopeKind.Equipment, NodePermissions.WriteOperate),
], NoScopePaths);
var found = await new ArtifactApiKeyStore(state).FindAsync("abc", Now, TestContext.Current.CancellationToken);
found!.Grants.Select(g => g.NodeAclId).ShouldBe(["acl-cluster", "acl-equipment"], ignoreOrder: true);
}
/// <summary>
/// Widening the store did not widen the ROLES. The narrow grant reaches the mapper and still
/// confers nothing, so a rule authored as "WriteOperate on one equipment" cannot become
/// server-wide write authority on a node with ACL enforcement switched off.
/// </summary>
/// <remarks>
/// This assertion is the reason the widening is safe, and it lives here rather than only in the
/// mapper's own tests because the store is what changed: a test of the mapper alone would have
/// stayed green no matter what the store returned.
/// </remarks>
[Fact]
public async Task Widening_the_store_does_not_widen_the_interim_roles()
{
var state = new DataPlaneAuthorityState();
state.Apply("cluster-a", "rev-1",
[Key("abc", "p")],
[Acl("acl-equipment", "p", NodeAclScopeKind.Equipment, NodePermissions.WriteOperate)],
NoScopePaths);
var found = await new ArtifactApiKeyStore(state).FindAsync("abc", Now, TestContext.Current.CancellationToken);
found!.Grants.ShouldHaveSingleItem();
ApiKeyInterimRoleMapper.Map(found.Grants, "p").ShouldBeEmpty();
}
[Fact]
public async Task A_revoked_key_is_denied_once_the_revoking_artifact_arrives()
{
// The whole revocation story for a driver-only node: it takes effect on the next deployment, and
// this is that deployment landing.
var state = new DataPlaneAuthorityState();
var sut = new ArtifactApiKeyStore(state);
state.Apply("cluster-a", "rev-1", [Key("abc", "p")], [], NoScopePaths);
(await sut.FindAsync("abc", Now, TestContext.Current.CancellationToken)).ShouldNotBeNull();
var revoked = Key("abc", "p");
revoked.Enabled = false;
state.Apply("cluster-a", "rev-2", [revoked], [], NoScopePaths);
(await sut.FindAsync("abc", Now, TestContext.Current.CancellationToken)).ShouldBeNull();
}
[Fact]
public async Task Anonymous_grants_resolve_without_a_key()
{
// The anonymous principal has grants but obviously no credential, so FindGrantsAsync is its path.
var state = new DataPlaneAuthorityState();
state.Apply("cluster-a", "rev-1", [],
[Acl("acl-anon", OpcUaPrincipals.Anonymous, NodeAclScopeKind.Cluster, NodePermissions.Read)],
NoScopePaths);
var grants = await new ArtifactApiKeyStore(state).FindGrantsAsync(OpcUaPrincipals.Anonymous, TestContext.Current.CancellationToken);
grants.ShouldHaveSingleItem().PermissionFlags.ShouldBe(NodePermissions.Read);
}
private static ApiKey Key(string id, string principal) => new()
{
ApiKeyId = id,
Name = id,
SecretHash = "hash",
Principal = principal,
Enabled = true,
CreatedUtc = Now.AddDays(-1),
};
private static NodeAcl Acl(string id, string principal, NodeAclScopeKind scope, NodePermissions flags) => new()
{
NodeAclId = id,
ClusterId = "cluster-a",
Principal = principal,
ScopeKind = scope,
PermissionFlags = flags,
};
}