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