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
@@ -17,6 +17,15 @@ public sealed class SecretsGrpcHubClientOptions
/// The hub's gRPC address, as an absolute <c>http</c> or <c>https</c> URI (for example
/// <c>https://central:5001</c>). Required.
/// </summary>
/// <remarks>
/// A plain <c>http</c> endpoint is accepted so an h2c-only or in-cluster deployment can be
/// stood up, but it sends <see cref="BearerToken"/> in cleartext on <em>every</em> call — anyone
/// on the path reads the hub credential and can then pull central's entire secret inventory.
/// Treat <c>http</c> as a development and loopback-only convenience; a site node reaching
/// central across a plant network must use <c>https</c>. This is documentation rather than a
/// rule the validator enforces, because "internal" is a property of a deployment's network and
/// cannot be decided from a URI string.
/// </remarks>
public string Endpoint { get; set; } = string.Empty;
/// <summary>