using System.Security.Cryptography; using Microsoft.Extensions.Configuration; using Spectre.Console; using Spectre.Console.Testing; using ZB.MOM.WW.Secrets.Abstractions; using ZB.MOM.WW.Secrets.Cli.Interactive; using ZB.MOM.WW.Secrets.Cli.Interactive.Flows; using ZB.MOM.WW.Secrets.MasterKey; namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive.Flows; /// /// Drives the deployment-setup against a real temp-SQLite full session /// whose references three ${secret:NAME} tokens — one /// seeded (Ok), one absent (Missing), and one tombstoned. Each flow renders to a scripted /// , so the tests assert both the rendered and /// the resulting store state. The invariant under test is that the operator can fill every startup gap /// from one screen without any typed secret value ever reaching the terminal. /// public sealed class ReferenceAuditFlowTests : IDisposable { private readonly string _dir = Path.Combine(Path.GetTempPath(), $"zb-secrets-refaudit-{Guid.NewGuid():N}"); private string DbPath => Path.Combine(_dir, "secrets.db"); public ReferenceAuditFlowTests() => Directory.CreateDirectory(_dir); public void Dispose() { if (Directory.Exists(_dir)) { try { Directory.Delete(_dir, recursive: true); } catch (IOException) { /* best-effort temp cleanup */ } } } private static TestConsole NewConsole() { var console = (TestConsole)new TestConsole().Interactive(); console.Profile.Width = 240; // wide enough that the status table never truncates a cell return console; } // A full (KEK-capable) session on a fresh temp SQLite store, with an optional in-memory configuration // layered onto the target so the reference auditor sees ${secret:...} tokens. Keyed by an operator- // override KEK so the test never depends on ambient environment state. private async Task NewSessionAsync( IEnumerable>? configEntries = null) { byte[] key = new byte[32]; RandomNumberGenerator.Fill(key); var kek = new LiteralMasterKeyProvider(Convert.ToBase64String(key)); StoreTarget target = new TargetConfigReader().Manual(DbPath, new MasterKeyOptions { Source = MasterKeySource.Environment, EnvVarName = $"ZB_TEST_UNSET_{Guid.NewGuid():N}", }); if (configEntries is not null) { IConfigurationRoot config = new ConfigurationBuilder().AddInMemoryCollection(configEntries).Build(); target = target with { Configuration = config }; } return await new SecretsSessionFactory().OpenAsync(target, kek, CancellationToken.None); } // The canonical three-reference configuration: svc/ok (seeded), svc/missing (absent), svc/tomb // (tombstoned). Ordinal name order is svc/missing < svc/ok < svc/tomb, so the non-Ok findings the // flow prompts for arrive in the order missing, then tomb. private static IEnumerable> ThreeReferenceConfig() => [ new("ConnectionStrings:Ok", "${secret:svc/ok}"), new("Api:MissingKey", "${secret:svc/missing}"), new("Feature:TombValue", "${secret:svc/tomb}"), ]; private static async Task SeedAsync(SecretsSession session, string name, string value) { var secretName = new SecretName(name); StoredSecret row = session.Cipher!.Encrypt(secretName, value, SecretContentType.Text); await session.Store.UpsertAsync(row, CancellationToken.None); } // Seeds svc/ok live and leaves svc/missing absent, then seeds + tombstones svc/tomb. private static async Task SeedThreeReferenceStateAsync(SecretsSession session) { await SeedAsync(session, "svc/ok", "OK-SEEDED-VALUE"); await SeedAsync(session, "svc/tomb", "TOMB-ORIGINAL-VALUE"); await session.Store.DeleteAsync(new SecretName("svc/tomb"), "test", CancellationToken.None); } private static int CountOccurrences(string haystack, string needle) { int count = 0; for (int i = haystack.IndexOf(needle, StringComparison.Ordinal); i >= 0; i = haystack.IndexOf(needle, i + needle.Length, StringComparison.Ordinal)) { count++; } return count; } [Fact] public async Task Renders_status_table_for_all_references() { SecretsSession session = await NewSessionAsync(ThreeReferenceConfig()); await SeedThreeReferenceStateAsync(session); TestConsole console = NewConsole(); console.Input.PushTextWithEnter("n"); // svc/missing: decline (this test only asserts the rendered table) console.Input.PushTextWithEnter("n"); // svc/tomb: decline await new ReferenceAuditFlow().RunAsync(console, session, CancellationToken.None); string output = console.Output; Assert.Contains("svc/ok", output, StringComparison.Ordinal); Assert.Contains("svc/missing", output, StringComparison.Ordinal); Assert.Contains("svc/tomb", output, StringComparison.Ordinal); Assert.Contains("Missing", output, StringComparison.Ordinal); Assert.Contains("Tombstoned", output, StringComparison.Ordinal); Assert.Contains("ConnectionStrings:Ok", output, StringComparison.Ordinal); Assert.Contains("Api:MissingKey", output, StringComparison.Ordinal); Assert.Contains("Feature:TombValue", output, StringComparison.Ordinal); Assert.True(new ReferenceAuditFlow().RequiresKek); } [Fact] public async Task Offers_to_seed_each_non_ok_reference_and_seeds_accepted_ones() { SecretsSession session = await NewSessionAsync(ThreeReferenceConfig()); await SeedThreeReferenceStateAsync(session); TestConsole console = NewConsole(); // svc/missing (first non-Ok): accept, value, Text (Enter), empty description. console.Input.PushTextWithEnter("y"); console.Input.PushTextWithEnter("MISSING-SEED-SENTINEL"); console.Input.PushKey(ConsoleKey.Enter); console.Input.PushKey(ConsoleKey.Enter); // svc/tomb (second non-Ok): accept, value, Text (Enter), empty description. console.Input.PushTextWithEnter("y"); console.Input.PushTextWithEnter("TOMB-SEED-SENTINEL"); console.Input.PushKey(ConsoleKey.Enter); console.Input.PushKey(ConsoleKey.Enter); await new ReferenceAuditFlow().RunAsync(console, session, CancellationToken.None); IReadOnlyList after = await new ReferenceAuditor().AuditAsync(session, CancellationToken.None); Assert.All(after, f => Assert.Equal(ReferenceStatus.Ok, f.Status)); // The store now decrypts both freshly-seeded values. StoredSecret? missing = await session.Store.GetAsync(new SecretName("svc/missing"), CancellationToken.None); StoredSecret? tomb = await session.Store.GetAsync(new SecretName("svc/tomb"), CancellationToken.None); Assert.Equal("MISSING-SEED-SENTINEL", session.Cipher!.Decrypt(missing!)); Assert.Equal("TOMB-SEED-SENTINEL", session.Cipher!.Decrypt(tomb!)); Assert.False(tomb!.IsDeleted); // upsert revived the tombstone Assert.DoesNotContain("MISSING-SEED-SENTINEL", console.Output, StringComparison.Ordinal); Assert.DoesNotContain("TOMB-SEED-SENTINEL", console.Output, StringComparison.Ordinal); } [Fact] public async Task Skipped_references_are_left_untouched() { SecretsSession session = await NewSessionAsync(ThreeReferenceConfig()); await SeedThreeReferenceStateAsync(session); TestConsole console = NewConsole(); console.Input.PushTextWithEnter("n"); // svc/missing: decline console.Input.PushTextWithEnter("n"); // svc/tomb: decline await new ReferenceAuditFlow().RunAsync(console, session, CancellationToken.None); StoredSecret? missing = await session.Store.GetAsync(new SecretName("svc/missing"), CancellationToken.None); StoredSecret? tomb = await session.Store.GetAsync(new SecretName("svc/tomb"), CancellationToken.None); Assert.Null(missing); Assert.NotNull(tomb); Assert.True(tomb!.IsDeleted); } [Fact] public async Task Manual_target_without_appsettings_reports_nothing_to_audit() { SecretsSession session = await NewSessionAsync(); // Manual target: empty composed configuration TestConsole console = NewConsole(); await new ReferenceAuditFlow().RunAsync(console, session, CancellationToken.None); Assert.Contains("No ${secret:} references found", console.Output, StringComparison.Ordinal); Assert.Equal(0, CountOccurrences(console.Output, "Seed '")); // no seed prompt was rendered } }