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.Shared; using ZB.MOM.WW.ScadaBridge.CentralUI.Services; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.Security; using SecuredWritesPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Operations.SecuredWrites; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components; /// /// bUnit rendering + interaction tests for the Secured Writes page (M7 T14b / C5). /// The page is the UI for the two-person MxGateway write workflow: an Operator submits, /// a different Verifier approves/rejects, and everyone sees the history. The page only /// SUBMITS commands through — the server enforces /// roles, the no-self-approval guard, the device relay, and the audit trail — so these /// tests substitute the service and assert the page wires the inputs/actions correctly. /// The real policies are registered so the per-region AuthorizeView Policy=... /// blocks are genuinely evaluated against the test principal's role claims. /// public class SecuredWritesTests : BunitContext { private readonly ISecuredWriteService _service = Substitute.For(); private readonly ISiteRepository _siteRepo = Substitute.For(); private readonly IDialogService _dialog = Substitute.For(); public SecuredWritesTests() { Services.AddSingleton(_service); Services.AddSingleton(_siteRepo); Services.AddSingleton(_dialog); // Confirm dialogs resolve to "yes" without a DialogHost in the render scope. _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(true)); // Empty list by default; individual tests seed the queue/history. _service.ListAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(Array.Empty())); } /// /// Registers the real authorization stack + a principal with the given username and /// roles so the page's AuthorizeView Policy=... regions evaluate genuinely. /// private void AddAuth(string username, params string[] roles) { var claims = new List { new(JwtTokenService.UsernameClaimType, username) }; claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r))); 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 on // policy evaluation; force the real one so the regions are actually gated. Services.AddSingleton(); } private void SeedSites(params Site[] sites) { _siteRepo.GetAllSitesAsync(Arg.Any()) .Returns(Task.FromResult>(sites.ToList())); foreach (var site in sites) { _siteRepo.GetDataConnectionsBySiteIdAsync(site.Id, Arg.Any()) .Returns(Task.FromResult>(Array.Empty())); } } private void SeedConnections(int siteId, params DataConnection[] connections) => _siteRepo.GetDataConnectionsBySiteIdAsync(siteId, Arg.Any()) .Returns(Task.FromResult>(connections.ToList())); /// /// Renders the page inside a so its /// AuthorizeView regions have the cascading Task<AuthenticationState> /// they require (mirrors the NavMenu tests). /// private IRenderedComponent RenderPage() { var host = Render(parameters => parameters .Add(p => p.ChildContent, (RenderFragment)(builder => { builder.OpenComponent(0); builder.CloseComponent(); }))); return host.FindComponent(); } private static SecuredWriteDto Dto( long id, string status, string operatorUser, string? verifier = null, string? executionError = null, string siteId = "plant-a", string connection = "GW-1", string tag = "Pump.Setpoint", string valueJson = "true", string valueType = "Boolean") => new(id, siteId, connection, tag, valueJson, valueType, status, operatorUser, OperatorComment: null, SubmittedAtUtc: DateTime.UtcNow, VerifierUser: verifier, VerifierComment: null, DecidedAtUtc: status == "Pending" ? null : DateTime.UtcNow, ExecutedAtUtc: status is "Executed" or "Failed" ? DateTime.UtcNow : null, ExecutionError: executionError); // ── (a) Operator: submit form present + Submit calls SubmitAsync with the values ── [Fact] public void Operator_SeesSubmitForm() { AddAuth("op-user", Roles.Operator); SeedSites(new Site("Plant-A", "plant-a") { Id = 1 }); var cut = RenderPage(); cut.WaitForAssertion(() => { Assert.NotNull(cut.Find("[data-test='secured-write-submit-region']")); Assert.NotNull(cut.Find("[data-test='secured-write-submit']")); }); } [Fact] public void Operator_Submit_CallsServiceWithEnteredValues() { AddAuth("op-user", Roles.Operator); SeedSites(new Site("Plant-A", "plant-a") { Id = 1 }); SeedConnections(1, new DataConnection("GW-1", "MxGateway", 1) { Id = 10 }); _service.SubmitAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(SecuredWriteActionResult.Ok(Dto(1, "Pending", "op-user")))); var cut = RenderPage(); cut.Find("[data-test='secured-write-site']").Change("plant-a"); cut.Find("[data-test='secured-write-connection']").Change("GW-1"); cut.Find("[data-test='secured-write-tagpath']").Change("Pump.Setpoint"); cut.Find("[data-test='secured-write-value']").Change("true"); cut.Find("[data-test='secured-write-datatype']").Change("Boolean"); cut.Find("[data-test='secured-write-comment']").Change("routine setpoint"); cut.Find("[data-test='secured-write-submit']").Click(); _service.Received(1).SubmitAsync( "plant-a", "GW-1", "Pump.Setpoint", "true", "Boolean", "routine setpoint", Arg.Any()); } // ── (b) Verifier: pending row renders with Approve/Reject; own-row actions disabled ── [Fact] public void Verifier_PendingRow_HasEnabledActions_ForOtherOperatorsRow() { AddAuth("verifier-user", Roles.Verifier); SeedSites(new Site("Plant-A", "plant-a") { Id = 1 }); _service.ListAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>( new[] { Dto(1, "Pending", "someone-else") })); var cut = RenderPage(); cut.WaitForAssertion(() => { Assert.NotNull(cut.Find("[data-test='secured-write-pending-region']")); var approve = cut.Find("[data-test='secured-write-approve']"); var reject = cut.Find("[data-test='secured-write-reject']"); Assert.False(approve.HasAttribute("disabled")); Assert.False(reject.HasAttribute("disabled")); }); } [Fact] public void Verifier_OwnSubmission_HasApproveAndRejectDisabled() { // DisableLogin dev caveat: one identity holds both roles; the page must still // disable approve/reject on a row the current user submitted (server enforces // this too, but the UI surfaces it up front). AddAuth("self", Roles.Operator, Roles.Verifier); SeedSites(new Site("Plant-A", "plant-a") { Id = 1 }); _service.ListAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>( new[] { Dto(1, "Pending", "self") })); var cut = RenderPage(); cut.WaitForAssertion(() => { var approve = cut.Find("[data-test='secured-write-approve']"); var reject = cut.Find("[data-test='secured-write-reject']"); Assert.True(approve.HasAttribute("disabled")); Assert.True(reject.HasAttribute("disabled")); Assert.NotNull(cut.Find("[data-test='secured-write-own-note']")); }); } [Fact] public void Verifier_Approve_CallsApproveAsync() { AddAuth("verifier-user", Roles.Verifier); SeedSites(new Site("Plant-A", "plant-a") { Id = 1 }); _service.ListAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>( new[] { Dto(7, "Pending", "someone-else") })); _service.ApproveAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(SecuredWriteActionResult.Ok(Dto(7, "Executed", "someone-else", "verifier-user")))); var cut = RenderPage(); cut.Find("[data-test='secured-write-approve']").Click(); _service.Received(1).ApproveAsync(7, Arg.Any(), Arg.Any()); } // ── (c) History: terminal rows render ── [Fact] public void History_RendersTerminalRows() { AddAuth("viewer", Roles.Viewer); SeedSites(new Site("Plant-A", "plant-a") { Id = 1 }); _service.ListAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(new[] { Dto(1, "Executed", "op-a", "ver-a", tag: "Pump.Setpoint"), Dto(2, "Rejected", "op-b", "ver-b", tag: "Valve.Cmd"), Dto(3, "Failed", "op-c", "ver-c", executionError: "device timeout", tag: "Motor.Run"), Dto(4, "Pending", "op-d", tag: "ShouldNotAppear"), })); var cut = RenderPage(); cut.WaitForAssertion(() => { Assert.NotNull(cut.Find("[data-test='secured-write-history-region']")); var rows = cut.FindAll("[data-test='secured-write-history-row']"); // Three terminal rows (Executed/Rejected/Failed); the Pending row is excluded. Assert.Equal(3, rows.Count); Assert.Contains("Executed", cut.Markup); Assert.Contains("Rejected", cut.Markup); Assert.Contains("device timeout", cut.Markup); Assert.DoesNotContain("ShouldNotAppear", cut.Markup); }); } [Fact] public void Viewer_DoesNotSeeOperatorOrVerifierRegions() { AddAuth("viewer", Roles.Viewer); SeedSites(new Site("Plant-A", "plant-a") { Id = 1 }); var cut = RenderPage(); cut.WaitForAssertion(() => { Assert.Empty(cut.FindAll("[data-test='secured-write-submit-region']")); Assert.Empty(cut.FindAll("[data-test='secured-write-pending-region']")); // History is visible to any authenticated user. Assert.NotNull(cut.Find("[data-test='secured-write-history-region']")); }); } }