Files
scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.Grpc.Tests/GrpcSecretsHubClientTests.cs
T
Joseph Doherty 1c31e6bcb8 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
2026-08-07 06:29:51 -04:00

234 lines
9.1 KiB
C#

using Microsoft.Extensions.Options;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Replicator.Grpc.V1;
namespace ZB.MOM.WW.Secrets.Replicator.Grpc.Tests;
/// <summary>
/// The real hub client, driven over a fake <c>CallInvoker</c>: what it puts on the wire (credential
/// and deadline) and what it does with what comes back (per-item conversion, per-item isolation).
/// </summary>
/// <remarks>
/// Everything here is deliberately exercised through <c>GrpcSecretsHubClient</c> itself rather than
/// through a fake of the <c>ISecretsHubReader</c> seam. A seam fake proves nothing about the header,
/// the deadline, or the conversion — all three live only in this class, and all three are exactly
/// where a follower silently talks to the hub wrong.
/// </remarks>
public sealed class GrpcSecretsHubClientTests
{
private static readonly DateTimeOffset Now = new(2026, 8, 7, 12, 0, 0, TimeSpan.Zero);
private readonly FakeSecretsHubCallInvoker _invoker = new();
private readonly RecordingLogger<GrpcSecretsHubClient> _logger = new();
private readonly SecretsGrpcHubClientOptions _options = new()
{
Endpoint = "https://central:5001",
BearerToken = "hub-token",
CallDeadline = TimeSpan.FromSeconds(17),
};
private GrpcSecretsHubClient CreateClient() => new(
new SecretsHub.SecretsHubClient(_invoker),
Options.Create(_options),
_logger,
new FixedTimeProvider(Now));
[Fact]
public async Task Every_call_presents_the_bearer_credential()
{
_invoker.ManifestResponse = new GetManifestResponse { Entries = { TestWire.Entry("db/password") } };
_invoker.SecretsResponse = new GetSecretsResponse { Secrets = { TestWire.Row("db/password", 0xAA) } };
GrpcSecretsHubClient client = CreateClient();
await client.GetManifestAsync(CancellationToken.None);
await client.GetManyAsync([new SecretName("db/password")], CancellationToken.None);
Assert.Equal(["GetManifest", "GetSecrets"], _invoker.Methods);
Assert.Equal("Bearer hub-token", _invoker.AuthorizationOn(0));
Assert.Equal("Bearer hub-token", _invoker.AuthorizationOn(1));
}
[Fact]
public async Task Every_call_carries_a_deadline_of_now_plus_the_configured_budget()
{
await CreateClient().GetManifestAsync(CancellationToken.None);
Assert.Equal(Now.UtcDateTime.AddSeconds(17), _invoker.Calls[0].Deadline);
}
[Fact]
public async Task The_deadline_is_recomputed_per_call_rather_than_cached()
{
// A CallOptions built once at construction would carry an absolute deadline that expires
// shortly after startup and then fails every sweep forever, which reads as a hub outage.
GrpcSecretsHubClient client = CreateClient();
await client.GetManifestAsync(CancellationToken.None);
await client.GetManifestAsync(CancellationToken.None);
Assert.Equal(_invoker.Calls[0].Deadline, _invoker.Calls[1].Deadline);
Assert.NotSame(_invoker.Calls[0].Headers, _invoker.Calls[1].Headers);
}
[Fact]
public async Task The_fetch_asks_for_exactly_the_names_it_was_given()
{
_invoker.SecretsResponse = new GetSecretsResponse();
await CreateClient().GetManyAsync(
[new SecretName("db/password"), new SecretName("api/key")], CancellationToken.None);
Assert.Equal(["db/password", "api/key"], _invoker.LastSecretsRequest!.Names);
}
[Fact]
public async Task A_wire_row_comes_back_as_a_domain_row()
{
_invoker.SecretsResponse = new GetSecretsResponse { Secrets = { TestWire.Row("db/password", 0xAA) } };
IReadOnlyList<StoredSecret> rows =
await CreateClient().GetManyAsync([new SecretName("db/password")], CancellationToken.None);
StoredSecret row = Assert.Single(rows);
Assert.Equal("db/password", row.Name.Value);
Assert.Equal(SecretContentType.Text, row.ContentType);
Assert.Equal(new byte[] { 0xAA }, row.Ciphertext);
Assert.Equal(TestWire.FixedInstant, row.UpdatedUtc);
}
[Fact]
public async Task A_wire_manifest_entry_comes_back_as_a_domain_entry()
{
_invoker.ManifestResponse = new GetManifestResponse { Entries = { TestWire.Entry("api/key") } };
IReadOnlyList<SecretManifestEntry> entries =
await CreateClient().GetManifestAsync(CancellationToken.None);
SecretManifestEntry entry = Assert.Single(entries);
Assert.Equal("api/key", entry.Name.Value);
Assert.Equal(TestWire.FixedInstant, entry.UpdatedUtc);
}
[Fact]
public async Task A_malformed_row_is_dropped_and_its_well_formed_siblings_survive()
{
// The whole point: one unparseable row must not cost the batch. Eagerly materializing the
// response would fail all three, and the identical pull set would re-fail every interval.
SecretRow bad = TestWire.Row("api/key", 0xBB);
bad.ContentType = "Kerberos";
_invoker.SecretsResponse = new GetSecretsResponse
{
Secrets = { TestWire.Row("db/password", 0xAA), bad, TestWire.Row("svc/token", 0xCC) },
};
IReadOnlyList<StoredSecret> rows = await CreateClient().GetManyAsync(
[new SecretName("db/password"), new SecretName("api/key"), new SecretName("svc/token")],
CancellationToken.None);
Assert.Equal(["db/password", "svc/token"], rows.Select(r => r.Name.Value));
}
[Fact]
public async Task A_dropped_row_costs_exactly_one_warning_that_names_it()
{
SecretRow bad = TestWire.Row("api/key", 0xBB);
bad.ContentType = "Kerberos";
_invoker.SecretsResponse = new GetSecretsResponse
{
Secrets = { TestWire.Row("db/password", 0xAA), bad },
};
await CreateClient().GetManyAsync(
[new SecretName("db/password"), new SecretName("api/key")], CancellationToken.None);
string warning = Assert.Single(_logger.Warnings);
Assert.Contains("api/key", warning, StringComparison.Ordinal);
}
[Fact]
public async Task A_dropped_row_never_logs_the_material_it_was_carrying()
{
// A parse failure is an operations problem, so it is logged — but a replicated row is
// ciphertext plus its wrapped DEK, and none of that may reach a log sink.
SecretRow bad = TestWire.Row("api/key", 0xBB);
bad.UpdatedUtc = "yesterday";
_invoker.SecretsResponse = new GetSecretsResponse { Secrets = { bad } };
await CreateClient().GetManyAsync([new SecretName("api/key")], CancellationToken.None);
string warning = Assert.Single(_logger.Warnings);
foreach (string forbidden in new[] { "sha256:test", "hub-token", "Ciphertext", "WrappedDek" })
{
Assert.DoesNotContain(forbidden, warning, StringComparison.OrdinalIgnoreCase);
}
}
[Fact]
public async Task A_row_whose_own_name_is_unparseable_is_dropped_without_echoing_it()
{
// The name is the one field a warning may legitimately carry — but here the name IS what
// failed to parse, so it is replaced by a marker rather than trusted into the log line.
SecretRow bad = TestWire.Row("db/password", 0xAA);
bad.Name = "../../etc/passwd";
_invoker.SecretsResponse = new GetSecretsResponse { Secrets = { bad } };
IReadOnlyList<StoredSecret> rows =
await CreateClient().GetManyAsync([new SecretName("db/password")], CancellationToken.None);
Assert.Empty(rows);
string warning = Assert.Single(_logger.Warnings);
Assert.DoesNotContain("etc/passwd", warning, StringComparison.Ordinal);
}
[Fact]
public async Task A_malformed_manifest_entry_is_dropped_and_the_rest_of_the_manifest_survives()
{
ManifestEntry bad = TestWire.Entry("api/key");
bad.UpdatedUtc = "not-a-timestamp";
_invoker.ManifestResponse = new GetManifestResponse
{
Entries = { TestWire.Entry("db/password"), bad, TestWire.Entry("svc/token") },
};
IReadOnlyList<SecretManifestEntry> entries =
await CreateClient().GetManifestAsync(CancellationToken.None);
Assert.Equal(["db/password", "svc/token"], entries.Select(e => e.Name.Value));
Assert.Single(_logger.Warnings);
}
[Fact]
public async Task Conversion_preserves_the_order_the_hub_sent()
{
_invoker.SecretsResponse = new GetSecretsResponse
{
Secrets = { TestWire.Row("db/password", 0xAA), TestWire.Row("api/key", 0xBB) },
};
IReadOnlyList<StoredSecret> rows = await CreateClient().GetManyAsync(
[new SecretName("db/password"), new SecretName("api/key")], CancellationToken.None);
Assert.Equal(["db/password", "api/key"], rows.Select(r => r.Name.Value));
}
[Fact]
public async Task A_well_formed_response_logs_nothing()
{
_invoker.ManifestResponse = new GetManifestResponse { Entries = { TestWire.Entry("db/password") } };
await CreateClient().GetManifestAsync(CancellationToken.None);
Assert.Empty(_logger.Warnings);
Assert.Empty(_logger.Information);
}
}