159 lines
5.7 KiB
C#
159 lines
5.7 KiB
C#
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);
|
|
|
|
/// <summary>
|
|
/// Builds a real encrypted row for <paramref name="plaintext"/> 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.
|
|
/// </summary>
|
|
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 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);
|
|
}
|
|
}
|