0db253b55f
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).
244 lines
10 KiB
C#
244 lines
10 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>
|
|
/// bUnit tests for the admin "Trigger failover" control on the Health dashboard
|
|
/// (decision 2026-07-22). The control is destructive and privileged, so the three
|
|
/// things that must hold are: only an Administrator sees it, it is refused when
|
|
/// there is no standby to take over, and it never fires without an explicit
|
|
/// confirmation.
|
|
/// </summary>
|
|
public class HealthFailoverButtonTests : BunitContext
|
|
{
|
|
private readonly IManualFailoverService _failover = Substitute.For<IManualFailoverService>();
|
|
private readonly IDialogService _dialog = Substitute.For<IDialogService>();
|
|
|
|
private void Arrange(string role)
|
|
{
|
|
var claims = new[]
|
|
{
|
|
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
|
new Claim(JwtTokenService.RoleClaimType, role)
|
|
};
|
|
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 service so the admin gate is
|
|
// genuinely exercised rather than trivially satisfied (same note as NavMenuTests).
|
|
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
|
|
Services.AddSingleton(_failover);
|
|
Services.AddSingleton(_dialog);
|
|
}
|
|
|
|
/// <summary>
|
|
/// AuthorizeView requires a cascading Task<AuthenticationState>, which the app
|
|
/// supplies from its layout; a bare component render has to provide it explicitly.
|
|
/// </summary>
|
|
private IRenderedComponent<CentralFailoverControl> Render(int onlineNodes)
|
|
{
|
|
var host = Render<CascadingAuthenticationState>(parameters => parameters
|
|
.Add(p => p.ChildContent, (RenderFragment)(builder =>
|
|
{
|
|
builder.OpenComponent<CentralFailoverControl>(0);
|
|
builder.AddAttribute(1, nameof(CentralFailoverControl.OnlineCentralNodeCount), onlineNodes);
|
|
builder.CloseComponent();
|
|
})));
|
|
|
|
return host.FindComponent<CentralFailoverControl>();
|
|
}
|
|
|
|
[Fact]
|
|
public void Button_is_hidden_for_a_non_admin()
|
|
{
|
|
Arrange("Viewer");
|
|
|
|
var cut = Render(onlineNodes: 2);
|
|
|
|
Assert.Empty(cut.FindAll("button"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Button_is_rendered_and_enabled_for_an_admin_with_a_standby()
|
|
{
|
|
Arrange("Administrator");
|
|
|
|
var cut = Render(onlineNodes: 2);
|
|
|
|
var button = cut.Find("button");
|
|
Assert.False(button.HasAttribute("disabled"));
|
|
Assert.Contains("failover", button.TextContent, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void Button_is_disabled_with_an_explanatory_title_when_there_is_no_standby()
|
|
{
|
|
Arrange("Administrator");
|
|
|
|
var cut = Render(onlineNodes: 1);
|
|
|
|
var button = cut.Find("button");
|
|
Assert.True(button.HasAttribute("disabled"));
|
|
// The tooltip must say WHY, not just that it is unavailable.
|
|
Assert.Contains("outage", button.GetAttribute("title"), StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Confirming_triggers_the_failover_exactly_once()
|
|
{
|
|
Arrange("Administrator");
|
|
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(true);
|
|
_failover.FailOverCentralAsync(Arg.Any<string>())
|
|
.Returns(Task.FromResult<string?>("akka.tcp://scadabridge@central-a:8081"));
|
|
|
|
var cut = Render(onlineNodes: 2);
|
|
await cut.Find("button").ClickAsync(new());
|
|
|
|
await _failover.Received(1).FailOverCentralAsync("tester");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Cancelling_the_confirmation_does_not_trigger_a_failover()
|
|
{
|
|
Arrange("Administrator");
|
|
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(false);
|
|
|
|
var cut = Render(onlineNodes: 2);
|
|
await cut.Find("button").ClickAsync(new());
|
|
|
|
await _failover.DidNotReceive().FailOverCentralAsync(Arg.Any<string>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Confirmation_warns_that_this_page_will_disconnect()
|
|
{
|
|
// Traefik routes the UI to the ACTIVE node — the very node being failed over — so
|
|
// the admin's own circuit drops. If the dialog does not say so, a working failover
|
|
// looks like a crash they caused.
|
|
Arrange("Administrator");
|
|
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(true);
|
|
|
|
var cut = Render(onlineNodes: 2);
|
|
await cut.Find("button").ClickAsync(new());
|
|
|
|
await _dialog.Received(1).ConfirmAsync(
|
|
Arg.Any<string>(),
|
|
Arg.Is<string>(m => m.Contains("reconnect", StringComparison.OrdinalIgnoreCase)),
|
|
Arg.Any<bool>(),
|
|
Arg.Any<string?>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_refused_failover_surfaces_the_refusal_instead_of_claiming_success()
|
|
{
|
|
// The service returns null when the peer guard refuses. The UI must not report a
|
|
// failover that never happened.
|
|
Arrange("Administrator");
|
|
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(true);
|
|
_failover.FailOverCentralAsync(Arg.Any<string>()).Returns(Task.FromResult<string?>(null));
|
|
|
|
var cut = Render(onlineNodes: 2);
|
|
await cut.Find("button").ClickAsync(new());
|
|
|
|
Assert.Contains("no standby", cut.Markup, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
// ---- Site-pair failover (Task 10) ------------------------------------------
|
|
// Same control, SiteId set. The differences that matter: it calls the site path,
|
|
// and its confirmation must NOT claim the admin's own page will drop (it won't —
|
|
// the site is a different cluster).
|
|
|
|
private IRenderedComponent<CentralFailoverControl> RenderForSite(int onlineNodes, string siteId)
|
|
{
|
|
var host = Render<CascadingAuthenticationState>(parameters => parameters
|
|
.Add(p => p.ChildContent, (RenderFragment)(builder =>
|
|
{
|
|
builder.OpenComponent<CentralFailoverControl>(0);
|
|
builder.AddAttribute(1, nameof(CentralFailoverControl.OnlineCentralNodeCount), onlineNodes);
|
|
builder.AddAttribute(2, nameof(CentralFailoverControl.SiteId), siteId);
|
|
builder.CloseComponent();
|
|
})));
|
|
|
|
return host.FindComponent<CentralFailoverControl>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Site_card_confirming_triggers_the_site_failover_path()
|
|
{
|
|
Arrange("Administrator");
|
|
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(true);
|
|
_failover.FailOverSiteAsync(Arg.Any<string>(), Arg.Any<string>())
|
|
.Returns(Task.FromResult(new SiteFailoverOutcome(true, "akka.tcp://scadabridge@site-a-a:8082", null)));
|
|
|
|
var cut = RenderForSite(onlineNodes: 2, siteId: "SiteA");
|
|
await cut.Find("button").ClickAsync(new());
|
|
|
|
await _failover.Received(1).FailOverSiteAsync("SiteA", "tester");
|
|
await _failover.DidNotReceive().FailOverCentralAsync(Arg.Any<string>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Site_card_confirmation_does_not_claim_this_page_disconnects()
|
|
{
|
|
// Central failover drops the admin's circuit; a SITE failover does not, and saying
|
|
// otherwise would train operators to distrust the warning that does matter.
|
|
Arrange("Administrator");
|
|
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(true);
|
|
_failover.FailOverSiteAsync(Arg.Any<string>(), Arg.Any<string>())
|
|
.Returns(Task.FromResult(new SiteFailoverOutcome(true, "addr", null)));
|
|
|
|
var cut = RenderForSite(onlineNodes: 2, siteId: "SiteA");
|
|
await cut.Find("button").ClickAsync(new());
|
|
|
|
await _dialog.Received(1).ConfirmAsync(
|
|
Arg.Any<string>(),
|
|
Arg.Is<string>(m => !m.Contains("reconnect", StringComparison.OrdinalIgnoreCase)),
|
|
Arg.Any<bool>(),
|
|
Arg.Any<string?>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Site_card_surfaces_a_refusal_from_the_site()
|
|
{
|
|
Arrange("Administrator");
|
|
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(true);
|
|
_failover.FailOverSiteAsync(Arg.Any<string>(), Arg.Any<string>())
|
|
.Returns(Task.FromResult(new SiteFailoverOutcome(
|
|
false, null, "No standby available — failing over a lone node would be an outage.")));
|
|
|
|
var cut = RenderForSite(onlineNodes: 2, siteId: "SiteA");
|
|
await cut.Find("button").ClickAsync(new());
|
|
|
|
Assert.Contains("No standby available", cut.Markup);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Site_card_surfaces_an_unreachable_site_distinctly_from_a_refusal()
|
|
{
|
|
// A timeout is not a "no" from the site — the operator must be able to tell the
|
|
// difference, because the failover may or may not have taken effect.
|
|
Arrange("Administrator");
|
|
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(true);
|
|
_failover.FailOverSiteAsync(Arg.Any<string>(), Arg.Any<string>())
|
|
.Returns(Task.FromResult(new SiteFailoverOutcome(
|
|
false, null, "Site did not respond: Ask timed out")));
|
|
|
|
var cut = RenderForSite(onlineNodes: 2, siteId: "SiteA");
|
|
await cut.Find("button").ClickAsync(new());
|
|
|
|
Assert.Contains("did not respond", cut.Markup);
|
|
}
|
|
}
|