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; /// /// 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. /// public class HealthFailoverButtonTests : BunitContext { private readonly IManualFailoverService _failover = Substitute.For(); private readonly IDialogService _dialog = Substitute.For(); 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(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(); Services.AddSingleton(_failover); Services.AddSingleton(_dialog); } /// /// AuthorizeView requires a cascading Task<AuthenticationState>, which the app /// supplies from its layout; a bare component render has to provide it explicitly. /// private IRenderedComponent Render(int onlineNodes) { var host = Render(parameters => parameters .Add(p => p.ChildContent, (RenderFragment)(builder => { builder.OpenComponent(0); builder.AddAttribute(1, nameof(CentralFailoverControl.OnlineCentralNodeCount), onlineNodes); builder.CloseComponent(); }))); return host.FindComponent(); } [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(), Arg.Any(), Arg.Any()).Returns(true); _failover.FailOverCentralAsync(Arg.Any()) .Returns(Task.FromResult("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(), Arg.Any(), Arg.Any()).Returns(false); var cut = Render(onlineNodes: 2); await cut.Find("button").ClickAsync(new()); await _failover.DidNotReceive().FailOverCentralAsync(Arg.Any()); } [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(), Arg.Any(), Arg.Any()).Returns(true); var cut = Render(onlineNodes: 2); await cut.Find("button").ClickAsync(new()); await _dialog.Received(1).ConfirmAsync( Arg.Any(), Arg.Is(m => m.Contains("reconnect", StringComparison.OrdinalIgnoreCase)), Arg.Any()); } [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(), Arg.Any(), Arg.Any()).Returns(true); _failover.FailOverCentralAsync(Arg.Any()).Returns(Task.FromResult(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 RenderForSite(int onlineNodes, string siteId) { var host = Render(parameters => parameters .Add(p => p.ChildContent, (RenderFragment)(builder => { builder.OpenComponent(0); builder.AddAttribute(1, nameof(CentralFailoverControl.OnlineCentralNodeCount), onlineNodes); builder.AddAttribute(2, nameof(CentralFailoverControl.SiteId), siteId); builder.CloseComponent(); }))); return host.FindComponent(); } [Fact] public async Task Site_card_confirming_triggers_the_site_failover_path() { Arrange("Administrator"); _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); _failover.FailOverSiteAsync(Arg.Any(), Arg.Any()) .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()); } [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(), Arg.Any(), Arg.Any()).Returns(true); _failover.FailOverSiteAsync(Arg.Any(), Arg.Any()) .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(), Arg.Is(m => !m.Contains("reconnect", StringComparison.OrdinalIgnoreCase)), Arg.Any()); } [Fact] public async Task Site_card_surfaces_a_refusal_from_the_site() { Arrange("Administrator"); _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); _failover.FailOverSiteAsync(Arg.Any(), Arg.Any()) .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(), Arg.Any(), Arg.Any()).Returns(true); _failover.FailOverSiteAsync(Arg.Any(), Arg.Any()) .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); } }