40944a0d04
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.
383 lines
18 KiB
C#
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<AuthenticationState></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']"));
|
|
});
|
|
}
|
|
}
|