Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/SecuredWritesTests.cs
T
Joseph Doherty 0db253b55f fix(centralui): confirm dialogs name their own action instead of always saying "Delete" (register row 28)
The Health dashboard's "Trigger failover" confirmation offered a red button
labelled *Delete*. The copy was right; the button was not. `DialogHost` derived
the confirm label solely from `danger`, so `danger: true` meant the verb was
literally "Delete" for every caller — and no caller could say otherwise, because
`IDialogService.ConfirmAsync` had no label parameter.

`danger` now selects styling only; a new optional `confirmLabel` names the action
and wins over the fallback. The kind/danger fallback is deliberately RETAINED
("Save" for a prompt, "Delete" for a bare danger confirm, "Confirm" otherwise):
around 20 call sites are genuine deletes where it is correct, and the Playwright
suites click the literal "Delete". Requiring the label everywhere would have
churned those 20 sites for no behaviour change, with the miss-detection resting
on Playwright runs that need a live rig.

Audited all 34 confirm call sites; 9 read "Delete" for something that is not a
delete and are relabelled:

  Fail over  CentralFailoverControl (central pair, site pair)
  Approve    SecuredWrites — approve (this one WRITES TO A DEVICE)
  Reject     SecuredWrites — reject
  Disable    Topology — disable instance
  Discard    SiteCallsReport, NotificationReport, ParkedMessages (bulk + single)

The remaining 25 are correct as they stand: 18 genuine deletes plus 7 non-danger
confirms ("Revert to base", the three Retry paths) whose button reads "Confirm".

Tests: FailoverConfirmLabelTests drives the real DialogService behind a real
DialogHost — a substitute would pin the argument but not what the operator reads
— covering both branches, that the button keeps btn-danger, and that an
unlabelled danger confirm still falls back to "Delete". Note its click is
deliberately not awaited: with a real dialog the handler parks on ConfirmAsync
until answered, so awaiting deadlocks. Existing IDialogService fakes and
NSubstitute matchers gain the 4th parameter (an optional param is baked into the
call site, so a 3-arg Arg.Any stub silently stops matching a labelled call).
2026-08-15 13:22:15 -04:00

327 lines
14 KiB
C#

using System.Security.Claims;
using System.Text.RegularExpressions;
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;
/// <summary>
/// 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 <see cref="ISecuredWriteService"/> — 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 <c>AuthorizeView Policy=...</c>
/// blocks are genuinely evaluated against the test principal's role claims.
/// </summary>
public class SecuredWritesTests : BunitContext
{
private readonly ISecuredWriteService _service = Substitute.For<ISecuredWriteService>();
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private readonly IDialogService _dialog = Substitute.For<IDialogService>();
// The submit region embeds a NodeBrowserDialog (@inject IBrowseService) for the
// OPC UA tag picker. The dialog is only rendered when the Operator opens it, but
// bUnit resolves the injected service at render time, so it must be registered
// even though these tests never drive the browser.
private readonly IBrowseService _browse = Substitute.For<IBrowseService>();
public SecuredWritesTests()
{
Services.AddSingleton(_service);
Services.AddSingleton(_siteRepo);
Services.AddSingleton(_dialog);
Services.AddSingleton(_browse);
// Confirm dialogs resolve to "yes" without a DialogHost in the render scope.
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>())
.Returns(Task.FromResult(true));
// Empty list by default; individual tests seed the queue/history.
_service.ListAsync(
Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new SecuredWriteListResult(Array.Empty<SecuredWriteDto>(), 0)));
}
/// <summary>Stubs <c>ListAsync</c> (any filter/paging args) to return <paramref name="rows"/>.</summary>
private void SeedList(IReadOnlyList<SecuredWriteDto> rows, int? totalCount = null)
=> _service.ListAsync(
Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new SecuredWriteListResult(rows, totalCount ?? rows.Count)));
/// <summary>
/// Registers the real authorization stack + a principal with the given username and
/// roles so the page's <c>AuthorizeView Policy=...</c> regions evaluate genuinely.
/// </summary>
private void AddAuth(string username, params string[] roles)
{
var claims = new List<Claim> { new(JwtTokenService.UsernameClaimType, username) };
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
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 on
// policy evaluation; force the real one so the regions are actually gated.
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
}
private void SeedSites(params Site[] sites)
{
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(sites.ToList()));
foreach (var site in sites)
{
_siteRepo.GetDataConnectionsBySiteIdAsync(site.Id, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<DataConnection>>(Array.Empty<DataConnection>()));
}
}
private void SeedConnections(int siteId, params DataConnection[] connections)
=> _siteRepo.GetDataConnectionsBySiteIdAsync(siteId, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<DataConnection>>(connections.ToList()));
/// <summary>
/// Renders the page inside a <see cref="CascadingAuthenticationState"/> so its
/// <c>AuthorizeView</c> regions have the cascading <c>Task&lt;AuthenticationState&gt;</c>
/// they require (mirrors the NavMenu tests).
/// </summary>
private IRenderedComponent<SecuredWritesPage> RenderPage()
{
var host = Render<CascadingAuthenticationState>(parameters => parameters
.Add(p => p.ChildContent, (RenderFragment)(builder =>
{
builder.OpenComponent<SecuredWritesPage>(0);
builder.CloseComponent();
})));
return host.FindComponent<SecuredWritesPage>();
}
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<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.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<CancellationToken>());
}
// ── (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 });
SeedList(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 });
SeedList(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 });
SeedList(new[] { Dto(7, "Pending", "someone-else") });
_service.ApproveAsync(Arg.Any<long>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.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<string?>(), Arg.Any<CancellationToken>());
}
// ── (c) History: terminal rows render ──
[Fact]
public void History_RendersTerminalRows()
{
AddAuth("viewer", Roles.Viewer);
SeedSites(new Site("Plant-A", "plant-a") { Id = 1 });
SeedList(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);
});
}
// ── (d) History pager: page-size 50, driven by TotalCount ──
[Fact]
public void History_Pager_NextRequestsSecondPage_UsingTotalCount()
{
// 60 terminal rows returned but TotalCount = 120 → a second page exists, so the
// "Next" button is present + enabled and clicking it re-queries with skip: 50.
AddAuth("viewer", Roles.Viewer);
SeedSites(new Site("Plant-A", "plant-a") { Id = 1 });
var terminal = Enumerable.Range(1, 60)
.Select(i => Dto(i, "Executed", $"op-{i}", $"ver-{i}"))
.ToArray();
SeedList(terminal, totalCount: 120);
var cut = RenderPage();
cut.WaitForAssertion(() =>
{
var next = cut.Find("[data-test='secured-write-history-next']");
Assert.False(next.HasAttribute("disabled"));
});
cut.Find("[data-test='secured-write-history-next']").Click();
// The history page-2 fetch offsets by one page (skip: 50).
_service.Received().ListAsync(
Arg.Any<string?>(), Arg.Any<string?>(), 50, Arg.Any<int>(), Arg.Any<CancellationToken>());
}
// ── (e) Approve dialog surfaces submission age (stale-setpoint scenario) ──
[Fact]
public void Approve_ConfirmDialog_ShowsSubmissionAge()
{
AddAuth("verifier-user", Roles.Verifier);
SeedSites(new Site("Plant-A", "plant-a") { Id = 1 });
var stale = Dto(9, "Pending", "someone-else") with { SubmittedAtUtc = DateTime.UtcNow.AddHours(-26) };
SeedList(new[] { stale });
_service.ApproveAsync(Arg.Any<long>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(SecuredWriteActionResult.Ok(Dto(9, "Executed", "someone-else", "verifier-user"))));
var cut = RenderPage();
cut.WaitForAssertion(() => Assert.NotNull(cut.Find("[data-test='secured-write-approve']")));
cut.Find("[data-test='secured-write-approve']").Click();
_dialog.Received(1).ConfirmAsync(
Arg.Any<string>(),
Arg.Is<string>(m => Regex.IsMatch(m, "Submitted .* ago")),
Arg.Any<bool>(),
Arg.Any<string?>());
}
[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']"));
});
}
}