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
82 lines
2.9 KiB
C#
82 lines
2.9 KiB
C#
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());
|
|
}
|
|
}
|