using Microsoft.EntityFrameworkCore; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Cluster; using ZB.MOM.WW.OtOpcUa.Configuration; 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.OpcUaServer.Security; using ZB.MOM.WW.OtOpcUa.Host.OpcUa; namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa; /// /// The key source for a node WITH a ConfigDb (#520 stage 2) — an admin-role node, including a fused /// admin+driver one. /// /// /// /// Why cluster confinement is tested here and not on ArtifactApiKeyStore. The two /// stores read different things. The artifact store's snapshot was already narrowed to the node's /// own cluster by DeploymentArtifact.ParseDataPlaneAuthority /// (ClusterFilterMode.ScopeTo), so it cannot see another cluster's rows at all. This store /// queries the fleet-wide ConfigDb, so the confinement is its own job — and until it was added, a /// Cluster-scoped grant authored in ANY cluster conferred an interim server-wide role on a fused /// central node. /// /// /// The in-memory EF provider is the convention in the ControlPlane/Runtime test projects; nothing /// here touches the central SQL Server. Note it compares strings with LINQ-to-Objects ordinal /// semantics, NOT SQL Server's case-insensitive collation — which is exactly why the ClusterId /// match is done explicitly with in the store /// rather than left to the provider. pins it. /// /// public sealed class ConfigDbApiKeyStoreTests { private const string ThisCluster = "SITE-A"; private const string OtherCluster = "SITE-B"; private static readonly DateTime Now = new(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc); /// /// THE REGRESSION TEST. A Cluster-scoped grant authored in another cluster must not reach this /// node — it would collapse into a server-wide WriteOperate role via /// ApiKeyInterimRoleMapper, i.e. write authority granted in SITE-B taking effect in SITE-A. /// [Fact] public async Task A_cluster_scoped_grant_from_another_cluster_confers_nothing() { var factory = NewInMemoryDbFactory(); Seed(factory, keys: [Key("abc", "scadabridge-dcl")], acls: [Acl("acl-other", OtherCluster, "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)]); var found = await new ConfigDbApiKeyStore(factory, ThisCluster).FindAsync("abc", Now, default); // The KEY still resolves — keys are fleet-wide credentials (ApiKey carries no ClusterId); it is // the grants that are cluster-bound. Asserting a null lookup here would pass for the wrong reason. found.ShouldNotBeNull(); found!.Grants.ShouldBeEmpty(); } /// /// The positive control for the test above: without it, a "fix" that returned no grants at all /// would pass. /// [Fact] public async Task A_cluster_scoped_grant_in_this_cluster_still_confers_its_permissions() { var factory = NewInMemoryDbFactory(); Seed(factory, keys: [Key("abc", "scadabridge-dcl")], acls: [ Acl("acl-mine", ThisCluster, "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate), Acl("acl-other", OtherCluster, "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate), ]); var found = await new ConfigDbApiKeyStore(factory, ThisCluster).FindAsync("abc", Now, default); found!.Grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-mine"); } /// /// A legacy / single-cluster node carries no cluster-{ClusterId} role, so its ClusterId is /// null and NO filter applies — mirroring the artifact path's ClusterFilterMode.None. /// Failing closed here would silently strip every grant on an existing deployment. /// [Fact] public async Task A_node_with_no_cluster_scoped_role_sees_every_cluster_scoped_grant() { var factory = NewInMemoryDbFactory(); Seed(factory, keys: [Key("abc", "p")], acls: [ Acl("acl-a", ThisCluster, "p", NodeAclScopeKind.Cluster, NodePermissions.Read), Acl("acl-b", OtherCluster, "p", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate), ]); var found = await new ConfigDbApiKeyStore(factory, clusterId: null).FindAsync("abc", Now, default); found!.Grants.Select(g => g.NodeAclId).ShouldBe(["acl-a", "acl-b"], ignoreOrder: true); } /// /// The node's ClusterId comes from its role suffix, and that arrives in either the operator's /// spelling (Cluster:Roles binds verbatim) or lower-cased (OTOPCUA_ROLES goes through /// RoleParser.Parse, which calls ToLowerInvariant). The ACL row's ClusterId is /// authored independently. A case-sensitive match would therefore strip every grant on a node /// configured the second way — so the match is OrdinalIgnoreCase, the same comparison /// DeploymentArtifact.ParseDataPlaneAuthority uses. /// [Fact] public async Task A_ClusterId_matches_case_insensitively() { var factory = NewInMemoryDbFactory(); Seed(factory, keys: [Key("abc", "p")], acls: [Acl("acl-mine", "SITE-A", "p", NodeAclScopeKind.Cluster, NodePermissions.Read)]); var found = await new ConfigDbApiKeyStore(factory, "site-a").FindAsync("abc", Now, default); found!.Grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-mine"); } /// /// FindGrantsAsync is the anonymous principal's path — no credential, so it never goes /// through FindAsync. It must be confined to the node's cluster too. /// [Fact] public async Task Anonymous_grants_are_cluster_confined_on_their_own_path() { var factory = NewInMemoryDbFactory(); Seed(factory, keys: [], acls: [ Acl("acl-anon-mine", ThisCluster, OpcUaPrincipals.Anonymous, NodeAclScopeKind.Cluster, NodePermissions.Read), Acl("acl-anon-other", OtherCluster, OpcUaPrincipals.Anonymous, NodeAclScopeKind.Cluster, NodePermissions.WriteOperate), ]); var grants = await new ConfigDbApiKeyStore(factory, ThisCluster) .FindGrantsAsync(OpcUaPrincipals.Anonymous, default); grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-anon-mine"); } /// /// Narrower-than-Cluster scopes ARE returned (#520 stage 5 widened this) — the cluster filter is /// now the only narrowing this store performs. /// /// /// The scope-kind filter that used to sit alongside it was redundant: ApiKeyInterimRoleMapper /// applies exactly that filter itself, so removing it changes no role — pinned below — while a /// truncated Grants collection would have quietly misled anything that read it once scope /// started being enforced. /// [Fact] public async Task A_narrower_scope_in_this_cluster_is_returned() { var factory = NewInMemoryDbFactory(); Seed(factory, keys: [Key("abc", "p")], acls: [ Acl("acl-cluster", ThisCluster, "p", NodeAclScopeKind.Cluster, NodePermissions.AlarmAcknowledge), Acl("acl-equipment", ThisCluster, "p", NodeAclScopeKind.Equipment, NodePermissions.WriteOperate), ]); var found = await new ConfigDbApiKeyStore(factory, ThisCluster).FindAsync("abc", Now, default); found!.Grants.Select(g => g.NodeAclId).ShouldBe(["acl-cluster", "acl-equipment"], ignoreOrder: true); // Both grants now reach the mapper, and it still takes flags from the CLUSTER-scoped one only: // AlarmAck is conferred, WriteOperate is not. The two grants carry different flags precisely so // this cannot pass by accident — which is why widening the store is safe with enforcement off. ApiKeyInterimRoleMapper.Map(found.Grants, "p").ShouldBe([OpcUaDataPlaneRoles.AlarmAck]); } /// /// The ClusterId derivation itself: the same one ClusterRoleInfo performs for /// IClusterRoleInfo.ClusterId, off the node's OWN configured roles. /// [Fact] public void ResolveClusterId_reads_the_nodes_own_cluster_scoped_role() { ConfigDbApiKeyStore.ResolveClusterId(new AkkaClusterOptions { Roles = ["driver", "cluster-SITE-A"] }) .ShouldBe("SITE-A"); ConfigDbApiKeyStore.ResolveClusterId(new AkkaClusterOptions { Roles = ["admin", "driver"] }) .ShouldBeNull(); ConfigDbApiKeyStore.ResolveClusterId(new AkkaClusterOptions()) .ShouldBeNull(); } private static void Seed( IDbContextFactory factory, IReadOnlyList keys, IReadOnlyList acls) { using var db = factory.CreateDbContext(); db.ApiKeys.AddRange(keys); db.NodeAcls.AddRange(acls); db.SaveChanges(); } private static ApiKey Key(string id, string principal) => new() { ApiKeyRowId = Guid.NewGuid(), ApiKeyId = id, Name = id, SecretHash = "hash", Principal = principal, Enabled = true, CreatedUtc = Now.AddDays(-1), }; private static NodeAcl Acl( string id, string clusterId, string principal, NodeAclScopeKind scope, NodePermissions flags) => new() { NodeAclRowId = Guid.NewGuid(), NodeAclId = id, ClusterId = clusterId, Principal = principal, ScopeKind = scope, PermissionFlags = flags, }; private static IDbContextFactory NewInMemoryDbFactory() => new InMemoryConfigDbFactory(Guid.NewGuid().ToString("N")); private sealed class InMemoryConfigDbFactory(string dbName) : IDbContextFactory { /// Creates a context over this test's own in-memory database. /// A new . public OtOpcUaConfigDbContext CreateDbContext() => new(new DbContextOptionsBuilder().UseInMemoryDatabase(dbName).Options); } }