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) +{ + + +} + +@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 @@ +