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; /// /// Secrets-006: an that lands while a resolve is /// reading the store must not be undone by that resolve re-caching the old value, and expired /// plaintext must not stay referenced for the life of the process. /// public class DefaultSecretResolverCacheTests { private static readonly TimeSpan Ttl = TimeSpan.FromSeconds(30); private static StoredSecret Row(AesGcmEnvelopeCipher cipher, SecretName name, string value, long revision) => cipher.Encrypt(name, value, SecretContentType.Text) with { Revision = revision, CreatedUtc = DateTimeOffset.UnixEpoch, UpdatedUtc = DateTimeOffset.UnixEpoch, }; /// A store whose next read is held until the test releases it. private sealed class GatedStore : ISecretStore { private readonly CountingSecretStore _inner = new(); public TaskCompletionSource ReadStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public TaskCompletionSource Release { get; private set; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public bool Gate { get; set; } public int Reads => _inner.GetCount; public void Seed(StoredSecret row) => _inner.Seed(row); public async Task GetAsync(SecretName name, CancellationToken ct) { StoredSecret? row = await _inner.GetAsync(name, ct); if (Gate) { Gate = false; ReadStarted.TrySetResult(); await Release.Task; } return row; } 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> GetManifestAsync(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(); } /// A clock whose timers fire only when the test says so. private sealed class ManualTimerTimeProvider : TimeProvider { public DateTimeOffset Now { get; set; } = DateTimeOffset.UnixEpoch; public List<(TimerCallback Callback, object? State)> Timers { get; } = new(); public override DateTimeOffset GetUtcNow() => Now; public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) { Timers.Add((callback, state)); return new NullTimer(); } public void FireAll() { foreach ((TimerCallback callback, object? state) in Timers) { callback(state); } } private sealed class NullTimer : ITimer { public bool Change(TimeSpan dueTime, TimeSpan period) => true; public void Dispose() { } public ValueTask DisposeAsync() => ValueTask.CompletedTask; } } [Fact] public async Task An_invalidate_during_an_in_flight_resolve_is_not_undone_by_it() { var cipher = new AesGcmEnvelopeCipher(new FakeMasterKeyProvider("k1")); var store = new GatedStore(); var name = new SecretName("db/central"); store.Seed(Row(cipher, name, "old-password", revision: 3)); using var resolver = new DefaultSecretResolver(store, cipher, new CapturingAuditWriter(), Ttl); // A request thread misses the cache and reads revision 3 ... store.Gate = true; Task inFlight = resolver.GetAsync(name, CancellationToken.None); await store.ReadStarted.Task; // ... the operator rotates to revision 4 and the write path invalidates ... store.Seed(Row(cipher, name, "new-password", revision: 4)); resolver.Invalidate(name); // ... and only then does the request thread finish with the old value. store.Release.TrySetResult(); Assert.Equal("old-password", await inFlight); // The next resolve must go back to the store, not serve revision 3 for another TTL. Assert.Equal("new-password", await resolver.GetAsync(name, CancellationToken.None)); Assert.Equal(2, store.Reads); } [Fact] public async Task An_expired_entry_is_dropped_when_it_is_looked_up() { var cipher = new AesGcmEnvelopeCipher(new FakeMasterKeyProvider("k1")); var store = new CountingSecretStore(); var clock = new MutableTimeProvider(DateTimeOffset.UnixEpoch); var name = new SecretName("db/central"); store.Seed(Row(cipher, name, "hunter2", 1)); using var resolver = new DefaultSecretResolver(store, cipher, new CapturingAuditWriter(), Ttl, null, clock); await resolver.GetAsync(name, CancellationToken.None); Assert.Equal(1, resolver.CachedCount); // The secret is deleted out-of-band; the expired entry must not outlive the lookup. store.Seed(Row(cipher, name, "hunter2", 2) with { IsDeleted = true }); clock.Advance(Ttl + TimeSpan.FromSeconds(1)); Assert.Null(await resolver.GetAsync(name, CancellationToken.None)); Assert.Equal(0, resolver.CachedCount); } [Fact] public async Task The_sweep_drops_expired_plaintext_that_is_never_looked_up_again() { var cipher = new AesGcmEnvelopeCipher(new FakeMasterKeyProvider("k1")); var store = new CountingSecretStore(); var clock = new ManualTimerTimeProvider(); var name = new SecretName("db/central"); store.Seed(Row(cipher, name, "hunter2", 1)); using var resolver = new DefaultSecretResolver(store, cipher, new CapturingAuditWriter(), Ttl, null, clock); await resolver.GetAsync(name, CancellationToken.None); // e.g. startup expansion Assert.Single(clock.Timers); clock.FireAll(); Assert.Equal(1, resolver.CachedCount); // positive control: not yet expired clock.Now += Ttl; clock.FireAll(); Assert.Equal(0, resolver.CachedCount); } [Fact] public async Task A_non_positive_ttl_caches_nothing() { var cipher = new AesGcmEnvelopeCipher(new FakeMasterKeyProvider("k1")); var store = new CountingSecretStore(); var name = new SecretName("db/central"); store.Seed(Row(cipher, name, "hunter2", 1)); using var resolver = new DefaultSecretResolver(store, cipher, new CapturingAuditWriter(), TimeSpan.Zero); await resolver.GetAsync(name, CancellationToken.None); await resolver.GetAsync(name, CancellationToken.None); Assert.Equal(0, resolver.CachedCount); Assert.Equal(2, store.GetCount); } }