diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/ReferenceAuditFlow.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/ReferenceAuditFlow.cs index b26d0ab..0c3aa62 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/ReferenceAuditFlow.cs +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/ReferenceAuditFlow.cs @@ -35,6 +35,9 @@ public sealed class ReferenceAuditFlow : IInteractiveFlow ArgumentNullException.ThrowIfNull(console); ArgumentNullException.ThrowIfNull(session); + ISecretCipher cipher = session.Cipher + ?? throw new InvalidOperationException("flow requires an unlocked KEK session"); + var auditor = new ReferenceAuditor(); IReadOnlyList findings = await auditor.AuditAsync(session, ct).ConfigureAwait(false); if (findings.Count == 0) @@ -72,7 +75,7 @@ public sealed class ReferenceAuditFlow : IInteractiveFlow continue; } - await SeedAsync(console, session, new SecretName(finding.SecretName), ct).ConfigureAwait(false); + await SeedAsync(console, session, cipher, new SecretName(finding.SecretName), ct).ConfigureAwait(false); console.MarkupLineInterpolated($"[green]Sealed '{finding.SecretName}'.[/]"); } @@ -88,7 +91,7 @@ public sealed class ReferenceAuditFlow : IInteractiveFlow // Prompts for a masked value + content type + optional description and seals the secret under a fresh // per-secret DEK. Mirrors SetSecretFlow's seal-and-stamp shape without coupling to it. private static async Task SeedAsync( - IAnsiConsole console, SecretsSession session, SecretName name, CancellationToken ct) + IAnsiConsole console, SecretsSession session, ISecretCipher cipher, SecretName name, CancellationToken ct) { string value = console.Prompt( new TextPrompt($" Value for [green]{Markup.Escape(name.Value)}[/]:").Secret()); @@ -97,12 +100,11 @@ public sealed class ReferenceAuditFlow : IInteractiveFlow string description = console.Prompt( new TextPrompt(" Description [grey](optional)[/]:").AllowEmpty()); - string actor = string.IsNullOrWhiteSpace(Environment.UserName) ? "cli" : Environment.UserName; - StoredSecret row = session.Cipher!.Encrypt(name, value, contentType) with + StoredSecret row = cipher.Encrypt(name, value, contentType) with { Description = string.IsNullOrWhiteSpace(description) ? null : description, - CreatedBy = actor, - UpdatedBy = actor, + CreatedBy = FlowPrompts.Actor, + UpdatedBy = FlowPrompts.Actor, }; await session.Store.UpsertAsync(row, ct).ConfigureAwait(false); // revives a tombstone: overwrite clears IsDeleted. } diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/ReferenceAuditFlowTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/ReferenceAuditFlowTests.cs index 2217abc..dec20e8 100644 --- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/ReferenceAuditFlowTests.cs +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/ReferenceAuditFlowTests.cs @@ -160,6 +160,50 @@ public sealed class ReferenceAuditFlowTests : IDisposable Assert.DoesNotContain("MISSING-SEED-SENTINEL", console.Output, StringComparison.Ordinal); Assert.DoesNotContain("TOMB-SEED-SENTINEL", console.Output, StringComparison.Ordinal); + + // The closing re-audit summary reflects the now-all-Ok state. + Assert.Contains("Audit complete: 3 Ok.", console.Output, StringComparison.Ordinal); + } + + [Fact] + public async Task Undecryptable_reference_is_offered_as_overwrite_and_reseeded() + { + KeyValuePair[] config = [new("Api:Bad", "${secret:svc/bad}")]; + + // Seal svc/bad under a DIFFERENT KEK on the same store, so the audited session cannot decrypt it. + SecretsSession writer = await NewSessionAsync(config); + await SeedAsync(writer, "svc/bad", "OLD-UNREADABLE-VALUE"); + + SecretsSession session = await NewSessionAsync(config); // fresh random KEK → svc/bad is Undecryptable + + TestConsole console = NewConsole(); + console.Input.PushTextWithEnter("y"); // overwrite confirm (defaults false for Undecryptable) + console.Input.PushTextWithEnter("NEW-READABLE-SENTINEL"); // fresh value (masked) + console.Input.PushKey(ConsoleKey.Enter); // content-type Text + console.Input.PushKey(ConsoleKey.Enter); // description empty + + await new ReferenceAuditFlow().RunAsync(console, session, CancellationToken.None); + + Assert.Contains("Undecryptable", console.Output, StringComparison.Ordinal); // rendered in the table + + StoredSecret? row = await session.Store.GetAsync(new SecretName("svc/bad"), CancellationToken.None); + Assert.Equal("NEW-READABLE-SENTINEL", session.Cipher!.Decrypt(row!)); // now decrypts under the session KEK + Assert.DoesNotContain("NEW-READABLE-SENTINEL", console.Output, StringComparison.Ordinal); + } + + [Fact] + public async Task Invalid_name_reference_renders_hint_and_is_not_prompted() + { + KeyValuePair[] config = [new("Api:Broken", "${secret:REPLACE ME}")]; + SecretsSession session = await NewSessionAsync(config); + + TestConsole console = NewConsole(); // push NO input: an InvalidName reference must never prompt + await new ReferenceAuditFlow().RunAsync(console, session, CancellationToken.None); + + Assert.Contains("InvalidName", console.Output, StringComparison.Ordinal); // table status + Assert.Contains("not a valid secret name", console.Output, StringComparison.Ordinal); // fix-the-token hint + Assert.Equal(0, CountOccurrences(console.Output, "Seed '")); // no seed prompt + Assert.Contains("Audit complete: 1 InvalidName.", console.Output, StringComparison.Ordinal); } [Fact]