Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/FailoverConfirmLabelTests.cs
T
2026-09-09 16:13:58 -04:00

140 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();
Services.AddTestConfigurationMutations();
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);
}
}