using System.Security.Cryptography; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using ZB.MOM.WW.Secrets.Abstractions; using ZB.MOM.WW.Secrets.Configuration; using ZB.MOM.WW.Secrets.DependencyInjection; using ZB.MOM.WW.Secrets.Sqlite; namespace ZB.MOM.WW.MxGateway.Tests.Configuration; /// /// Exercises the exact pre-host ${secret:} expansion flow that /// runs before the host is /// built: a throwaway AddZbSecrets container migrates the SQLite store, then a /// rewrites configuration tokens in place. A seeded reference /// resolves to its plaintext; a reference to an absent secret is fail-closed /// (). /// public sealed class PreHostSecretExpansionTests { [Fact] public async Task ExpandConfiguration_SeededReference_ResolvesToPlaintext() { using var fixture = SecretsFixture.Create(); const string plaintext = "s3cr3t-plaintext-value"; await fixture.SeedAsync("test/value", plaintext); IConfigurationRoot config = fixture.BuildConfig(("MxGateway:Ldap:ServiceAccountPassword", "${secret:test/value}")); await new SecretReferenceExpander(fixture.Resolver).ExpandConfigurationAsync(config, default); Assert.Equal(plaintext, config["MxGateway:Ldap:ServiceAccountPassword"]); } /// /// G-4: the shipped appsettings.json sources the LDAP bind password from /// ${secret:ldap/mxgateway/bind}. Proves that exact reference at that exact key resolves to /// the seeded plaintext through the same pre-host expander the host runs. /// [Fact] public async Task ExpandConfiguration_LdapBindReference_ResolvesToSeededPassword() { using var fixture = SecretsFixture.Create(); const string bindPassword = "seeded-bind-password"; await fixture.SeedAsync("ldap/mxgateway/bind", bindPassword); IConfigurationRoot config = fixture.BuildConfig( ("MxGateway:Ldap:ServiceAccountPassword", "${secret:ldap/mxgateway/bind}")); await new SecretReferenceExpander(fixture.Resolver).ExpandConfigurationAsync(config, default); Assert.Equal(bindPassword, config["MxGateway:Ldap:ServiceAccountPassword"]); } [Fact] public async Task ExpandConfiguration_MissingReference_ThrowsFailClosed() { using var fixture = SecretsFixture.Create(); IConfigurationRoot config = fixture.BuildConfig(("MxGateway:Ldap:ServiceAccountPassword", "${secret:missing}")); await Assert.ThrowsAsync( () => new SecretReferenceExpander(fixture.Resolver).ExpandConfigurationAsync(config, default)); } /// /// A self-contained secrets store: a unique temp SQLite path + a per-test master-key env var /// (so parallel tests never collide), wired through the real AddZbSecrets DI graph the /// host uses. Disposal removes the temp file and clears the env var. /// private sealed class SecretsFixture : IDisposable { private readonly ServiceProvider _provider; private readonly string _sqlitePath; private readonly string _envVarName; private SecretsFixture(ServiceProvider provider, string sqlitePath, string envVarName) { _provider = provider; _sqlitePath = sqlitePath; _envVarName = envVarName; } public ISecretResolver Resolver => _provider.GetRequiredService(); public static SecretsFixture Create() { string unique = Guid.NewGuid().ToString("N"); string sqlitePath = Path.Combine(Path.GetTempPath(), $"mxgw-secrets-{unique}.db"); string envVarName = $"ZB_SECRETS_MASTER_KEY_TEST_{unique}"; Environment.SetEnvironmentVariable(envVarName, Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))); IConfigurationRoot secretsConfig = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Secrets:SqlitePath"] = sqlitePath, ["Secrets:MasterKey:Source"] = "Environment", ["Secrets:MasterKey:EnvVarName"] = envVarName, ["Secrets:RunMigrationsOnStartup"] = "true", ["Secrets:ResolveCacheTtl"] = "00:00:30", }) .Build(); ServiceProvider provider = new ServiceCollection() .AddZbSecrets(secretsConfig, "Secrets") .BuildServiceProvider(); // Mirror the pre-host path: migrate the store explicitly before the first resolve. provider.GetRequiredService().MigrateAsync(default).GetAwaiter().GetResult(); return new SecretsFixture(provider, sqlitePath, envVarName); } // Seals a plaintext value under a fresh DEK and upserts it — the same store/cipher pair the // CLI 'secret set' verb uses. public async Task SeedAsync(string name, string plaintext) { var secretName = new SecretName(name); ISecretCipher cipher = _provider.GetRequiredService(); ISecretStore store = _provider.GetRequiredService(); StoredSecret sealed_ = cipher.Encrypt(secretName, plaintext, SecretContentType.Text); await store.UpsertAsync(sealed_, default); } public IConfigurationRoot BuildConfig(params (string Key, string Value)[] entries) => new ConfigurationBuilder() .AddInMemoryCollection(entries.ToDictionary(e => e.Key, e => (string?)e.Value)) .Build(); public void Dispose() { _provider.Dispose(); Environment.SetEnvironmentVariable(_envVarName, null); // The SQLite store runs in WAL mode with connection pooling, so a pooled handle can // outlive _provider.Dispose() and keep the .db (plus its -wal/-shm sidecars) open. Clear // the pool first, then delete all three files so no temp artifact leaks between tests. Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); foreach (string path in new[] { _sqlitePath, _sqlitePath + "-wal", _sqlitePath + "-shm" }) { try { if (File.Exists(path)) { File.Delete(path); } } catch (IOException) { // Best-effort cleanup of the temp store; a locked file must not fail the test. } } } } }