using System.Text.Json; using ZB.MOM.WW.Audit; using ZB.MOM.WW.Secrets.Abstractions; using ZB.MOM.WW.Secrets.Crypto; using ZB.MOM.WW.Secrets.Tests.Fakes; namespace ZB.MOM.WW.Secrets.Tests; public class DefaultSecretResolverTests { private static readonly TimeSpan Ttl = TimeSpan.FromSeconds(30); /// /// Builds a real encrypted row for using the real cipher, then /// stamps a revision + timestamps (the cipher leaves those at defaults) and optionally tombstones /// it, so tests exercise the true decrypt path rather than a fake. /// private static StoredSecret MakeRow( AesGcmEnvelopeCipher cipher, SecretName name, string plaintext, bool deleted = false) { StoredSecret row = cipher.Encrypt(name, plaintext, SecretContentType.Text); return row with { Revision = 1, CreatedUtc = DateTimeOffset.UnixEpoch, UpdatedUtc = DateTimeOffset.UnixEpoch, IsDeleted = deleted, DeletedUtc = deleted ? DateTimeOffset.UnixEpoch : null, }; } private static (DefaultSecretResolver resolver, CountingSecretStore store, CapturingAuditWriter audit, AesGcmEnvelopeCipher cipher, MutableTimeProvider clock) NewSut(ISecretActorAccessor? actor = null) { var cipher = new AesGcmEnvelopeCipher(new FakeMasterKeyProvider("k1")); var store = new CountingSecretStore(); var audit = new CapturingAuditWriter(); var clock = new MutableTimeProvider(DateTimeOffset.UnixEpoch); var resolver = new DefaultSecretResolver(store, cipher, audit, Ttl, actor, clock); return (resolver, store, audit, cipher, clock); } [Fact] public async Task Get_ReturnsDecryptedPlaintext_AndAuditsSuccess() { var (resolver, store, audit, cipher, _) = NewSut(); var name = new SecretName("sql/foo"); store.Seed(MakeRow(cipher, name, "hunter2")); string? value = await resolver.GetAsync(name, CancellationToken.None); Assert.Equal("hunter2", value); AuditEvent evt = Assert.Single(audit.Events); Assert.Equal(AuditOutcome.Success, evt.Outcome); Assert.Equal("sql/foo", evt.Target); // Hard guarantee: the plaintext must NEVER appear in any serialized audit field. foreach (AuditEvent captured in audit.Events) { string json = JsonSerializer.Serialize(captured); Assert.DoesNotContain("hunter2", json, StringComparison.Ordinal); } } [Fact] public async Task Get_CachesWithinTtl() { var (resolver, store, _, cipher, _) = NewSut(); var name = new SecretName("sql/foo"); store.Seed(MakeRow(cipher, name, "hunter2")); string? first = await resolver.GetAsync(name, CancellationToken.None); string? second = await resolver.GetAsync(name, CancellationToken.None); Assert.Equal("hunter2", first); Assert.Equal("hunter2", second); Assert.Equal(1, store.GetCount); // second call served from the TTL cache } [Fact] public async Task Get_ReloadsAfterTtlExpiry() { var (resolver, store, _, cipher, clock) = NewSut(); var name = new SecretName("sql/foo"); store.Seed(MakeRow(cipher, name, "hunter2")); await resolver.GetAsync(name, CancellationToken.None); clock.Advance(Ttl + TimeSpan.FromSeconds(1)); // past the TTL await resolver.GetAsync(name, CancellationToken.None); Assert.Equal(2, store.GetCount); } [Fact] public async Task Get_MissingSecret_ReturnsNull_AuditsFailure() { var (resolver, _, audit, _, _) = NewSut(); string? value = await resolver.GetAsync(new SecretName("sql/absent"), CancellationToken.None); Assert.Null(value); AuditEvent evt = Assert.Single(audit.Events); Assert.Equal(AuditOutcome.Failure, evt.Outcome); Assert.Contains("not-found", evt.Action, StringComparison.Ordinal); } [Fact] public async Task Get_TombstonedSecret_ReturnsNull() { var (resolver, store, audit, cipher, _) = NewSut(); var name = new SecretName("sql/dead"); store.Seed(MakeRow(cipher, name, "hunter2", deleted: true)); string? value = await resolver.GetAsync(name, CancellationToken.None); Assert.Null(value); AuditEvent evt = Assert.Single(audit.Events); Assert.Equal(AuditOutcome.Failure, evt.Outcome); } [Fact] public async Task Get_DecryptionFailure_AuditsFailure_AndRethrows() { // The resolver's cipher and the encrypting cipher share a KekId ("k1") but hold different // random key bytes, so the DEK unwrap fails closed with a SecretDecryptionException. var store = new CountingSecretStore(); var audit = new CapturingAuditWriter(); var clock = new MutableTimeProvider(DateTimeOffset.UnixEpoch); var resolverCipher = new AesGcmEnvelopeCipher(new FakeMasterKeyProvider("k1")); var resolver = new DefaultSecretResolver(store, resolverCipher, audit, Ttl, actorAccessor: null, clock); var foreignCipher = new AesGcmEnvelopeCipher(new FakeMasterKeyProvider("k1")); // same id, different key var name = new SecretName("sql/foo"); store.Seed(MakeRow(foreignCipher, name, "hunter2")); // Fail-loud: the integrity failure must propagate, not be masked as a benign miss/null. await Assert.ThrowsAsync(() => resolver.GetAsync(name, CancellationToken.None)); AuditEvent evt = Assert.Single(audit.Events); Assert.Equal(AuditOutcome.Failure, evt.Outcome); Assert.Equal("secret.resolve.decryption-failed", evt.Action); Assert.Equal("sql/foo", evt.Target); // Hard guarantee: the plaintext must NEVER appear in any serialized audit field. foreach (AuditEvent captured in audit.Events) { string json = JsonSerializer.Serialize(captured); Assert.DoesNotContain("hunter2", json, StringComparison.Ordinal); } } [Fact] public async Task Invalidate_RemovesCacheEntry() { var (resolver, store, _, cipher, _) = NewSut(); var name = new SecretName("sql/foo"); store.Seed(MakeRow(cipher, name, "hunter2")); await resolver.GetAsync(name, CancellationToken.None); // caches (count 1) resolver.Invalidate(name); await resolver.GetAsync(name, CancellationToken.None); // cache gone → reload (count 2) Assert.Equal(2, store.GetCount); } [Fact] public async Task Get_DefaultActorIsSystem_WhenNoAccessor() { var (resolver, store, audit, cipher, _) = NewSut(actor: null); var name = new SecretName("sql/foo"); store.Seed(MakeRow(cipher, name, "hunter2")); await resolver.GetAsync(name, CancellationToken.None); Assert.Equal("system", Assert.Single(audit.Events).Actor); } [Fact] public async Task Get_UsesActorFromAccessor() { var (resolver, store, audit, cipher, _) = NewSut(new FixedActorAccessor("alice")); var name = new SecretName("sql/foo"); store.Seed(MakeRow(cipher, name, "hunter2")); await resolver.GetAsync(name, CancellationToken.None); Assert.Equal("alice", Assert.Single(audit.Events).Actor); } }