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
This commit is contained in:
+81
@@ -0,0 +1,81 @@
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Protocol;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The DTO is the trust boundary for anything a peer sends. Every rejection here must surface as an
|
||||
/// <see cref="ArgumentException"/>, because that is the exception type the receiving actor filters
|
||||
/// on — anything else escapes its handler, restarts the actor, and (since the row is redelivered)
|
||||
/// loops.
|
||||
/// </summary>
|
||||
public sealed class HostileWireInputTests
|
||||
{
|
||||
private static SecretRowDto Valid() => new()
|
||||
{
|
||||
Name = "app/ok",
|
||||
ContentType = "Text",
|
||||
Ciphertext = [1],
|
||||
Nonce = [2],
|
||||
Tag = [3],
|
||||
WrappedDek = [4],
|
||||
WrapNonce = [5],
|
||||
WrapTag = [6],
|
||||
KekId = "sha256:x",
|
||||
Revision = 0,
|
||||
CreatedUtc = "2026-01-01T00:00:00.0000000+00:00",
|
||||
UpdatedUtc = "2026-01-01T00:00:00.0000000+00:00",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void A_valid_row_materializes()
|
||||
{
|
||||
StoredSecret row = Valid().ToStoredSecret();
|
||||
|
||||
Assert.Equal("app/ok", row.Name.Value);
|
||||
Assert.Equal(SecretContentType.Text, row.ContentType);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("../../etc/passwd")]
|
||||
[InlineData("/rooted")]
|
||||
[InlineData("has space")]
|
||||
[InlineData("")]
|
||||
public void A_malformed_name_is_rejected(string name)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => (Valid() with { Name = name }).ToStoredSecret());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// An in-range number names no member — Enum.Parse would happily produce (SecretContentType)4096
|
||||
// and the store would persist it for later readers that assume a defined value.
|
||||
[InlineData("4096")]
|
||||
// Overflows the underlying type: Enum.Parse throws OverflowException, which is NOT an
|
||||
// ArgumentException, so it would escape the actor's filter and restart it.
|
||||
[InlineData("99999999999999999999")]
|
||||
[InlineData("NotAContentType")]
|
||||
[InlineData("")]
|
||||
public void An_unrecognized_content_type_is_rejected_as_an_ArgumentException(string contentType)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(
|
||||
() => (Valid() with { ContentType = contentType }).ToStoredSecret());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_null_crypto_blob_is_rejected_at_the_boundary()
|
||||
{
|
||||
// `required byte[]` means "present in the payload", not "non-null" — an explicit JSON null
|
||||
// satisfies it. Caught here, or it blows up deep in the store's parameter binding instead.
|
||||
Assert.Throws<ArgumentException>(
|
||||
() => (Valid() with { Ciphertext = null! }).ToStoredSecret());
|
||||
Assert.Throws<ArgumentException>(
|
||||
() => (Valid() with { WrappedDek = null! }).ToStoredSecret());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_malformed_timestamp_is_rejected()
|
||||
{
|
||||
Assert.ThrowsAny<FormatException>(
|
||||
() => (Valid() with { UpdatedUtc = "not-a-date" }).ToStoredSecret());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user