9f67147e7b
Deferred-work register rows 37, 86, 87, 88 — the structural duplication and the three behavioral divergences it had accumulated, fixed together. Row 37 (structural). `IDeploymentManagerRepository.QueryDeploymentSummariesAsync` is DELETED. It and `QueryDeploymentListPageAsync` were two paged reads of the same insert-only table with different contracts — raw offset vs page number, no total vs total, no instance join vs joined, instance-id-list scoping vs site-id scoping. The CLI/HTTP `QueryDeployments` handler now goes through `QueryDeploymentListPageAsync` with a `DeploymentListFilter`, exactly like the Central UI page. The `DeploymentRecordSummary` projection went with it (`DeploymentListRow` is the single row shape); its `MaxPageSize` const moved to `DeploymentListPage`, which also gains `DefaultPageSize`. Row 86 (Medium, authorization — the headline). `SiteScopeService.ResolveAsync` was claims-only, so an Administrator carrying `SiteId` claims saw the whole fleet through the CLI (`ManagementActor.EnforceSiteScope` has always exempted Administrator) and only their granted sites on the Deployments page. One user, one dataset, two answers. Administrator = system-wide is the intended semantic on both sides; the UI was the defect. The role claim is read explicitly with an OrdinalIgnoreCase comparison rather than via `IsInRole`, which compares claim VALUES ordinally and only resolves when the identity carries the matching roleType — the two sides must not disagree on a differently-cased role name. An Administrator's site claims are not collected at all, so the invariant "system-wide => empty permitted-id set" holds for every caller. Designer is NOT exempt: the class doc claimed "any Admin/Design user" is system-wide, which was true of neither side, so the doc was corrected rather than the bypass widened. Row 87 (Low). The scoped branch used to resolve the site grant into an instance-id list by loading EVERY instance in the system, non-transactionally, on every scoped query; the grant now rides as `SiteIdScope` and is resolved by the query's own join. An unparseable permitted-site id is DROPPED, never ignored — dropping can only narrow a grant, discarding it would widen it to fleet-wide. And `PageSize <= 0` no longer means UNLIMITED down at the repository (`--page-size 0` materialized the whole retention window): it means "unspecified" and takes the CLI's own default of 50, capped at 1000. Bounding without telling would be silent truncation, so the handler now returns a `DeploymentQueryResult` envelope carrying `totalCount` plus the page coordinates actually applied — with a CLI table projection (mirroring row 57's `AuditConfigTableProjection`) that unwraps it and appends a "showing N of M" note. Row 88 (Low, UX). An unrecognised `--status` fell back to "no filter", so a typo like `--status Sucess` returned every status — a complete-looking answer to a question the caller never asked. It is now a `ManagementError` naming the valid values. Parsing stays case-insensitive; a numeric string is rejected too, since `Enum.TryParse` accepts "7" and yields an undefined value (guarded with `Enum.IsDefined`). Loud failure over silent full results, per row 46. Tests: 7 new `SiteScopeServiceTests` pinning Administrator = system-wide (verified failing 6/7 against the pre-fix service, the Designer pin passing both ways); the ManagementActor deployment suite migrated onto the consolidated query with new pins for the status error, case-insensitive parse, numeric rejection, page-size default/ cap/floor, empty-scope-on-garbage-grant, and "reads no instances at all"; a repository test for site-scope+status composing over a bounded page; and 6 CLI tests for the new table projection.
121 lines
4.4 KiB
C#
121 lines
4.4 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
|
|
|
/// <summary>
|
|
/// Table projection for <c>deploy status</c> (deferred-work register rows 37/87).
|
|
///
|
|
/// <para>
|
|
/// The handler now returns a <c>DeploymentQueryResult</c> envelope —
|
|
/// <c>{ "deployments": [ … ], "totalCount": N, "page": P, "pageSize": S }</c> — where
|
|
/// it used to return a bare row array. Handed to the generic renderer unprojected,
|
|
/// an object takes the Property/Value branch and the whole <c>deployments</c> array
|
|
/// lands in a single unreadable cell. This unwraps the envelope to the row array the
|
|
/// renderer expects, exactly as <see cref="AuditConfigTableProjection"/> does for
|
|
/// <c>audit-config query</c>.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// The envelope's counts would be lost by a plain unwrap, and losing THOSE is the one
|
|
/// thing this projection must not do: the page size is bounded server-side now, so a
|
|
/// full page is no longer proof that there are no more rows. When the page is a strict
|
|
/// subset a single trailing row carries a <c>note</c> cell saying how many of how many
|
|
/// are shown and how to get the rest. When the page IS the whole result set the column
|
|
/// is not emitted at all.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// JSON output is never touched — this runs only on the table path.
|
|
/// </para>
|
|
/// </summary>
|
|
internal static class DeploymentStatusTableProjection
|
|
{
|
|
/// <summary>
|
|
/// Projects a <c>DeploymentQueryResult</c> envelope (or a bare row array) to the
|
|
/// row array the table renderer consumes. Returns the input unchanged when it is
|
|
/// not JSON or not one of those shapes, so the renderer's own fallbacks apply.
|
|
/// </summary>
|
|
/// <param name="json">The raw success JSON body from the management API.</param>
|
|
/// <returns>JSON suitable for table rendering.</returns>
|
|
internal static string ProjectRows(string json)
|
|
{
|
|
JsonDocument doc;
|
|
try
|
|
{
|
|
doc = JsonDocument.Parse(json);
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
// Not JSON (e.g. a proxy error page) — let the renderer print it verbatim.
|
|
return json;
|
|
}
|
|
|
|
using (doc)
|
|
{
|
|
var root = doc.RootElement;
|
|
|
|
// A bare array is accepted so the command still renders usefully against
|
|
// an older server that returns the pre-envelope shape.
|
|
if (root.ValueKind == JsonValueKind.Array)
|
|
return BuildRows(root, totalCount: null).ToJsonString();
|
|
|
|
if (root.ValueKind == JsonValueKind.Object
|
|
&& TryGetPropertyCI(root, "deployments", out var rows)
|
|
&& rows.ValueKind == JsonValueKind.Array)
|
|
{
|
|
int? total = TryGetPropertyCI(root, "totalCount", out var tc)
|
|
&& tc.ValueKind == JsonValueKind.Number
|
|
&& tc.TryGetInt32(out var n)
|
|
? n
|
|
: null;
|
|
return BuildRows(rows, total).ToJsonString();
|
|
}
|
|
|
|
// Some other object (e.g. an error envelope) — leave it to the renderer.
|
|
return json;
|
|
}
|
|
}
|
|
|
|
private static JsonArray BuildRows(JsonElement rows, int? totalCount)
|
|
{
|
|
var arr = new JsonArray();
|
|
var shown = 0;
|
|
foreach (var item in rows.EnumerateArray())
|
|
{
|
|
arr.Add(item.ValueKind == JsonValueKind.Object
|
|
? JsonNode.Parse(item.GetRawText())!
|
|
: JsonValue.Create(item.ToString())!);
|
|
shown++;
|
|
}
|
|
|
|
// Only when the page is a strict subset — otherwise the count is just the row
|
|
// count the operator can already see, and the extra column would be noise.
|
|
if (totalCount is int total && total > shown)
|
|
{
|
|
arr.Add(new JsonObject
|
|
{
|
|
["note"] = JsonValue.Create(
|
|
$"showing {shown} of {total} — use --page / --page-size for the rest"),
|
|
});
|
|
}
|
|
|
|
return arr;
|
|
}
|
|
|
|
private static bool TryGetPropertyCI(JsonElement obj, string name, out JsonElement value)
|
|
{
|
|
foreach (var prop in obj.EnumerateObject())
|
|
{
|
|
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
value = prop.Value;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
value = default;
|
|
return false;
|
|
}
|
|
}
|