Files
scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests/SecretReplicationSerializerTests.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

161 lines
6.4 KiB
C#

using Akka.Actor;
using Akka.Serialization;
using Akka.TestKit.Xunit2;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Protocol;
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests;
/// <summary>
/// Wire-contract tests. Secret ciphertext crosses the network as these bytes, so the round-trip is
/// asserted field-by-field rather than by equality alone — a silently dropped crypto field would
/// produce a row that stores fine and fails to decrypt much later, on another node.
/// </summary>
public sealed class SecretReplicationSerializerTests : TestKit
{
public SecretReplicationSerializerTests()
: base(AkkaSecretsReplication.SerializationConfig)
{
}
private static StoredSecret Row(string name) => new()
{
Name = new SecretName(name),
Description = "a description",
ContentType = SecretContentType.ConnectionString,
Ciphertext = [1, 2, 3, 250],
Nonce = [4, 5, 6],
Tag = [7, 8, 9],
WrappedDek = [10, 11, 12],
WrapNonce = [13, 14, 15],
WrapTag = [16, 17, 18],
KekId = "sha256:abc",
Revision = 42,
IsDeleted = true,
DeletedUtc = new DateTimeOffset(2026, 3, 2, 1, 0, 0, TimeSpan.Zero),
CreatedUtc = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero),
UpdatedUtc = new DateTimeOffset(2026, 2, 1, 0, 0, 0, TimeSpan.Zero),
CreatedBy = "alice",
UpdatedBy = "bob",
};
private object RoundTrip(object message)
{
Serializer serializer = Sys.Serialization.FindSerializerFor(message);
Assert.IsType<SecretReplicationSerializer>(serializer);
byte[] bytes = serializer.ToBinary(message);
string manifest = ((SerializerWithStringManifest)serializer).Manifest(message);
return ((SerializerWithStringManifest)serializer).FromBinary(bytes, manifest);
}
[Fact]
public void SecretRowsMessage_round_trips_every_field()
{
StoredSecret original = Row("app/db-conn");
var message = new SecretRowsMessage([SecretRowDto.FromStoredSecret(original)]);
var restored = (SecretRowsMessage)RoundTrip(message);
StoredSecret result = restored.Rows.Single().ToStoredSecret();
Assert.Equal(original.Name.Value, result.Name.Value);
Assert.Equal(original.Description, result.Description);
Assert.Equal(original.ContentType, result.ContentType);
Assert.Equal(original.Ciphertext, result.Ciphertext);
Assert.Equal(original.Nonce, result.Nonce);
Assert.Equal(original.Tag, result.Tag);
Assert.Equal(original.WrappedDek, result.WrappedDek);
Assert.Equal(original.WrapNonce, result.WrapNonce);
Assert.Equal(original.WrapTag, result.WrapTag);
Assert.Equal(original.KekId, result.KekId);
Assert.Equal(original.Revision, result.Revision);
Assert.Equal(original.IsDeleted, result.IsDeleted);
Assert.Equal(original.DeletedUtc, result.DeletedUtc);
Assert.Equal(original.CreatedUtc, result.CreatedUtc);
Assert.Equal(original.UpdatedUtc, result.UpdatedUtc);
Assert.Equal(original.CreatedBy, result.CreatedBy);
Assert.Equal(original.UpdatedBy, result.UpdatedBy);
}
[Fact]
public void Timestamps_survive_the_wire_exactly()
{
// Last-writer-wins compares UpdatedUtc for EQUALITY to break revision ties. A serializer that
// rounded to the millisecond would turn a tie into a spurious "newer" and two nodes would
// then overwrite each other forever, so tick-exactness is a correctness requirement.
StoredSecret original = Row("app/precise") with
{
UpdatedUtc = new DateTimeOffset(2026, 2, 1, 12, 34, 56, TimeSpan.Zero).AddTicks(1234567),
};
var restored = (SecretRowsMessage)RoundTrip(
new SecretRowsMessage([SecretRowDto.FromStoredSecret(original)]));
Assert.Equal(original.UpdatedUtc, restored.Rows.Single().ToStoredSecret().UpdatedUtc);
Assert.Equal(original.UpdatedUtc.Ticks, restored.Rows.Single().ToStoredSecret().UpdatedUtc.Ticks);
}
[Fact]
public void ManifestAnnounce_round_trips()
{
var message = new SecretManifestAnnounce(
[
SecretManifestEntryDto.FromEntry(new SecretManifestEntry
{
Name = new SecretName("a/one"),
Revision = 3,
UpdatedUtc = new DateTimeOffset(2026, 5, 5, 5, 5, 5, TimeSpan.Zero),
IsDeleted = true,
}),
]);
var restored = (SecretManifestAnnounce)RoundTrip(message);
SecretManifestEntry entry = restored.Entries.Single().ToEntry();
Assert.Equal("a/one", entry.Name.Value);
Assert.Equal(3, entry.Revision);
Assert.True(entry.IsDeleted);
Assert.Equal(new DateTimeOffset(2026, 5, 5, 5, 5, 5, TimeSpan.Zero), entry.UpdatedUtc);
}
[Fact]
public void PullRequest_round_trips()
{
var restored = (SecretPullRequest)RoundTrip(new SecretPullRequest(["a/one", "b/two"]));
Assert.Equal(["a/one", "b/two"], restored.Names);
}
[Fact]
public void An_unknown_manifest_fails_loudly_rather_than_guessing()
{
// A mixed-version cluster must fail on an unrecognized payload, not mis-deserialize a secret.
var serializer = (SerializerWithStringManifest)Sys.Serialization
.FindSerializerFor(new SecretPullRequest([]));
Assert.Throws<System.Runtime.Serialization.SerializationException>(
() => serializer.FromBinary([1, 2, 3], "zbs-from-the-future-v9"));
}
[Fact]
public void The_wire_contract_carries_no_field_that_could_hold_key_material()
{
// Structural guard on the trust boundary: the DTO's surface is fixed and reviewed. If someone
// adds a property to SecretRowDto, this test fails and forces a deliberate look at whether
// the new field is safe to put on the wire.
string[] properties = [.. typeof(SecretRowDto)
.GetProperties()
.Select(p => p.Name)
.Order(StringComparer.Ordinal)];
Assert.Equal(
[
"Ciphertext", "ContentType", "CreatedBy", "CreatedUtc", "DeletedUtc", "Description",
"IsDeleted", "KekId", "Name", "Nonce", "Revision", "Tag", "UpdatedBy", "UpdatedUtc",
"WrapNonce", "WrapTag", "WrappedDek",
],
properties);
}
}