feat(secrets-cli): interactive shell skeleton — target picker, degraded-KEK upgrade, flow dispatch
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
using Spectre.Console;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.Secrets.Cli.Interactive;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One menu action of the interactive console. Implementations are UI-owning (they render to the
|
||||||
|
/// supplied <see cref="IAnsiConsole"/>) but store-logic-free — they operate through the seams on the
|
||||||
|
/// <see cref="SecretsSession"/> the shell hands them and never open stores or resolve keys themselves.
|
||||||
|
/// </summary>
|
||||||
|
public interface IInteractiveFlow
|
||||||
|
{
|
||||||
|
/// <summary>The menu label shown for this flow in the shell's action list.</summary>
|
||||||
|
string Title { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see langword="true"/>.
|
||||||
|
/// </summary>
|
||||||
|
bool RequiresKek { get; }
|
||||||
|
|
||||||
|
/// <summary>Runs the flow against the current session, rendering its own UI to <paramref name="console"/>.</summary>
|
||||||
|
/// <param name="console">The console the flow renders to (the shell owns lifecycle; flows only draw).</param>
|
||||||
|
/// <param name="session">The open session — guaranteed KEK-capable when <see cref="RequiresKek"/> is <see langword="true"/>.</param>
|
||||||
|
/// <param name="ct">A token to cancel the flow.</param>
|
||||||
|
Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The interactive <c>secret</c> console: pick a store target, open a session, then loop a header +
|
||||||
|
/// action menu that dispatches to pluggable <see cref="IInteractiveFlow"/>s. This is the ONLY class
|
||||||
|
/// that touches <see cref="IAnsiConsole"/> — 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.
|
||||||
|
/// </summary>
|
||||||
|
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<IInteractiveFlow> _flows;
|
||||||
|
|
||||||
|
/// <summary>Creates the shell over its console, recents store, target reader, session factory, and flow set.</summary>
|
||||||
|
/// <param name="console">The single console surface the shell (and its flows) render to.</param>
|
||||||
|
/// <param name="recents">Recently-used target store, offered first in the picker and re-stamped on selection.</param>
|
||||||
|
/// <param name="reader">Composes a target's Secrets configuration from appsettings or manual input.</param>
|
||||||
|
/// <param name="sessions">Opens (and, on KEK upgrade, re-opens) a <see cref="SecretsSession"/> for a target.</param>
|
||||||
|
/// <param name="flows">The pluggable menu actions, listed in order above the built-in switch/quit entries.</param>
|
||||||
|
public InteractiveShell(
|
||||||
|
IAnsiConsole console,
|
||||||
|
RecentTargetsStore recents,
|
||||||
|
TargetConfigReader reader,
|
||||||
|
SecretsSessionFactory sessions,
|
||||||
|
IReadOnlyList<IInteractiveFlow> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Runs the pick-target → menu loop until the operator quits.</summary>
|
||||||
|
/// <param name="ct">A token to cancel session opens and flow dispatch.</param>
|
||||||
|
/// <returns><c>0</c> on a clean quit.</returns>
|
||||||
|
public async Task<int> 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<MenuOutcome> 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<RecentTargetsStore.RecentTarget> recents = _recents.Load();
|
||||||
|
|
||||||
|
List<string> choices = [];
|
||||||
|
Dictionary<string, RecentTargetsStore.RecentTarget> 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<string>()
|
||||||
|
.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<string>("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<string>("SQLite database path:"));
|
||||||
|
string envVar = _console.Prompt(new TextPrompt<string>("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<SecretsSession?> 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<string>()
|
||||||
|
.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<string>("Paste base64 KEK:").Secret());
|
||||||
|
provider = new LiteralMasterKeyProvider(base64);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
string filePath = _console.Prompt(new TextPrompt<string>("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<string> BuildMenu()
|
||||||
|
{
|
||||||
|
List<string> actions = [];
|
||||||
|
actions.AddRange(_flows.Select(f => f.Title));
|
||||||
|
actions.Add(SwitchLabel);
|
||||||
|
actions.Add(QuitLabel);
|
||||||
|
|
||||||
|
return new SelectionPrompt<string>()
|
||||||
|
.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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+284
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drives <see cref="InteractiveShell"/> end-to-end through a scripted <see cref="TestConsole"/>: 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 <see cref="TestConsole.Output"/> and on what a fake flow observed.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InteractiveShellTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _dir =
|
||||||
|
Path.Combine(Path.GetTempPath(), $"zb-secrets-shell-{Guid.NewGuid():N}");
|
||||||
|
private readonly List<string> _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);
|
||||||
|
|
||||||
|
/// <summary>Records what the shell dispatched to it without touching store logic.</summary>
|
||||||
|
private sealed class FakeFlow(string title, bool requiresKek = false, Action<SecretsSession>? 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<RecentTargetsStore.RecentTarget> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A hand-advanced clock so a re-Touch produces a strictly newer timestamp.</summary>
|
||||||
|
private sealed class MutableClock(DateTimeOffset start) : TimeProvider
|
||||||
|
{
|
||||||
|
private DateTimeOffset _now = start;
|
||||||
|
|
||||||
|
public void Advance(TimeSpan by) => _now += by;
|
||||||
|
|
||||||
|
public override DateTimeOffset GetUtcNow() => _now;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user