feat(secrets): SecretsOptions + AddZbSecrets DI + migration hosted service

This commit is contained in:
Joseph Doherty
2026-07-15 17:05:30 -04:00
parent 8c4175d8ce
commit 7f1db1b214
6 changed files with 283 additions and 0 deletions
@@ -0,0 +1,116 @@
using System.Security.Cryptography;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.DependencyInjection;
using ZB.MOM.WW.Secrets.Sqlite;
namespace ZB.MOM.WW.Secrets.Tests.DependencyInjection;
/// <summary>
/// Verifies that <see cref="SecretsServiceCollectionExtensions.AddZbSecrets"/> wires the whole
/// core: every seam resolves, a real secret round-trips through the composed graph, and a missing
/// master key fails closed.
/// </summary>
public sealed class AddZbSecretsTests
{
private static string NewTempDbPath() =>
Path.Combine(Path.GetTempPath(), $"zb-secrets-di-{Guid.NewGuid():N}.db");
private static string Base64Key32() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
private static IConfiguration BuildConfig(string sqlitePath, string envVarName) =>
new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Secrets:SqlitePath"] = sqlitePath,
["Secrets:MasterKey:Source"] = "Environment",
["Secrets:MasterKey:EnvVarName"] = envVarName,
})
.Build();
[Fact]
public void Registers_All_CoreServices()
{
string dbPath = NewTempDbPath();
string envVar = $"ZB_TEST_KEY_{Guid.NewGuid():N}";
Environment.SetEnvironmentVariable(envVar, Base64Key32());
try
{
var services = new ServiceCollection();
services.AddZbSecrets(BuildConfig(dbPath, envVar), "Secrets");
using ServiceProvider provider = services.BuildServiceProvider();
Assert.NotNull(provider.GetService<ISecretResolver>());
Assert.NotNull(provider.GetService<ISecretStore>());
Assert.NotNull(provider.GetService<IMasterKeyProvider>());
Assert.NotNull(provider.GetService<ISecretCipher>());
ISecretReplicator? replicator = provider.GetService<ISecretReplicator>();
Assert.NotNull(replicator);
Assert.IsType<NoOpSecretReplicator>(replicator);
}
finally
{
Environment.SetEnvironmentVariable(envVar, null);
File.Delete(dbPath);
}
}
[Fact]
public async Task Resolver_Works_EndToEnd_ThroughDi()
{
string dbPath = NewTempDbPath();
string envVar = $"ZB_TEST_KEY_{Guid.NewGuid():N}";
Environment.SetEnvironmentVariable(envVar, Base64Key32());
try
{
var services = new ServiceCollection();
services.AddZbSecrets(BuildConfig(dbPath, envVar), "Secrets");
using ServiceProvider provider = services.BuildServiceProvider();
// Run the migration first so the schema exists (as the hosted service would on startup).
await provider.GetRequiredService<SqliteSecretsStoreMigrator>().MigrateAsync(CancellationToken.None);
var name = new SecretName("db/password");
const string plaintext = "super-secret-value";
ISecretCipher cipher = provider.GetRequiredService<ISecretCipher>();
ISecretStore store = provider.GetRequiredService<ISecretStore>();
StoredSecret row = cipher.Encrypt(name, plaintext, SecretContentType.Text);
await store.UpsertAsync(row, CancellationToken.None);
ISecretResolver resolver = provider.GetRequiredService<ISecretResolver>();
string? resolved = await resolver.GetAsync(name, CancellationToken.None);
Assert.Equal(plaintext, resolved);
}
finally
{
Environment.SetEnvironmentVariable(envVar, null);
File.Delete(dbPath);
}
}
[Fact]
public void MasterKey_Unavailable_FailsClosed()
{
string dbPath = NewTempDbPath();
string envVar = $"ZB_TEST_KEY_{Guid.NewGuid():N}";
// Deliberately do NOT set the env var — the master key cannot be resolved.
try
{
var services = new ServiceCollection();
services.AddZbSecrets(BuildConfig(dbPath, envVar), "Secrets");
using ServiceProvider provider = services.BuildServiceProvider();
IMasterKeyProvider keyProvider = provider.GetRequiredService<IMasterKeyProvider>();
Assert.Throws<MasterKeyUnavailableException>(() => keyProvider.GetMasterKey());
}
finally
{
File.Delete(dbPath);
}
}
}