Files
scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests/TwoNodeClusterReplicationTests.cs
T
Joseph Doherty dd0a846b64 feat(secrets): cluster replication via SQL Server and Akka.NET (G-7, 0.2.0)
Secrets were per-node SQLite, so a secret written on one node was invisible to
the rest of a cluster. G-7's design resolved the "shared SQL store vs Akka
replicator" fork to build only the former; both are built here so the choice is
a deployment decision (availability vs partition tolerance) rather than a
library limitation.

Two new packages — ZB.MOM.WW.Secrets.Replicator.SqlServer (shared store, plus a
local-store-with-hub mode) and .Replicator.AkkaDotNet (peer-to-peer over
distributed pub/sub). Core gains ISecretsStoreMigrator, one shared
SecretLastWriterWins predicate so no two stores can disagree on a tie, the
transport-agnostic reconciler, and ReplicatingSecretStore — which closes a real
gap: nothing had ever called ISecretReplicator.PublishAsync, so the seam was
inert and local writes would not have propagated at all.

Verified 182 pass / 1 skip / 0 warnings, including 15 live tests against a real
SQL Server 2022 (the SQLite suite ported case-for-case, so any behavioural
divergence between the stores fails) and a 9-test in-process 2-node Akka
cluster over real remoting. A post-build review caught six defects, all fixed
and now covered: both replication modes could not resolve from the container
(no test had built one), an unbounded fetch that broke past SQL Server's
2100-parameter cap, a poison row that aborted the rest of its batch forever,
Enum.Parse on peer input that could restart the actor in a loop, null crypto
blobs crossing the trust boundary, and a silently dropped pull-read failure.

Packed at 0.2.0 and vulnerability-scanned clean; not yet published to the feed.

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
2026-07-18 04:08:23 -04:00

355 lines
14 KiB
C#

using Akka.Actor;
using Akka.Cluster;
using Akka.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Replication;
using ZB.MOM.WW.Secrets.Sqlite;
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests;
/// <summary>
/// Two real cluster nodes, two real SQLite stores, real remoting over loopback — the gate the G-7
/// design called for, run in-process so it belongs to the ordinary offline suite instead of needing
/// a deployed rig.
/// </summary>
/// <remarks>
/// Nothing here is faked: each node runs its own <see cref="ActorSystem"/> with Akka.Remote on its
/// own port, they form a cluster, and rows travel through the actual serializer and the actual
/// distributed pub/sub mediator. A mock-based test of this behaviour would prove almost nothing —
/// the failure modes worth catching (self-echo, sender loss across the mediator, tombstones that do
/// not propagate, a partition that never re-converges) only appear when messages really cross nodes.
/// </remarks>
public sealed class TwoNodeClusterReplicationTests : IAsyncLifetime
{
private readonly List<string> _dbPaths = [];
private ActorSystem _systemA = null!;
private ActorSystem _systemB = null!;
private SqliteSecretStore _storeA = null!;
private SqliteSecretStore _storeB = null!;
private ISecretStore _writableA = null!;
private IActorRef _replicatorB = null!;
// Short so anti-entropy assertions do not dominate the suite's runtime.
private static readonly TimeSpan AnnounceInterval = TimeSpan.FromMilliseconds(300);
private static readonly TimeSpan Patience = TimeSpan.FromSeconds(20);
public async Task InitializeAsync()
{
_storeA = CreateStore();
_storeB = CreateStore();
// Port 0 lets the OS assign free ports, so parallel test runs cannot collide.
_systemA = ActorSystem.Create("zb-secrets-cluster", ClusterConfig(port: 0));
int portA = ClusterPort(_systemA);
// Node A seeds itself; node B joins it.
_systemA.Dispose();
_systemA = ActorSystem.Create("zb-secrets-cluster", ClusterConfig(portA, seedPort: portA));
_systemB = ActorSystem.Create("zb-secrets-cluster", ClusterConfig(port: 0, seedPort: portA));
await AwaitClusterUpAsync(_systemA, expectedMembers: 2);
await AwaitClusterUpAsync(_systemB, expectedMembers: 2);
IActorRef replicatorA = _systemA.ActorOf(
SecretReplicationActor.Props(_storeA, null, AnnounceInterval), "zb-secret-replication");
_replicatorB = _systemB.ActorOf(
SecretReplicationActor.Props(_storeB, null, AnnounceInterval), "zb-secret-replication");
_writableA = new ReplicatingSecretStore(
_storeA, new AkkaSecretReplicator(replicatorA), NullLogger<ReplicatingSecretStore>.Instance);
// The mediator gossips subscriptions between nodes; publishing before both sides are
// registered would silently drop the message.
await AwaitBothSubscribedAsync();
}
private SqliteSecretStore CreateStore()
{
string path = Path.Combine(Path.GetTempPath(), $"zb-akka-{Guid.NewGuid():N}.db");
_dbPaths.Add(path);
var factory = new SecretsSqliteConnectionFactory(path);
new SqliteSecretsStoreMigrator(factory).MigrateAsync(CancellationToken.None).GetAwaiter().GetResult();
return new SqliteSecretStore(factory);
}
private static Config ClusterConfig(int port, int? seedPort = null)
{
string seeds = seedPort is null
? "[]"
: $"[\"akka.tcp://zb-secrets-cluster@127.0.0.1:{seedPort}\"]";
return ConfigurationFactory.ParseString($$"""
akka {
loglevel = WARNING
actor.provider = cluster
remote.dot-netty.tcp {
hostname = "127.0.0.1"
public-hostname = "127.0.0.1"
port = {{port}}
}
cluster {
seed-nodes = {{seeds}}
downing-provider-class = "Akka.Cluster.SBR.SplitBrainResolverProvider"
}
}
""").WithFallback(AkkaSecretsReplication.SerializationConfig);
}
private static int ClusterPort(ActorSystem system) =>
Cluster.Get(system).SelfAddress.Port
?? throw new InvalidOperationException("Cluster address has no port.");
private static async Task AwaitClusterUpAsync(ActorSystem system, int expectedMembers)
{
Cluster cluster = Cluster.Get(system);
DateTime deadline = DateTime.UtcNow + Patience;
while (DateTime.UtcNow < deadline)
{
if (cluster.State.Members.Count(m => m.Status == MemberStatus.Up) >= expectedMembers)
{
return;
}
await Task.Delay(100);
}
throw new TimeoutException(
$"Cluster did not reach {expectedMembers} Up members within {Patience}.");
}
// Writes a throwaway secret on A and waits for it to land on B. That round trip is the only
// reliable signal that pub/sub subscriptions have gossiped across both nodes.
private async Task AwaitBothSubscribedAsync()
{
var probe = new SecretName("warmup/probe");
DateTime deadline = DateTime.UtcNow + Patience;
while (DateTime.UtcNow < deadline)
{
await _writableA.UpsertAsync(Row("warmup/probe", 0x01), CancellationToken.None);
if (await WaitForAsync(_storeB, probe, _ => true, TimeSpan.FromMilliseconds(500)))
{
await _storeA.DeleteAsync(probe, "warmup", CancellationToken.None);
await _storeB.DeleteAsync(probe, "warmup", CancellationToken.None);
return;
}
}
throw new TimeoutException("Nodes did not establish pub/sub subscriptions in time.");
}
private static StoredSecret Row(string name, byte marker) => new()
{
Name = new SecretName(name),
ContentType = SecretContentType.Text,
Ciphertext = [marker],
Nonce = [1],
Tag = [2],
WrappedDek = [3],
WrapNonce = [4],
WrapTag = [5],
KekId = "sha256:shared-kek",
Revision = 0,
CreatedUtc = DateTimeOffset.UtcNow,
UpdatedUtc = DateTimeOffset.UtcNow,
};
private static async Task<bool> WaitForAsync(
ISecretStore store, SecretName name, Func<StoredSecret, bool> predicate, TimeSpan? timeout = null)
{
DateTime deadline = DateTime.UtcNow + (timeout ?? Patience);
while (DateTime.UtcNow < deadline)
{
StoredSecret? row = await store.GetAsync(name, CancellationToken.None);
if (row is not null && predicate(row))
{
return true;
}
await Task.Delay(50);
}
return false;
}
[Fact]
public async Task A_secret_written_on_node_A_becomes_resolvable_on_node_B()
{
var name = new SecretName("db/password");
await _writableA.UpsertAsync(Row("db/password", 0xAA), CancellationToken.None);
Assert.True(
await WaitForAsync(_storeB, name, r => r.Ciphertext[0] == 0xAA),
"Node B never received the secret written on node A.");
}
[Fact]
public async Task The_ciphertext_arrives_byte_identical()
{
var name = new SecretName("app/exact");
await _writableA.UpsertAsync(Row("app/exact", 0x7F), CancellationToken.None);
Assert.True(await WaitForAsync(_storeB, name, _ => true));
StoredSecret onA = (await _storeA.GetAsync(name, CancellationToken.None))!;
StoredSecret onB = (await _storeB.GetAsync(name, CancellationToken.None))!;
Assert.Equal(onA.Ciphertext, onB.Ciphertext);
Assert.Equal(onA.WrappedDek, onB.WrappedDek);
Assert.Equal(onA.KekId, onB.KekId);
// Verbatim: the peer keeps the originating node's revision and timestamp, or the row would
// look newer on B than on A and bounce back.
Assert.Equal(onA.Revision, onB.Revision);
Assert.Equal(onA.UpdatedUtc, onB.UpdatedUtc);
}
[Fact]
public async Task A_delete_on_node_A_tombstones_the_row_on_node_B()
{
var name = new SecretName("app/doomed");
await _writableA.UpsertAsync(Row("app/doomed", 0xBB), CancellationToken.None);
Assert.True(await WaitForAsync(_storeB, name, _ => true));
await _writableA.DeleteAsync(name, "operator", CancellationToken.None);
Assert.True(
await WaitForAsync(_storeB, name, r => r.IsDeleted),
"The delete never propagated to node B.");
}
[Fact]
public async Task An_update_overwrites_the_earlier_value_on_the_peer()
{
var name = new SecretName("app/rotating");
await _writableA.UpsertAsync(Row("app/rotating", 0x01), CancellationToken.None);
Assert.True(await WaitForAsync(_storeB, name, r => r.Ciphertext[0] == 0x01));
await _writableA.UpsertAsync(Row("app/rotating", 0x02), CancellationToken.None);
Assert.True(
await WaitForAsync(_storeB, name, r => r.Ciphertext[0] == 0x02 && r.Revision == 1),
"Node B did not converge on the updated value.");
}
[Fact]
public async Task Anti_entropy_repairs_a_write_that_never_got_broadcast()
{
// Simulates a dropped publish — the write goes straight to node A's store, bypassing the
// replicator entirely, so the ONLY route to node B is the periodic manifest exchange. This is
// the case that makes best-effort publishing safe.
var name = new SecretName("app/missed-broadcast");
await _storeA.UpsertAsync(Row("app/missed-broadcast", 0xCC), CancellationToken.None);
Assert.True(
await WaitForAsync(_storeB, name, r => r.Ciphertext[0] == 0xCC),
"Anti-entropy failed to repair a write that was never broadcast.");
}
[Fact]
public async Task Anti_entropy_pulls_in_the_other_direction_too()
{
// The mirror case: node B holds a row node A has never heard of. A cannot request a name it
// does not know exists, so this only converges if the exchange pushes as well as pulls.
var name = new SecretName("app/born-on-b");
await _storeB.UpsertAsync(Row("app/born-on-b", 0xDD), CancellationToken.None);
Assert.True(
await WaitForAsync(_storeA, name, r => r.Ciphertext[0] == 0xDD),
"Node A never learned about a secret that originated on node B.");
}
[Fact]
public async Task A_node_that_joins_late_catches_up_on_everything()
{
// Partition-heal / cold-start: the whole reason to choose this transport over a shared store.
await _writableA.UpsertAsync(Row("bulk/one", 0x11), CancellationToken.None);
await _writableA.UpsertAsync(Row("bulk/two", 0x22), CancellationToken.None);
await _writableA.UpsertAsync(Row("bulk/three", 0x33), CancellationToken.None);
SqliteSecretStore lateStore = CreateStore();
_systemB.ActorOf(
SecretReplicationActor.Props(lateStore, null, AnnounceInterval), "late-joiner");
Assert.True(await WaitForAsync(lateStore, new SecretName("bulk/one"), r => r.Ciphertext[0] == 0x11));
Assert.True(await WaitForAsync(lateStore, new SecretName("bulk/two"), r => r.Ciphertext[0] == 0x22));
Assert.True(await WaitForAsync(lateStore, new SecretName("bulk/three"), r => r.Ciphertext[0] == 0x33));
}
[Fact]
public async Task A_broadcast_round_trip_does_not_drift_the_originating_nodes_revision()
{
// Note on what this does and does NOT prove: the revision would also stay put if the self-echo
// filter were removed, because re-applying an identical row ties on (updated_utc, revision)
// and last-writer-wins rejects a tie. So this covers the drift symptom via defence in depth,
// not the filter itself. The filter is what stops the echo being forwarded onward, which
// needs three nodes to observe — worth adding if a third node ever joins this rig.
var name = new SecretName("app/no-echo");
await _writableA.UpsertAsync(Row("app/no-echo", 0xEE), CancellationToken.None);
Assert.True(await WaitForAsync(_storeB, name, _ => true));
await Task.Delay(AnnounceInterval * 4);
StoredSecret onA = (await _storeA.GetAsync(name, CancellationToken.None))!;
Assert.Equal(0, onA.Revision);
}
[Fact]
public async Task A_malformed_row_from_a_peer_is_discarded_without_stalling_replication()
{
// Peer input is untrusted: a path-traversing name must be dropped, and — critically — the
// actor must keep serving everything else afterwards.
_replicatorB.Tell(new Protocol.SecretRowsMessage(
[
new Protocol.SecretRowDto
{
Name = "../../etc/passwd",
ContentType = "Text",
Ciphertext = [1], Nonce = [1], Tag = [1],
WrappedDek = [1], WrapNonce = [1], WrapTag = [1],
KekId = "sha256:x",
Revision = 0,
CreatedUtc = DateTimeOffset.UtcNow.ToString("O"),
UpdatedUtc = DateTimeOffset.UtcNow.ToString("O"),
},
]));
var name = new SecretName("app/after-malformed");
await _writableA.UpsertAsync(Row("app/after-malformed", 0x5A), CancellationToken.None);
Assert.True(
await WaitForAsync(_storeB, name, r => r.Ciphertext[0] == 0x5A),
"Replication stalled after a malformed row was received.");
}
public async Task DisposeAsync()
{
await CoordinatedShutdown.Get(_systemA).Run(CoordinatedShutdown.ClrExitReason.Instance);
await CoordinatedShutdown.Get(_systemB).Run(CoordinatedShutdown.ClrExitReason.Instance);
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
foreach (string db in _dbPaths)
{
foreach (string path in new[] { db, db + "-wal", db + "-shm" })
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch (IOException)
{
// Best-effort temp cleanup.
}
}
}
}
}