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.Crypto; using ZB.MOM.WW.Secrets.MasterKey; namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive; /// /// Exercises against real temp-dir SQLite sessions: distinct-reference /// collation (one finding per name, every config path listed), the four full-session classifications /// (present/decryptable → Ok, absent → Missing, tombstoned → Tombstoned, wrong-KEK → Undecryptable), /// and the degraded-session presence-only downgrade (present → PresentUnverified). /// public sealed class ReferenceAuditorTests : IDisposable { private readonly string _dir = Path.Combine(Path.GetTempPath(), $"zb-secrets-audit-{Guid.NewGuid():N}"); private readonly List _envVars = []; private string DbPath => Path.Combine(_dir, "secrets.db"); public ReferenceAuditorTests() => 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 IConfigurationRoot Config(params (string Key, string Value)[] pairs) => new ConfigurationBuilder() .AddInMemoryCollection(pairs.ToDictionary(p => p.Key, p => (string?)p.Value)) .Build(); private StoreTarget Target(MasterKeyOptions masterKey, IConfigurationRoot config) => new( "test", AppSettingsPath: null, new SecretsOptions { SqlitePath = DbPath, MasterKey = masterKey }, SqlServer: null, config); private MasterKeyOptions EnvKey() => new() { Source = MasterKeySource.Environment, EnvVarName = SetTempKeyEnv(), }; private static MasterKeyOptions UnsetEnvKey() => new() { Source = MasterKeySource.Environment, EnvVarName = $"ZB_TEST_UNSET_{Guid.NewGuid():N}", }; private Task Open(MasterKeyOptions masterKey, IConfigurationRoot config) => new SecretsSessionFactory().OpenAsync(Target(masterKey, config), overrideKek: null, CancellationToken.None); [Fact] public async Task Finds_all_distinct_secret_references_with_their_config_paths() { IConfigurationRoot config = Config( ("Data:Primary:ConnectionString", "Server=db;Password=${secret:db/pw}"), ("Data:Replica:ConnectionString", "Server=db2;Password=${secret:db/pw}"), ("PlainValue", "not a reference")); SecretsSession session = await Open(EnvKey(), config); IReadOnlyList findings = await new ReferenceAuditor().AuditAsync(session, CancellationToken.None); ReferenceFinding finding = Assert.Single(findings); Assert.Equal("db/pw", finding.SecretName); Assert.Equal( ["Data:Primary:ConnectionString", "Data:Replica:ConnectionString"], finding.ConfigPaths); } [Fact] public async Task Classifies_present_decryptable_secret_as_Ok() { SecretsSession session = await Open(EnvKey(), Config(("Api:Key", "${secret:api/key}"))); var name = new SecretName("api/key"); await session.Store.UpsertAsync( session.Cipher!.Encrypt(name, "s3cret", SecretContentType.Text), CancellationToken.None); IReadOnlyList findings = await new ReferenceAuditor().AuditAsync(session, CancellationToken.None); Assert.Equal(ReferenceStatus.Ok, Assert.Single(findings).Status); } [Fact] public async Task Classifies_absent_secret_as_Missing() { SecretsSession session = await Open(EnvKey(), Config(("Api:Key", "${secret:api/key}"))); IReadOnlyList findings = await new ReferenceAuditor().AuditAsync(session, CancellationToken.None); Assert.Equal(ReferenceStatus.Missing, Assert.Single(findings).Status); } [Fact] public async Task Classifies_tombstoned_secret_as_Tombstoned() { SecretsSession session = await Open(EnvKey(), Config(("Api:Key", "${secret:api/key}"))); var name = new SecretName("api/key"); await session.Store.UpsertAsync( session.Cipher!.Encrypt(name, "s3cret", SecretContentType.Text), CancellationToken.None); Assert.True(await session.Store.DeleteAsync(name, actor: "test", CancellationToken.None)); IReadOnlyList findings = await new ReferenceAuditor().AuditAsync(session, CancellationToken.None); Assert.Equal(ReferenceStatus.Tombstoned, Assert.Single(findings).Status); } [Fact] public async Task Classifies_wrong_kek_row_as_Undecryptable() { SecretsSession session = await Open(EnvKey(), Config(("Api:Key", "${secret:api/key}"))); // Seal a row under a DIFFERENT KEK (KEK-A) and persist it into the session's store; the // session's own cipher (KEK-B) then cannot unwrap it → Undecryptable. byte[] keyA = new byte[32]; RandomNumberGenerator.Fill(keyA); var cipherA = new AesGcmEnvelopeCipher(new LiteralMasterKeyProvider(Convert.ToBase64String(keyA))); var name = new SecretName("api/key"); await session.Store.UpsertAsync( cipherA.Encrypt(name, "s3cret", SecretContentType.Text), CancellationToken.None); IReadOnlyList findings = await new ReferenceAuditor().AuditAsync(session, CancellationToken.None); Assert.Equal(ReferenceStatus.Undecryptable, Assert.Single(findings).Status); } [Fact] public async Task Malformed_reference_yields_InvalidName_finding_without_aborting_audit() { SecretsSession session = await Open(EnvKey(), Config( ("Bad:Ref", "${secret:REPLACE ME!}"), ("Good:Ref", "${secret:api/key}"))); IReadOnlyList findings = await new ReferenceAuditor().AuditAsync(session, CancellationToken.None); Assert.Equal(2, findings.Count); Assert.Equal(ReferenceStatus.InvalidName, findings.Single(f => f.SecretName == "REPLACE ME!").Status); Assert.Equal(ReferenceStatus.Missing, findings.Single(f => f.SecretName == "api/key").Status); } [Fact] public async Task Comment_keys_are_skipped_like_the_expander() { SecretsSession session = await Open(EnvKey(), Config( ("Docs:_comment", "Reference a secret like ${secret:example/thing}"), ("Real:Key", "${secret:api/key}"))); IReadOnlyList findings = await new ReferenceAuditor().AuditAsync(session, CancellationToken.None); ReferenceFinding finding = Assert.Single(findings); Assert.Equal("api/key", finding.SecretName); } [Fact] public async Task Case_variant_references_collapse_to_one_normalized_finding() { SecretsSession session = await Open(EnvKey(), Config( ("A:Conn", "${secret:Db/Pw}"), ("B:Conn", "${secret:db/pw}"))); IReadOnlyList findings = await new ReferenceAuditor().AuditAsync(session, CancellationToken.None); ReferenceFinding finding = Assert.Single(findings); Assert.Equal("db/pw", finding.SecretName); Assert.Equal(["A:Conn", "B:Conn"], finding.ConfigPaths); } [Fact] public async Task Degraded_session_reports_PresenceOnly_status() { // Seed a present row and a tombstoned row through a FULL session first. var present = new SecretName("svc/token"); var gone = new SecretName("old/key"); SecretsSession seed = await Open(EnvKey(), new ConfigurationBuilder().Build()); await seed.Store.UpsertAsync( seed.Cipher!.Encrypt(present, "tok", SecretContentType.Text), CancellationToken.None); await seed.Store.UpsertAsync( seed.Cipher!.Encrypt(gone, "old", SecretContentType.Text), CancellationToken.None); Assert.True(await seed.Store.DeleteAsync(gone, actor: "test", CancellationToken.None)); // Re-open the SAME store degraded (KEK unresolvable) and audit references to all three. SecretsSession degraded = await Open(UnsetEnvKey(), Config( ("A:Token", "${secret:svc/token}"), ("B:Old", "${secret:old/key}"), ("C:Missing", "${secret:no/such}"))); Assert.False(degraded.KekAvailable); IReadOnlyList findings = await new ReferenceAuditor().AuditAsync(degraded, CancellationToken.None); Assert.Equal(ReferenceStatus.PresentUnverified, findings.Single(f => f.SecretName == "svc/token").Status); Assert.Equal(ReferenceStatus.Tombstoned, findings.Single(f => f.SecretName == "old/key").Status); Assert.Equal(ReferenceStatus.Missing, findings.Single(f => f.SecretName == "no/such").Status); } }