Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/SecuredWritesTests.cs
T
Joseph Doherty 40944a0d04 feat(centralui): row 75 — Approve is caution amber, not destructive red
The Secured Writes page rendered Approve and Reject as IDENTICAL red buttons.
Row 28 fixed the labels but left the styling, and the styling was the worse
half: at the moment of a two-person-control decision the colour carried no
discriminating information at all, and the affirmative half of the control wore
the delete affordance.

Root cause is that `danger` was a two-state flag for a three-state concept, so a
consequential-but-not-destructive action had to choose between looking like a
delete and looking like the safe default. Adds the missing middle rung:

    DialogSeverity { Normal -> btn-primary, Caution -> btn-warning,
                     Destructive -> btn-danger }

DECISION. Approve -> Caution (amber): approving executes a real write to live
plant equipment, so it is not a benign primary action, but it is not destroying
anything either. Reject KEEPS Destructive (red): it is terminal and irreversible
for the request — the Operator must resubmit — and holding it red is what keeps
the two opposite decisions visually distinct now that Approve is amber. Dropping
Reject to neutral was considered and rejected: it would invert the bias, making
declining look like the safe default. Colour is never the only signal — both
buttons carry their verb.

Only Destructive inherits the row-28 "Delete" label fallback; an unlabelled
Caution reads "Confirm", since a caution action is by definition not a delete —
otherwise row 28 would come back through the new door.

ADDITIVE OVERLOAD, deliberately. The boolean form stays the signature for all
~34 existing call sites, which are genuinely two-state, so nothing in src/
outside SecuredWrites.razor changed. Adding a fifth optional parameter to the
boolean form instead would have silently un-matched every existing four-argument
NSubstitute stub — the exact row-28 gotcha (null Task, caller NREs, no compile
error). The overload cannot: the only breakage was six hand-written IDialogService
fakes failing to compile, which is the loud failure mode. That gotcha did land
once, in SecuredWritesTests' own stub, and is now stubbed on both overloads with
a comment saying why.

DialogState's `bool Danger` became `DialogSeverity Severity` rather than gaining
a second field — it is read only by DialogHost, and one field cannot disagree
with itself.

Fail-before, both halves:
  - Approve call site reverted to `danger: true` -> both new Approve pins fail.
  - Caution remapped to btn-danger in DialogHost -> the amber styling pin fails.

Tests: DialogSeverityStylingTests renders the REAL DialogService behind a real
DialogHost (FailoverConfirmLabelTests' harness), so it pins what the user sees,
not what an argument was — all three severities, both label fallbacks, and the
boolean adapter. SecuredWritesTests pins that the page asks for Caution, with a
negative control that it did NOT also go through the boolean form (without which
the assertion would pass on a call site left at danger:true). Reject's red is
pinned too, so a later "make this page less alarming" pass cannot flatten both
to one colour.

No Playwright selectors needed updating — the suite has no Secured Writes
coverage, and no `.btn-danger` selector elsewhere targets this page.

Docs: Component-CentralUI.md gains the severity contract under Technology (next
to the row-28 label contract) plus the decision on the Secured Writes section.

CentralUI.Tests: 1055/1055 green.
2026-08-15 21:33:08 -04:00

383 lines
18 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.
// BOTH overloads must be stubbed: Reject takes the bool form, Approve takes the
// DialogSeverity form (register row 75). An unstubbed NSubstitute call returns a
// null Task, so missing one here surfaces as an NRE inside the click handler
// rather than as anything that names the dialog — the row-28 gotcha.
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>())
.Returns(Task.FromResult(true));
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<DialogSeverity>(), 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<DialogSeverity>(),
Arg.Any<string?>());
}
// ── (f) Approve/Reject confirm-button severity (register row 75) ──
[Fact]
public void Approve_ConfirmDialog_IsCautionNotDestructive()
{
// Row 75. Approving executes a real write to live plant equipment, so it is not a
// benign primary action — but it is not a delete either, and red made it both
// look like one AND look identical to the Reject button beside it, leaving colour
// carrying no information at the moment of a two-person-control decision.
AddAuth("verifier-user", Roles.Verifier);
SeedSites(new Site("Plant-A", "plant-a") { Id = 1 });
SeedList(new[] { Dto(11, "Pending", "someone-else") });
_service.ApproveAsync(Arg.Any<long>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(SecuredWriteActionResult.Ok(Dto(11, "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.Any<string>(), DialogSeverity.Caution, "Approve");
// Negative control: it must not ALSO have gone through the boolean form, which is
// the one that renders red. Without this the assertion above would still pass if
// the call site had been left on danger:true and merely gained a severity call.
_dialog.DidNotReceive().ConfirmAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>());
}
[Fact]
public void Reject_ConfirmDialog_KeepsDestructiveStyling()
{
// Row 75 decided Reject KEEPS red: it is terminal and irreversible for the request
// (the Operator must resubmit), and holding it red is what keeps the two opposite
// decisions visually distinct now that Approve is amber. Pinned so a later
// "make the page less alarming" pass cannot quietly flatten both to one colour.
AddAuth("verifier-user", Roles.Verifier);
SeedSites(new Site("Plant-A", "plant-a") { Id = 1 });
SeedList(new[] { Dto(12, "Pending", "someone-else") });
_service.RejectAsync(Arg.Any<long>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(SecuredWriteActionResult.Ok(Dto(12, "Rejected", "someone-else", "verifier-user"))));
var cut = RenderPage();
cut.WaitForAssertion(() => Assert.NotNull(cut.Find("[data-test='secured-write-reject']")));
cut.Find("[data-test='secured-write-reject']").Click();
_dialog.Received(1).ConfirmAsync(
Arg.Any<string>(), Arg.Any<string>(), danger: true, confirmLabel: "Reject");
}
[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']"));
});
}
}