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);
}
}