fix(secrets): Grpc hub client — per-row parse isolation (review Critical) + real-client CallInvoker tests

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
This commit is contained in:
Joseph Doherty
2026-08-07 06:29:51 -04:00
parent c5ea3a7b59
commit 1c31e6bcb8
8 changed files with 649 additions and 73 deletions
@@ -1,3 +1,4 @@
using System.Reflection;
using Google.Protobuf;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Replicator.Grpc.V1;
@@ -192,14 +193,18 @@ public sealed class WireToDomainTests
}
[Fact]
public void Batch_conversions_preserve_order()
public void There_is_no_batch_entry_point_to_convert_a_whole_response_with()
{
SecretRow first = FullRow();
SecretRow second = FullRow();
second.Name = "api/key";
// 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();
IReadOnlyList<StoredSecret> rows = WireToDomain.ToRows([first, second]);
Assert.Equal(["db/password", "api/key"], rows.Select(r => r.Name.Value));
Assert.Equal(["ToEntry", "ToRow"], members);
}
}