using System.Security.Cryptography; using Microsoft.Extensions.Configuration; using ZB.MOM.WW.Secrets.Abstractions; using ZB.MOM.WW.Secrets.Cli.Interactive; using ZB.MOM.WW.Secrets.MasterKey; using ZB.MOM.WW.Secrets.Replicator.SqlServer; namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive; /// /// Exercises against real temp-dir SQLite targets: the happy path /// (env KEK present → full session + seal/upsert/get/decrypt round-trip), the lockout-recovery path /// (KEK unresolvable → degraded session that still lists metadata), the operator-override upgrade, and /// the SQL-Server-with-unresolved-secret-ref fall-back to the local store. /// public sealed class SecretsSessionFactoryTests : IDisposable { private readonly string _dir = Path.Combine(Path.GetTempPath(), $"zb-secrets-session-{Guid.NewGuid():N}"); private readonly List _envVars = []; private string DbPath => Path.Combine(_dir, "secrets.db"); public SecretsSessionFactoryTests() => Directory.CreateDirectory(_dir); public void Dispose() { foreach (string name in _envVars) { Environment.SetEnvironmentVariable(name, null); } if (Directory.Exists(_dir)) { try { Directory.Delete(_dir, recursive: true); } catch (IOException) { /* best-effort temp cleanup */ } } } private string SetTempKeyEnv() { byte[] key = new byte[32]; RandomNumberGenerator.Fill(key); string name = $"ZB_TEST_KEK_{Guid.NewGuid():N}"; Environment.SetEnvironmentVariable(name, Convert.ToBase64String(key)); _envVars.Add(name); return name; } private static MasterKeyOptions UnsetEnvKey() => new() { Source = MasterKeySource.Environment, EnvVarName = $"ZB_TEST_UNSET_{Guid.NewGuid():N}", }; [Fact] public async Task Opens_full_session_when_env_kek_present() { string envVar = SetTempKeyEnv(); StoreTarget target = new TargetConfigReader().Manual(DbPath, new MasterKeyOptions { Source = MasterKeySource.Environment, EnvVarName = envVar, }); SecretsSession session = await new SecretsSessionFactory().OpenAsync(target, overrideKek: null, CancellationToken.None); Assert.True(session.KekAvailable); Assert.Equal("sqlite", session.StoreKind); Assert.NotNull(session.Cipher); var name = new SecretName("test/roundtrip"); StoredSecret row = session.Cipher!.Encrypt(name, "hunter2", SecretContentType.Text); await session.Store.UpsertAsync(row, CancellationToken.None); StoredSecret? fetched = await session.Store.GetAsync(name, CancellationToken.None); Assert.NotNull(fetched); Assert.Equal("hunter2", session.Cipher!.Decrypt(fetched!)); } [Fact] public async Task Opens_degraded_session_when_env_var_unset() { StoreTarget target = new TargetConfigReader().Manual(DbPath, UnsetEnvKey()); SecretsSession session = await new SecretsSessionFactory().OpenAsync(target, overrideKek: null, CancellationToken.None); Assert.False(session.KekAvailable); Assert.Null(session.Cipher); Assert.Null(session.MasterKey); Assert.NotEmpty(session.Warnings); // Store is still usable for metadata ops even without a KEK. IReadOnlyList list = await session.Store.ListAsync(includeDeleted: false, CancellationToken.None); Assert.Empty(list); } [Fact] public async Task Opens_degraded_session_when_master_key_source_is_out_of_range() { StoreTarget target = new TargetConfigReader().Manual(DbPath, new MasterKeyOptions { Source = (MasterKeySource)999, }); SecretsSession session = await new SecretsSessionFactory().OpenAsync(target, overrideKek: null, CancellationToken.None); Assert.False(session.KekAvailable); Assert.Null(session.Cipher); Assert.NotEmpty(session.Warnings); Assert.Contains(session.Warnings, w => w.Contains("999", StringComparison.Ordinal)); // Store is still usable despite the malformed KEK source. IReadOnlyList list = await session.Store.ListAsync(includeDeleted: false, CancellationToken.None); Assert.Empty(list); } [Fact] public async Task Override_kek_upgrades_degraded_session() { StoreTarget target = new TargetConfigReader().Manual(DbPath, UnsetEnvKey()); byte[] key = new byte[32]; RandomNumberGenerator.Fill(key); var overrideKek = new LiteralMasterKeyProvider(Convert.ToBase64String(key)); SecretsSession session = await new SecretsSessionFactory().OpenAsync(target, overrideKek, CancellationToken.None); Assert.True(session.KekAvailable); Assert.NotNull(session.Cipher); Assert.Same(overrideKek, session.MasterKey); } [Fact] public async Task SqlServer_target_with_unresolved_secret_ref_connstr_falls_back_to_sqlite_with_warning() { string envVar = SetTempKeyEnv(); SecretsOptions secrets = new() { SqlitePath = DbPath, MasterKey = new MasterKeyOptions { Source = MasterKeySource.Environment, EnvVarName = envVar }, }; SqlServerSecretsOptions sqlServer = new() { ConnectionString = "Server=hub;Database=secrets;User Id=app;Password=${secret:sql/hub-pw}", }; StoreTarget target = new( "sql-hub", AppSettingsPath: null, secrets, sqlServer, new ConfigurationBuilder().Build()); SecretsSession session = await new SecretsSessionFactory().OpenAsync(target, overrideKek: null, CancellationToken.None); Assert.Equal("sqlite", session.StoreKind); Assert.Contains(session.Warnings, w => w.Contains("SQLite", StringComparison.OrdinalIgnoreCase)); } }