fix(secrets-cli): CRUD flows — escaped validation markup, shared prompt helpers, delete existence check

This commit is contained in:
Joseph Doherty
2026-07-19 09:47:10 -04:00
parent 6ad710bdbd
commit e5a4d0276a
5 changed files with 98 additions and 50 deletions
@@ -23,7 +23,15 @@ public sealed class DeleteSecretFlow : IInteractiveFlow
ArgumentNullException.ThrowIfNull(session);
var name = new SecretName(console.Prompt(
new TextPrompt<string>("Secret [green]name[/]:").Validate(NameValidation)));
new TextPrompt<string>("Secret [green]name[/]:").Validate(FlowPrompts.ValidateSecretName)));
// Check existence first, so an unknown name is reported without a pointless confirm prompt.
StoredSecret? existing = await session.Store.GetAsync(name, ct).ConfigureAwait(false);
if (existing is null || existing.IsDeleted)
{
console.MarkupLineInterpolated($"[red]No secret named '{name.Value}'.[/]");
return;
}
if (!console.Prompt(new ConfirmationPrompt($"Delete (tombstone) '{name.Value}'?") { DefaultValue = false }))
{
@@ -31,25 +39,10 @@ public sealed class DeleteSecretFlow : IInteractiveFlow
return;
}
string actor = string.IsNullOrWhiteSpace(Environment.UserName) ? "cli" : Environment.UserName;
bool removed = await session.Store.DeleteAsync(name, actor, ct).ConfigureAwait(false);
bool removed = await session.Store.DeleteAsync(name, FlowPrompts.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,40 @@
using Spectre.Console;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
/// <summary>
/// Shared prompt/console helpers reused across the CRUD flows: a re-prompting secret-name validator and
/// the CLI actor-stamp fallback. Centralized so the validation and audit-actor conventions stay identical
/// in every flow.
/// </summary>
internal static class FlowPrompts
{
/// <summary>
/// A <see cref="TextPrompt{T}"/> validation callback that accepts a raw name iff it is a legal
/// <see cref="SecretName"/>, otherwise re-prompts with the constructor's own diagnostic. The
/// diagnostic is <see cref="Markup.Escape(string)"/>d because it embeds bracketed text (e.g. the
/// allowed-character class <c>[a-z0-9._/-]</c>) that Spectre would otherwise parse as markup and throw on.
/// </summary>
/// <param name="raw">The raw name entered at the prompt.</param>
/// <returns>Success when <paramref name="raw"/> is a valid name; an escaped error otherwise.</returns>
public static ValidationResult ValidateSecretName(string raw)
{
try
{
_ = new SecretName(raw);
return ValidationResult.Success();
}
catch (ArgumentException ex)
{
return ValidationResult.Error(Markup.Escape(ex.Message));
}
}
/// <summary>
/// The principal recorded as <c>created_by</c>/<c>updated_by</c> for CLI-originated writes — the OS
/// user name, or the literal <c>"cli"</c> when it is unavailable (mirrors <c>Program.cs</c>).
/// </summary>
public static string Actor =>
string.IsNullOrWhiteSpace(Environment.UserName) ? "cli" : Environment.UserName;
}
@@ -23,8 +23,11 @@ public sealed class RevealSecretFlow : IInteractiveFlow
ArgumentNullException.ThrowIfNull(console);
ArgumentNullException.ThrowIfNull(session);
ISecretCipher cipher = session.Cipher
?? throw new InvalidOperationException("flow requires an unlocked KEK session");
var name = new SecretName(console.Prompt(
new TextPrompt<string>("Secret [green]name[/]:").Validate(NameValidation)));
new TextPrompt<string>("Secret [green]name[/]:").Validate(FlowPrompts.ValidateSecretName)));
StoredSecret? row = await session.Store.GetAsync(name, ct).ConfigureAwait(false);
if (row is null || row.IsDeleted)
@@ -38,20 +41,6 @@ public sealed class RevealSecretFlow : IInteractiveFlow
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);
}
console.WriteLine(cipher.Decrypt(row));
}
}
@@ -23,8 +23,11 @@ public sealed class SetSecretFlow : IInteractiveFlow
ArgumentNullException.ThrowIfNull(console);
ArgumentNullException.ThrowIfNull(session);
ISecretCipher cipher = session.Cipher
?? throw new InvalidOperationException("flow requires an unlocked KEK session");
var name = new SecretName(console.Prompt(
new TextPrompt<string>("Secret [green]name[/]:").Validate(NameValidation)));
new TextPrompt<string>("Secret [green]name[/]:").Validate(FlowPrompts.ValidateSecretName)));
StoredSecret? existing = await session.Store.GetAsync(name, ct).ConfigureAwait(false);
if (existing is { IsDeleted: false } &&
@@ -40,8 +43,8 @@ public sealed class SetSecretFlow : IInteractiveFlow
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
string actor = FlowPrompts.Actor;
StoredSecret row = cipher.Encrypt(name, value, contentType) with
{
Description = string.IsNullOrWhiteSpace(description) ? null : description,
CreatedBy = actor,
@@ -51,18 +54,4 @@ public sealed class SetSecretFlow : IInteractiveFlow
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);
}
}
}