c5ea3a7b59
Client half of the central-hosted secrets hub: a site node follows central and never pushes. - `ISecretsHubReader` + `GrpcSecretsHubClient` — the read half of the hub as a follower sees it (manifest + bulk fetch, nothing else). Bearer credential and a per-call deadline are attached per call; responses convert through `WireToDomain`. Faults are deliberately not caught here, so an unreachable hub can never be mistaken for an empty one. - `WireToDomain` — strict, per-row proto -> domain parse mirroring the Akka replicator's `SecretRowDto`: a name the domain would reject, a content type naming no enum member, or a non-round-trip timestamp is refused at the boundary. Explicit proto3 presence keeps null distinguishable from empty. - `GrpcSecretSyncService` — `SqlServerSecretSyncService` with the push half removed. One `ReconcileAsync` per tick (pull-only by the reconciler's own contract, and batched, so a cold-start pull set cannot exceed gRPC's 4 MB cap), never throws out of `ExecuteAsync`, warns once and retries on the next interval. The local store is left completely undecorated — the sweep is its only hub-ward writer. - `SecretsGrpcHubClientOptions` — fail-closed `Validate()` naming the exact configuration key, including the section a caller actually bound. The bearer token is never echoed by any message. - `AddZbSecretsGrpcHubClient` — binds, eagerly validates, and registers the channel under a service KEY so it cannot lose a `TryAdd` race to an application's own `GrpcChannel` and dial the wrong endpoint with the hub credential attached. Tests 52/52 (red-first), including a source-scan guard pinning that no client file names `ComputePushSet`, `ReadLocalAsync`, or `ApplyReplicatedAsync`, and a reflection pin that the reader seam exposes exactly two read operations. Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
206 lines
6.7 KiB
C#
206 lines
6.7 KiB
C#
using Google.Protobuf;
|
|
using ZB.MOM.WW.Secrets.Abstractions;
|
|
using ZB.MOM.WW.Secrets.Replicator.Grpc.V1;
|
|
using ZB.MOM.WW.Secrets.Replicator.Grpc.Wire;
|
|
|
|
namespace ZB.MOM.WW.Secrets.Replicator.Grpc.Tests;
|
|
|
|
/// <summary>
|
|
/// The proto -> domain boundary: a follower rebuilding rows a hub sent it. These are the checks
|
|
/// protobuf itself cannot make — a name that normalizes and validates, a content type that names a
|
|
/// real member, and timestamps whose exact representation survives, because last-writer-wins
|
|
/// compares them.
|
|
/// </summary>
|
|
public sealed class WireToDomainTests
|
|
{
|
|
private static SecretRow FullRow() => new()
|
|
{
|
|
Name = "db/password",
|
|
Description = "primary db",
|
|
ContentType = nameof(SecretContentType.ConnectionString),
|
|
Ciphertext = ByteString.CopyFrom(0xAA, 0xBB),
|
|
Nonce = ByteString.CopyFrom(1, 2, 3),
|
|
Tag = ByteString.CopyFrom(4, 5),
|
|
WrappedDek = ByteString.CopyFrom(6),
|
|
WrapNonce = ByteString.CopyFrom(7),
|
|
WrapTag = ByteString.CopyFrom(8),
|
|
KekId = "sha256:abc",
|
|
Revision = 7,
|
|
IsDeleted = false,
|
|
CreatedUtc = new DateTimeOffset(2026, 8, 7, 1, 2, 3, TimeSpan.Zero).ToString("O"),
|
|
UpdatedUtc = new DateTimeOffset(2026, 8, 7, 4, 5, 6, TimeSpan.Zero).ToString("O"),
|
|
CreatedBy = "alice",
|
|
UpdatedBy = "bob",
|
|
};
|
|
|
|
[Fact]
|
|
public void Row_maps_every_field_onto_the_domain_type()
|
|
{
|
|
StoredSecret row = WireToDomain.ToRow(FullRow());
|
|
|
|
Assert.Equal("db/password", row.Name.Value);
|
|
Assert.Equal("primary db", row.Description);
|
|
Assert.Equal(SecretContentType.ConnectionString, row.ContentType);
|
|
Assert.Equal(new byte[] { 0xAA, 0xBB }, row.Ciphertext);
|
|
Assert.Equal(new byte[] { 1, 2, 3 }, row.Nonce);
|
|
Assert.Equal(new byte[] { 4, 5 }, row.Tag);
|
|
Assert.Equal(new byte[] { 6 }, row.WrappedDek);
|
|
Assert.Equal(new byte[] { 7 }, row.WrapNonce);
|
|
Assert.Equal(new byte[] { 8 }, row.WrapTag);
|
|
Assert.Equal("sha256:abc", row.KekId);
|
|
Assert.Equal(7, row.Revision);
|
|
Assert.False(row.IsDeleted);
|
|
Assert.Null(row.DeletedUtc);
|
|
Assert.Equal("alice", row.CreatedBy);
|
|
Assert.Equal("bob", row.UpdatedBy);
|
|
}
|
|
|
|
[Fact]
|
|
public void Row_rejects_an_unrecognized_content_type()
|
|
{
|
|
// The reason the wire carries a NAME rather than a proto enum: an unknown value is rejected
|
|
// at the boundary instead of silently decoding to the zero value and retyping the secret.
|
|
SecretRow wire = FullRow();
|
|
wire.ContentType = "Kerberos";
|
|
|
|
ArgumentException ex = Assert.Throws<ArgumentException>(() => WireToDomain.ToRow(wire));
|
|
Assert.Contains("Kerberos", ex.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Row_rejects_a_numeric_content_type_that_names_no_member()
|
|
{
|
|
SecretRow wire = FullRow();
|
|
wire.ContentType = "9999";
|
|
|
|
Assert.Throws<ArgumentException>(() => WireToDomain.ToRow(wire));
|
|
}
|
|
|
|
[Fact]
|
|
public void Row_rejects_a_name_the_domain_would_not_accept()
|
|
{
|
|
SecretRow wire = FullRow();
|
|
wire.ContentType = nameof(SecretContentType.Text);
|
|
wire.Name = "../../etc/passwd";
|
|
|
|
Assert.Throws<ArgumentException>(() => WireToDomain.ToRow(wire));
|
|
}
|
|
|
|
[Fact]
|
|
public void Row_distinguishes_an_absent_description_from_an_empty_one()
|
|
{
|
|
SecretRow absent = FullRow();
|
|
absent.ClearDescription();
|
|
Assert.Null(WireToDomain.ToRow(absent).Description);
|
|
|
|
SecretRow empty = FullRow();
|
|
empty.Description = string.Empty;
|
|
Assert.Equal(string.Empty, WireToDomain.ToRow(empty).Description);
|
|
}
|
|
|
|
[Fact]
|
|
public void Row_distinguishes_absent_from_empty_for_every_nullable_field()
|
|
{
|
|
SecretRow absent = FullRow();
|
|
absent.ClearCreatedBy();
|
|
absent.ClearUpdatedBy();
|
|
|
|
StoredSecret row = WireToDomain.ToRow(absent);
|
|
|
|
Assert.Null(row.CreatedBy);
|
|
Assert.Null(row.UpdatedBy);
|
|
}
|
|
|
|
[Fact]
|
|
public void Row_round_trips_a_non_utc_offset_exactly()
|
|
{
|
|
// The whole reason the contract uses "O" strings and not google.protobuf.Timestamp: an
|
|
// offset-bearing DateTimeOffset must come back byte-identical, because LWW compares it.
|
|
var updated = new DateTimeOffset(2026, 8, 7, 4, 5, 6, 789, TimeSpan.FromHours(-5));
|
|
|
|
SecretRow wire = FullRow();
|
|
wire.UpdatedUtc = updated.ToString("O");
|
|
|
|
StoredSecret row = WireToDomain.ToRow(wire);
|
|
|
|
Assert.Equal(updated, row.UpdatedUtc);
|
|
Assert.Equal(updated.Offset, row.UpdatedUtc.Offset);
|
|
Assert.Equal(updated.ToString("O"), row.UpdatedUtc.ToString("O"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Row_maps_a_tombstone_with_its_deletion_timestamp()
|
|
{
|
|
var deleted = new DateTimeOffset(2026, 8, 7, 9, 9, 9, TimeSpan.Zero);
|
|
|
|
SecretRow wire = FullRow();
|
|
wire.IsDeleted = true;
|
|
wire.DeletedUtc = deleted.ToString("O");
|
|
|
|
StoredSecret row = WireToDomain.ToRow(wire);
|
|
|
|
Assert.True(row.IsDeleted);
|
|
Assert.Equal(deleted, row.DeletedUtc);
|
|
}
|
|
|
|
[Fact]
|
|
public void Row_leaves_the_deletion_timestamp_null_when_the_field_is_absent()
|
|
{
|
|
SecretRow wire = FullRow();
|
|
wire.ClearDeletedUtc();
|
|
|
|
Assert.Null(WireToDomain.ToRow(wire).DeletedUtc);
|
|
}
|
|
|
|
[Fact]
|
|
public void Row_rejects_a_malformed_timestamp()
|
|
{
|
|
SecretRow wire = FullRow();
|
|
wire.UpdatedUtc = "yesterday";
|
|
|
|
Assert.Throws<FormatException>(() => WireToDomain.ToRow(wire));
|
|
}
|
|
|
|
[Fact]
|
|
public void Manifest_entry_maps_and_validates_its_name()
|
|
{
|
|
var updated = new DateTimeOffset(2026, 8, 7, 4, 5, 6, TimeSpan.FromHours(2));
|
|
|
|
SecretManifestEntry entry = WireToDomain.ToEntry(new ManifestEntry
|
|
{
|
|
Name = "api/key",
|
|
Revision = 3,
|
|
UpdatedUtc = updated.ToString("O"),
|
|
IsDeleted = true,
|
|
});
|
|
|
|
Assert.Equal("api/key", entry.Name.Value);
|
|
Assert.Equal(3, entry.Revision);
|
|
Assert.Equal(updated, entry.UpdatedUtc);
|
|
Assert.True(entry.IsDeleted);
|
|
}
|
|
|
|
[Fact]
|
|
public void Manifest_entry_rejects_a_name_the_domain_would_not_accept()
|
|
{
|
|
Assert.Throws<ArgumentException>(() => WireToDomain.ToEntry(new ManifestEntry
|
|
{
|
|
Name = "/rooted",
|
|
Revision = 1,
|
|
UpdatedUtc = DateTimeOffset.UtcNow.ToString("O"),
|
|
}));
|
|
}
|
|
|
|
[Fact]
|
|
public void Batch_conversions_preserve_order()
|
|
{
|
|
SecretRow first = FullRow();
|
|
SecretRow second = FullRow();
|
|
second.Name = "api/key";
|
|
|
|
IReadOnlyList<StoredSecret> rows = WireToDomain.ToRows([first, second]);
|
|
|
|
Assert.Equal(["db/password", "api/key"], rows.Select(r => r.Name.Value));
|
|
}
|
|
}
|