using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations; using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests; /// /// End-to-end contract between the two halves of #520 stage 4: writes the /// API keys + ACL grants into the artifact, and /// reads them back out on the node. /// /// /// These deliberately compose a REAL artifact rather than hand-writing the JSON. A hand-written fixture /// pins the parser against my own idea of the format, not against the producer — the failure it would /// miss is exactly the one that matters here: someone changes how the composer serialises (a naming /// policy, a JsonStringEnumConverter) and every key and grant silently stops arriving at the /// node, with no error anywhere and no test going red. /// public sealed class DataPlaneAuthorityArtifactTests : ControlPlaneActorTestBase { private const string NodeA = "node-a:4840"; private const string NodeB = "node-b:4840"; [Fact] public async Task A_key_survives_the_round_trip_through_a_composed_artifact() { var f = NewInMemoryDbFactory(); await using (var db = f.CreateDbContext()) { db.ServerClusters.Add(NewCluster("cluster-a")); db.ApiKeys.Add(NewKey("abc123", "scadabridge-dcl")); await db.SaveChangesAsync(); } var artifact = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext()); var parsed = DeploymentArtifact.ParseDataPlaneAuthority(artifact.Blob, NodeA); var key = parsed.Keys.ShouldHaveSingleItem(); key.ApiKeyId.ShouldBe("abc123"); key.Principal.ShouldBe("scadabridge-dcl"); key.SecretHash.ShouldBe("hash-of-abc123"); key.Enabled.ShouldBeTrue(); } [Fact] public async Task A_grant_survives_the_round_trip_with_its_scope_kind_and_permission_flags() { // The enum fields are the fragile part: ConfigComposer writes them as NUMBERS, so the parser reads // int. If that ever diverges the grant is dropped and a key silently authenticates with no // permissions — which looks exactly like a correctly-unprivileged key. var f = NewInMemoryDbFactory(); await using (var db = f.CreateDbContext()) { db.ServerClusters.Add(NewCluster("cluster-a")); db.NodeAcls.Add(NewAcl("acl-1", "cluster-a", "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.Read | NodePermissions.WriteOperate)); await db.SaveChangesAsync(); } var artifact = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext()); var parsed = DeploymentArtifact.ParseDataPlaneAuthority(artifact.Blob, NodeA); var grant = parsed.Grants.ShouldHaveSingleItem(); grant.Principal.ShouldBe("scadabridge-dcl"); grant.ScopeKind.ShouldBe(NodeAclScopeKind.Cluster); grant.PermissionFlags.ShouldBe(NodePermissions.Read | NodePermissions.WriteOperate); } [Fact] public async Task A_non_cluster_scope_kind_round_trips_as_itself() { // Falsifies "the enum happens to work because Cluster is 0". Equipment is 4, so a broken // enum read would surface as Cluster here rather than as a dropped row. var f = NewInMemoryDbFactory(); await using (var db = f.CreateDbContext()) { db.ServerClusters.Add(NewCluster("cluster-a")); var acl = NewAcl("acl-1", "cluster-a", "p", NodeAclScopeKind.Equipment, NodePermissions.Read); acl.ScopeId = "EQ-1"; db.NodeAcls.Add(acl); await db.SaveChangesAsync(); } var artifact = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext()); var parsed = DeploymentArtifact.ParseDataPlaneAuthority(artifact.Blob, NodeA); var grant = parsed.Grants.ShouldHaveSingleItem(); grant.ScopeKind.ShouldBe(NodeAclScopeKind.Equipment); grant.ScopeId.ShouldBe("EQ-1"); } [Fact] public async Task An_expiry_round_trips_as_the_same_UTC_instant() { // Covers the round trip only. It does NOT cover the timezone hazard: the in-memory EF provider // preserves DateTimeKind.Utc, so the composer writes a "Z" here and the parser's Unspecified // branch never runs — verified by breaking that branch and watching this test still pass. SQL // Server's datetime2 DOES materialise as Unspecified, so the branch is real in production and is // pinned directly at the parser by // DeploymentArtifactDataPlaneAuthorityTests.An_expiry_written_without_an_offset_is_read_as_UTC. var expires = new DateTime(2030, 6, 15, 12, 0, 0, DateTimeKind.Utc); var f = NewInMemoryDbFactory(); await using (var db = f.CreateDbContext()) { db.ServerClusters.Add(NewCluster("cluster-a")); var key = NewKey("abc123", "p"); key.ExpiresUtc = expires; db.ApiKeys.Add(key); await db.SaveChangesAsync(); } var artifact = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext()); var parsed = DeploymentArtifact.ParseDataPlaneAuthority(artifact.Blob, NodeA); var parsedKey = parsed.Keys.ShouldHaveSingleItem(); parsedKey.ExpiresUtc.ShouldNotBeNull(); parsedKey.ExpiresUtc!.Value.ShouldBe(expires); parsedKey.ExpiresUtc!.Value.Kind.ShouldBe(DateTimeKind.Utc); } [Fact] public async Task Grants_are_scoped_to_the_nodes_own_cluster_but_keys_are_not() { // Multi-cluster artifact: node-a belongs to cluster-a, so it must not receive cluster-b's grants. // Keys carry no ClusterId — a key is a fleet-wide credential, and it is the ACL rows that decide // what it can reach — so BOTH keys arrive. var f = NewInMemoryDbFactory(); await using (var db = f.CreateDbContext()) { db.ServerClusters.Add(NewCluster("cluster-a")); db.ServerClusters.Add(NewCluster("cluster-b")); db.ClusterNodes.Add(NewNode(NodeA, "cluster-a")); db.ClusterNodes.Add(NewNode(NodeB, "cluster-b")); db.ApiKeys.Add(NewKey("key-a", "principal-a")); db.ApiKeys.Add(NewKey("key-b", "principal-b")); db.NodeAcls.Add(NewAcl("acl-a", "cluster-a", "principal-a", NodeAclScopeKind.Cluster, NodePermissions.Read)); db.NodeAcls.Add(NewAcl("acl-b", "cluster-b", "principal-b", NodeAclScopeKind.Cluster, NodePermissions.Read)); await db.SaveChangesAsync(); } var artifact = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext()); var parsed = DeploymentArtifact.ParseDataPlaneAuthority(artifact.Blob, NodeA); parsed.ClusterId.ShouldBe("cluster-a"); parsed.Grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-a"); parsed.Keys.Select(k => k.ApiKeyId).OrderBy(x => x).ShouldBe(["key-a", "key-b"]); } [Fact] public async Task A_node_absent_from_a_multi_cluster_artifact_receives_no_grants() { // ResolveClusterScope reads an unlisted node as Suppress — it was not deployed to. Handing it // another cluster's grants would be the worst possible reading of that. var f = NewInMemoryDbFactory(); await using (var db = f.CreateDbContext()) { db.ServerClusters.Add(NewCluster("cluster-a")); db.ServerClusters.Add(NewCluster("cluster-b")); db.ClusterNodes.Add(NewNode(NodeB, "cluster-b")); db.NodeAcls.Add(NewAcl("acl-b", "cluster-b", "principal-b", NodeAclScopeKind.Cluster, NodePermissions.Read)); await db.SaveChangesAsync(); } var artifact = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext()); var parsed = DeploymentArtifact.ParseDataPlaneAuthority(artifact.Blob, "node-nowhere:4840"); parsed.Grants.ShouldBeEmpty(); } [Fact] public async Task A_revoked_key_still_ships_so_the_node_can_see_the_revocation() { // Filtering revoked keys out of the artifact would make a revocation indistinguishable from a key // that was never deployed — and worse, would leave the node's previous snapshot as the last word // on that id. The key ships with Enabled=false and the lookup denies it. var f = NewInMemoryDbFactory(); await using (var db = f.CreateDbContext()) { db.ServerClusters.Add(NewCluster("cluster-a")); var key = NewKey("abc123", "p"); key.Enabled = false; db.ApiKeys.Add(key); await db.SaveChangesAsync(); } var artifact = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext()); var parsed = DeploymentArtifact.ParseDataPlaneAuthority(artifact.Blob, NodeA); parsed.Keys.ShouldHaveSingleItem().Enabled.ShouldBeFalse(); } [Fact] public void An_empty_or_malformed_blob_yields_nothing() { DeploymentArtifact.ParseDataPlaneAuthority(ReadOnlySpan.Empty, NodeA) .Keys.ShouldBeEmpty(); DeploymentArtifact.ParseDataPlaneAuthority("not json at all"u8, NodeA) .Grants.ShouldBeEmpty(); } [Fact] public async Task Adding_a_key_changes_the_revision_hash() { // The node's generation stamp is driven by this hash, so a key change that did not move it would // leave the node believing it had already applied the new authorization inputs. var f = NewInMemoryDbFactory(); await using (var db = f.CreateDbContext()) { db.ServerClusters.Add(NewCluster("cluster-a")); await db.SaveChangesAsync(); } var before = (await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext())).RevisionHash; await using (var db = f.CreateDbContext()) { db.ApiKeys.Add(NewKey("abc123", "p")); await db.SaveChangesAsync(); } var after = (await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext())).RevisionHash; after.ShouldNotBe(before); } private static readonly DateTime FixedTimestamp = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); private static ServerCluster NewCluster(string id) => new() { ClusterId = id, Name = id, Enterprise = "ent", Site = "site", RedundancyMode = RedundancyMode.None, CreatedBy = "test", CreatedAt = FixedTimestamp, }; private static ClusterNode NewNode(string nodeId, string clusterId) => new() { NodeId = nodeId, ClusterId = clusterId, Host = nodeId.Split(':')[0], ApplicationUri = $"urn:{nodeId}", CreatedBy = "test", CreatedAt = FixedTimestamp, }; private static ApiKey NewKey(string apiKeyId, string principal) => new() { ApiKeyRowId = Guid.NewGuid(), ApiKeyId = apiKeyId, Name = $"key {apiKeyId}", SecretHash = $"hash-of-{apiKeyId}", Principal = principal, Enabled = true, CreatedUtc = FixedTimestamp, }; private static NodeAcl NewAcl( string id, string clusterId, string principal, NodeAclScopeKind scopeKind, NodePermissions flags) => new() { NodeAclRowId = Guid.NewGuid(), NodeAclId = id, ClusterId = clusterId, Principal = principal, ScopeKind = scopeKind, PermissionFlags = flags, }; }