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,
};
}