feat(secrets-cli): interactive shell skeleton — target picker, degraded-KEK upgrade, flow dispatch
This commit is contained in:
+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