feat(ui): admin manual-failover control on the health page
CentralFailoverControl lives in Components/Health/ (matching AuditKpiTiles / SiteCallKpiTiles) rather than inline in Health.razor, so it is testable without standing up the whole dashboard's DI graph. - Admin-gated via AuthorizeView + RequireAdmin. The Health page itself is intentionally all-roles, so the gate belongs on the control, not the page. - Disabled with an explanatory title when the pair has no online standby; the authoritative guard remains server-side against live cluster membership. - Confirmation dialog (IDialogService, the page idiom) warns that singletons hand over, in-flight work on the active node is interrupted, and THIS PAGE will disconnect and reconnect against the new active node -- Traefik routes the UI to the node being restarted, so a working failover otherwise reads as a crash the admin caused. - A refused failover (service returns null) surfaces the refusal; the UI never reports a failover that did not happen. 7 bUnit tests. Two harness requirements that bit first: AuthorizeView needs a cascading AuthenticationState (the app supplies it from the layout), and BunitContext pre-registers a placeholder IAuthorizationService that throws on policy evaluation -- both handled the same way NavMenuTests documents. Runbook paragraphs added to Component-ClusterInfrastructure.md (new Manual Failover section) and docker/README.md.
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
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>()).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>()).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>()).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>());
|
||||
}
|
||||
|
||||
[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>()).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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user