From e49496c856e04c3ca11e68ec38ba3cdac7773365 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Jul 2026 09:16:06 -0400 Subject: [PATCH] =?UTF-8?q?feat(secrets-cli):=20interactive=20shell=20skel?= =?UTF-8?q?eton=20=E2=80=94=20target=20picker,=20degraded-KEK=20upgrade,?= =?UTF-8?q?=20flow=20dispatch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Interactive/IInteractiveFlow.cs | 26 ++ .../Interactive/InteractiveShell.cs | 328 ++++++++++++++++++ .../Cli/Interactive/InteractiveShellTests.cs | 284 +++++++++++++++ 3 files changed, 638 insertions(+) create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/IInteractiveFlow.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/InteractiveShell.cs create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/InteractiveShellTests.cs diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/IInteractiveFlow.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/IInteractiveFlow.cs new file mode 100644 index 0000000..991a5df --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/IInteractiveFlow.cs @@ -0,0 +1,26 @@ +using Spectre.Console; + +namespace ZB.MOM.WW.Secrets.Cli.Interactive; + +/// +/// One menu action of the interactive console. Implementations are UI-owning (they render to the +/// supplied ) but store-logic-free — they operate through the seams on the +/// the shell hands them and never open stores or resolve keys themselves. +/// +public interface IInteractiveFlow +{ + /// The menu label shown for this flow in the shell's action list. + string Title { get; } + + /// + /// Whether the flow needs a decrypt/seal-capable session; the shell upgrades a degraded session + /// (by prompting the operator for a KEK) before dispatch when this is . + /// + bool RequiresKek { get; } + + /// Runs the flow against the current session, rendering its own UI to . + /// The console the flow renders to (the shell owns lifecycle; flows only draw). + /// The open session — guaranteed KEK-capable when is . + /// A token to cancel the flow. + Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct); +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/InteractiveShell.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/InteractiveShell.cs new file mode 100644 index 0000000..b7e4491 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/InteractiveShell.cs @@ -0,0 +1,328 @@ +using System.Data.Common; +using Spectre.Console; +using ZB.MOM.WW.Secrets.Abstractions; +using ZB.MOM.WW.Secrets.MasterKey; + +namespace ZB.MOM.WW.Secrets.Cli.Interactive; + +/// +/// The interactive secret console: pick a store target, open a session, then loop a header + +/// action menu that dispatches to pluggable s. This is the ONLY class +/// that touches — services stay UI-free and flows, though UI-owning, carry +/// no store logic. Flow failures are contained (rendered as an error panel) so one bad action never +/// tears down the session; a degraded (no-KEK) session is upgraded on demand before a KEK-requiring +/// flow runs. +/// +public sealed class InteractiveShell +{ + private const string EnterPathLabel = "Enter an appsettings.json path…"; + private const string ManualEntryLabel = "Manual entry (SQLite db + KEK env var)…"; + private const string SwitchLabel = "Switch store target"; + private const string QuitLabel = "Quit"; + private const string PasteKeyLabel = "Paste base64 key"; + private const string KeyFileLabel = "Key file path"; + private const string CancelLabel = "Cancel"; + + private readonly IAnsiConsole _console; + private readonly RecentTargetsStore _recents; + private readonly TargetConfigReader _reader; + private readonly SecretsSessionFactory _sessions; + private readonly IReadOnlyList _flows; + + /// Creates the shell over its console, recents store, target reader, session factory, and flow set. + /// The single console surface the shell (and its flows) render to. + /// Recently-used target store, offered first in the picker and re-stamped on selection. + /// Composes a target's Secrets configuration from appsettings or manual input. + /// Opens (and, on KEK upgrade, re-opens) a for a target. + /// The pluggable menu actions, listed in order above the built-in switch/quit entries. + public InteractiveShell( + IAnsiConsole console, + RecentTargetsStore recents, + TargetConfigReader reader, + SecretsSessionFactory sessions, + IReadOnlyList flows) + { + ArgumentNullException.ThrowIfNull(console); + ArgumentNullException.ThrowIfNull(recents); + ArgumentNullException.ThrowIfNull(reader); + ArgumentNullException.ThrowIfNull(sessions); + ArgumentNullException.ThrowIfNull(flows); + + _console = console; + _recents = recents; + _reader = reader; + _sessions = sessions; + _flows = flows; + } + + /// Runs the pick-target → menu loop until the operator quits. + /// A token to cancel session opens and flow dispatch. + /// 0 on a clean quit. + public async Task RunAsync(CancellationToken ct) + { + while (true) + { + StoreTarget? target = PickTarget(); + if (target is null) + { + return 0; // operator cancelled the picker (Ctrl-C) → exit + } + + SecretsSession session = await _sessions.OpenAsync(target, overrideKek: null, ct).ConfigureAwait(false); + RenderWarnings(session); + + if (await RunMenuLoopAsync(session, ct).ConfigureAwait(false) == MenuOutcome.Quit) + { + return 0; + } + + // MenuOutcome.SwitchTarget → fall through and re-open the picker. + } + } + + // The outcome of the inner menu loop: the operator either quit outright or asked to switch targets. + private enum MenuOutcome + { + Quit, + SwitchTarget, + } + + private async Task RunMenuLoopAsync(SecretsSession session, CancellationToken ct) + { + while (true) + { + RenderHeader(session); + + string choice; + try + { + choice = _console.Prompt(BuildMenu()); + } + catch (OperationCanceledException) + { + return MenuOutcome.Quit; // Ctrl-C at the menu → clean exit + } + + switch (choice) + { + case QuitLabel: + return MenuOutcome.Quit; + case SwitchLabel: + return MenuOutcome.SwitchTarget; + } + + IInteractiveFlow flow = _flows.First(f => f.Title == choice); + try + { + if (flow.RequiresKek && !session.KekAvailable) + { + SecretsSession? upgraded = await TryUpgradeSessionAsync(session, ct).ConfigureAwait(false); + if (upgraded is null) + { + continue; // cancelled or key rejected → back to the menu + } + + session = upgraded; // keep the upgraded session for the rest of the loop + } + + await flow.RunAsync(_console, session, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Ctrl-C mid-flow → abandon the action and return to the menu. + } + catch (Exception ex) when (IsContainable(ex)) + { + RenderErrorPanel(ex); + } + } + } + + // The store/key faults a flow may realistically surface. SecretDecryptionException and + // MasterKeyUnavailableException both derive from InvalidOperationException; DbException (base of both + // Microsoft.Data.Sqlite and Microsoft.Data.SqlClient exceptions) covers store-provider faults. A + // blanket catch is deliberately avoided so genuinely unexpected faults still surface. + private static bool IsContainable(Exception ex) => + ex is InvalidOperationException or IOException or ArgumentException or DbException; + + // Presents the target picker: recents newest-first, then the two entry options. Returns the resolved + // target, or null if the operator cancelled the selection (Ctrl-C). Re-prompts on a recoverable + // read error so a bad path does not drop the operator out of the console. + private StoreTarget? PickTarget() + { + while (true) + { + IReadOnlyList recents = _recents.Load(); + + List choices = []; + Dictionary byLabel = new(StringComparer.Ordinal); + foreach (RecentTargetsStore.RecentTarget recent in recents) + { + string label = $"{recent.Name} — {recent.AppSettingsPath}"; + if (byLabel.ContainsKey(label)) + { + continue; // Load() already de-dups by path, but guard against colliding labels. + } + + choices.Add(label); + byLabel[label] = recent; + } + + choices.Add(EnterPathLabel); + choices.Add(ManualEntryLabel); + + string picked; + try + { + picked = _console.Prompt(new SelectionPrompt() + .Title("Select a store target") + .AddChoices(choices)); + } + catch (OperationCanceledException) + { + return null; + } + + try + { + if (picked == ManualEntryLabel) + { + return ReadManualTarget(); + } + + string appSettingsPath = picked == EnterPathLabel + ? _console.Prompt(new TextPrompt("appsettings.json path:")) + : byLabel[picked].AppSettingsPath; + + StoreTarget target = _reader.Read(appSettingsPath); + _recents.Touch(target.Name, target.AppSettingsPath!); // Read targets always carry a path + return target; + } + catch (OperationCanceledException) + { + return null; + } + catch (Exception ex) when (IsContainable(ex)) + { + RenderErrorPanel(ex); // e.g. FileNotFoundException — show it and re-open the picker + } + } + } + + private StoreTarget ReadManualTarget() + { + string dbPath = _console.Prompt(new TextPrompt("SQLite database path:")); + string envVar = _console.Prompt(new TextPrompt("Master-key environment variable name:") + .DefaultValue("ZB_SECRETS_MASTER_KEY")); + + // Manual targets have no appsettings.json, so they are not remembered as recents. + return _reader.Manual(dbPath, new MasterKeyOptions + { + Source = MasterKeySource.Environment, + EnvVarName = envVar, + }); + } + + // Prompts the operator for a KEK to lift a degraded session, re-opening it with the supplied key. + // Returns the upgraded (KEK-capable) session, or null if the operator cancelled or the key was + // rejected — in which case the caller stays on the menu. + private async Task TryUpgradeSessionAsync(SecretsSession session, CancellationToken ct) + { + _console.MarkupLine("[yellow]This action needs a KEK, but the session is degraded.[/]"); + + string source; + try + { + source = _console.Prompt(new SelectionPrompt() + .Title("Provide the key") + .AddChoices(PasteKeyLabel, KeyFileLabel, CancelLabel)); + } + catch (OperationCanceledException) + { + return null; + } + + if (source == CancelLabel) + { + return null; + } + + IMasterKeyProvider provider; + try + { + if (source == PasteKeyLabel) + { + string base64 = _console.Prompt(new TextPrompt("Paste base64 KEK:").Secret()); + provider = new LiteralMasterKeyProvider(base64); + } + else + { + string filePath = _console.Prompt(new TextPrompt("Key file path:")); + provider = MasterKeyProviderFactory.Create(new MasterKeyOptions + { + Source = MasterKeySource.File, + FilePath = filePath, + }); + } + } + catch (ArgumentException ex) + { + // LiteralMasterKeyProvider rejects a bad paste eagerly; name the problem and stay on the menu. + _console.MarkupLineInterpolated($"[red]Invalid key: {ex.Message}[/]"); + return null; + } + + SecretsSession upgraded = await _sessions.OpenAsync(session.Target, provider, ct).ConfigureAwait(false); + if (!upgraded.KekAvailable) + { + RenderWarnings(upgraded); + _console.MarkupLine("[red]That key did not unlock the session.[/]"); + return null; + } + + return upgraded; + } + + private SelectionPrompt BuildMenu() + { + List actions = []; + actions.AddRange(_flows.Select(f => f.Title)); + actions.Add(SwitchLabel); + actions.Add(QuitLabel); + + return new SelectionPrompt() + .Title("Action") + .AddChoices(actions); + } + + private void RenderHeader(SecretsSession session) + { + string kek = session.KekAvailable + ? "[green]KEK OK[/]" + : "[red]KEK UNAVAILABLE (degraded)[/]"; + + Markup body = new( + $"[bold]{Markup.Escape(session.Target.Name)}[/] " + + $"store: {Markup.Escape(session.StoreKind)} {kek}"); + + _console.Write(new Panel(body).Header("Target")); + } + + private void RenderWarnings(SecretsSession session) + { + foreach (string warning in session.Warnings) + { + _console.MarkupLineInterpolated($"[yellow]{warning}[/]"); + } + } + + private void RenderErrorPanel(Exception ex) + { + Markup body = new( + $"[red]{Markup.Escape(ex.Message)}[/]\n\nIf this is a key problem, run the KEK doctor."); + + _console.Write(new Panel(body) + .Header("[red]Action failed[/]") + .BorderColor(Color.Red)); + } +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/InteractiveShellTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/InteractiveShellTests.cs new file mode 100644 index 0000000..5e61497 --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/InteractiveShellTests.cs @@ -0,0 +1,284 @@ +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; + } +}