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; /// /// The key source for a driver-only node (#520 stage 4) — the one with no ConfigDb, which until this /// stage denied every UserName token. /// public sealed class ArtifactApiKeyStoreTests { private static readonly DateTime Now = new(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc); /// /// No test here exercises a narrower-than-Cluster scope, so the resolved scope paths are empty. /// private static readonly IReadOnlyDictionary> NoScopePaths = new Dictionary>(); [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, default)).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, default); found.ShouldNotBeNull(); found!.Key.Principal.ShouldBe("scadabridge-dcl"); found.Grants.ShouldHaveSingleItem().PermissionFlags.ShouldBe(NodePermissions.WriteOperate); } /// /// The FULL grant set is returned, narrow scopes included (#520 stage 5). It used to narrow to /// Cluster here, which was redundant — applies exactly /// that filter itself — and became a trap once scope was enforced, because a caller reading /// ApiKeyLookup.Grants as "this principal's grants" got a silently truncated set. /// /// /// Cluster confinement still happens, just not here: ParseDataPlaneAuthority already kept /// only this node's cluster's rows, which is where this store differs from /// ConfigDbApiKeyStore (that one reads the fleet-wide database and confines it itself; see /// ConfigDbApiKeyStoreTests). /// [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, default); found!.Grants.Select(g => g.NodeAclId).ShouldBe(["acl-cluster", "acl-equipment"], ignoreOrder: true); } /// /// 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. /// /// /// 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. /// [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, default); 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, default)).ShouldNotBeNull(); var revoked = Key("abc", "p"); revoked.Enabled = false; state.Apply("cluster-a", "rev-2", [revoked], [], NoScopePaths); (await sut.FindAsync("abc", Now, default)).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, default); 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, }; }