feat(ui): secured-write history paging + submission age in approve dialog (arch-review S2/P3)

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 05:51:20 -04:00
parent 655834f0b8
commit d8a39f3c35
5 changed files with 162 additions and 35 deletions
@@ -1,4 +1,5 @@
using System.Security.Claims;
using System.Text.RegularExpressions;
using Bunit;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
@@ -48,10 +49,17 @@ public class SecuredWritesTests : BunitContext
.Returns(Task.FromResult(true));
// Empty list by default; individual tests seed the queue/history.
_service.ListAsync(Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<SecuredWriteDto>>(Array.Empty<SecuredWriteDto>()));
_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.
@@ -165,9 +173,7 @@ public class SecuredWritesTests : BunitContext
{
AddAuth("verifier-user", Roles.Verifier);
SeedSites(new Site("Plant-A", "plant-a") { Id = 1 });
_service.ListAsync(Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<SecuredWriteDto>>(
new[] { Dto(1, "Pending", "someone-else") }));
SeedList(new[] { Dto(1, "Pending", "someone-else") });
var cut = RenderPage();
@@ -189,9 +195,7 @@ public class SecuredWritesTests : BunitContext
// 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<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<SecuredWriteDto>>(
new[] { Dto(1, "Pending", "self") }));
SeedList(new[] { Dto(1, "Pending", "self") });
var cut = RenderPage();
@@ -210,9 +214,7 @@ public class SecuredWritesTests : BunitContext
{
AddAuth("verifier-user", Roles.Verifier);
SeedSites(new Site("Plant-A", "plant-a") { Id = 1 });
_service.ListAsync(Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<SecuredWriteDto>>(
new[] { Dto(7, "Pending", "someone-else") }));
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"))));
@@ -229,14 +231,13 @@ public class SecuredWritesTests : BunitContext
{
AddAuth("viewer", Roles.Viewer);
SeedSites(new Site("Plant-A", "plant-a") { Id = 1 });
_service.ListAsync(Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<SecuredWriteDto>>(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"),
}));
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();
@@ -253,6 +254,58 @@ public class SecuredWritesTests : BunitContext
});
}
// ── (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>());
}
[Fact]
public void Viewer_DoesNotSeeOperatorOrVerifierRegions()
{