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.MasterKey; namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive; /// /// Drives end-to-end through a scripted : the /// target picker (recents-first + manual entry), the header/menu loop, flow dispatch, the degraded-session /// KEK-upgrade prompt, and the error-containment panel. The shell is the only UI-owning class, so these /// tests assert on rendered and on what a fake flow observed. /// public sealed class InteractiveShellTests : IDisposable { private readonly string _dir = Path.Combine(Path.GetTempPath(), $"zb-secrets-shell-{Guid.NewGuid():N}"); private readonly List _envVars = []; public InteractiveShellTests() => 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 DbPath => Path.Combine(_dir, "secrets.db"); private static string NewKeyBase64() { byte[] key = new byte[32]; RandomNumberGenerator.Fill(key); return Convert.ToBase64String(key); } private string SetTempKeyEnv() { string name = $"ZB_TEST_KEK_{Guid.NewGuid():N}"; Environment.SetEnvironmentVariable(name, NewKeyBase64()); _envVars.Add(name); return name; } private string UnsetEnvVarName() { string name = $"ZB_TEST_UNSET_{Guid.NewGuid():N}"; _envVars.Add(name); // ensure cleanup even though never set return name; } private string WriteAppSettings(string envVarName) { string path = Path.Combine(_dir, "appsettings.json"); File.WriteAllText(path, $$""" { "Secrets": { "SqlitePath": "secrets.db", "MasterKey": { "Source": "Environment", "EnvVarName": "{{envVarName}}" } } } """); return path; } private static TestConsole NewConsole() => new TestConsole().Interactive(); private static InteractiveShell NewShell( TestConsole console, RecentTargetsStore recents, params IInteractiveFlow[] flows) => new(console, recents, new TargetConfigReader(), new SecretsSessionFactory(), flows); private RecentTargetsStore NewRecents(TimeProvider? clock = null) => new(Path.Combine(_dir, "recents.json"), clock); /// Records what the shell dispatched to it without touching store logic. private sealed class FakeFlow(string title, bool requiresKek = false, Action? onRun = null) : IInteractiveFlow { public string Title { get; } = title; public bool RequiresKek { get; } = requiresKek; public int InvocationCount { get; private set; } public SecretsSession? LastSession { get; private set; } public Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct) { InvocationCount++; LastSession = session; onRun?.Invoke(session); return Task.CompletedTask; } } // --- Picker input helpers (SelectionPrompt: DownArrow to move, Enter to accept) --- private static void SelectIndex(TestConsole console, int index) { for (int i = 0; i < index; i++) { console.Input.PushKey(ConsoleKey.DownArrow); } console.Input.PushKey(ConsoleKey.Enter); } // Manual-entry target: with no recents the picker is [Enter appsettings(0), Manual entry(1)]. private void ScriptManualEntry(TestConsole console, string envVarName) { SelectIndex(console, 1); // "Manual entry…" console.Input.PushTextWithEnter(DbPath); console.Input.PushTextWithEnter(envVarName); } [Fact] public async Task Shell_shows_target_picker_then_menu_and_quits() { TestConsole console = NewConsole(); InteractiveShell shell = NewShell(console, NewRecents()); ScriptManualEntry(console, UnsetEnvVarName()); // No flows: menu is [Switch store target(0), Quit(1)]. SelectIndex(console, 1); // Quit int result = await shell.RunAsync(CancellationToken.None); Assert.Equal(0, result); Assert.Contains("Manual entry", console.Output, StringComparison.Ordinal); Assert.Contains("appsettings", console.Output, StringComparison.Ordinal); Assert.Contains("Switch store target", console.Output, StringComparison.Ordinal); Assert.Contains("Quit", console.Output, StringComparison.Ordinal); } [Fact] public async Task Recents_appear_first_in_target_picker_and_selection_opens_session() { string envVar = SetTempKeyEnv(); string appSettings = WriteAppSettings(envVar); var clock = new MutableClock(DateTimeOffset.Parse("2026-01-01T00:00:00Z")); RecentTargetsStore recents = NewRecents(clock); recents.Touch("prod-app", appSettings); DateTimeOffset original = recents.Load()[0].LastUsedUtc; clock.Advance(TimeSpan.FromHours(1)); TestConsole console = NewConsole(); InteractiveShell shell = NewShell(console, recents); SelectIndex(console, 0); // recent is first SelectIndex(console, 1); // menu (no flows): Quit int result = await shell.RunAsync(CancellationToken.None); Assert.Equal(0, result); Assert.Contains("prod-app", console.Output, StringComparison.Ordinal); IReadOnlyList after = recents.Load(); Assert.Equal(appSettings, after[0].AppSettingsPath); // still first Assert.True(after[0].LastUsedUtc > original); // re-Touched with a newer stamp } [Fact] public async Task Menu_lists_registered_flows_by_title_plus_builtins() { var alpha = new FakeFlow("Alpha flow"); var beta = new FakeFlow("Beta flow"); TestConsole console = NewConsole(); InteractiveShell shell = NewShell(console, NewRecents(), alpha, beta); ScriptManualEntry(console, UnsetEnvVarName()); // Menu is [Alpha(0), Beta(1), Switch(2), Quit(3)]. SelectIndex(console, 3); // Quit int result = await shell.RunAsync(CancellationToken.None); Assert.Equal(0, result); Assert.Contains("Alpha flow", console.Output, StringComparison.Ordinal); Assert.Contains("Beta flow", console.Output, StringComparison.Ordinal); Assert.Contains("Switch store target", console.Output, StringComparison.Ordinal); Assert.Contains("Quit", console.Output, StringComparison.Ordinal); } [Fact] public async Task Degraded_session_flow_requiring_kek_prompts_for_key_first() { var flow = new FakeFlow("Seal something", requiresKek: true); string pastedKey = NewKeyBase64(); TestConsole console = NewConsole(); InteractiveShell shell = NewShell(console, NewRecents(), flow); ScriptManualEntry(console, UnsetEnvVarName()); // degraded session // Menu is [Seal something(0), Switch(1), Quit(2)]. SelectIndex(console, 0); // choose the KEK-requiring flow SelectIndex(console, 0); // key source: "Paste base64 key" console.Input.PushTextWithEnter(pastedKey); SelectIndex(console, 2); // back at the menu → Quit int result = await shell.RunAsync(CancellationToken.None); Assert.Equal(0, result); Assert.Equal(1, flow.InvocationCount); Assert.NotNull(flow.LastSession); Assert.True(flow.LastSession!.KekAvailable); // session was upgraded before dispatch Assert.DoesNotContain(pastedKey, console.Output, StringComparison.Ordinal); // masked } [Fact] public async Task Flow_not_requiring_kek_runs_on_degraded_session() { var flow = new FakeFlow("List metadata", requiresKek: false); TestConsole console = NewConsole(); InteractiveShell shell = NewShell(console, NewRecents(), flow); ScriptManualEntry(console, UnsetEnvVarName()); // degraded session SelectIndex(console, 0); // run the flow SelectIndex(console, 2); // menu [flow(0), Switch(1), Quit(2)] → Quit int result = await shell.RunAsync(CancellationToken.None); Assert.Equal(0, result); Assert.Equal(1, flow.InvocationCount); Assert.NotNull(flow.LastSession); Assert.False(flow.LastSession!.KekAvailable); // ran on the degraded session } [Fact] public async Task Flow_exception_renders_error_panel_and_returns_to_menu() { var flow = new FakeFlow( "Boom", requiresKek: false, onRun: _ => throw new SecretDecryptionException("tag mismatch")); TestConsole console = NewConsole(); InteractiveShell shell = NewShell(console, NewRecents(), flow); ScriptManualEntry(console, UnsetEnvVarName()); SelectIndex(console, 0); // run the throwing flow SelectIndex(console, 2); // loop returned to menu → Quit int result = await shell.RunAsync(CancellationToken.None); Assert.Equal(0, result); Assert.Equal(1, flow.InvocationCount); Assert.Contains("KEK doctor", console.Output, StringComparison.Ordinal); } [Fact] public async Task Switch_target_reopens_picker() { TestConsole console = NewConsole(); InteractiveShell shell = NewShell(console, NewRecents()); ScriptManualEntry(console, UnsetEnvVarName()); // first target SelectIndex(console, 0); // menu [Switch(0), Quit(1)] → Switch ScriptManualEntry(console, UnsetEnvVarName()); // picker reopened → second target SelectIndex(console, 1); // Quit int result = await shell.RunAsync(CancellationToken.None); Assert.Equal(0, result); } /// A hand-advanced clock so a re-Touch produces a strictly newer timestamp. private sealed class MutableClock(DateTimeOffset start) : TimeProvider { private DateTimeOffset _now = start; public void Advance(TimeSpan by) => _now += by; public override DateTimeOffset GetUtcNow() => _now; } }