Files
scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/DefaultSecretResolverCacheTests.cs
T
Joseph Doherty 16fac72858 fix(secrets): resolver never re-caches across Invalidate; expired plaintext is evicted (scadaproj#45)
Secrets-006: DefaultSecretResolver keeps a per-key invalidation generation; Invalidate bumps it
before evicting, and a resolve that read the store under an older generation withdraws its insert,
so a rotation mid-resolve is not undone for another TTL. Expired entries are removed on lookup and
by a periodic TimeProvider sweep (every TTL, at least 1 s); a non-positive TTL caches nothing.
The resolver is now IDisposable (stops the sweep timer).

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:32:28 -05:00

175 lines
7.2 KiB
C#

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;
/// <summary>
/// Secrets-006: an <see cref="DefaultSecretResolver.Invalidate"/> 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.
/// </summary>
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,
};
/// <summary>A store whose next read is held until the test releases it.</summary>
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<StoredSecret?> 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<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct) => throw new NotSupportedException();
public Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct) => throw new NotSupportedException();
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct) => throw new NotSupportedException();
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) => throw new NotSupportedException();
public Task<bool> ApplyRewrapAsync(StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) => throw new NotSupportedException();
}
/// <summary>A clock whose timers fire only when the test says so.</summary>
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<string?> 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);
}
}