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; /// /// The real hub client, driven over a fake CallInvoker: what it puts on the wire (credential /// and deadline) and what it does with what comes back (per-item conversion, per-item isolation). /// /// /// Everything here is deliberately exercised through GrpcSecretsHubClient itself rather than /// through a fake of the ISecretsHubReader 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. /// 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 _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 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 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 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 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 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 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); } }