1c31e6bcb8
A single malformed row or manifest entry poisoned the whole sweep, permanently. WireToDomain.ToRows/ToManifest materialized eagerly, so one unparseable element threw out of the fetch callback, past SecretReplicationReconciler's per-batch handling, into SweepSafelyAsync's whole-sweep catch. The follower's pull set is derived from the hub's manifest, so the identical bad element arrived in the identical batch on every interval and nothing ever converged — logged as "sync failed", indistinguishable from a hub outage. Isolation now matches the Akka sibling's split exactly: WireToDomain stays a strict per-item parser and loses its two batch entry points (their absence is pinned by a reflection test), while GrpcSecretsHubClient converts each item inside its own try — the same shape as SecretReplicationActor.Materialize. A dropped item costs one warning naming the secret and a reason category, and every well-formed sibling still converges. Only parse-shaped exceptions are caught (ArgumentException, FormatException); a defect still propagates. A row whose own name is unparseable is logged under a marker rather than echoing the untrusted string, and no ciphertext, DEK or credential reaches a log line. GrpcSecretsHubClient had zero coverage — the existing fake implements the ISecretsHubReader seam directly, bypassing the header, the deadline and the conversion, which are the only things that class does. It is now driven for real through a fake CallInvoker handed to the generated client. The stale doc comment claiming the reconciler isolated the failure is replaced by one describing the mechanism that actually exists, and Endpoint gains a note that a plain-http hub sends the bearer token cleartext (doc only, no validation change). Tests 52 -> 67 in the package suite; solution green, no new warnings. Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
211 lines
7.2 KiB
C#
211 lines
7.2 KiB
C#
using System.Reflection;
|
|
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 There_is_no_batch_entry_point_to_convert_a_whole_response_with()
|
|
{
|
|
// A batch method here would fail a whole response on one bad element, and since the pull set
|
|
// is derived from the hub's manifest the identical bad element would re-fail the identical
|
|
// sweep forever. Isolation belongs to GrpcSecretsHubClient, which converts item by item, so
|
|
// this pins that the strict parser offers nothing that could poison a batch again.
|
|
string[] members = typeof(WireToDomain)
|
|
.GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)
|
|
.Select(m => m.Name)
|
|
.Order(StringComparer.Ordinal)
|
|
.ToArray();
|
|
|
|
Assert.Equal(["ToEntry", "ToRow"], members);
|
|
}
|
|
}
|