feat(secrets-cli): CRUD flows for the interactive console
This commit is contained in:
@@ -0,0 +1,55 @@
|
|||||||
|
using Spectre.Console;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Soft-deletes (tombstones) a secret after an explicit confirmation. Tombstoning only rewrites metadata
|
||||||
|
/// columns — it never touches ciphertext — so it needs no KEK and runs on a degraded session. A declined
|
||||||
|
/// confirmation leaves the row untouched; a confirmed delete reports whether a matching row was found.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DeleteSecretFlow : IInteractiveFlow
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Title => "Delete a secret";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool RequiresKek => false;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(console);
|
||||||
|
ArgumentNullException.ThrowIfNull(session);
|
||||||
|
|
||||||
|
var name = new SecretName(console.Prompt(
|
||||||
|
new TextPrompt<string>("Secret [green]name[/]:").Validate(NameValidation)));
|
||||||
|
|
||||||
|
if (!console.Prompt(new ConfirmationPrompt($"Delete (tombstone) '{name.Value}'?") { DefaultValue = false }))
|
||||||
|
{
|
||||||
|
console.MarkupLine("[yellow]Left in place.[/]");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string actor = string.IsNullOrWhiteSpace(Environment.UserName) ? "cli" : Environment.UserName;
|
||||||
|
bool removed = await session.Store.DeleteAsync(name, actor, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
console.MarkupLine(removed
|
||||||
|
? $"[green]Tombstoned '{Markup.Escape(name.Value)}'.[/]"
|
||||||
|
: $"[red]No secret named '{Markup.Escape(name.Value)}'.[/]");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-prompts on an invalid name by surfacing the SecretName constructor's own diagnostic.
|
||||||
|
private static ValidationResult NameValidation(string raw)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ = new SecretName(raw);
|
||||||
|
return ValidationResult.Success();
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
return ValidationResult.Error(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using Spectre.Console;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lists the safe metadata projection of every live secret as a table — name, content type, KEK id,
|
||||||
|
/// revision, last-updated stamp, and updater. It renders only <see cref="SecretMetadata"/> (which carries
|
||||||
|
/// no ciphertext or plaintext), needs no KEK, and never decrypts, so it is the one CRUD flow that runs on
|
||||||
|
/// a degraded session.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ListSecretsFlow : IInteractiveFlow
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Title => "List secrets";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool RequiresKek => false;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(console);
|
||||||
|
ArgumentNullException.ThrowIfNull(session);
|
||||||
|
|
||||||
|
IReadOnlyList<SecretMetadata> items =
|
||||||
|
await session.Store.ListAsync(includeDeleted: false, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (items.Count == 0)
|
||||||
|
{
|
||||||
|
console.MarkupLine("[grey](none)[/]");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var table = new Table()
|
||||||
|
.AddColumn("Name")
|
||||||
|
.AddColumn("ContentType")
|
||||||
|
.AddColumn("KekId")
|
||||||
|
.AddColumn("Revision")
|
||||||
|
.AddColumn("Updated")
|
||||||
|
.AddColumn("UpdatedBy");
|
||||||
|
|
||||||
|
foreach (SecretMetadata m in items)
|
||||||
|
{
|
||||||
|
table.AddRow(
|
||||||
|
Markup.Escape(m.Name.Value),
|
||||||
|
Markup.Escape(m.ContentType.ToString()),
|
||||||
|
Markup.Escape(m.KekId),
|
||||||
|
m.Revision.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
Markup.Escape(m.UpdatedUtc.ToString("u", System.Globalization.CultureInfo.InvariantCulture)),
|
||||||
|
Markup.Escape(m.UpdatedBy ?? "—"));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.Write(table);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using Spectre.Console;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reveals a secret's decrypted plaintext on screen — the one flow whose purpose is to surface a value.
|
||||||
|
/// It gates the disclosure behind an explicit confirmation: on decline nothing is printed; on accept the
|
||||||
|
/// value is decrypted with the session cipher and written exactly once (no caching, no resolver). Requires
|
||||||
|
/// a KEK-capable session.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RevealSecretFlow : IInteractiveFlow
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Title => "Get (reveal) a secret";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool RequiresKek => true;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(console);
|
||||||
|
ArgumentNullException.ThrowIfNull(session);
|
||||||
|
|
||||||
|
var name = new SecretName(console.Prompt(
|
||||||
|
new TextPrompt<string>("Secret [green]name[/]:").Validate(NameValidation)));
|
||||||
|
|
||||||
|
StoredSecret? row = await session.Store.GetAsync(name, ct).ConfigureAwait(false);
|
||||||
|
if (row is null || row.IsDeleted)
|
||||||
|
{
|
||||||
|
console.MarkupLineInterpolated($"[red]No secret named '{name.Value}'.[/]");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!console.Prompt(new ConfirmationPrompt("Reveal the value on screen?") { DefaultValue = false }))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.WriteLine(session.Cipher!.Decrypt(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-prompts on an invalid name by surfacing the SecretName constructor's own diagnostic.
|
||||||
|
private static ValidationResult NameValidation(string raw)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ = new SecretName(raw);
|
||||||
|
return ValidationResult.Success();
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
return ValidationResult.Error(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using Spectre.Console;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates or rotates a secret: prompts for a validated name, confirms before overwriting an existing
|
||||||
|
/// live row, reads the value through a masked prompt, and seals it under a fresh per-secret DEK via the
|
||||||
|
/// session cipher. The value is never echoed and the confirmation line carries the name only. Requires a
|
||||||
|
/// KEK-capable session (the shell upgrades a degraded session before dispatch).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SetSecretFlow : IInteractiveFlow
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Title => "Set / rotate a secret";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool RequiresKek => true;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(console);
|
||||||
|
ArgumentNullException.ThrowIfNull(session);
|
||||||
|
|
||||||
|
var name = new SecretName(console.Prompt(
|
||||||
|
new TextPrompt<string>("Secret [green]name[/]:").Validate(NameValidation)));
|
||||||
|
|
||||||
|
StoredSecret? existing = await session.Store.GetAsync(name, ct).ConfigureAwait(false);
|
||||||
|
if (existing is { IsDeleted: false } &&
|
||||||
|
!console.Prompt(new ConfirmationPrompt($"'{name.Value}' exists — overwrite in place?") { DefaultValue = false }))
|
||||||
|
{
|
||||||
|
console.MarkupLine("[yellow]Left unchanged.[/]");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string value = console.Prompt(new TextPrompt<string>("Secret [green]value[/]:").Secret());
|
||||||
|
SecretContentType contentType = console.Prompt(
|
||||||
|
new SelectionPrompt<SecretContentType>().Title("Content type").AddChoices(Enum.GetValues<SecretContentType>()));
|
||||||
|
string description = console.Prompt(
|
||||||
|
new TextPrompt<string>("Description [grey](optional)[/]:").AllowEmpty());
|
||||||
|
|
||||||
|
string actor = string.IsNullOrWhiteSpace(Environment.UserName) ? "cli" : Environment.UserName;
|
||||||
|
StoredSecret row = session.Cipher!.Encrypt(name, value, contentType) with
|
||||||
|
{
|
||||||
|
Description = string.IsNullOrWhiteSpace(description) ? null : description,
|
||||||
|
CreatedBy = actor,
|
||||||
|
UpdatedBy = actor,
|
||||||
|
};
|
||||||
|
await session.Store.UpsertAsync(row, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
console.MarkupLineInterpolated($"[green]Sealed '{name.Value}'.[/]");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-prompts on an invalid name by surfacing the SecretName constructor's own diagnostic.
|
||||||
|
private static ValidationResult NameValidation(string raw)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ = new SecretName(raw);
|
||||||
|
return ValidationResult.Success();
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
return ValidationResult.Error(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+220
@@ -0,0 +1,220 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drives the four CRUD flows (<see cref="ListSecretsFlow"/>, <see cref="SetSecretFlow"/>,
|
||||||
|
/// <see cref="RevealSecretFlow"/>, <see cref="DeleteSecretFlow"/>) directly against a real temp-SQLite
|
||||||
|
/// full session (KEK supplied as a <see cref="LiteralMasterKeyProvider"/> override). Each flow renders to
|
||||||
|
/// a scripted <see cref="TestConsole"/>, so the tests assert on rendered <see cref="TestConsole.Output"/>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<SecretsSession> 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 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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user