140 lines
6.3 KiB
C#
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);
|
|
}
|
|
}
|