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']"));
});
}
}