using System.Globalization; using Google.Protobuf; using Grpc.Core; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ZB.MOM.WW.Secrets.Abstractions; using ZB.MOM.WW.Secrets.Replicator.Grpc.V1; using ZB.MOM.WW.Secrets.Replicator.Grpc.Wire; namespace ZB.MOM.WW.Secrets.Replicator.Grpc.Tests; /// /// Covers the read-only hub service and the domain→wire projection behind it. The contract the /// proto states is binding here: a name the hub does not hold is omitted, never an error, and /// every field crosses losslessly. /// public sealed class SecretsHubServiceTests { [Fact] public async Task GetManifest_returns_every_entry_including_tombstones() { var store = new HubFakeSecretStore(); store.Seed(Row("alpha")); store.Seed(Row("beta", isDeleted: true)); SecretsHubService service = Service(store); GetManifestResponse response = await service.GetManifest(new GetManifestRequest(), HubServerCallContextStub.Empty()); Assert.Equal(["alpha", "beta"], response.Entries.Select(e => e.Name).Order()); ManifestEntry tombstone = response.Entries.Single(e => e.Name == "beta"); Assert.True(tombstone.IsDeleted); } [Fact] public async Task GetManifest_maps_every_manifest_field() { var updated = new DateTimeOffset(2026, 8, 7, 13, 45, 12, 345, TimeSpan.FromHours(-5)) .AddTicks(6789); var store = new HubFakeSecretStore(); store.SeedManifest(new SecretManifestEntry { Name = new SecretName("db/central"), Revision = 42, UpdatedUtc = updated, IsDeleted = true, }); GetManifestResponse response = await Service(store) .GetManifest(new GetManifestRequest(), HubServerCallContextStub.Empty()); ManifestEntry entry = Assert.Single(response.Entries); Assert.Equal("db/central", entry.Name); Assert.Equal(42, entry.Revision); Assert.Equal(updated.ToString("O", CultureInfo.InvariantCulture), entry.UpdatedUtc); Assert.True(entry.IsDeleted); } [Fact] public async Task GetSecrets_omits_names_the_hub_does_not_hold() { var store = new HubFakeSecretStore(); store.Seed(Row("present")); var request = new GetSecretsRequest { Names = { "present", "absent" } }; GetSecretsResponse response = await Service(store).GetSecrets(request, HubServerCallContextStub.Empty()); SecretRow row = Assert.Single(response.Secrets); Assert.Equal("present", row.Name); } [Fact] public async Task GetSecrets_returns_tombstoned_rows() { // A follower needs the tombstone itself to converge on a delete — omitting it would make a // deleted secret un-deletable downstream. var store = new HubFakeSecretStore(); store.Seed(Row("gone", isDeleted: true)); GetSecretsResponse response = await Service(store) .GetSecrets(new GetSecretsRequest { Names = { "gone" } }, HubServerCallContextStub.Empty()); Assert.True(Assert.Single(response.Secrets).IsDeleted); } [Theory] [InlineData("")] [InlineData(" ")] [InlineData("../etc/passwd")] [InlineData("/rooted")] [InlineData("has space")] [InlineData("double//slash")] public async Task GetSecrets_skips_an_invalid_name_instead_of_faulting(string invalid) { var store = new HubFakeSecretStore(); store.Seed(Row("present")); var request = new GetSecretsRequest { Names = { invalid, "present" } }; GetSecretsResponse response = await Service(store).GetSecrets(request, HubServerCallContextStub.Empty()); Assert.Equal("present", Assert.Single(response.Secrets).Name); } [Fact] public async Task GetSecrets_normalizes_names_before_looking_them_up() { var store = new HubFakeSecretStore(); store.Seed(Row("db/central")); GetSecretsResponse response = await Service(store).GetSecrets( new GetSecretsRequest { Names = { " DB/Central " } }, HubServerCallContextStub.Empty()); Assert.Equal("db/central", Assert.Single(response.Secrets).Name); } [Fact] public async Task GetSecrets_returns_one_row_per_distinct_name() { var store = new HubFakeSecretStore(); store.Seed(Row("dup")); GetSecretsResponse response = await Service(store).GetSecrets( new GetSecretsRequest { Names = { "dup", "dup", "DUP" } }, HubServerCallContextStub.Empty()); Assert.Single(response.Secrets); Assert.Equal(1, store.GetCallCount); } [Fact] public async Task GetSecrets_on_an_empty_request_reads_nothing() { var store = new HubFakeSecretStore(); store.Seed(Row("present")); GetSecretsResponse response = await Service(store) .GetSecrets(new GetSecretsRequest(), HubServerCallContextStub.Empty()); Assert.Empty(response.Secrets); Assert.Equal(0, store.GetCallCount); } [Fact] public async Task GetSecrets_serves_a_request_exactly_at_the_cap() { var store = new HubFakeSecretStore(); store.Seed(Row("n1")); store.Seed(Row("n2")); store.Seed(Row("n3")); var request = new GetSecretsRequest { Names = { "n1", "n2", "n3" } }; GetSecretsResponse response = await Service(store, maxNamesPerRequest: 3) .GetSecrets(request, HubServerCallContextStub.Empty()); Assert.Equal(3, response.Secrets.Count); } [Fact] public async Task GetSecrets_refuses_a_request_over_the_cap() { var store = new HubFakeSecretStore(); store.Seed(Row("n1")); var request = new GetSecretsRequest { Names = { "n1", "n2", "n3", "n4" } }; RpcException error = await Assert.ThrowsAsync( () => Service(store, maxNamesPerRequest: 3).GetSecrets(request, HubServerCallContextStub.Empty())); Assert.Equal(StatusCode.ResourceExhausted, error.StatusCode); } [Fact] public async Task GetSecrets_over_the_cap_reads_nothing_at_all() { // The refusal has to land BEFORE the sequential store loop — a cap that stops the response // after doing the reads would defend nothing. var store = new HubFakeSecretStore(); store.Seed(Row("n1")); var request = new GetSecretsRequest { Names = { "n1", "n1", "n1" } }; await Assert.ThrowsAsync( () => Service(store, maxNamesPerRequest: 2).GetSecrets(request, HubServerCallContextStub.Empty())); Assert.Equal(0, store.GetCallCount); } [Fact] public async Task GetSecrets_cap_refusal_states_the_cap_and_the_count_but_never_a_name() { var store = new HubFakeSecretStore(); var request = new GetSecretsRequest { Names = { "db/central", "db/site-a", "db/site-b" } }; RpcException error = await Assert.ThrowsAsync( () => Service(store, maxNamesPerRequest: 2).GetSecrets(request, HubServerCallContextStub.Empty())); Assert.Contains("2", error.Status.Detail, StringComparison.Ordinal); Assert.Contains("3", error.Status.Detail, StringComparison.Ordinal); foreach (string name in request.Names) { Assert.DoesNotContain(name, error.Status.Detail, StringComparison.OrdinalIgnoreCase); } } [Fact] public async Task GetSecrets_falls_back_to_the_default_cap_when_options_carry_a_nonpositive_one() { // Registration validates the cap, so this only happens when the options were built some // other way — and the safe reading of "0" is the default, never "no cap". var store = new HubFakeSecretStore(); var request = new GetSecretsRequest(); for (int i = 0; i <= SecretsGrpcHubServerOptions.DefaultMaxNamesPerRequest; i++) { request.Names.Add($"n{i}"); } RpcException error = await Assert.ThrowsAsync( () => Service(store, maxNamesPerRequest: 0).GetSecrets(request, HubServerCallContextStub.Empty())); Assert.Equal(StatusCode.ResourceExhausted, error.StatusCode); } [Fact] public async Task GetSecrets_hands_the_call_cancellation_token_to_the_store() { using var cts = new CancellationTokenSource(); await cts.CancelAsync(); var store = new HubFakeSecretStore(); store.Seed(Row("present")); await Service(store).GetSecrets( new GetSecretsRequest { Names = { "present" } }, HubServerCallContextStub.WithCancellation(cts.Token)); // The fake does not honour the token, so what is pinned here is the propagation itself: a // real store sees the caller's cancellation, and an abandoned call stops costing reads. Assert.True(store.LastCancellationToken.IsCancellationRequested); Assert.Equal(cts.Token, store.LastCancellationToken); } [Fact] public void DomainToWire_maps_a_fully_populated_row_field_by_field() { // Deliberately non-UTC offsets: DateTimeOffset carries an offset that a UTC-instant // representation would silently normalize away. var created = new DateTimeOffset(2026, 1, 2, 3, 4, 5, 678, TimeSpan.FromHours(-5)).AddTicks(9012); var updated = new DateTimeOffset(2026, 8, 7, 9, 10, 11, 121, TimeSpan.FromHours(5.5)).AddTicks(3456); var deleted = new DateTimeOffset(2026, 8, 7, 9, 30, 0, TimeSpan.FromHours(9)); var row = new StoredSecret { Name = new SecretName("db/central"), Description = "central connection string", ContentType = SecretContentType.ConnectionString, Ciphertext = [1, 2, 3, 4], Nonce = [5, 6, 7], Tag = [8, 9], WrappedDek = [10, 11, 12], WrapNonce = [13, 14], WrapTag = [15], KekId = "kek-2026-08", Revision = 7, IsDeleted = true, DeletedUtc = deleted, CreatedUtc = created, UpdatedUtc = updated, CreatedBy = "alice", UpdatedBy = "bob", }; SecretRow wire = DomainToWire.ToWire(row); Assert.Equal("db/central", wire.Name); Assert.Equal("central connection string", wire.Description); Assert.Equal("ConnectionString", wire.ContentType); Assert.Equal(new byte[] { 1, 2, 3, 4 }, wire.Ciphertext.ToByteArray()); Assert.Equal(new byte[] { 5, 6, 7 }, wire.Nonce.ToByteArray()); Assert.Equal(new byte[] { 8, 9 }, wire.Tag.ToByteArray()); Assert.Equal(new byte[] { 10, 11, 12 }, wire.WrappedDek.ToByteArray()); Assert.Equal(new byte[] { 13, 14 }, wire.WrapNonce.ToByteArray()); Assert.Equal(new byte[] { 15 }, wire.WrapTag.ToByteArray()); Assert.Equal("kek-2026-08", wire.KekId); Assert.Equal(7, wire.Revision); Assert.True(wire.IsDeleted); Assert.Equal(deleted.ToString("O", CultureInfo.InvariantCulture), wire.DeletedUtc); Assert.Equal(created.ToString("O", CultureInfo.InvariantCulture), wire.CreatedUtc); Assert.Equal(updated.ToString("O", CultureInfo.InvariantCulture), wire.UpdatedUtc); Assert.Equal("alice", wire.CreatedBy); Assert.Equal("bob", wire.UpdatedBy); } [Fact] public void DomainToWire_leaves_nullable_fields_absent_rather_than_empty() { StoredSecret row = Row("minimal"); SecretRow wire = DomainToWire.ToWire(row); Assert.False(wire.HasDescription); Assert.False(wire.HasDeletedUtc); Assert.False(wire.HasCreatedBy); Assert.False(wire.HasUpdatedBy); } [Fact] public void DomainToWire_distinguishes_an_empty_string_from_a_null() { StoredSecret row = Row("empties") with { Description = string.Empty, CreatedBy = string.Empty }; SecretRow wire = DomainToWire.ToWire(row); Assert.True(wire.HasDescription); Assert.Equal(string.Empty, wire.Description); Assert.True(wire.HasCreatedBy); Assert.Equal(string.Empty, wire.CreatedBy); } [Theory] [InlineData(SecretContentType.Text, "Text")] [InlineData(SecretContentType.ConnectionString, "ConnectionString")] [InlineData(SecretContentType.Json, "Json")] [InlineData(SecretContentType.BinaryBase64, "BinaryBase64")] public void DomainToWire_writes_the_content_type_member_name(SecretContentType type, string expected) { SecretRow wire = DomainToWire.ToWire(Row("typed") with { ContentType = type }); Assert.Equal(expected, wire.ContentType); } private static SecretsHubService Service(ISecretStore store, int? maxNamesPerRequest = null) { var options = new SecretsGrpcHubServerOptions { BearerToken = "irrelevant-to-the-service" }; if (maxNamesPerRequest is int max) { options.MaxNamesPerRequest = max; } return new SecretsHubService(store, Options.Create(options), NullLogger.Instance); } private static StoredSecret Row(string name, bool isDeleted = false) => new() { Name = new SecretName(name), ContentType = SecretContentType.Text, Ciphertext = [1], Nonce = [2], Tag = [3], WrappedDek = [4], WrapNonce = [5], WrapTag = [6], KekId = "kek", Revision = 1, IsDeleted = isDeleted, DeletedUtc = isDeleted ? DateTimeOffset.UnixEpoch : null, CreatedUtc = DateTimeOffset.UnixEpoch, UpdatedUtc = DateTimeOffset.UnixEpoch, }; } /// /// An in-memory . Only the two read members the hub adapts are /// implemented; the write half throws, which is itself part of the pull-only pin — a hub that ever /// started writing would fail loudly in these tests. /// internal sealed class HubFakeSecretStore : ISecretStore { private readonly Dictionary _rows = new(StringComparer.Ordinal); private readonly List _manifestOverride = []; /// How many single-row reads the service issued. public int GetCallCount { get; private set; } /// The cancellation token the service passed on the most recent read. public CancellationToken LastCancellationToken { get; private set; } /// Adds a row, and the manifest entry derived from it. public void Seed(StoredSecret row) => _rows[row.Name.Value] = row; /// Adds a manifest entry with no backing row, for manifest-shape assertions. public void SeedManifest(SecretManifestEntry entry) => _manifestOverride.Add(entry); /// public Task GetAsync(SecretName name, CancellationToken ct) { GetCallCount++; LastCancellationToken = ct; return Task.FromResult(_rows.GetValueOrDefault(name.Value)); } /// public Task> GetManifestAsync(CancellationToken ct) { if (_manifestOverride.Count > 0) { return Task.FromResult>(_manifestOverride); } IReadOnlyList entries = _rows.Values .Select(r => new SecretManifestEntry { Name = r.Name, Revision = r.Revision, UpdatedUtc = r.UpdatedUtc, IsDeleted = r.IsDeleted, }) .ToList(); return Task.FromResult(entries); } /// public Task UpsertAsync(StoredSecret row, CancellationToken ct) => throw new NotSupportedException(); /// public Task DeleteAsync(SecretName name, string? actor, CancellationToken ct) => throw new NotSupportedException(); /// public Task> ListAsync(bool includeDeleted, CancellationToken ct) => throw new NotSupportedException(); /// public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) => throw new NotSupportedException(); /// public Task ApplyRewrapAsync( StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) => throw new NotSupportedException(); }