62ab8f68d1
Fixes scadaproj#2. ConfirmDeleteModal used the bare `modal` class and the
Secrets.Ui RCL shipped no CSS at all, silently depending on the host having
no opinion about that selector. Bootstrap 5 does: `.modal { display: none }`
made the delete modal permanently invisible on every Bootstrap host
(ScadaBridge, OtOpcUa, HistorianGateway — confirmed live on the first two)
while every @onclick handler kept working. Only MxGateway, the lone
Bootstrap-free host, could ever have rendered it.
Fix, entirely inside the RCL so a package bump repairs every host with no
host-side changes:
- class vocabulary renamed to zb-secrets-modal / -backdrop / -card / -title
so no host framework selector can match the elements;
- the component emits its own <style> block alongside the markup. Scoped
.razor.css was deliberately NOT used: three of the four family hosts never
link a scoped-CSS bundle, so isolation CSS would silently fail to load —
the exact defect class being fixed. Theme tokens with fallbacks keep the
card legible even on a host without the kit stylesheet.
- data-testids unchanged, so existing Playwright gates and bUnit tests keep
their selectors.
New bUnit pins: no Bootstrap-reserved class name appears in the rendered
markup, and the component ships a style block that both positions the
overlay and gives it a display mode. Suite 182 pass / 0 fail / 0.2.3.
Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
192 lines
7.4 KiB
C#
192 lines
7.4 KiB
C#
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<ISecretStore>(store);
|
|
Services.AddSingleton<IAuditWriter>(audit);
|
|
|
|
var name = new SecretName("db/primary");
|
|
var auth = this.AddTestAuthorization();
|
|
auth.SetAuthorized("carol");
|
|
auth.SetPolicies(SecretsAuthorization.ManagePolicy);
|
|
|
|
bool deleted = false;
|
|
var cut = RenderComponent<ConfirmDeleteModal>(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(".zb-secrets-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<ISecretStore>(store);
|
|
Services.AddSingleton<IAuditWriter>(audit);
|
|
|
|
var name = new SecretName("db/primary");
|
|
var auth = this.AddTestAuthorization();
|
|
auth.SetAuthorized("carol");
|
|
auth.SetPolicies(SecretsAuthorization.ManagePolicy);
|
|
|
|
bool cancelled = false;
|
|
var cut = RenderComponent<ConfirmDeleteModal>(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_StoreFault_AuditsFailure_ShowsError_DoesNotThrow()
|
|
{
|
|
var store = new RecordingSecretStore
|
|
{
|
|
DeleteFault = new MasterKeyUnavailableException("KEK unavailable."),
|
|
};
|
|
var audit = new CapturingAuditWriter();
|
|
Services.AddSingleton<ISecretStore>(store);
|
|
Services.AddSingleton<IAuditWriter>(audit);
|
|
|
|
var name = new SecretName("db/primary");
|
|
var auth = this.AddTestAuthorization();
|
|
auth.SetAuthorized("carol");
|
|
auth.SetPolicies(SecretsAuthorization.ManagePolicy);
|
|
|
|
bool deleted = false;
|
|
var cut = RenderComponent<ConfirmDeleteModal>(p => p
|
|
.Add(c => c.Visible, true)
|
|
.Add(c => c.Name, name)
|
|
.Add(c => c.OnDeleted, () => deleted = true));
|
|
|
|
// The click must not throw into the Blazor event pipeline.
|
|
cut.Find("[data-testid=confirm-delete]").Click();
|
|
|
|
// OnDeleted not raised; a Failure audit recorded; an error surfaced.
|
|
Assert.False(deleted);
|
|
Assert.Single(audit.Events);
|
|
AuditEvent evt = audit.Events[0];
|
|
Assert.Equal("secret.delete", evt.Action);
|
|
Assert.Equal(AuditOutcome.Failure, evt.Outcome);
|
|
Assert.Equal("Secrets", evt.Category);
|
|
Assert.Equal("db/primary", evt.Target);
|
|
Assert.Equal("carol", evt.Actor);
|
|
|
|
Assert.NotEmpty(cut.FindAll("[data-testid=delete-error]"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ConfirmDeleteModal_NotVisible_RendersNothing()
|
|
{
|
|
var store = new RecordingSecretStore();
|
|
var audit = new CapturingAuditWriter();
|
|
Services.AddSingleton<ISecretStore>(store);
|
|
Services.AddSingleton<IAuditWriter>(audit);
|
|
|
|
var name = new SecretName("db/primary");
|
|
this.AddTestAuthorization().SetAuthorized("carol");
|
|
|
|
var cut = RenderComponent<ConfirmDeleteModal>(p => p
|
|
.Add(c => c.Visible, false)
|
|
.Add(c => c.Name, name));
|
|
|
|
Assert.Empty(cut.FindAll(".zb-secrets-modal"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// scadaproj#2 regression pins. The modal previously used the bare <c>modal</c> class and
|
|
/// shipped no CSS, silently depending on the host having no opinion about it — Bootstrap 5's
|
|
/// <c>.modal { display: none }</c> made the overlay permanently invisible on every
|
|
/// Bootstrap host (ScadaBridge, OtOpcUa, HistorianGateway) while every handler kept working.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ConfirmDeleteModal_UsesNoFrameworkReservedClassNames()
|
|
{
|
|
var cut = RenderVisibleModal();
|
|
|
|
// Bootstrap owns these selectors; any element carrying one is again at the mercy of
|
|
// whatever stylesheet the host happens to link.
|
|
string[] reserved = ["modal", "modal-title", "modal-backdrop", "modal-dialog", "modal-content"];
|
|
foreach (string cls in reserved)
|
|
{
|
|
Assert.Empty(cut.FindAll($".{cls}"));
|
|
}
|
|
|
|
// The elements themselves still render, under the component-scoped vocabulary.
|
|
Assert.NotEmpty(cut.FindAll(".zb-secrets-modal"));
|
|
Assert.NotEmpty(cut.FindAll(".zb-secrets-modal-backdrop"));
|
|
Assert.NotEmpty(cut.FindAll(".zb-secrets-modal-card"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ConfirmDeleteModal_ShipsItsOwnStyles()
|
|
{
|
|
var cut = RenderVisibleModal();
|
|
|
|
// The RCL must carry the modal's CSS itself: no family host links a Secrets.Ui
|
|
// stylesheet, and three of the four never link a scoped-CSS bundle at all — so any
|
|
// styling delivered outside the component silently fails to load exactly like the
|
|
// original defect. The inline block must both position the overlay and give it a
|
|
// display mode, or a host reset/framework rule can hide it again.
|
|
var style = cut.FindAll("style").SingleOrDefault();
|
|
Assert.NotNull(style);
|
|
Assert.Contains(".zb-secrets-modal", style.TextContent);
|
|
Assert.Contains("position: fixed", style.TextContent);
|
|
Assert.Contains("display: flex", style.TextContent);
|
|
}
|
|
|
|
private IRenderedComponent<ConfirmDeleteModal> RenderVisibleModal()
|
|
{
|
|
Services.AddSingleton<ISecretStore>(new RecordingSecretStore());
|
|
Services.AddSingleton<IAuditWriter>(new CapturingAuditWriter());
|
|
this.AddTestAuthorization().SetAuthorized("carol");
|
|
|
|
return RenderComponent<ConfirmDeleteModal>(p => p
|
|
.Add(c => c.Visible, true)
|
|
.Add(c => c.Name, new SecretName("db/primary")));
|
|
}
|
|
}
|