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; /// /// 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 "Delete": DialogHost assumed every danger: true /// confirm was a delete, and no caller could say otherwise. These tests run the /// real behind a real DialogHost — not a /// substitute — so they cover both halves of the fix: the call site passing /// confirmLabel, 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 sbDialog.* focus-trap /// calls are exercised in mode as no-ops. /// public class FailoverConfirmLabelTests : BunitContext { private readonly IManualFailoverService _failover = Substitute.For(); private readonly DialogService _dialog = new(); /// Initializes the bUnit context with an authenticated administrator and substitute failover service. 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(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(); 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(_dialog); JSInterop.Mode = JSRuntimeMode.Loose; } /// /// Renders the failover control and a live DialogHost side by side under /// the cascading auth state AuthorizeView needs, mirroring how /// MainLayout hosts the single shared dialog. /// private IRenderedComponent RenderControlWithHost(string? siteId) { return Render(parameters => parameters .Add(p => p.ChildContent, (RenderFragment)(builder => { builder.OpenComponent(0); builder.AddAttribute(1, nameof(CentralFailoverControl.OnlineCentralNodeCount), 2); if (siteId is not null) { builder.AddAttribute(2, nameof(CentralFailoverControl.SiteId), siteId); } builder.CloseComponent(); builder.OpenComponent(3); builder.CloseComponent(); }))); } /// /// Opens the failover dialog and returns the footer buttons. /// /// /// The click is deliberately NOT awaited. With a real /// the control's handler parks on await ConfirmAsync(...) until someone /// answers the dialog, so awaiting the click would deadlock the test. The dialog /// state is published synchronously before ConfirmAsync returns its task, so /// the modal is rendered by the time the click dispatch returns. /// private IReadOnlyList 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(); } /// Verifies that the failover confirm button reads "Fail over" rather than "Delete". /// The site id to fail over, or for the central cluster. [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); } /// Verifies that the failover confirm button keeps its destructive styling. [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); } /// Verifies that a danger confirmation without a custom label still falls back to "Delete". /// A task that represents the asynchronous operation. [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 fail-over label fix is additive and did not change the default. var cut = Render(); 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); } }