feat(management): secured-write list paging with TotalCount (arch-review P3)

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 05:42:35 -04:00
parent 03295e91bb
commit 8d4ffa7ef1
8 changed files with 195 additions and 8 deletions
@@ -0,0 +1,71 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary>
/// Builds the <c>secured-write</c> command group. Currently exposes the read-only
/// <c>list</c> sub-command, which relays a <see cref="ListSecuredWritesCommand"/> to the
/// central <c>ManagementActor</c> (gated any-of <c>Operator</c> / <c>Verifier</c> /
/// <c>Administrator</c>). The list is offset-paged via <c>--skip</c> / <c>--take</c>
/// (arch-review P3); omitting them preserves the historical unpaged default (first 200 rows).
/// </summary>
public static class SecuredWriteCommands
{
// Options are static so the parsed values can be read back from both the SetAction
// and the internal BuildListCommand helper (used by command-construction tests).
private static readonly Option<string?> StatusOption =
new("--status") { Description = "Filter by status (e.g. Pending, Approved, Rejected, Expired, Executed, Failed); omit to match all" };
private static readonly Option<string?> SiteOption =
new("--site") { Description = "Filter by site identifier; omit to match all sites" };
private static readonly Option<int> SkipOption =
new("--skip") { Description = "Number of rows to skip (offset paging)", DefaultValueFactory = _ => 0 };
private static readonly Option<int> TakeOption =
new("--take") { Description = "Maximum rows to return (clamped to 1..500 server-side)", DefaultValueFactory = _ => 200 };
/// <summary>Builds the <c>secured-write</c> command group with its <c>list</c> sub-command.</summary>
/// <param name="urlOption">Global <c>--url</c> option for the management API endpoint.</param>
/// <param name="formatOption">Global <c>--format</c> option for output format.</param>
/// <param name="usernameOption">Global <c>--username</c> option for authentication.</param>
/// <param name="passwordOption">Global <c>--password</c> option for authentication.</param>
/// <returns>The configured <c>secured-write</c> command.</returns>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("secured-write") { Description = "Query two-person (MxGateway) secured writes" };
command.Add(BuildList(urlOption, formatOption, usernameOption, passwordOption));
return command;
}
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var cmd = new Command("list") { Description = "List secured writes (pending queue + terminal history), optionally filtered and paged" };
cmd.Add(StatusOption);
cmd.Add(SiteOption);
cmd.Add(SkipOption);
cmd.Add(TakeOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
BuildListCommand(result));
});
return cmd;
}
/// <summary>
/// Builds a <see cref="ListSecuredWritesCommand"/> from a parsed <c>secured-write list</c>
/// invocation. Exposed internally so command-construction tests can assert field mapping
/// (including the <c>--skip</c> / <c>--take</c> paging options) without triggering the HTTP call.
/// </summary>
/// <param name="result">The parsed command-line result from the <c>secured-write list</c> invocation.</param>
/// <returns>A <see cref="ListSecuredWritesCommand"/> populated from the parsed result.</returns>
internal static ListSecuredWritesCommand BuildListCommand(ParseResult result)
{
var status = result.GetValue(StatusOption);
var site = result.GetValue(SiteOption);
var skip = result.GetValue(SkipOption);
var take = result.GetValue(TakeOption);
return new ListSecuredWritesCommand(status, site, skip, take);
}
}
+1
View File
@@ -35,6 +35,7 @@ rootCommand.Add(DbConnectionCommands.Build(urlOption, formatOption, usernameOpti
rootCommand.Add(ApiMethodCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(BundleCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(CachedCallCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(SecuredWriteCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.SetAction(_ =>
{
@@ -51,12 +51,16 @@ public record ApproveSecuredWriteCommand(long Id, string? Comment);
public record RejectSecuredWriteCommand(long Id, string? Comment);
/// <summary>
/// Read-only query for secured writes, optionally filtered by status and site.
/// A <c>null</c> filter matches every row.
/// Read-only query for secured writes, optionally filtered by status and site,
/// with offset paging. A <c>null</c> filter matches every row. The <paramref name="Skip"/>
/// / <paramref name="Take"/> defaults preserve the historical unpaged wire behaviour
/// (first 200 rows) for callers that omit them (additive evolution).
/// </summary>
/// <param name="Status">Status filter; <c>null</c> matches every status.</param>
/// <param name="SiteId">Site id filter; <c>null</c> matches every site.</param>
public record ListSecuredWritesCommand(string? Status, string? SiteId);
/// <param name="Skip">Number of rows to skip (offset paging); clamped to &gt;= 0 server-side.</param>
/// <param name="Take">Maximum rows to return; clamped to 1..500 server-side. Default 200.</param>
public record ListSecuredWritesCommand(string? Status, string? SiteId, int Skip = 0, int Take = 200);
/// <summary>
/// Result projection of a single pending secured write row, mirroring the
@@ -79,5 +83,11 @@ public record SecuredWriteDto(
DateTime? ExecutedAtUtc,
string? ExecutionError);
/// <summary>Result wrapper for <see cref="ListSecuredWritesCommand"/>.</summary>
public record SecuredWriteListResult(IReadOnlyList<SecuredWriteDto> Items);
/// <summary>
/// Result wrapper for <see cref="ListSecuredWritesCommand"/>. <paramref name="TotalCount"/>
/// is the count of rows matching the query filters ignoring paging, so the UI can render
/// a pager independent of the returned page size.
/// </summary>
/// <param name="Items">The page of matching secured-write rows.</param>
/// <param name="TotalCount">Total number of rows matching the filters (unpaged).</param>
public record SecuredWriteListResult(IReadOnlyList<SecuredWriteDto> Items, int TotalCount);
@@ -1394,7 +1394,13 @@ public class ManagementActor : ReceiveActor
IServiceProvider sp, ListSecuredWritesCommand cmd)
{
var repo = sp.GetRequiredService<ISecuredWriteRepository>();
var rows = await repo.QueryAsync(cmd.Status, cmd.SiteId, skip: 0, take: 200);
// Offset paging (arch-review P3): clamp untrusted paging inputs so a crafted
// command can neither request a negative offset nor an unbounded page.
var take = Math.Clamp(cmd.Take, 1, 500);
var skip = Math.Max(0, cmd.Skip);
var rows = await repo.QueryAsync(cmd.Status, cmd.SiteId, skip, take);
var totalCount = await repo.CountAsync(cmd.Status, cmd.SiteId);
// Opportunistic expiry sweep (arch-review S2): every time the list is read, walk
// the page and expire any overdue Pending row. TryExpireIfStaleAsync self-filters
@@ -1411,7 +1417,7 @@ public class ManagementActor : ReceiveActor
await TryExpireIfStaleAsync(sp, repo, row);
}
return new SecuredWriteListResult(rows.Select(ToSecuredWriteDto).ToList());
return new SecuredWriteListResult(rows.Select(ToSecuredWriteDto).ToList(), totalCount);
}
// ========================================================================