Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/FailoverConfirmLabelTests.cs
T
Joseph Doherty 0db253b55f fix(centralui): confirm dialogs name their own action instead of always saying "Delete" (register row 28)
The Health dashboard's "Trigger failover" confirmation offered a red button
labelled *Delete*. The copy was right; the button was not. `DialogHost` derived
the confirm label solely from `danger`, so `danger: true` meant the verb was
literally "Delete" for every caller — and no caller could say otherwise, because
`IDialogService.ConfirmAsync` had no label parameter.

`danger` now selects styling only; a new optional `confirmLabel` names the action
and wins over the fallback. The kind/danger fallback is deliberately RETAINED
("Save" for a prompt, "Delete" for a bare danger confirm, "Confirm" otherwise):
around 20 call sites are genuine deletes where it is correct, and the Playwright
suites click the literal "Delete". Requiring the label everywhere would have
churned those 20 sites for no behaviour change, with the miss-detection resting
on Playwright runs that need a live rig.

Audited all 34 confirm call sites; 9 read "Delete" for something that is not a
delete and are relabelled:

  Fail over  CentralFailoverControl (central pair, site pair)
  Approve    SecuredWrites — approve (this one WRITES TO A DEVICE)
  Reject     SecuredWrites — reject
  Disable    Topology — disable instance
  Discard    SiteCallsReport, NotificationReport, ParkedMessages (bulk + single)

The remaining 25 are correct as they stand: 18 genuine deletes plus 7 non-danger
confirms ("Revert to base", the three Retry paths) whose button reads "Confirm".

Tests: FailoverConfirmLabelTests drives the real DialogService behind a real
DialogHost — a substitute would pin the argument but not what the operator reads
— covering both branches, that the button keeps btn-danger, and that an
unlabelled danger confirm still falls back to "Delete". Note its click is
deliberately not awaited: with a real dialog the handler parks on ConfirmAsync
until answered, so awaiting deadlocks. Existing IDialogService fakes and
NSubstitute matchers gain the 4th parameter (an optional param is baked into the
call site, so a 3-arg Arg.Any stub silently stops matching a labelled call).
2026-08-15 13:22:15 -04:00

139 lines
6.3 KiB
C#

using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Health;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Monitoring;
/// <summary>
/// Pins the verb on the "Trigger failover" confirmation button (deferred-work
/// register row 28). The dialog copy was always right, but the red confirm button
/// read <em>"Delete"</em>: <c>DialogHost</c> assumed every <c>danger: true</c>
/// confirm was a delete, and no caller could say otherwise. These tests run the
/// real <see cref="DialogService"/> behind a real <c>DialogHost</c> — not a
/// substitute — so they cover both halves of the fix: the call site passing
/// <c>confirmLabel</c>, and the host preferring it over its own default. A
/// substitute-based test would pin the argument but not what the user reads.
///
/// bUnit cannot run real JS interop, so the host's <c>sbDialog.*</c> focus-trap
/// calls are exercised in <see cref="JSRuntimeMode.Loose"/> mode as no-ops.
/// </summary>
public class FailoverConfirmLabelTests : BunitContext
{
private readonly IManualFailoverService _failover = Substitute.For<IManualFailoverService>();
private readonly DialogService _dialog = new();
public FailoverConfirmLabelTests()
{
var claims = new[]
{
new Claim(JwtTokenService.UsernameClaimType, "tester"),
new Claim(JwtTokenService.RoleClaimType, "Administrator")
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
// BunitContext pre-registers a placeholder IAuthorizationService that throws when
// AuthorizeView evaluates a policy; force the real one so the admin gate is real.
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
Services.AddSingleton(_failover);
// The concrete service registered as the interface, so the control and the
// host share one instance (DialogHost injects IDialogService and casts).
Services.AddSingleton<IDialogService>(_dialog);
JSInterop.Mode = JSRuntimeMode.Loose;
}
/// <summary>
/// Renders the failover control and a live <c>DialogHost</c> side by side under
/// the cascading auth state <c>AuthorizeView</c> needs, mirroring how
/// <c>MainLayout</c> hosts the single shared dialog.
/// </summary>
private IRenderedComponent<CascadingAuthenticationState> RenderControlWithHost(string? siteId)
{
return Render<CascadingAuthenticationState>(parameters => parameters
.Add(p => p.ChildContent, (RenderFragment)(builder =>
{
builder.OpenComponent<CentralFailoverControl>(0);
builder.AddAttribute(1, nameof(CentralFailoverControl.OnlineCentralNodeCount), 2);
if (siteId is not null)
{
builder.AddAttribute(2, nameof(CentralFailoverControl.SiteId), siteId);
}
builder.CloseComponent();
builder.OpenComponent<DialogHost>(3);
builder.CloseComponent();
})));
}
/// <summary>
/// Opens the failover dialog and returns the footer buttons.
/// </summary>
/// <remarks>
/// The click is deliberately NOT awaited. With a real <see cref="DialogService"/>
/// the control's handler parks on <c>await ConfirmAsync(...)</c> until someone
/// answers the dialog, so awaiting the click would deadlock the test. The dialog
/// state is published synchronously before <c>ConfirmAsync</c> returns its task, so
/// the modal is rendered by the time the click dispatch returns.
/// </remarks>
private IReadOnlyList<AngleSharp.Dom.IElement> OpenFailoverDialog(string? siteId)
{
var host = RenderControlWithHost(siteId);
var trigger = host.FindAll("button")
.First(b => b.TextContent.Contains("failover", StringComparison.OrdinalIgnoreCase));
_ = trigger.ClickAsync(new());
host.WaitForState(() => host.FindAll("div.modal-footer button").Count > 0);
return host.FindAll("div.modal-footer button").ToList();
}
[Theory]
[InlineData(null)] // central pair
[InlineData("SiteA")] // site pair
public void Failover_confirm_button_says_fail_over_not_delete(string? siteId)
{
var footerButtons = OpenFailoverDialog(siteId).Select(b => b.TextContent.Trim()).ToList();
Assert.Contains("Fail over", footerButtons);
// The whole point of the row: the destructive verb must not be "Delete".
Assert.DoesNotContain("Delete", footerButtons);
// Cancel is still the way out — the fix must not have eaten the footer.
Assert.Contains("Cancel", footerButtons);
}
[Fact]
public void Failover_confirm_button_keeps_destructive_styling()
{
// The label and the styling are separate concerns now (danger = red,
// confirmLabel = the verb). Relabelling must not have downgraded the
// button to a benign primary one.
var confirm = OpenFailoverDialog(siteId: null).First(b => b.TextContent.Trim() == "Fail over");
Assert.Contains("btn-danger", confirm.ClassList);
}
[Fact]
public async Task An_unlabelled_danger_confirm_still_falls_back_to_delete()
{
// The fallback is retained deliberately: ~20 genuine delete call sites rely
// on it (and the Playwright suites click the literal "Delete"). This pins that
// the row-28 fix is additive and did not change the default.
var cut = Render<DialogHost>();
await cut.InvokeAsync(() => { _ = _dialog.ConfirmAsync("Delete thing", "Sure?", danger: true); });
cut.Render();
var footerButtons = cut.FindAll("div.modal-footer button").Select(b => b.TextContent.Trim()).ToList();
Assert.Contains("Delete", footerButtons);
}
}