using System.Globalization; using Google.Protobuf; using Microsoft.Extensions.Logging.Abstractions; 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 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) => new(store, 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; } /// 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++; 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(); }