dd0a846b64
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
161 lines
6.4 KiB
C#
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);
|
|
}
|
|
}
|