using System.Security.Cryptography; 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 four CRUD flows (, , /// , ) directly against a real temp-SQLite /// full session (KEK supplied as a override). Each flow renders to /// a scripted , so the tests assert on rendered /// and on the resulting store state — the invariant under test is that value plaintext only ever reaches /// the screen through the reveal flow's explicit confirmation. /// public sealed class CrudFlowTests : IDisposable { private readonly string _dir = Path.Combine(Path.GetTempPath(), $"zb-secrets-crud-{Guid.NewGuid():N}"); private string DbPath => Path.Combine(_dir, "secrets.db"); public CrudFlowTests() => 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 metadata table never truncates a cell return console; } // A full (KEK-capable) session on a fresh temp SQLite store, keyed by an operator-override KEK so the // test never depends on ambient environment state. private async Task NewFullSessionAsync() { 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}", }); return await new SecretsSessionFactory().OpenAsync(target, kek, CancellationToken.None); } private static async Task SeedAsync( SecretsSession session, string name, string value, SecretContentType contentType = SecretContentType.Text) { var secretName = new SecretName(name); StoredSecret row = session.Cipher!.Encrypt(secretName, value, contentType); await session.Store.UpsertAsync(row, 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; } // --- List --- [Fact] public async Task Renders_metadata_table_never_values() { SecretsSession session = await NewFullSessionAsync(); await SeedAsync(session, "db/conn", "SENTINEL-CONN-VALUE", SecretContentType.ConnectionString); await SeedAsync(session, "api/key", "SENTINEL-API-VALUE"); TestConsole console = NewConsole(); await new ListSecretsFlow().RunAsync(console, session, CancellationToken.None); string output = console.Output; Assert.Contains("db/conn", output, StringComparison.Ordinal); Assert.Contains("api/key", output, StringComparison.Ordinal); Assert.Contains("ConnectionString", output, StringComparison.Ordinal); Assert.Contains(session.MasterKey!.KekId, output, StringComparison.Ordinal); // kek id column Assert.DoesNotContain("SENTINEL-CONN-VALUE", output, StringComparison.Ordinal); Assert.DoesNotContain("SENTINEL-API-VALUE", output, StringComparison.Ordinal); Assert.False(new ListSecretsFlow().RequiresKek); } // --- Set / rotate --- [Fact] public async Task Masked_prompt_seals_and_upserts() { SecretsSession session = await NewFullSessionAsync(); TestConsole console = NewConsole(); console.Input.PushTextWithEnter("app/token"); // name console.Input.PushTextWithEnter("TOPSECRET-PLAINTEXT"); // value (masked) console.Input.PushKey(ConsoleKey.Enter); // content-type: first choice (Text) console.Input.PushKey(ConsoleKey.Enter); // description: empty await new SetSecretFlow().RunAsync(console, session, CancellationToken.None); StoredSecret? row = await session.Store.GetAsync(new SecretName("app/token"), CancellationToken.None); Assert.NotNull(row); Assert.Equal("TOPSECRET-PLAINTEXT", session.Cipher!.Decrypt(row!)); Assert.DoesNotContain("TOPSECRET-PLAINTEXT", console.Output, StringComparison.Ordinal); // never echoed } [Fact] public async Task Invalid_name_reprompts_then_completes() { SecretsSession session = await NewFullSessionAsync(); TestConsole console = NewConsole(); console.Input.PushTextWithEnter("bad name"); // invalid (space) → must re-prompt, not throw console.Input.PushTextWithEnter("good/name"); // valid retry console.Input.PushTextWithEnter("VALUE-AFTER-REPROMPT"); // value console.Input.PushKey(ConsoleKey.Enter); // content-type: Text console.Input.PushKey(ConsoleKey.Enter); // description: empty // The bug this pins: SecretName's error text embeds "[a-z0-9._/-]", which Spectre parses as // markup and throws InvalidOperationException on unless the validator escapes it. Reaching the // assertion at all proves the re-prompt happened cleanly. await new SetSecretFlow().RunAsync(console, session, CancellationToken.None); StoredSecret? row = await session.Store.GetAsync(new SecretName("good/name"), CancellationToken.None); Assert.NotNull(row); Assert.Equal("VALUE-AFTER-REPROMPT", session.Cipher!.Decrypt(row!)); } [Fact] public async Task Existing_name_confirms_overwrite() { SecretsSession session = await NewFullSessionAsync(); await SeedAsync(session, "shared/api", "OLD-VALUE"); // Decline: the value must be left unchanged. TestConsole declineConsole = NewConsole(); declineConsole.Input.PushTextWithEnter("shared/api"); // name (exists) declineConsole.Input.PushTextWithEnter("n"); // overwrite? no await new SetSecretFlow().RunAsync(declineConsole, session, CancellationToken.None); StoredSecret? afterDecline = await session.Store.GetAsync(new SecretName("shared/api"), CancellationToken.None); Assert.Equal("OLD-VALUE", session.Cipher!.Decrypt(afterDecline!)); // Accept: the value is overwritten in place. TestConsole acceptConsole = NewConsole(); acceptConsole.Input.PushTextWithEnter("shared/api"); // name (exists) acceptConsole.Input.PushTextWithEnter("y"); // overwrite? yes acceptConsole.Input.PushTextWithEnter("NEW-VALUE"); // value (masked) acceptConsole.Input.PushKey(ConsoleKey.Enter); // content-type: Text acceptConsole.Input.PushKey(ConsoleKey.Enter); // description: empty await new SetSecretFlow().RunAsync(acceptConsole, session, CancellationToken.None); StoredSecret? afterAccept = await session.Store.GetAsync(new SecretName("shared/api"), CancellationToken.None); Assert.Equal("NEW-VALUE", session.Cipher!.Decrypt(afterAccept!)); } // --- Reveal --- [Fact] public async Task Confirm_then_prints_value_once() { SecretsSession session = await NewFullSessionAsync(); await SeedAsync(session, "reveal/me", "REVEAL-SENTINEL-XYZ"); TestConsole console = NewConsole(); console.Input.PushTextWithEnter("reveal/me"); // name console.Input.PushTextWithEnter("y"); // reveal on screen? yes await new RevealSecretFlow().RunAsync(console, session, CancellationToken.None); Assert.Equal(1, CountOccurrences(console.Output, "REVEAL-SENTINEL-XYZ")); } [Fact] public async Task Decline_prints_nothing() { SecretsSession session = await NewFullSessionAsync(); await SeedAsync(session, "reveal/me", "REVEAL-SENTINEL-XYZ"); TestConsole console = NewConsole(); console.Input.PushTextWithEnter("reveal/me"); // name console.Input.PushTextWithEnter("n"); // reveal on screen? no await new RevealSecretFlow().RunAsync(console, session, CancellationToken.None); Assert.DoesNotContain("REVEAL-SENTINEL-XYZ", console.Output, StringComparison.Ordinal); } // --- Delete --- [Fact] public async Task Confirm_tombstones_row() { SecretsSession session = await NewFullSessionAsync(); await SeedAsync(session, "del/me", "GONE"); TestConsole console = NewConsole(); console.Input.PushTextWithEnter("del/me"); // name console.Input.PushTextWithEnter("y"); // delete? yes await new DeleteSecretFlow().RunAsync(console, session, CancellationToken.None); StoredSecret? row = await session.Store.GetAsync(new SecretName("del/me"), CancellationToken.None); Assert.NotNull(row); Assert.True(row!.IsDeleted); } [Fact] public async Task Unknown_name_reports_not_found_without_confirming() { SecretsSession session = await NewFullSessionAsync(); TestConsole console = NewConsole(); console.Input.PushTextWithEnter("never/existed"); // name only — no confirm input is scripted // If the flow prompted for a confirm before checking existence, the missing input would fail. await new DeleteSecretFlow().RunAsync(console, session, CancellationToken.None); Assert.Contains("No secret named", console.Output, StringComparison.Ordinal); StoredSecret? row = await session.Store.GetAsync(new SecretName("never/existed"), CancellationToken.None); Assert.Null(row); } [Fact] public async Task Decline_leaves_row() { SecretsSession session = await NewFullSessionAsync(); await SeedAsync(session, "del/me", "STAYS"); TestConsole console = NewConsole(); console.Input.PushTextWithEnter("del/me"); // name console.Input.PushTextWithEnter("n"); // delete? no await new DeleteSecretFlow().RunAsync(console, session, CancellationToken.None); StoredSecret? row = await session.Store.GetAsync(new SecretName("del/me"), CancellationToken.None); Assert.NotNull(row); Assert.False(row!.IsDeleted); } }