diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/ConfirmDeleteModal.razor b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/ConfirmDeleteModal.razor
new file mode 100644
index 0000000..d5e2a12
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/ConfirmDeleteModal.razor
@@ -0,0 +1,95 @@
+@namespace ZB.MOM.WW.Secrets.Ui.Components
+@implements IDisposable
+@inject ISecretStore Store
+@inject IAuditWriter Audit
+@inject IServiceProvider Services
+@* Components/ConfirmDeleteModal.razor — an in-page Blazor overlay (NOT a JS confirm/alert dialog).
+ Shown while Visible is true; Confirm tombstones + audits the delete, Cancel dismisses. *@
+
+@if (Visible)
+{
+
+
+
+
Delete secret '@Name.Value'?
+
This tombstones the secret so the removal can propagate. The action is audited.
+
+ @if (_error is not null)
+ {
+
@_error
+ }
+
+
+
+ Delete
+
+
+ Cancel
+
+
+
+
+}
+
+@code {
+ private readonly CancellationTokenSource _cts = new();
+ private string? _error;
+ private bool _busy;
+
+ /// Whether the modal overlay is rendered.
+ [Parameter] public bool Visible { get; set; }
+
+ /// The secret the modal offers to delete.
+ [Parameter] public SecretName Name { get; set; }
+
+ /// Raised after the secret has been tombstoned and audited.
+ [Parameter] public EventCallback OnDeleted { get; set; }
+
+ /// Raised when the user cancels the deletion.
+ [Parameter] public EventCallback OnCancelled { get; set; }
+
+ /// The cascading authentication state, used as the fallback source of the audit actor.
+ [CascadingParameter] private Task? AuthState { get; set; }
+
+ private async Task ConfirmAsync()
+ {
+ _error = null;
+ _busy = true;
+ try
+ {
+ string actor = await SecretActorResolver.ResolveAsync(Services, AuthState);
+
+ await Store.DeleteAsync(Name, actor, _cts.Token);
+
+ await Audit.WriteAsync(
+ new AuditEvent
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = DateTimeOffset.UtcNow,
+ Actor = actor,
+ Action = "secret.delete",
+ Outcome = AuditOutcome.Success,
+ Category = "Secrets",
+ Target = Name.Value,
+ },
+ _cts.Token);
+
+ await OnDeleted.InvokeAsync();
+ }
+ finally
+ {
+ _busy = false;
+ }
+ }
+
+ private Task CancelAsync() => OnCancelled.InvokeAsync();
+
+ ///
+ public void Dispose()
+ {
+ _cts.Cancel();
+ _cts.Dispose();
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/RevealButton.razor b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/RevealButton.razor
new file mode 100644
index 0000000..cdb5742
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/RevealButton.razor
@@ -0,0 +1,110 @@
+@namespace ZB.MOM.WW.Secrets.Ui.Components
+@implements IDisposable
+@inject ISecretStore Store
+@inject ISecretCipher Cipher
+@inject IAuditWriter Audit
+@inject IServiceProvider Services
+@* Components/RevealButton.razor — a policy-gated, audited reveal. The plaintext is loaded and
+ shown only AFTER the click, straight from the store+cipher (a human reveal shows the CURRENT
+ value, distinct from any cached machine resolve). The value never enters an audit field. *@
+
+@if (!_revealed)
+{
+
+}
+else
+{
+
+ @if (_value is not null)
+ {
+ @_value
+ }
+ else
+ {
+ @(_error ?? "Unavailable.")
+ }
+
+
+}
+
+@code {
+ private readonly CancellationTokenSource _cts = new();
+ private bool _revealed;
+ private string? _value;
+ private string? _error;
+ private bool _busy;
+
+ /// The secret whose plaintext this button reveals.
+ [Parameter] public SecretName Name { get; set; }
+
+ /// The cascading authentication state, used as the fallback source of the audit actor.
+ [CascadingParameter] private Task? AuthState { get; set; }
+
+ private async Task RevealAsync()
+ {
+ _busy = true;
+ AuditOutcome outcome;
+ try
+ {
+ StoredSecret? row = await Store.GetAsync(Name, _cts.Token);
+ if (row is null)
+ {
+ _value = null;
+ _error = "Secret not found.";
+ outcome = AuditOutcome.Failure;
+ }
+ else
+ {
+ _value = Cipher.Decrypt(row);
+ _error = null;
+ outcome = AuditOutcome.Success;
+ }
+ }
+ catch (SecretDecryptionException)
+ {
+ _value = null;
+ _error = "Unable to decrypt secret.";
+ outcome = AuditOutcome.Failure;
+ }
+ finally
+ {
+ _busy = false;
+ _revealed = true;
+ }
+
+ string actor = await SecretActorResolver.ResolveAsync(Services, AuthState);
+ await Audit.WriteAsync(
+ new AuditEvent
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = DateTimeOffset.UtcNow,
+ Actor = actor,
+ Action = "secret.reveal",
+ Outcome = outcome,
+ Category = "Secrets",
+ Target = Name.Value,
+ },
+ _cts.Token);
+ }
+
+ private void Hide()
+ {
+ _revealed = false;
+ _value = null;
+ _error = null;
+ }
+
+ ///
+ public void Dispose()
+ {
+ _cts.Cancel();
+ _cts.Dispose();
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretEditor.razor b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretEditor.razor
new file mode 100644
index 0000000..06a169d
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretEditor.razor
@@ -0,0 +1,153 @@
+@namespace ZB.MOM.WW.Secrets.Ui.Components
+@implements IDisposable
+@inject ISecretCipher Cipher
+@inject ISecretStore Store
+@inject IAuditWriter Audit
+@inject IServiceProvider Services
+@* Components/SecretEditor.razor — add (ExistingName == null) or rotate (ExistingName set) a secret.
+ The value is entered through a masked input and never rendered back or placed in any audit field. *@
+
+
+
+ @if (IsRotate)
+ {
+
+ }
+ else
+ {
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @if (_error is not null)
+ {
+ @_error
+ }
+
+
+
+ @(IsRotate ? "Rotate" : "Add")
+
+
+ Cancel
+
+
+
+
+@code {
+ private readonly CancellationTokenSource _cts = new();
+
+ private string _name = string.Empty;
+ private string _description = string.Empty;
+ private SecretContentType _contentType = SecretContentType.Text;
+ private string _value = string.Empty;
+ private string? _error;
+ private bool _busy;
+
+ ///
+ /// The secret being rotated. When null the editor is in add mode (name is
+ /// editable); when set, the editor is in rotate mode (name is read-only and fixed).
+ ///
+ [Parameter] public SecretName? ExistingName { get; set; }
+
+ /// Raised after a successful add or rotate, so a parent can refresh its listing.
+ [Parameter] public EventCallback OnSaved { get; set; }
+
+ /// Raised when the user cancels the editor without saving.
+ [Parameter] public EventCallback OnCancelled { get; set; }
+
+ /// The cascading authentication state, used as the fallback source of the audit actor.
+ [CascadingParameter] private Task? AuthState { get; set; }
+
+ private bool IsRotate => ExistingName is not null;
+
+ ///
+ protected override void OnParametersSet()
+ {
+ if (IsRotate)
+ {
+ _name = ExistingName!.Value.Value;
+ }
+ }
+
+ private async Task SubmitAsync()
+ {
+ _error = null;
+
+ SecretName name;
+ try
+ {
+ name = ExistingName ?? new SecretName(_name);
+ }
+ catch (ArgumentException ex)
+ {
+ _error = ex.Message;
+ return;
+ }
+
+ _busy = true;
+ try
+ {
+ string actor = await SecretActorResolver.ResolveAsync(Services, AuthState);
+
+ StoredSecret row = Cipher.Encrypt(name, _value, _contentType);
+ row = row with
+ {
+ Description = string.IsNullOrWhiteSpace(_description) ? null : _description,
+ CreatedBy = actor,
+ UpdatedBy = actor,
+ };
+ await Store.UpsertAsync(row, _cts.Token);
+
+ await Audit.WriteAsync(
+ new AuditEvent
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = DateTimeOffset.UtcNow,
+ Actor = actor,
+ Action = IsRotate ? "secret.rotate" : "secret.add",
+ Outcome = AuditOutcome.Success,
+ Category = "Secrets",
+ Target = name.Value,
+ },
+ _cts.Token);
+
+ await OnSaved.InvokeAsync();
+ }
+ finally
+ {
+ _busy = false;
+ }
+ }
+
+ private Task CancelAsync() => OnCancelled.InvokeAsync();
+
+ ///
+ public void Dispose()
+ {
+ _cts.Cancel();
+ _cts.Dispose();
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretsList.razor b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretsList.razor
index a7b2511..d0de9b8 100644
--- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretsList.razor
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretsList.razor
@@ -1,7 +1,24 @@
@namespace ZB.MOM.WW.Secrets.Ui
@implements IDisposable
@inject ISecretStore Store
-@* Components/SecretsList.razor — metadata-only listing of secrets. Never renders a value or ciphertext. *@
+@* Components/SecretsList.razor — metadata-only listing of secrets. Never renders a value or
+ ciphertext. Add/rotate use the in-page SecretEditor; delete uses the in-page ConfirmDeleteModal;
+ reveal is the policy-gated, audited RevealButton. The list reloads after any mutation. *@
+
+
+
+
+ Add secret
+
+
+
+
+@if (_editorMode != EditorMode.None)
+{
+
+}
@if (_secrets is null)
@@ -43,16 +60,20 @@
@secret.Revision |
@secret.UpdatedUtc.UtcDateTime.ToString("u") |
@secret.UpdatedBy |
-
+ |
- @* Placeholder — actual reveal wiring is a later task. Gated so it
- renders only for principals holding the reveal policy. *@
-
+
+
+
|
@@ -65,14 +86,59 @@
}
+
+
@code {
private readonly CancellationTokenSource _cts = new();
private IReadOnlyList? _secrets;
- ///
- protected override async Task OnInitializedAsync()
+ private EditorMode _editorMode = EditorMode.None;
+ private SecretName? _rotateName;
+ private SecretName? _deleteTarget;
+
+ private enum EditorMode
{
- _secrets = await Store.ListAsync(includeDeleted: false, _cts.Token);
+ None,
+ Add,
+ Rotate,
+ }
+
+ ///
+ protected override async Task OnInitializedAsync() => await ReloadAsync();
+
+ private async Task ReloadAsync()
+ => _secrets = await Store.ListAsync(includeDeleted: false, _cts.Token);
+
+ private void ShowAdd()
+ {
+ _rotateName = null;
+ _editorMode = EditorMode.Add;
+ }
+
+ private void ShowRotate(SecretName name)
+ {
+ _rotateName = name;
+ _editorMode = EditorMode.Rotate;
+ }
+
+ private void CloseEditor()
+ {
+ _editorMode = EditorMode.None;
+ _rotateName = null;
+ }
+
+ private void ShowDelete(SecretName name) => _deleteTarget = name;
+
+ private void CloseDelete() => _deleteTarget = null;
+
+ private async Task OnMutatedAsync()
+ {
+ CloseEditor();
+ CloseDelete();
+ await ReloadAsync();
}
///
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/SecretActorResolver.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/SecretActorResolver.cs
new file mode 100644
index 0000000..d783945
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/SecretActorResolver.cs
@@ -0,0 +1,45 @@
+using Microsoft.AspNetCore.Components.Authorization;
+using Microsoft.Extensions.DependencyInjection;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.Secrets.Ui;
+
+///
+/// Resolves the principal to record as the audit Actor for a UI-driven secret mutation or
+/// reveal. The resolution order is: an explicitly wired (if the
+/// host registered one and it reports a non-blank actor); otherwise the authenticated user's
+/// from the cascading authentication state;
+/// otherwise the literal "unknown" so an action is never attributed to a blank actor.
+///
+internal static class SecretActorResolver
+{
+ /// The fallback actor used when no accessor and no authenticated identity name is available.
+ public const string Unknown = "unknown";
+
+ ///
+ /// Resolves the audit actor. Never returns null or blank.
+ ///
+ /// The component's service provider, used to optionally resolve an .
+ /// The cascading authentication-state task, or null if unavailable.
+ /// The resolved actor identity string.
+ public static async Task ResolveAsync(IServiceProvider services, Task? authState)
+ {
+ string? fromAccessor = services.GetService()?.CurrentActor;
+ if (!string.IsNullOrWhiteSpace(fromAccessor))
+ {
+ return fromAccessor;
+ }
+
+ if (authState is not null)
+ {
+ AuthenticationState state = await authState.ConfigureAwait(false);
+ string? name = state.User.Identity?.Name;
+ if (!string.IsNullOrWhiteSpace(name))
+ {
+ return name;
+ }
+ }
+
+ return Unknown;
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/ZB.MOM.WW.Secrets.Ui.csproj b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/ZB.MOM.WW.Secrets.Ui.csproj
index c3b2a71..9333ce5 100644
--- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/ZB.MOM.WW.Secrets.Ui.csproj
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/ZB.MOM.WW.Secrets.Ui.csproj
@@ -29,6 +29,7 @@
+
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/_Imports.razor b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/_Imports.razor
index 39c22eb..8aa7a01 100644
--- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/_Imports.razor
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/_Imports.razor
@@ -2,6 +2,9 @@
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Authorization
+@using Microsoft.Extensions.DependencyInjection
@using ZB.MOM.WW.Theme
+@using ZB.MOM.WW.Audit
@using ZB.MOM.WW.Secrets.Abstractions
@using ZB.MOM.WW.Secrets.Ui
+@using ZB.MOM.WW.Secrets.Ui.Components
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/ConfirmDeleteModalTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/ConfirmDeleteModalTests.cs
new file mode 100644
index 0000000..de03688
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/ConfirmDeleteModalTests.cs
@@ -0,0 +1,100 @@
+using Bunit;
+using Bunit.TestDoubles;
+using Microsoft.Extensions.DependencyInjection;
+using ZB.MOM.WW.Audit;
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.Ui.Components;
+using ZB.MOM.WW.Secrets.Ui.Tests.Fakes;
+
+namespace ZB.MOM.WW.Secrets.Ui.Tests;
+
+public class ConfirmDeleteModalTests : TestContext
+{
+ [Fact]
+ public void ConfirmDeleteModal_IsInPageModal_NoJsDialog()
+ {
+ // Strict JS interop mode: any IJSRuntime call (a JS confirm/alert dialog) would throw,
+ // proving the confirmation is a rendered in-page modal, not a browser dialog.
+ JSInterop.Mode = JSRuntimeMode.Strict;
+
+ var store = new RecordingSecretStore();
+ var audit = new CapturingAuditWriter();
+ Services.AddSingleton(store);
+ Services.AddSingleton(audit);
+
+ var name = new SecretName("db/primary");
+ var auth = this.AddTestAuthorization();
+ auth.SetAuthorized("carol");
+ auth.SetPolicies(SecretsAuthorization.ManagePolicy);
+
+ bool deleted = false;
+ var cut = RenderComponent(p => p
+ .Add(c => c.Visible, true)
+ .Add(c => c.Name, name)
+ .Add(c => c.OnDeleted, () => deleted = true));
+
+ // The confirmation is a rendered in-page modal element.
+ Assert.NotEmpty(cut.FindAll(".modal"));
+ Assert.Contains("db/primary", cut.Markup);
+
+ cut.Find("[data-testid=confirm-delete]").Click();
+
+ // Confirm tombstoned the row (with the resolved actor) and wrote a secret.delete audit.
+ Assert.Single(store.Deletes);
+ Assert.Equal("db/primary", store.Deletes[0].Name.Value);
+ Assert.Equal("carol", store.Deletes[0].Actor);
+ Assert.True(deleted);
+
+ Assert.Single(audit.Events);
+ AuditEvent evt = audit.Events[0];
+ Assert.Equal("secret.delete", evt.Action);
+ Assert.Equal(AuditOutcome.Success, evt.Outcome);
+ Assert.Equal("Secrets", evt.Category);
+ Assert.Equal("db/primary", evt.Target);
+ Assert.Equal("carol", evt.Actor);
+ }
+
+ [Fact]
+ public void ConfirmDeleteModal_Cancel_DoesNotDelete()
+ {
+ var store = new RecordingSecretStore();
+ var audit = new CapturingAuditWriter();
+ Services.AddSingleton(store);
+ Services.AddSingleton(audit);
+
+ var name = new SecretName("db/primary");
+ var auth = this.AddTestAuthorization();
+ auth.SetAuthorized("carol");
+ auth.SetPolicies(SecretsAuthorization.ManagePolicy);
+
+ bool cancelled = false;
+ var cut = RenderComponent(p => p
+ .Add(c => c.Visible, true)
+ .Add(c => c.Name, name)
+ .Add(c => c.OnCancelled, () => cancelled = true));
+
+ cut.Find("[data-testid=cancel-delete]").Click();
+
+ Assert.True(cancelled);
+ Assert.Empty(store.Deletes);
+ Assert.Empty(audit.Events);
+ }
+
+ [Fact]
+ public void ConfirmDeleteModal_NotVisible_RendersNothing()
+ {
+ var store = new RecordingSecretStore();
+ var audit = new CapturingAuditWriter();
+ Services.AddSingleton(store);
+ Services.AddSingleton(audit);
+
+ var name = new SecretName("db/primary");
+ this.AddTestAuthorization().SetAuthorized("carol");
+
+ var cut = RenderComponent(p => p
+ .Add(c => c.Visible, false)
+ .Add(c => c.Name, name));
+
+ Assert.Empty(cut.FindAll(".modal"));
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/Fakes/CapturingAuditWriter.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/Fakes/CapturingAuditWriter.cs
new file mode 100644
index 0000000..e94d4ce
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/Fakes/CapturingAuditWriter.cs
@@ -0,0 +1,24 @@
+using System.Collections.Concurrent;
+using ZB.MOM.WW.Audit;
+
+namespace ZB.MOM.WW.Secrets.Ui.Tests.Fakes;
+
+///
+/// An test double that collects every written,
+/// so a test can assert on the audit trail (action, outcome, target, actor) and — critically — that
+/// no plaintext ever appears in any audit field.
+///
+public sealed class CapturingAuditWriter : IAuditWriter
+{
+ private readonly ConcurrentQueue _events = new();
+
+ /// The events captured so far, in write order.
+ public IReadOnlyList Events => _events.ToArray();
+
+ ///
+ public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
+ {
+ _events.Enqueue(evt);
+ return Task.CompletedTask;
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/Fakes/FakeCipher.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/Fakes/FakeCipher.cs
new file mode 100644
index 0000000..fb13a8d
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/Fakes/FakeCipher.cs
@@ -0,0 +1,45 @@
+using System.Text;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.Secrets.Ui.Tests.Fakes;
+
+///
+/// A trivial, reversible stand-in for UI tests. It does not perform real
+/// cryptography — it simply carries the UTF-8 plaintext in so a
+/// test can prove the editor's encrypt→upsert round-trips back to the entered value. Never used in
+/// production. A flag lets a test drive the decrypt-failure reveal path.
+///
+public sealed class FakeCipher : ISecretCipher
+{
+ /// When true, throws to exercise the failure path.
+ public bool FailDecrypt { get; set; }
+
+ ///
+ public StoredSecret Encrypt(SecretName name, string plaintext, SecretContentType contentType)
+ => new()
+ {
+ Name = name,
+ ContentType = contentType,
+ Ciphertext = Encoding.UTF8.GetBytes(plaintext),
+ Nonce = [1, 2, 3],
+ Tag = [4, 5, 6],
+ WrappedDek = [7, 8, 9],
+ WrapNonce = [10, 11, 12],
+ WrapTag = [13, 14, 15],
+ KekId = "fake-kek",
+ Revision = 0,
+ CreatedUtc = default,
+ UpdatedUtc = default,
+ };
+
+ ///
+ public string Decrypt(StoredSecret secret)
+ {
+ if (FailDecrypt)
+ {
+ throw new SecretDecryptionException("Fake decrypt failure.");
+ }
+
+ return Encoding.UTF8.GetString(secret.Ciphertext);
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/Fakes/RecordingSecretStore.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/Fakes/RecordingSecretStore.cs
new file mode 100644
index 0000000..7b75f8d
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/Fakes/RecordingSecretStore.cs
@@ -0,0 +1,75 @@
+using System.Collections.Concurrent;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.Secrets.Ui.Tests.Fakes;
+
+///
+/// A hand-written test double (the family does not use Moq) that keeps
+/// an in-memory table of rows and records every and
+/// call so a test can assert on the mutation trail.
+///
+public sealed class RecordingSecretStore : ISecretStore
+{
+ private readonly ConcurrentDictionary _rows = new();
+ private readonly ConcurrentQueue _upserts = new();
+ private readonly ConcurrentQueue<(SecretName Name, string? Actor)> _deletes = new();
+
+ /// Rows upserted, in call order.
+ public IReadOnlyList Upserts => _upserts.ToArray();
+
+ /// Delete calls (name + recorded actor), in call order.
+ public IReadOnlyList<(SecretName Name, string? Actor)> Deletes => _deletes.ToArray();
+
+ /// Seeds a row so and can return it.
+ /// The row to seed.
+ public void Seed(StoredSecret row) => _rows[row.Name.Value] = row;
+
+ ///
+ public Task GetAsync(SecretName name, CancellationToken ct)
+ => Task.FromResult(_rows.TryGetValue(name.Value, out StoredSecret? row) ? row : null);
+
+ ///
+ public Task UpsertAsync(StoredSecret row, CancellationToken ct)
+ {
+ _rows[row.Name.Value] = row;
+ _upserts.Enqueue(row);
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task DeleteAsync(SecretName name, string? actor, CancellationToken ct)
+ {
+ _deletes.Enqueue((name, actor));
+ return Task.FromResult(_rows.TryRemove(name.Value, out _));
+ }
+
+ ///
+ public Task> ListAsync(bool includeDeleted, CancellationToken ct)
+ {
+ IReadOnlyList projection = _rows.Values
+ .Where(r => includeDeleted || !r.IsDeleted)
+ .Select(r => new SecretMetadata
+ {
+ Name = r.Name,
+ Description = r.Description,
+ ContentType = r.ContentType,
+ KekId = r.KekId,
+ Revision = r.Revision,
+ IsDeleted = r.IsDeleted,
+ CreatedUtc = r.CreatedUtc,
+ UpdatedUtc = r.UpdatedUtc,
+ CreatedBy = r.CreatedBy,
+ UpdatedBy = r.UpdatedBy,
+ })
+ .ToArray();
+ return Task.FromResult(projection);
+ }
+
+ ///
+ public Task> GetManifestAsync(CancellationToken ct)
+ => throw new NotSupportedException();
+
+ ///
+ public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct)
+ => throw new NotSupportedException();
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/RevealButtonTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/RevealButtonTests.cs
new file mode 100644
index 0000000..bd52d09
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/RevealButtonTests.cs
@@ -0,0 +1,98 @@
+using Bunit;
+using Bunit.TestDoubles;
+using Microsoft.Extensions.DependencyInjection;
+using ZB.MOM.WW.Audit;
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.Ui.Components;
+using ZB.MOM.WW.Secrets.Ui.Tests.Fakes;
+
+namespace ZB.MOM.WW.Secrets.Ui.Tests;
+
+public class RevealButtonTests : TestContext
+{
+ private const string PlainValue = "the-plaintext-value-42";
+
+ private (RecordingSecretStore Store, CapturingAuditWriter Audit, FakeCipher Cipher) RegisterServices()
+ {
+ var store = new RecordingSecretStore();
+ var audit = new CapturingAuditWriter();
+ var cipher = new FakeCipher();
+ Services.AddSingleton(store);
+ Services.AddSingleton(audit);
+ Services.AddSingleton(cipher);
+ return (store, audit, cipher);
+ }
+
+ private static StoredSecret Row(SecretName name, string plaintext)
+ => new FakeCipher().Encrypt(name, plaintext, SecretContentType.Text) with
+ {
+ Revision = 1,
+ CreatedUtc = DateTimeOffset.UnixEpoch,
+ UpdatedUtc = DateTimeOffset.UnixEpoch,
+ };
+
+ [Fact]
+ public void RevealButton_ShowsValueOnClick_AndAudits()
+ {
+ var (store, audit, _) = RegisterServices();
+ var name = new SecretName("api/token");
+ store.Seed(Row(name, PlainValue));
+
+ var auth = this.AddTestAuthorization();
+ auth.SetAuthorized("carol");
+ auth.SetPolicies(SecretsAuthorization.RevealPolicy);
+
+ var cut = RenderComponent(p => p.Add(c => c.Name, name));
+
+ // The value is NOT present before the reveal click.
+ Assert.DoesNotContain(PlainValue, cut.Markup);
+ Assert.Empty(audit.Events);
+
+ cut.Find("button.reveal-secret").Click();
+
+ // After the click the plaintext is shown and a Success reveal audit is recorded.
+ Assert.Contains(PlainValue, cut.Markup);
+ Assert.Single(audit.Events);
+ AuditEvent evt = audit.Events[0];
+ Assert.Equal("secret.reveal", evt.Action);
+ Assert.Equal(AuditOutcome.Success, evt.Outcome);
+ Assert.Equal("Secrets", evt.Category);
+ Assert.Equal("api/token", evt.Target);
+ Assert.Equal("carol", evt.Actor);
+ AssertNoValueLeak(evt);
+ }
+
+ [Fact]
+ public void RevealButton_MissingSecret_AuditsFailure()
+ {
+ var (_, audit, _) = RegisterServices();
+ var name = new SecretName("api/missing");
+
+ var auth = this.AddTestAuthorization();
+ auth.SetAuthorized("carol");
+ auth.SetPolicies(SecretsAuthorization.RevealPolicy);
+
+ var cut = RenderComponent(p => p.Add(c => c.Name, name));
+
+ cut.Find("button.reveal-secret").Click();
+
+ // No value shown; a Failure reveal audit recorded.
+ Assert.DoesNotContain(PlainValue, cut.Markup);
+ Assert.Single(audit.Events);
+ AuditEvent evt = audit.Events[0];
+ Assert.Equal("secret.reveal", evt.Action);
+ Assert.Equal(AuditOutcome.Failure, evt.Outcome);
+ Assert.Equal("api/missing", evt.Target);
+ }
+
+ private static void AssertNoValueLeak(AuditEvent evt)
+ {
+ foreach (string? field in new[] { evt.Action, evt.Actor, evt.Category, evt.Target, evt.DetailsJson, evt.ToString() })
+ {
+ if (field is not null)
+ {
+ Assert.DoesNotContain(PlainValue, field, StringComparison.Ordinal);
+ }
+ }
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/SecretEditorTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/SecretEditorTests.cs
new file mode 100644
index 0000000..a51bc7b
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/SecretEditorTests.cs
@@ -0,0 +1,112 @@
+using Bunit;
+using Bunit.TestDoubles;
+using Microsoft.AspNetCore.Components;
+using Microsoft.Extensions.DependencyInjection;
+using ZB.MOM.WW.Audit;
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.Ui.Components;
+using ZB.MOM.WW.Secrets.Ui.Tests.Fakes;
+
+namespace ZB.MOM.WW.Secrets.Ui.Tests;
+
+public class SecretEditorTests : TestContext
+{
+ private const string EnteredValue = "sup3r-s3cret-value!";
+
+ private (RecordingSecretStore Store, CapturingAuditWriter Audit, FakeCipher Cipher) RegisterServices()
+ {
+ var store = new RecordingSecretStore();
+ var audit = new CapturingAuditWriter();
+ var cipher = new FakeCipher();
+ Services.AddSingleton(store);
+ Services.AddSingleton(audit);
+ Services.AddSingleton(cipher);
+ return (store, audit, cipher);
+ }
+
+ [Fact]
+ public void SecretEditor_Add_EncryptsAndUpserts()
+ {
+ var (store, audit, cipher) = RegisterServices();
+ var auth = this.AddTestAuthorization();
+ auth.SetAuthorized("carol");
+ auth.SetPolicies(SecretsAuthorization.ManagePolicy);
+
+ var cut = RenderComponent(p => p
+ .Add(c => c.OnSaved, () => { }));
+
+ // The value control is a masked password input.
+ var valueInput = cut.Find("[data-testid=secret-value]");
+ Assert.Equal("password", valueInput.GetAttribute("type"));
+
+ cut.Find("[data-testid=secret-name]").Change("app/thing");
+ cut.Find("[data-testid=secret-description]").Change("A thing");
+ cut.Find("[data-testid=secret-content-type]").Change(nameof(SecretContentType.Json));
+ valueInput.Change(EnteredValue);
+
+ cut.Find("[data-testid=editor-submit]").Click();
+
+ // Exactly one upsert, round-tripping to the entered value under the entered name.
+ Assert.Single(store.Upserts);
+ StoredSecret row = store.Upserts[0];
+ Assert.Equal("app/thing", row.Name.Value);
+ Assert.Equal(SecretContentType.Json, row.ContentType);
+ Assert.Equal("A thing", row.Description);
+ Assert.Equal("carol", row.UpdatedBy);
+ Assert.Equal("carol", row.CreatedBy);
+ Assert.Equal(EnteredValue, cipher.Decrypt(row));
+
+ // A single secret.add audit was written, Success, correct target/actor, and NO value leak.
+ Assert.Single(audit.Events);
+ AuditEvent evt = audit.Events[0];
+ Assert.Equal("secret.add", evt.Action);
+ Assert.Equal(AuditOutcome.Success, evt.Outcome);
+ Assert.Equal("Secrets", evt.Category);
+ Assert.Equal("app/thing", evt.Target);
+ Assert.Equal("carol", evt.Actor);
+ AssertNoValueLeak(evt);
+ }
+
+ [Fact]
+ public void SecretEditor_Rotate_PreservesName_Overwrites()
+ {
+ var (store, audit, cipher) = RegisterServices();
+ var name = new SecretName("db/primary");
+ var auth = this.AddTestAuthorization();
+ auth.SetAuthorized("dave");
+ auth.SetPolicies(SecretsAuthorization.ManagePolicy);
+
+ var cut = RenderComponent(p => p
+ .Add(c => c.ExistingName, name)
+ .Add(c => c.OnSaved, () => { }));
+
+ // In rotate mode the name field is read-only and shows the existing name.
+ var nameInput = cut.Find("[data-testid=secret-name]");
+ Assert.True(nameInput.HasAttribute("readonly"));
+ Assert.Equal("db/primary", nameInput.GetAttribute("value"));
+
+ cut.Find("[data-testid=secret-value]").Change("rotated-value");
+ cut.Find("[data-testid=editor-submit]").Click();
+
+ Assert.Single(store.Upserts);
+ StoredSecret row = store.Upserts[0];
+ Assert.Equal("db/primary", row.Name.Value);
+ Assert.Equal("rotated-value", cipher.Decrypt(row));
+
+ Assert.Single(audit.Events);
+ Assert.Equal("secret.rotate", audit.Events[0].Action);
+ Assert.Equal("db/primary", audit.Events[0].Target);
+ AssertNoValueLeak(audit.Events[0], "rotated-value");
+ }
+
+ private static void AssertNoValueLeak(AuditEvent evt, string value = EnteredValue)
+ {
+ foreach (string? field in new[] { evt.Action, evt.Actor, evt.Category, evt.Target, evt.DetailsJson, evt.ToString() })
+ {
+ if (field is not null)
+ {
+ Assert.DoesNotContain(value, field, StringComparison.Ordinal);
+ }
+ }
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/SecretsListTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/SecretsListTests.cs
index 4f91b3b..673123e 100644
--- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/SecretsListTests.cs
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/SecretsListTests.cs
@@ -1,8 +1,10 @@
using Bunit;
using Bunit.TestDoubles;
using Microsoft.Extensions.DependencyInjection;
+using ZB.MOM.WW.Audit;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Ui;
+using ZB.MOM.WW.Secrets.Ui.Tests.Fakes;
namespace ZB.MOM.WW.Secrets.Ui.Tests;
@@ -33,7 +35,13 @@ public class SecretsListTests : TestContext
};
private void RegisterStore(params SecretMetadata[] rows)
- => Services.AddSingleton(new FakeSecretStore(rows));
+ {
+ // SecretsList now embeds RevealButton / ConfirmDeleteModal / SecretEditor, which inject the
+ // cipher and audit writer, so those seams must be registered even for list-only assertions.
+ Services.AddSingleton(new FakeSecretStore(rows));
+ Services.AddSingleton(new FakeCipher());
+ Services.AddSingleton(new CapturingAuditWriter());
+ }
[Fact]
public void Renders_Metadata_ForEachSecret()
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/ZB.MOM.WW.Secrets.Ui.Tests.csproj b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/ZB.MOM.WW.Secrets.Ui.Tests.csproj
index a6d9697..b1fdffd 100644
--- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/ZB.MOM.WW.Secrets.Ui.Tests.csproj
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/ZB.MOM.WW.Secrets.Ui.Tests.csproj
@@ -10,6 +10,7 @@
+