merge(agent-a): rows 37/86/87/88 — deployment reads consolidated on QueryDeploymentListPageAsync (QueryDeploymentSummariesAsync deleted); SiteScopeService honors the Administrator system-wide rule (claims no longer scope admins — the documented ManagementActor semantic now holds on both surfaces); site scope rides the query join (whole-table instance read gone), PageSize bounded (default 50, cap 1000); unparseable --status fails loudly naming valid values

This commit is contained in:
Joseph Doherty
2026-08-17 05:28:51 -04:00
21 changed files with 760 additions and 256 deletions
+3 -1
View File
@@ -51,7 +51,9 @@ Because `CookieAuthenticationStateProvider` serves a frozen principal, the circu
### Site-scoped authorization ### Site-scoped authorization
`SiteScopeService` (scoped, registered in `AddCentralUI`) reads `SiteId` claims from the circuit principal. Deployment users whose LDAP mapping carries site-scope rules carry one integer `SiteId` claim per permitted site; Admin and Design users carry none (system-wide). Pages that enumerate sites or issue cross-site commands call `SiteScopeService.FilterSitesAsync` and `IsSiteAllowedAsync` to enforce the grant before any operation. `SiteScopeService` (scoped, registered in `AddCentralUI`) reads the role and `SiteId` claims off the circuit principal. Deployment users whose LDAP mapping carries site-scope rules carry one integer `SiteId` claim per permitted site; a user with no `SiteId` claims is system-wide. Pages that enumerate sites or issue cross-site commands call `SiteScopeService.FilterSitesAsync` and `IsSiteAllowedAsync` to enforce the grant before any operation.
**A user holding the `Administrator` role is system-wide regardless of the `SiteId` claims they carry** (deferred-work register row 86). `ManagementActor.EnforceSiteScope` has always exempted Administrator, so before this the same user could act fleet-wide through the CLI/HTTP surface while the UI hid the same sites — one user, one dataset, two answers. The role claim is read explicitly (`JwtTokenService.RoleClaimType`, `OrdinalIgnoreCase`) rather than via `ClaimsPrincipal.IsInRole`, because `IsInRole` compares claim *values* ordinally and only resolves at all when the identity was built with the matching `roleType`; the actor compares case-insensitively, and the two sides must not disagree on a differently-cased role name. **`Designer` is NOT exempt** — the enforced rule has only ever bypassed Administrator, and this document previously claimed "Admin and Design users carry none (system-wide)", which was true of neither side. An Administrator's `SiteId` claims are not collected at all, so the invariant *system-wide ⇒ empty permitted-id set* holds for every caller and a consumer reading the id set without consulting `IsSystemWideAsync` cannot silently narrow an Administrator to a subset.
### Repository access pattern ### Repository access pattern
@@ -2,8 +2,10 @@
-- --
-- Replaces the single-column [IX_DeploymentRecords_DeployedAt] with the composite -- Replaces the single-column [IX_DeploymentRecords_DeployedAt] with the composite
-- [IX_DeploymentRecords_DeployedAt_Id] ([DeployedAt] DESC, [Id] DESC), which is exactly -- [IX_DeploymentRecords_DeployedAt_Id] ([DeployedAt] DESC, [Id] DESC), which is exactly
-- the order DeploymentManagerRepository.QueryDeploymentListPageAsync (and -- the order DeploymentManagerRepository.QueryDeploymentListPageAsync asks for. (It named
-- QueryDeploymentSummariesAsync) asks for. With both key columns descending, a forward -- QueryDeploymentSummariesAsync too; register row 37 folded that second paged read into
-- QueryDeploymentListPageAsync, so both the UI and the CLI now share this one key.)
-- With both key columns descending, a forward
-- scan delivers the requested order, so a paged Skip/Take reads a Top-N off the index -- scan delivers the requested order, so a paged Skip/Take reads a Top-N off the index
-- instead of sorting the whole filtered set on every page request. -- instead of sorting the whole filtered set on every page request.
-- --
+1
View File
@@ -149,6 +149,7 @@ Central cluster only. Sites have no user interface.
- `IDeploymentManagerRepository.QueryDeploymentListPageAsync(filter, pageNumber, pageSize)` returns one page of `DeploymentListRow` — `DeploymentRecord` **inner-joined to `Instance`**, so the instance's display name and site travel with the rows that need them (the join is exact: the FK is `Restrict` and instance deletion removes the records first) — plus the **total count** of the filtered set. - `IDeploymentManagerRepository.QueryDeploymentListPageAsync(filter, pageNumber, pageSize)` returns one page of `DeploymentListRow` — `DeploymentRecord` **inner-joined to `Instance`**, so the instance's display name and site travel with the rows that need them (the join is exact: the FK is `Restrict` and instance deletion removes the records first) — plus the **total count** of the filtered set.
- `GetDeploymentStatusCountsAsync(filter)` returns the tile counts from **one grouped aggregation**. It deliberately **ignores the filter's `Status`** — the tiles are the status *breakdown* of the otherwise-identically-filtered set, so each keeps its own total while it is the selected one. - `GetDeploymentStatusCountsAsync(filter)` returns the tile counts from **one grouped aggregation**. It deliberately **ignores the filter's `Status`** — the tiles are the status *breakdown* of the otherwise-identically-filtered set, so each keeps its own total while it is the selected one.
- **Site scoping runs in the query.** The permitted-site grant is pushed in as `SiteIdScope` and resolved through the record's instance; an **empty** grant is a real filter matching nothing, never "unconstrained". `DeploymentRecord` has no `SiteId` of its own — the join is what makes this expressible. - **Site scoping runs in the query.** The permitted-site grant is pushed in as `SiteIdScope` and resolved through the record's instance; an **empty** grant is a real filter matching nothing, never "unconstrained". `DeploymentRecord` has no `SiteId` of its own — the join is what makes this expressible.
- **This is now the ONLY paged read of `DeploymentRecords`.** The CLI/HTTP `QueryDeployments` command had a second one (`QueryDeploymentSummariesAsync`: offset instead of page number, no total, no instance join, instance-id-list scoping); it is **deleted**, and both surfaces share this query. See `Component-ManagementService.md` → *QueryDeployments* for the three behavioral drifts the duplication had accumulated (deferred-work register rows 37, 86–88).
- **Offset paging, not the Audit Log's keyset cursor — deliberately.** This page's pager is numbered and jump-to-any-page, so it needs a page count, which only a total can give it; a keyset cursor can express neither. The total is required for the tiles regardless. The deep-offset cost that pushes high-volume tables to keyset is bounded here by the terminal-record retention purge, unlike the 365-day central `AuditLog`. This mirrors the Notification Outbox, which is offset-paged for the same reason. Ordering is `DeployedAt DESC, Id DESC`; the `Id` tie-break is load-bearing, because `DeployedAt` ties on rapid redeploys and an unstable sort key makes offset paging repeat or drop rows between pages. - **Offset paging, not the Audit Log's keyset cursor — deliberately.** This page's pager is numbered and jump-to-any-page, so it needs a page count, which only a total can give it; a keyset cursor can express neither. The total is required for the tiles regardless. The deep-offset cost that pushes high-volume tables to keyset is bounded here by the terminal-record retention purge, unlike the 365-day central `AuditLog`. This mirrors the Notification Outbox, which is offset-paged for the same reason. Ordering is `DeployedAt DESC, Id DESC`; the `Id` tie-break is load-bearing, because `DeployedAt` ties on rapid redeploys and an unstable sort key makes offset paging repeat or drop rows between pages.
- The whole-table `GetAllDeploymentRecordsAsync` read was **deleted** with its last caller. - The whole-table `GetAllDeploymentRecordsAsync` read was **deleted** with its last caller.
- **Filtering.** Status filter (the tiles double as the control — clicking one applies it, clicking it again clears) plus a free-text search matched DB-side against instance unique name, deployment id, revision hash and initiating user. A filter change resets to page 1. Search input is **trailing-edge debounced at 500 ms**, so a typed term is one query rather than one per keystroke. - **Filtering.** Status filter (the tiles double as the control — clicking one applies it, clicking it again clears) plus a free-text search matched DB-side against instance unique name, deployment id, revision hash and initiating user. A filter change resets to page 1. Search input is **trailing-edge debounced at 500 ms**, so a typed term is one query rather than one per keystroke.
@@ -147,7 +147,14 @@ The whole folder surface is reachable from the CLI as `template folder list|crea
- **DeployArtifacts**: Deploy system-wide artifacts (shared scripts, external system definitions, DB connections, data connections) to all sites or a specific site. The command's `SiteId` is honored (arch-review C2): a value routes to the single-site deploy path (`ArtifactDeploymentService.DeployToSiteAsync`), while `null` deploys fleet-wide (`DeployToAllSitesAsync`). Site scope is enforced — a site-scoped (non-Administrator) Deployer may only target a site within its `PermittedSiteIds`, and may **not** deploy fleet-wide (`SiteId is null` is rejected with `SiteScopeViolationException` → `ManagementUnauthorized`, since `EnforceSiteScope(null)` is a deliberate no-op); fleet-wide deployment requires a system-wide Deployer or Administrator. - **DeployArtifacts**: Deploy system-wide artifacts (shared scripts, external system definitions, DB connections, data connections) to all sites or a specific site. The command's `SiteId` is honored (arch-review C2): a value routes to the single-site deploy path (`ArtifactDeploymentService.DeployToSiteAsync`), while `null` deploys fleet-wide (`DeployToAllSitesAsync`). Site scope is enforced — a site-scoped (non-Administrator) Deployer may only target a site within its `PermittedSiteIds`, and may **not** deploy fleet-wide (`SiteId is null` is rejected with `SiteScopeViolationException` → `ManagementUnauthorized`, since `EnforceSiteScope(null)` is a deliberate no-op); fleet-wide deployment requires a system-wide Deployer or Administrator.
- **DeploySite** (`MgmtDeploySite`, WP2.5): Bulk-deploy every deployable instance at one site — see the Instances section above and `Component-DeploymentManager.md`. - **DeploySite** (`MgmtDeploySite`, WP2.5): Bulk-deploy every deployable instance at one site — see the Instances section above and `Component-DeploymentManager.md`.
- **GetDeploymentStatus**: Query deployment status. - **GetDeploymentStatus**: Query deployment status.
- **QueryDeployments** (`InstanceId?`, `Status?`, `Page`, `PageSize`): List deployment records. **All four arguments are honoured DB-side (WP2.5)** and the result is a `DeploymentRecordSummary` projection. The handler previously ignored `Status`, `Page` and `PageSize` entirely (the CLI had been sending them all along) and loaded the whole insert-only `DeploymentRecords` table; for a site-scoped user it additionally loaded every instance and intersected the two sets in memory. Site scope is now resolved to the set of in-scope instance ids and pushed into the query as an id filter — an **empty** scope stays a real filter (a user permitted no in-scope instances sees nothing), never a no-op. This read path also drives the opportunistic, rate-limited terminal-record retention sweep described in `Component-DeploymentManager.md`. - **QueryDeployments** (`InstanceId?`, `Status?`, `Page`, `PageSize`): List deployment records. **All four arguments are honoured DB-side (WP2.5)** and the result is a `DeploymentQueryResult` envelope — `{ deployments, totalCount, page, pageSize }` over `DeploymentListRow`. The handler previously ignored `Status`, `Page` and `PageSize` entirely (the CLI had been sending them all along) and loaded the whole insert-only `DeploymentRecords` table; for a site-scoped user it additionally loaded every instance and intersected the two sets in memory. This read path also drives the opportunistic, rate-limited terminal-record retention sweep described in `Component-DeploymentManager.md`.
**One paged read, three drifts closed (deferred-work register rows 37, 86–88).** This handler used to own a second repository query, `QueryDeploymentSummariesAsync`, alongside the Central UI page's `QueryDeploymentListPageAsync`. That query is **deleted**; both surfaces now go through `QueryDeploymentListPageAsync` with a `DeploymentListFilter`, and the `DeploymentRecordSummary` projection went with it (`DeploymentListRow` is the single row shape). The duplication had drifted into three behavioral differences, all resolved here:
- **Site scope is a SITE-ID filter, resolved by the query's own join to `Instance`** (row 87). It used to be resolved in the handler by loading **every instance in the system** on every scoped query, purely to turn the site grant into an instance-id list — a second whole-table read, non-transactional with the one that mattered. An **empty** scope remains a real filter (a user permitted no sites sees nothing), never a no-op, and a permitted-site id that is not an integer is **dropped rather than ignored** — dropping can only narrow the grant, whereas discarding the grant would widen it to fleet-wide.
- **`PageSize` is bounded** (row 87). A non-positive value used to mean *unlimited* all the way down to the repository, so `--page-size 0` materialized the entire retention window into one response. It now means "unspecified" and takes `DeploymentListPage.DefaultPageSize` (50, the CLI's own default); every value is capped at `DeploymentListPage.MaxPageSize` (1000). `totalCount` travels with the bounded page precisely so a truncated page is distinguishable from a complete one.
- **An unrecognised `Status` is an ERROR, not "no filter"** (row 88). `--status Sucess` used to fall back to unfiltered and return every status — a plausible, complete-looking answer to a question the caller never asked. It now returns `ManagementError` naming the valid values (`Pending, InProgress, Success, Failed`). Parsing stays case-insensitive; a numeric string is rejected too (`Enum.TryParse` accepts `"7"` and yields an undefined value, so the parse is guarded by `Enum.IsDefined`). Same loud-failure principle as register row 46.
**Administrator is system-wide** on this path, as on every other site-scoped handler (`EnforceSiteScope`). The Central UI's `SiteScopeService` was claims-only and disagreed — an Administrator carrying `SiteId` claims saw the fleet here and only their sites on the Deployments page. The UI was corrected to match (register row 86); see `Component-CentralUI.md`.
### Secured Writes (MxGateway, two-person) ### Secured Writes (MxGateway, two-person)
+1
View File
@@ -116,6 +116,7 @@ Set in a local or docker-dev environment via the environment variable `ScadaBrid
- Monitor and manage the Notification Outbox (retry and discard parked notifications). - Monitor and manage the Notification Outbox (retry and discard parked notifications).
- View site event logs. - View site event logs.
- **Site scoping**: A user with site-scoped Deployer role can only perform these actions for instances at their permitted sites. - **Site scoping**: A user with site-scoped Deployer role can only perform these actions for instances at their permitted sites.
- **Administrator overrides site scoping.** A user who holds `Administrator` is system-wide **regardless of the `SiteId` claims they carry** — a scoped Deployer grant does not narrow an Administrator. This is enforced identically on both surfaces: `ManagementActor.EnforceSiteScope` (CLI / HTTP management API) and the Central UI's `SiteScopeService`. The two disagreed until deferred-work register row 86 — the UI was claims-only, so the same user saw the whole fleet through the CLI and only their sites in the UI. **No other role carries this bypass**; in particular `Designer` does not (Designer has no site-scoped permissions to bypass in the first place).
> **Area management (arch-review C6)**: Managing area definitions per site is gated **any-of [Designer, Deployer]**, not Administrator. Areas are authored both in the Designer tooling and inside the Central UI Deployment Topology workflow (`Topology.razor` is `RequireDeployment`), so either role qualifies; Administrator is not implicitly included in the actor gate. > **Area management (arch-review C6)**: Managing area definitions per site is gated **any-of [Designer, Deployer]**, not Administrator. Areas are authored both in the Designer tooling and inside the Central UI Deployment Topology workflow (`Topology.razor` is `RequireDeployment`), so either role qualifies; Administrator is not implicitly included in the actor gate.
@@ -94,10 +94,18 @@ public static class DeployCommands
private static Command BuildStatus(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption) private static Command BuildStatus(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{ {
var instanceIdOption = new Option<int?>("--instance-id") { Description = "Filter by instance ID" }; var instanceIdOption = new Option<int?>("--instance-id") { Description = "Filter by instance ID" };
var statusOption = new Option<string?>("--status") { Description = "Filter by status" }; // The valid set is named in the help text rather than enforced with
// AcceptOnlyFromAmong: that check is case-SENSITIVE, and the server has
// always accepted `--status success`. The server rejects an unrecognised
// value with the same list (register row 88) and is the authoritative
// check — HTTP API callers never pass through this option at all.
var statusOption = new Option<string?>("--status")
{
Description = "Filter by status (Pending, InProgress, Success, Failed; case-insensitive)",
};
var pageOption = new Option<int>("--page") { Description = "Page number" }; var pageOption = new Option<int>("--page") { Description = "Page number" };
pageOption.DefaultValueFactory = _ => 1; pageOption.DefaultValueFactory = _ => 1;
var pageSizeOption = new Option<int>("--page-size") { Description = "Page size" }; var pageSizeOption = new Option<int>("--page-size") { Description = "Page size (1-1000; 0 or unset uses 50)" };
pageSizeOption.DefaultValueFactory = _ => 50; pageSizeOption.DefaultValueFactory = _ => 50;
var cmd = new Command("status") { Description = "Query deployment status" }; var cmd = new Command("status") { Description = "Query deployment status" };
@@ -113,7 +121,8 @@ public static class DeployCommands
var pageSize = result.GetValue(pageSizeOption); var pageSize = result.GetValue(pageSizeOption);
return await CommandHelpers.ExecuteCommandAsync( return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, result, urlOption, formatOption, usernameOption, passwordOption,
new QueryDeploymentsCommand(instanceId, status, page, pageSize)); new QueryDeploymentsCommand(instanceId, status, page, pageSize),
tableProjector: DeploymentStatusTableProjection.ProjectRows);
}); });
return cmd; return cmd;
} }
@@ -0,0 +1,120 @@
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;
}
}
+16 -2
View File
@@ -1011,9 +1011,23 @@ scadabridge --url <url> deploy status [--instance-id <int>] [--status <string>]
| Option | Required | Default | Description | | Option | Required | Default | Description |
|--------|----------|---------|-------------| |--------|----------|---------|-------------|
| `--instance-id` | no | — | Filter by instance ID | | `--instance-id` | no | — | Filter by instance ID |
| `--status` | no | — | Filter by deployment status string | | `--status` | no | — | Filter by deployment status: `Pending`, `InProgress`, `Success`, `Failed` (case-insensitive) |
| `--page` | no | `1` | Page number | | `--page` | no | `1` | Page number |
| `--page-size` | no | `50` | Results per page | | `--page-size` | no | `50` | Results per page; capped at `1000`. `0` means "unspecified" and takes the default — it does **not** mean unlimited |
Output is an envelope: `deployments` (the page of rows, newest first), `totalCount`
(rows matching the filter across all pages), plus the `page` and `pageSize` the server
actually applied. `--format table` renders the rows and appends a `showing N of M`
note when the page is a strict subset.
An unrecognised `--status` is an **error** naming the valid values, not a silent
fallback to "all statuses" — a typo like `--status Sucess` used to return every
deployment record, which looks exactly like a complete answer to the question you
meant to ask.
Site scoping: a site-scoped Deployer sees only deployments for instances at their
permitted sites. A user holding `Administrator` is system-wide regardless of any site
grant, matching the Central UI.
--- ---
@@ -6,14 +6,26 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
/// <summary> /// <summary>
/// Resolves the set of sites the current user is permitted to operate on, from /// Resolves the set of sites the current user is permitted to operate on, from
/// the <c>SiteId</c> claims attached at login. /// the roles and <c>SiteId</c> claims attached at login.
/// <para> /// <para>
/// The design (Component-CentralUI, CLAUDE.md "Security &amp; Auth") makes the /// The design (Component-CentralUI, CLAUDE.md "Security &amp; Auth") makes the
/// Deployment role site-scoped: a Deployment user mapped through an LDAP group /// Deployment role site-scoped: a Deployment user mapped through an LDAP group
/// with site-scope rules carries one <see cref="JwtTokenService.SiteIdClaimType"/> /// with site-scope rules carries one <see cref="JwtTokenService.SiteIdClaimType"/>
/// claim per permitted site (the claim value is the integer <c>Site.Id</c>). /// claim per permitted site (the claim value is the integer <c>Site.Id</c>).
/// A Deployment user with no <c>SiteId</c> claim — and any Admin/Design user — is /// A user with no <c>SiteId</c> claim is system-wide, and so is any user holding
/// system-wide. /// the <see cref="Roles.Administrator"/> role — regardless of the <c>SiteId</c>
/// claims they carry.
/// </para>
/// <para>
/// <b>The Administrator bypass is not a convenience, it is the agreed semantic on
/// the other side of the same rule.</b> <c>ManagementActor</c> has always exempted
/// Administrator from site scoping (<c>EnforceSiteScope</c> and every handler that
/// calls it), so a user holding Administrator ALONGSIDE a scoped Deployer grant
/// could act fleet-wide through the CLI/HTTP surface while this service hid the
/// same sites in the UI — the UI was the side that disagreed with the documented
/// rule (deferred-work register row 86). Note that Designer is NOT exempt: the
/// enforced rule has only ever bypassed Administrator, and this doc used to claim
/// "any Admin/Design user" is system-wide, which was never true of either side.
/// </para> /// </para>
/// <para> /// <para>
/// Deployment and Monitoring pages must filter every site/instance list through /// Deployment and Monitoring pages must filter every site/instance list through
@@ -35,8 +47,9 @@ public sealed class SiteScopeService
} }
/// <summary> /// <summary>
/// True when the user is not restricted to a site subset (no <c>SiteId</c> /// True when the user is not restricted to a site subset — either because they
/// claims). System-wide users see and act on every site. /// hold <see cref="Roles.Administrator"/> or because they carry no
/// <c>SiteId</c> claims. System-wide users see and act on every site.
/// </summary> /// </summary>
/// <returns>A task that resolves to <c>true</c> if the user has no site-scope restriction.</returns> /// <returns>A task that resolves to <c>true</c> if the user has no site-scope restriction.</returns>
public async Task<bool> IsSystemWideAsync() public async Task<bool> IsSystemWideAsync()
@@ -83,19 +96,36 @@ public sealed class SiteScopeService
return cached; return cached;
var state = await _authStateProvider.GetAuthenticationStateAsync(); var state = await _authStateProvider.GetAuthenticationStateAsync();
var siteClaims = state.User.FindAll(JwtTokenService.SiteIdClaimType);
// Administrator => system-wide, whatever SiteId claims are present. Read
// the role CLAIM rather than IsInRole: the principal's role claim type is
// only bound to JwtTokenService.RoleClaimType when the identity was built
// by SessionClaimBuilder, and the comparison is OrdinalIgnoreCase to match
// ManagementActor's — IsInRole compares claim VALUES ordinally, which would
// make the two sides disagree on a differently-cased role name.
var isAdministrator = state.User
.FindAll(JwtTokenService.RoleClaimType)
.Any(c => string.Equals(c.Value, Roles.Administrator, StringComparison.OrdinalIgnoreCase));
var ids = new HashSet<int>(); var ids = new HashSet<int>();
foreach (var claim in siteClaims) if (!isAdministrator)
{
// An Administrator's SiteId claims are not merely overridden, they are
// not collected at all: the invariant "system-wide => empty set" then
// holds for every caller, so a consumer that reads the id set without
// first consulting IsSystemWide cannot silently narrow an Administrator
// to a subset of sites.
foreach (var claim in state.User.FindAll(JwtTokenService.SiteIdClaimType))
{ {
if (int.TryParse(claim.Value, out var id)) if (int.TryParse(claim.Value, out var id))
ids.Add(id); ids.Add(id);
} }
}
// No SiteId claims => system-wide. Absence of scope rules means an // No SiteId claims => system-wide. Absence of scope rules means an
// unrestricted deployer (this service is the sole // unrestricted deployer (this service is the sole
// site-scoping mechanism — there is no separate handler to mirror). // site-scoping mechanism — there is no separate handler to mirror).
var result = (IsSystemWide: ids.Count == 0, Sites: (IReadOnlySet<int>)ids); var result = (IsSystemWide: isAdministrator || ids.Count == 0, Sites: (IReadOnlySet<int>)ids);
_cached = result; _cached = result;
return result; return result;
} }
@@ -16,8 +16,21 @@ public interface IDeploymentManagerRepository
/// <returns>The deployment record, or null if not found.</returns> /// <returns>The deployment record, or null if not found.</returns>
Task<DeploymentRecord?> GetDeploymentRecordByIdAsync(int id, CancellationToken cancellationToken = default); Task<DeploymentRecord?> GetDeploymentRecordByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Database-side paged, filtered and instance-joined deployment listing — /// Database-side paged, filtered and instance-joined deployment listing — THE
/// the read path behind the Central UI deployment-status page. /// paged read of <c>DeploymentRecords</c>, shared by the Central UI
/// deployment-status page and the CLI/HTTP <c>QueryDeployments</c> command.
///
/// <para>
/// It was two reads until deferred-work register row 37. The other one
/// (<c>QueryDeploymentSummariesAsync</c>) differed in three ways that were all
/// defects rather than requirements: it took a raw <c>skip</c> instead of a page
/// number, returned no total (so a truncated page was indistinguishable from a
/// complete one), skipped the <c>Instance</c> join, and scoped by a caller-resolved
/// INSTANCE-ID LIST — which its one caller built by loading every instance in the
/// system on every scoped query. Site scoping is expressed here as
/// <see cref="Types.Deployment.DeploymentListFilter.SiteIdScope"/> and resolved by
/// the join, so no caller needs that read.
/// </para>
/// ///
/// <para> /// <para>
/// Replaces a <c>GetAllDeploymentRecords</c> whole-table read. That method's only /// Replaces a <c>GetAllDeploymentRecords</c> whole-table read. That method's only
@@ -50,7 +63,7 @@ public interface IDeploymentManagerRepository
/// </summary> /// </summary>
/// <param name="filter">Status / search / site-scope / instance constraints; all applied DB-side.</param> /// <param name="filter">Status / search / site-scope / instance constraints; all applied DB-side.</param>
/// <param name="pageNumber">1-based page number; values below 1 are floored at 1.</param> /// <param name="pageNumber">1-based page number; values below 1 are floored at 1.</param>
/// <param name="pageSize">Rows per page; clamped to <c>[1, DeploymentRecordSummary.MaxPageSize]</c>.</param> /// <param name="pageSize">Rows per page; clamped to <c>[1, DeploymentListPage.MaxPageSize]</c>. There is no "unlimited" value.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param> /// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>The requested page of rows plus the total count of the filtered set.</returns> /// <returns>The requested page of rows plus the total count of the filtered set.</returns>
Task<Types.Deployment.DeploymentListPage> QueryDeploymentListPageAsync( Task<Types.Deployment.DeploymentListPage> QueryDeploymentListPageAsync(
@@ -80,31 +93,6 @@ public interface IDeploymentManagerRepository
Types.Deployment.DeploymentListFilter filter, Types.Deployment.DeploymentListFilter filter,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Database-side paged and filtered deployment query returning row summaries.
/// Backs the <c>QueryDeployments</c> management command, which previously
/// loaded the ENTIRE deployment-record table (insert-only, one row per deploy
/// attempt for the life of the system) and ignored the page arguments the CLI
/// was already sending.
/// </summary>
/// <param name="instanceId">Restrict to one instance's history, or <see langword="null"/> for all.</param>
/// <param name="status">Restrict to one deployment status, or <see langword="null"/> for all.</param>
/// <param name="instanceIdScope">
/// When non-null, restrict to these instance ids. Used to apply a site-scoped
/// user's permitted-site filter IN THE DATABASE rather than by loading every
/// record and every instance and filtering in memory.
/// </param>
/// <param name="skip">Rows to skip; negative values are floored at 0.</param>
/// <param name="take">Page size; non-positive or null returns every remaining row.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>One page of deployment summaries, newest first.</returns>
Task<IReadOnlyList<Types.Deployment.DeploymentRecordSummary>> QueryDeploymentSummariesAsync(
int? instanceId,
DeploymentStatus? status,
IReadOnlyCollection<int>? instanceIdScope,
int skip,
int? take,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes TERMINAL deployment records (<see cref="DeploymentStatus.Success"/> / /// Deletes TERMINAL deployment records (<see cref="DeploymentStatus.Success"/> /
/// <see cref="DeploymentStatus.Failed"/>) that completed before /// <see cref="DeploymentStatus.Failed"/>) that completed before
/// <paramref name="cutoffUtc"/>, in one bounded batch. /// <paramref name="cutoffUtc"/>, in one bounded batch.
@@ -23,4 +23,36 @@ public record MgmtDeployArtifactsCommand(int? SiteId = null);
/// <param name="SiteId">Database id of the target site. Required — this command is never fleet-wide.</param> /// <param name="SiteId">Database id of the target site. Required — this command is never fleet-wide.</param>
public record MgmtDeploySiteCommand(int SiteId); public record MgmtDeploySiteCommand(int SiteId);
public record QueryDeploymentsCommand(int? InstanceId = null, string? Status = null, int Page = 1, int PageSize = 50); public record QueryDeploymentsCommand(int? InstanceId = null, string? Status = null, int Page = 1, int PageSize = 50);
/// <summary>
/// Result wrapper for <see cref="QueryDeploymentsCommand"/>.
///
/// <para>
/// The handler used to return a bare row array. It cannot any more: the page size
/// is now BOUNDED (a non-positive <c>PageSize</c> no longer means "unlimited" —
/// deferred-work register row 87), and a bounded page without a total is a silent
/// truncation. <see cref="TotalCount"/> is what lets a caller tell "that is all of
/// them" from "there is more, ask for page 2". Mirrors
/// <c>AuditLogQueryResult</c> / <c>SecuredWriteListResult</c>, the other paged
/// management query results.
/// </para>
///
/// <para>
/// <see cref="Page"/> and <see cref="PageSize"/> echo the values the server
/// actually applied, which are not necessarily the ones requested: both are
/// floored/clamped/defaulted server-side, so the echo is the only way a caller
/// learns the effective slice. Additive contract; new fields go on the end with a
/// default.
/// </para>
/// </summary>
/// <param name="Deployments">The requested page of deployment rows, newest first.</param>
/// <param name="TotalCount">Total rows matching the filter across all pages.</param>
/// <param name="Page">The 1-based page number actually served.</param>
/// <param name="PageSize">The page size actually applied.</param>
public record DeploymentQueryResult(
IReadOnlyList<Types.Deployment.DeploymentListRow> Deployments,
int TotalCount,
int Page,
int PageSize);
public record GetDeploymentDiffCommand(int InstanceId); public record GetDeploymentDiffCommand(int InstanceId);
@@ -19,6 +19,24 @@ public sealed record DeploymentListPage(
IReadOnlyList<DeploymentListRow> Rows, IReadOnlyList<DeploymentListRow> Rows,
int TotalCount) int TotalCount)
{ {
/// <summary>
/// Hard ceiling applied to a requested page size by every caller of the paged
/// deployment read. Lives on the page contract because the page is the single
/// shape that read now has: the older <c>DeploymentRecordSummary</c> projection
/// (which owned this constant, and whose query accepted an unlimited
/// <c>take</c>) was folded into this one by deferred-work register row 37.
/// </summary>
public const int MaxPageSize = 1000;
/// <summary>
/// Page size used when a caller supplies none (or a non-positive one). Matches
/// the CLI's own <c>--page-size</c> default, so <c>deploy status</c> with no
/// paging flags behaves identically whether the value travels on the wire or
/// is defaulted here (register row 87 — a non-positive size used to mean
/// "unlimited" and materialized the whole retention window).
/// </summary>
public const int DefaultPageSize = 50;
/// <summary>An empty page — no rows, no matches.</summary> /// <summary>An empty page — no rows, no matches.</summary>
public static DeploymentListPage Empty { get; } = new(Array.Empty<DeploymentListRow>(), 0); public static DeploymentListPage Empty { get; } = new(Array.Empty<DeploymentListRow>(), 0);
@@ -3,8 +3,11 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment; namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
/// <summary> /// <summary>
/// One rendered row of the Central UI deployment-status list: a /// One row of a deployment listing: a <c>DeploymentRecord</c> projection already
/// <c>DeploymentRecord</c> projection already joined to its <c>Instance</c>. /// joined to its <c>Instance</c>. THE row projection — the Central UI
/// deployment-status page and the CLI/HTTP <c>QueryDeployments</c> command share
/// it, having previously had a projection each (deferred-work register row 37;
/// the CLI-side <c>DeploymentRecordSummary</c> was the one that went).
/// ///
/// <para> /// <para>
/// The join is what removes the page's SECOND full-table read. Rendering a row /// The join is what removes the page's SECOND full-table read. Rendering a row
@@ -14,7 +17,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// Like <see cref="DeploymentRecordSummary"/>, the <c>RowVersion</c> /// The <c>RowVersion</c>
/// optimistic-concurrency token is deliberately absent — this is a READ projection /// optimistic-concurrency token is deliberately absent — this is a READ projection
/// and must never be mistaken for something that can be written back. Mutating /// and must never be mistaken for something that can be written back. Mutating
/// paths keep loading the tracked entity. /// paths keep loading the tracked entity.
@@ -1,54 +0,0 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
/// <summary>
/// Row-shaped projection of a <c>DeploymentRecord</c> for list surfaces (CLI
/// <c>deploy list</c> / <c>instance</c> history, the Central UI deployment-status
/// page).
///
/// <para>
/// Exists so a deployment listing can be paged and filtered in the DATABASE.
/// Deployment records are insert-only — one row per deploy attempt, retained for
/// the configured retention window — so the previous "load the whole table, then
/// filter in memory" read path degraded with the age of the system rather than
/// with the size of the requested page.
/// </para>
///
/// <para>
/// The <c>RowVersion</c> optimistic-concurrency token is deliberately absent: this
/// is a READ projection, and a summary must never be mistaken for something that
/// can be written back. Mutating paths keep loading the tracked entity.
/// </para>
///
/// <para>
/// Message-contract evolution rule: additive-only. New fields go on the end with a
/// default.
/// </para>
/// </summary>
/// <param name="Id">Deployment record row id.</param>
/// <param name="DeploymentId">The logical deployment id (GUID, "N" format).</param>
/// <param name="InstanceId">Instance the deployment targeted.</param>
/// <param name="Status">Terminal or in-flight deployment status.</param>
/// <param name="RevisionHash">Revision hash of the deployed configuration.</param>
/// <param name="DeployedBy">User who initiated the deployment.</param>
/// <param name="DeployedAt">When the deployment was initiated.</param>
/// <param name="CompletedAt">When the deployment reached a terminal status, if it has.</param>
/// <param name="ErrorMessage">Failure detail when the deployment did not succeed.</param>
public record DeploymentRecordSummary(
int Id,
string DeploymentId,
int InstanceId,
DeploymentStatus Status,
string? RevisionHash,
string DeployedBy,
DateTimeOffset DeployedAt,
DateTimeOffset? CompletedAt,
string? ErrorMessage)
{
/// <summary>
/// Hard ceiling applied to a requested page size, mirroring the in-memory
/// <c>Page</c> clamp the management actor already applies elsewhere.
/// </summary>
public const int MaxPageSize = 1000;
}
@@ -41,8 +41,9 @@ public class DeploymentRecordConfiguration : IEntityTypeConfiguration<Deployment
builder.HasIndex(d => d.InstanceId); builder.HasIndex(d => d.InstanceId);
// Deployment-list sort key. Every list read orders DeployedAt DESC, Id DESC // Deployment-list sort key. Every list read orders DeployedAt DESC, Id DESC
// (DeploymentManagerRepository.QueryDeploymentListPageAsync and // (DeploymentManagerRepository.QueryDeploymentListPageAsync, which register
// QueryDeploymentSummariesAsync) — Id is the deterministic tie-break, because // row 37 made the single paged read behind BOTH the Central UI page and the
// CLI/HTTP QueryDeployments command) — Id is the deterministic tie-break, because
// a bulk site deploy stamps many rows in the same DeployedAt instant and a // a bulk site deploy stamps many rows in the same DeployedAt instant and a
// non-deterministic order would let a row repeat or vanish across pages. // non-deterministic order would let a row repeat or vanish across pages.
// Declaring both key columns descending lets a forward scan feed the paged // Declaring both key columns descending lets a forward scan feed the paged
@@ -115,7 +115,7 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var page = Math.Max(1, pageNumber); var page = Math.Max(1, pageNumber);
var size = Math.Clamp(pageSize, 1, DeploymentRecordSummary.MaxPageSize); var size = Math.Clamp(pageSize, 1, DeploymentListPage.MaxPageSize);
var query = BuildListQuery(filter, applyStatus: true); var query = BuildListQuery(filter, applyStatus: true);
@@ -396,58 +396,6 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
return expired.Count; return expired.Count;
} }
/// <inheritdoc />
public async Task<IReadOnlyList<DeploymentRecordSummary>> QueryDeploymentSummariesAsync(
int? instanceId,
DeploymentStatus? status,
IReadOnlyCollection<int>? instanceIdScope,
int skip,
int? take,
CancellationToken cancellationToken = default)
{
var query = _dbContext.DeploymentRecords.AsNoTracking();
if (instanceId.HasValue)
query = query.Where(d => d.InstanceId == instanceId.Value);
if (status.HasValue)
query = query.Where(d => d.Status == status.Value);
if (instanceIdScope != null)
{
// Site scoping applied DB-side. An empty scope is a real, meaningful
// filter — a user permitted no in-scope instances sees nothing — so it
// must not be short-circuited into "no filter".
var scope = instanceIdScope as int[] ?? instanceIdScope.ToArray();
query = query.Where(d => scope.Contains(d.InstanceId));
}
// ThenByDescending(Id) for the same reason GetCurrentDeploymentStatusAsync
// does it: DeployedAt ties on rapid redeploys, and an unstable sort key
// makes Skip/Take paging non-deterministic (rows repeat or vanish between
// pages).
var paged = query
.OrderByDescending(d => d.DeployedAt)
.ThenByDescending(d => d.Id)
.Skip(Math.Max(0, skip));
if (take is > 0)
paged = paged.Take(Math.Min(take.Value, DeploymentRecordSummary.MaxPageSize));
return await paged
.Select(d => new DeploymentRecordSummary(
d.Id,
d.DeploymentId,
d.InstanceId,
d.Status,
d.RevisionHash,
d.DeployedBy,
d.DeployedAt,
d.CompletedAt,
d.ErrorMessage))
.ToListAsync(cancellationToken);
}
/// <inheritdoc /> /// <inheritdoc />
public async Task<int> PurgeTerminalDeploymentRecordsAsync( public async Task<int> PurgeTerminalDeploymentRecordsAsync(
DateTimeOffset cutoffUtc, DateTimeOffset cutoffUtc,
@@ -25,6 +25,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery; using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
@@ -2558,53 +2559,112 @@ public class ManagementActor : ReceiveActor
if (sp.GetService<DeploymentService>() is { } deploymentService) if (sp.GetService<DeploymentService>() is { } deploymentService)
await deploymentService.TryPurgeTerminalDeploymentRecordsAsync(); await deploymentService.TryPurgeTerminalDeploymentRecordsAsync();
// Page and Status are honoured DB-side. Both arrived on the command // Page, PageSize and Status are all honoured DB-side, through the SAME
// contract (and the CLI has been sending --page / --page-size / --status // paged read the Central UI deployment page uses (register row 37 — there
// all along) but the handler previously ignored all three and returned the // used to be a second, subtly different repository query behind this
// whole table. // handler alone).
var status = ParseDeploymentStatus(cmd.Status); var status = ParseDeploymentStatus(cmd.Status);
var skip = Math.Max(0, (Math.Max(1, cmd.Page) - 1) * Math.Max(1, cmd.PageSize)); var page = Math.Max(1, cmd.Page);
var take = cmd.PageSize > 0 ? cmd.PageSize : (int?)null; var pageSize = ResolveDeploymentPageSize(cmd.PageSize);
// Instance-scoped query: enforce against the target instance's site // Instance-scoped query: enforce against the target instance's site
// before reading its deployment history. // before reading its deployment history.
if (cmd.InstanceId.HasValue) if (cmd.InstanceId.HasValue)
{ {
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId.Value); await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId.Value);
return await repo.QueryDeploymentSummariesAsync( return await QueryDeploymentPage(
cmd.InstanceId.Value, status, instanceIdScope: null, skip, take); repo, new DeploymentListFilter(status, InstanceId: cmd.InstanceId.Value), page, pageSize);
} }
// Unfiltered query: a site-scoped Deployment user must only see records // Unfiltered query: a site-scoped Deployment user must only see records
// for instances at sites within their scope. DeploymentRecord has no // for instances at sites within their scope. DeploymentRecord has no
// SiteId, so the scope is resolved to the set of in-scope instance ids and // SiteId of its own, so the scope rides as a SITE-ID filter resolved by the
// pushed into the query as an id filter — rather than loading every record // query's own join to Instance. It used to be resolved here instead, by
// AND every instance and intersecting them in memory, which made an // loading EVERY instance in the system on every scoped query to turn the
// unfiltered listing cost two full-table loads regardless of page size. // site grant into an instance-id list (register row 87).
if (user.PermittedSiteIds.Length == 0 || user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) var siteScope = ResolveSiteIdScope(user);
return await repo.QueryDeploymentSummariesAsync(null, status, instanceIdScope: null, skip, take); return await QueryDeploymentPage(
repo, new DeploymentListFilter(status, SiteIdScope: siteScope), page, pageSize);
var permittedIds = new HashSet<string>(user.PermittedSiteIds);
var templateRepo = sp.GetRequiredService<ITemplateEngineRepository>();
var allInstances = await templateRepo.GetAllInstancesAsync();
var scopedInstanceIds = allInstances
.Where(i => permittedIds.Contains(i.SiteId.ToString()))
.Select(i => i.Id)
.ToArray();
return await repo.QueryDeploymentSummariesAsync(null, status, scopedInstanceIds, skip, take);
} }
/// <summary> /// <summary>
/// Parses the wire <c>Status</c> filter into a <see cref="DeploymentStatus"/>. /// Runs the shared paged deployment read and wraps it in the command's result
/// An unparseable value is treated as "no filter" rather than an error, which /// envelope, echoing the page coordinates that were actually applied.
/// preserves the previous handler's behaviour of ignoring the field entirely.
/// </summary> /// </summary>
private static DeploymentStatus? ParseDeploymentStatus(string? status) => private static async Task<DeploymentQueryResult> QueryDeploymentPage(
!string.IsNullOrWhiteSpace(status) && Enum.TryParse<DeploymentStatus>(status, ignoreCase: true, out var parsed) IDeploymentManagerRepository repo, DeploymentListFilter filter, int page, int pageSize)
? parsed {
: null; var result = await repo.QueryDeploymentListPageAsync(filter, page, pageSize);
return new DeploymentQueryResult(result.Rows, result.TotalCount, page, pageSize);
}
/// <summary>
/// Resolves the caller's site-scope grant into the query's
/// <see cref="DeploymentListFilter.SiteIdScope"/>: <see langword="null"/> for a
/// system-wide caller, otherwise the permitted site ids.
/// </summary>
/// <remarks>
/// Administrator is system-wide here, matching <see cref="EnforceSiteScope"/>
/// and the rest of this actor. A permitted-site id that is not an integer is
/// DROPPED rather than ignored: dropping can only narrow the grant, whereas
/// treating the whole grant as unparseable would widen it to fleet-wide. A
/// grant of nothing but garbage therefore yields an EMPTY scope, which the
/// filter treats as a real "sees nothing" constraint.
/// </remarks>
private static IReadOnlyCollection<int>? ResolveSiteIdScope(AuthenticatedUser user)
{
if (user.PermittedSiteIds.Length == 0) return null;
if (user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) return null;
var ids = new List<int>(user.PermittedSiteIds.Length);
foreach (var raw in user.PermittedSiteIds)
{
if (int.TryParse(raw, out var id))
ids.Add(id);
}
return ids;
}
/// <summary>
/// Bounds the requested deployment page size: a non-positive value means
/// "unspecified" and takes the default, and every value is capped at
/// <see cref="DeploymentListPage.MaxPageSize"/>.
/// </summary>
/// <remarks>
/// Register row 87. A non-positive size used to mean UNLIMITED all the way down
/// to the repository, so <c>--page-size 0</c> materialized the entire
/// terminal-record retention window into one response. The result envelope
/// carries the total count precisely so bounding the page does not hide rows.
/// </remarks>
private static int ResolveDeploymentPageSize(int requested) =>
requested <= 0
? DeploymentListPage.DefaultPageSize
: Math.Min(requested, DeploymentListPage.MaxPageSize);
/// <summary>
/// Parses the wire <c>Status</c> filter into a <see cref="DeploymentStatus"/>.
/// Absent/blank means "no filter"; an unrecognised value is an ERROR naming the
/// valid ones.
/// </summary>
/// <remarks>
/// Register row 88. An unparseable value used to fall back to "no filter", so
/// <c>--status Sucess</c> silently returned every status — the caller saw a
/// plausible, complete-looking result that did not answer the question they
/// asked. Same loud-failure principle as row 46.
/// </remarks>
/// <exception cref="ManagementCommandException">The value is not a deployment status.</exception>
private static DeploymentStatus? ParseDeploymentStatus(string? status)
{
if (string.IsNullOrWhiteSpace(status)) return null;
if (Enum.TryParse<DeploymentStatus>(status, ignoreCase: true, out var parsed)
&& Enum.IsDefined(parsed))
{
return parsed;
}
throw new ManagementCommandException(
$"Unknown deployment status '{status}'. Valid values: {string.Join(", ", Enum.GetNames<DeploymentStatus>())}.");
}
// ======================================================================== // ========================================================================
// Audit Log handler // Audit Log handler
@@ -0,0 +1,89 @@
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// <summary>
/// Tests for the <c>deploy status</c> table projection (deferred-work register rows
/// 37/87). The handler now returns a <see cref="DeploymentQueryResult"/> OBJECT where
/// it used to return a bare row array; without a projector the generic renderer takes
/// its Property/Value branch and prints the whole <c>deployments</c> array as raw JSON
/// inside one cell. The projection unwraps the envelope and keeps the total visible as
/// a trailing note — which matters more here than for the audit query, because the
/// page size is bounded server-side and a full page is no longer proof there is
/// nothing more.
/// </summary>
public class DeploymentStatusTableProjectionTests
{
/// <summary>
/// The real server shape, serialised from the REAL <see cref="DeploymentQueryResult"/>
/// / <see cref="DeploymentListRow"/> types rather than hand-written JSON, so renaming
/// one of their properties fails this test instead of silently blanking a column.
/// </summary>
private static string RealPayload(int rowCount, int totalCount) =>
JsonSerializer.Serialize(
new DeploymentQueryResult(
Enumerable.Range(1, rowCount).Select(i => new DeploymentListRow(
i, $"dep-{i:D3}", i, $"Line3.Pump{i}", 2, DeploymentStatus.Success,
$"rev-{i}", "alice",
new DateTimeOffset(2026, 8, 15, 12, 0, 0, TimeSpan.Zero), null, null)).ToList(),
totalCount,
Page: 1,
PageSize: rowCount),
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
[Fact]
public void Projection_UnwrapsTheEnvelopeToARowArray()
{
var projected = DeploymentStatusTableProjection.ProjectRows(RealPayload(2, 2));
using var doc = JsonDocument.Parse(projected);
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.Equal(2, doc.RootElement.GetArrayLength());
Assert.Equal("dep-001", doc.RootElement[0].GetProperty("deploymentId").GetString());
Assert.Equal("Line3.Pump1", doc.RootElement[0].GetProperty("instanceUniqueName").GetString());
}
[Fact]
public void Projection_WhenPageIsAStrictSubset_AppendsATotalNote()
{
var projected = DeploymentStatusTableProjection.ProjectRows(RealPayload(2, 57));
using var doc = JsonDocument.Parse(projected);
Assert.Equal(3, doc.RootElement.GetArrayLength());
var note = doc.RootElement[2].GetProperty("note").GetString();
Assert.Contains("showing 2 of 57", note);
Assert.Contains("--page-size", note);
}
[Fact]
public void Projection_WhenPageIsTheWholeResult_EmitsNoNoteColumn()
{
var projected = DeploymentStatusTableProjection.ProjectRows(RealPayload(2, 2));
using var doc = JsonDocument.Parse(projected);
Assert.All(doc.RootElement.EnumerateArray(),
e => Assert.False(e.TryGetProperty("note", out _)));
}
[Fact]
public void Projection_AcceptsABareRowArray_FromAPreEnvelopeServer()
{
var projected = DeploymentStatusTableProjection.ProjectRows("""[{"deploymentId":"dep-001"}]""");
using var doc = JsonDocument.Parse(projected);
Assert.Equal("dep-001", Assert.Single(doc.RootElement.EnumerateArray())
.GetProperty("deploymentId").GetString());
}
[Theory]
[InlineData("not json at all")]
[InlineData("""{"error":"boom"}""")]
public void Projection_LeavesUnrecognisedBodiesUntouched(string body)
{
Assert.Equal(body, DeploymentStatusTableProjection.ProjectRows(body));
}
}
@@ -90,4 +90,75 @@ public class SiteScopeServiceTests
Assert.True(await svc.IsSiteAllowedAsync(1)); Assert.True(await svc.IsSiteAllowedAsync(1));
Assert.True(await svc.IsSiteAllowedAsync(999)); Assert.True(await svc.IsSiteAllowedAsync(999));
} }
// ── Administrator is system-wide regardless of SiteId claims (register 86) ──
//
// ManagementActor has always exempted Administrator from site scoping
// (EnforceSiteScope and every handler that calls it). This service was
// claims-only, so an Administrator who ALSO carried a scoped Deployer grant
// could act fleet-wide through the CLI/HTTP surface while the UI hid the same
// sites. The UI was the side that disagreed with the documented rule.
[Fact]
public async Task AdministratorWithSiteClaims_IsSystemWide()
{
var svc = ForUser(Role("Administrator"), Role("Deployer"), SiteClaim(1), SiteClaim(3));
Assert.True(await svc.IsSystemWideAsync());
}
[Fact]
public async Task AdministratorWithSiteClaims_FilterSites_ReturnsAllSites()
{
var svc = ForUser(Role("Administrator"), Role("Deployer"), SiteClaim(1));
var filtered = await svc.FilterSitesAsync(Sites(1, 2, 3));
Assert.Equal(new[] { 1, 2, 3 }, filtered.Select(s => s.Id));
}
[Fact]
public async Task AdministratorWithSiteClaims_IsSiteAllowed_ForSitesOutsideTheClaims()
{
var svc = ForUser(Role("Administrator"), SiteClaim(1));
Assert.True(await svc.IsSiteAllowedAsync(2));
}
[Fact]
public async Task AdministratorWithSiteClaims_ReportsNoPermittedIds()
{
// The invariant "system-wide => empty id set" must hold for every caller, so
// a consumer that reads the ids without consulting IsSystemWide cannot
// silently narrow an Administrator to a subset of sites.
var svc = ForUser(Role("Administrator"), SiteClaim(1), SiteClaim(3));
Assert.Empty(await svc.PermittedSiteIdsAsync());
}
[Theory]
[InlineData("administrator")]
[InlineData("ADMINISTRATOR")]
public async Task AdministratorRoleMatch_IsCaseInsensitive(string role)
{
// ManagementActor compares roles with OrdinalIgnoreCase. ClaimsPrincipal
// .IsInRole compares claim VALUES ordinally, so matching the actor requires
// reading the role claims explicitly — otherwise the two sides would
// disagree on a differently-cased role name.
var svc = ForUser(Role(role), SiteClaim(1));
Assert.True(await svc.IsSystemWideAsync());
}
[Fact]
public async Task DesignerWithSiteClaims_IsStillScoped()
{
// The class doc used to claim "any Admin/Design user" is system-wide. Only
// Administrator is — on either side of the rule — and the doc was corrected
// rather than the bypass widened.
var svc = ForUser(Role("Designer"), SiteClaim(1));
Assert.False(await svc.IsSystemWideAsync());
Assert.False(await svc.IsSiteAllowedAsync(2));
}
} }
@@ -164,7 +164,7 @@ public class DeploymentListQueryRepositoryTests : IDisposable
new DeploymentListFilter(), pageNumber: 1, pageSize: int.MaxValue); new DeploymentListFilter(), pageNumber: 1, pageSize: int.MaxValue);
Assert.Single(page.Rows); Assert.Single(page.Rows);
Assert.True(DeploymentRecordSummary.MaxPageSize > 0); Assert.True(DeploymentListPage.MaxPageSize > 0);
} }
[Fact] [Fact]
@@ -300,6 +300,37 @@ public class DeploymentListQueryRepositoryTests : IDisposable
Assert.Empty(page.Rows); Assert.Empty(page.Rows);
} }
[Fact]
public async Task QueryPage_SiteScopeAndStatus_Compose_ForTheCliQueryPath()
{
// Register row 37: this is now the ONLY paged read of DeploymentRecords —
// the CLI/HTTP QueryDeployments command reaches it through the same filter
// the UI page uses, where it used to have a second repository method with
// instance-id-list scoping and no total. Its combination (site scope AND a
// status filter, over more rows than one page holds) is pinned here.
var s1 = await SeedSiteAsync("S1");
var s2 = await SeedSiteAsync("S2");
var a = await SeedInstanceAsync("Inst-A", s1);
var b = await SeedInstanceAsync("Inst-B", s2);
for (var i = 0; i < 4; i++)
{
await SeedRecordAsync($"dep-a{i}", a, DeploymentStatus.Failed, _base.AddMinutes(i));
await SeedRecordAsync($"dep-b{i}", b, DeploymentStatus.Failed, _base.AddMinutes(i));
}
await SeedRecordAsync("dep-a-ok", a, DeploymentStatus.Success, _base.AddHours(1));
var page = await _repository.QueryDeploymentListPageAsync(
new DeploymentListFilter(Status: DeploymentStatus.Failed, SiteIdScope: new[] { s1 }),
pageNumber: 1, pageSize: 2);
// Four in-scope failures exist; the page is bounded to two and the total
// reports the rest, so a bounded page is never mistaken for a complete one.
Assert.Equal(4, page.TotalCount);
Assert.Equal(2, page.Rows.Count);
Assert.All(page.Rows, r => Assert.Equal(s1, r.SiteId));
Assert.All(page.Rows, r => Assert.Equal(DeploymentStatus.Failed, r.Status));
}
// ── Status counts ───────────────────────────────────────────────────────── // ── Status counts ─────────────────────────────────────────────────────────
[Fact] [Fact]
@@ -1,3 +1,4 @@
using System.Text.Json;
using Akka.Actor; using Akka.Actor;
using Akka.TestKit.Xunit2; using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -1222,51 +1223,63 @@ public class ManagementActorTests : TestKit, IDisposable
new("deploy-" + instanceId, "operator") { Id = instanceId, InstanceId = instanceId }; new("deploy-" + instanceId, "operator") { Id = instanceId, InstanceId = instanceId };
/// <summary> /// <summary>
/// Row-summary equivalent of <see cref="DeploymentRecordFor"/>. QueryDeployments /// Row-projection equivalent of <see cref="DeploymentRecordFor"/>, on the site
/// now returns DB-side projections instead of whole tracked entities. /// given. QueryDeployments returns DB-side projections joined to their instance,
/// not whole tracked entities — and the join is what carries the
/// <c>SiteId</c> the scope filter is evaluated against.
/// </summary> /// </summary>
private static DeploymentRecordSummary DeploymentSummaryFor(int instanceId) => private static DeploymentListRow DeploymentRowFor(int instanceId, int siteId) =>
new(instanceId, "deploy-" + instanceId, instanceId, DeploymentStatus.Success, new(instanceId, "deploy-" + instanceId, instanceId, "Pump" + instanceId, siteId,
null, "operator", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null); DeploymentStatus.Success, null, "operator", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null);
/// <summary> /// <summary>
/// Stubs <c>QueryDeploymentSummariesAsync</c> so it applies the same /// Stubs the single paged deployment read so it applies the same
/// instance / status / scope / paging predicates the database would. /// instance / status / site-scope / paging predicates the database would.
/// ///
/// <para> /// <para>
/// The filtering moved OUT of the handler and INTO the query, so a stub that /// The filtering lives entirely in the query now (register row 37 folded the
/// ignored the arguments would make these tests pass no matter what the handler /// handler's own second repository method away), so a stub that ignored the
/// passed down. Honouring them here is what keeps the scope-enforcement /// filter would make these tests pass no matter what the handler passed down.
/// assertions meaningful. /// Honouring it here is what keeps the scope-enforcement assertions meaningful.
/// </para> /// </para>
/// </summary> /// </summary>
private static void StubDeploymentSummaryQuery( /// <param name="repo">The substituted repository to stub.</param>
/// <param name="rows">The seed rows, as (instance id, site id) pairs.</param>
private static void StubDeploymentPageQuery(
IDeploymentManagerRepository repo, IDeploymentManagerRepository repo,
IEnumerable<int> instanceIds) params (int InstanceId, int SiteId)[] rows)
{ {
var rows = instanceIds.Select(DeploymentSummaryFor).ToList(); var all = rows.Select(r => DeploymentRowFor(r.InstanceId, r.SiteId)).ToList();
repo.QueryDeploymentSummariesAsync( repo.QueryDeploymentListPageAsync(
Arg.Any<int?>(), Arg.Any<DeploymentStatus?>(), Arg.Any<IReadOnlyCollection<int>?>(), Arg.Any<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns(ci => .Returns(ci =>
{ {
var instanceId = ci.ArgAt<int?>(0); var filter = ci.ArgAt<DeploymentListFilter>(0);
var scope = ci.ArgAt<IReadOnlyCollection<int>?>(2); var page = Math.Max(1, ci.ArgAt<int>(1));
var skip = ci.ArgAt<int>(3); var size = Math.Max(1, ci.ArgAt<int>(2));
var take = ci.ArgAt<int?>(4);
IEnumerable<DeploymentRecordSummary> q = rows; IEnumerable<DeploymentListRow> q = all;
if (instanceId.HasValue) if (filter.InstanceId.HasValue)
q = q.Where(r => r.InstanceId == instanceId.Value); q = q.Where(r => r.InstanceId == filter.InstanceId.Value);
if (scope != null) if (filter.Status.HasValue)
q = q.Where(r => scope.Contains(r.InstanceId)); q = q.Where(r => r.Status == filter.Status.Value);
q = q.Skip(Math.Max(0, skip)); if (filter.SiteIdScope != null)
if (take is > 0) q = q.Where(r => filter.SiteIdScope.Contains(r.SiteId));
q = q.Take(take.Value);
return (IReadOnlyList<DeploymentRecordSummary>)q.ToList(); var matched = q.ToList();
var slice = matched.Skip((page - 1) * size).Take(size).ToList();
return new DeploymentListPage(slice, matched.Count);
}); });
} }
/// <summary>Captures the filter the handler pushed into the paged read.</summary>
private static DeploymentListFilter CapturedDeploymentFilter(IDeploymentManagerRepository repo)
{
var call = repo.ReceivedCalls()
.Single(c => c.GetMethodInfo().Name == nameof(IDeploymentManagerRepository.QueryDeploymentListPageAsync));
return (DeploymentListFilter)call.GetArguments()[0]!;
}
/// <summary> /// <summary>
/// Stubs <c>GetTemplateSummariesAsync</c> over <paramref name="count"/> synthetic /// Stubs <c>GetTemplateSummariesAsync</c> over <paramref name="count"/> synthetic
/// templates, applying the requested Skip/Take exactly as the database would. /// templates, applying the requested Skip/Take exactly as the database would.
@@ -1305,7 +1318,7 @@ public class ManagementActorTests : TestKit, IDisposable
public void QueryDeployments_UnfilteredWithDeploymentRole_ReturnsAllRecords() public void QueryDeployments_UnfilteredWithDeploymentRole_ReturnsAllRecords()
{ {
var deployRepo = Substitute.For<IDeploymentManagerRepository>(); var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentSummaryQuery(deployRepo, [1, 2]); StubDeploymentPageQuery(deployRepo, (1, 1), (2, 2));
_services.AddScoped(_ => deployRepo); _services.AddScoped(_ => deployRepo);
var actor = CreateActor(); var actor = CreateActor();
@@ -1322,7 +1335,7 @@ public class ManagementActorTests : TestKit, IDisposable
public void QueryDeployments_FilteredByInstanceId_ReturnsInstanceRecords() public void QueryDeployments_FilteredByInstanceId_ReturnsInstanceRecords()
{ {
var deployRepo = Substitute.For<IDeploymentManagerRepository>(); var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentSummaryQuery(deployRepo, [5]); StubDeploymentPageQuery(deployRepo, (5, 1));
_services.AddScoped(_ => deployRepo); _services.AddScoped(_ => deployRepo);
var actor = CreateActor(); var actor = CreateActor();
@@ -1351,8 +1364,7 @@ public class ManagementActorTests : TestKit, IDisposable
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId); Assert.Equal(envelope.CorrelationId, response.CorrelationId);
// The out-of-scope instance's deployment history must not be queried. // The out-of-scope instance's deployment history must not be queried.
deployRepo.DidNotReceiveWithAnyArgs().QueryDeploymentSummariesAsync( deployRepo.DidNotReceiveWithAnyArgs().QueryDeploymentListPageAsync(default!, default, default);
default, default, default, default, default);
} }
[Fact] [Fact]
@@ -1361,7 +1373,7 @@ public class ManagementActorTests : TestKit, IDisposable
_templateRepo.GetInstanceByIdAsync(5, Arg.Any<CancellationToken>()) _templateRepo.GetInstanceByIdAsync(5, Arg.Any<CancellationToken>())
.Returns(new Instance("Pump5") { Id = 5, SiteId = 1 }); .Returns(new Instance("Pump5") { Id = 5, SiteId = 1 });
var deployRepo = Substitute.For<IDeploymentManagerRepository>(); var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentSummaryQuery(deployRepo, [5]); StubDeploymentPageQuery(deployRepo, (5, 1));
_services.AddScoped(_ => deployRepo); _services.AddScoped(_ => deployRepo);
var actor = CreateActor(); var actor = CreateActor();
@@ -1376,17 +1388,8 @@ public class ManagementActorTests : TestKit, IDisposable
[Fact] [Fact]
public void QueryDeployments_UnfilteredForSiteScopedUser_DropsOutOfScopeRecords() public void QueryDeployments_UnfilteredForSiteScopedUser_DropsOutOfScopeRecords()
{ {
// ManagementService-023: the unfiltered branch now bulk-loads instances
// once via GetAllInstancesAsync (instead of N+1 GetInstanceByIdAsync per
// distinct InstanceId). Mock the bulk path accordingly.
_templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Instance>
{
new("Pump1") { Id = 1, SiteId = 1 },
new("Pump2") { Id = 2, SiteId = 2 },
});
var deployRepo = Substitute.For<IDeploymentManagerRepository>(); var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentSummaryQuery(deployRepo, [1, 2]); StubDeploymentPageQuery(deployRepo, (1, 1), (2, 2));
_services.AddScoped(_ => deployRepo); _services.AddScoped(_ => deployRepo);
var actor = CreateActor(); var actor = CreateActor();
@@ -1397,28 +1400,19 @@ public class ManagementActorTests : TestKit, IDisposable
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Contains("deploy-1", response.JsonData); Assert.Contains("deploy-1", response.JsonData);
Assert.DoesNotContain("deploy-2", response.JsonData); Assert.DoesNotContain("deploy-2", response.JsonData);
// The per-instance lookup must NOT have been used for the unfiltered Assert.Equal(new[] { 1 }, CapturedDeploymentFilter(deployRepo).SiteIdScope);
// branch — that was the N+1 the bulk load replaced.
_templateRepo.DidNotReceiveWithAnyArgs().GetInstanceByIdAsync(default);
} }
[Fact] [Fact]
public void QueryDeployments_UnfilteredForSiteScopedUser_UsesBulkInstanceLoad_NotPerRecordLookup() public void QueryDeployments_UnfilteredForSiteScopedUser_ReadsNoInstancesAtAll()
{ {
// ManagementService-023 regression pin: the unfiltered branch must issue // Register row 87. The scoped branch used to resolve the site grant into an
// GetAllInstancesAsync ONCE and never call GetInstanceByIdAsync, regardless // instance-id LIST, which meant loading EVERY instance in the system on
// of how many DeploymentRecords reference distinct InstanceIds. Before the // every scoped query (a bulk load that had itself replaced an N+1 of
// fix, three distinct instance ids would have produced three per-instance // per-record lookups). The grant now rides as a site-id filter resolved by
// lookups (textbook N+1). // the query's own join, so neither instance read may happen.
_templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Instance>
{
new("Pump1") { Id = 1, SiteId = 1 },
new("Pump2") { Id = 2, SiteId = 2 },
new("Pump3") { Id = 3, SiteId = 1 },
});
var deployRepo = Substitute.For<IDeploymentManagerRepository>(); var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentSummaryQuery(deployRepo, [1, 2, 3]); StubDeploymentPageQuery(deployRepo, (1, 1), (2, 2), (3, 1));
_services.AddScoped(_ => deployRepo); _services.AddScoped(_ => deployRepo);
var actor = CreateActor(); var actor = CreateActor();
@@ -1427,17 +1421,41 @@ public class ManagementActorTests : TestKit, IDisposable
actor.Tell(envelope); actor.Tell(envelope);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5)); ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
_templateRepo.Received(1).GetAllInstancesAsync(Arg.Any<CancellationToken>()); _templateRepo.DidNotReceiveWithAnyArgs().GetAllInstancesAsync();
_templateRepo.DidNotReceiveWithAnyArgs().GetInstanceByIdAsync(default); _templateRepo.DidNotReceiveWithAnyArgs().GetInstanceByIdAsync(default);
} }
[Fact]
public void QueryDeployments_SiteScopedUserWithUnparseableGrant_SeesNothing()
{
// A permitted-site id that is not an integer is dropped, never ignored:
// ignoring it would silently widen the grant to fleet-wide. A grant of
// nothing but garbage therefore yields an EMPTY scope — a real "sees
// nothing" filter, not "no filter".
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentPageQuery(deployRepo, (1, 1), (2, 2));
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "not-an-id" }, "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.DoesNotContain("deploy-1", response.JsonData);
Assert.DoesNotContain("deploy-2", response.JsonData);
Assert.Empty(CapturedDeploymentFilter(deployRepo).SiteIdScope!);
}
[Fact] [Fact]
public void QueryDeployments_UnfilteredForAdminUser_ReturnsAllRecords() public void QueryDeployments_UnfilteredForAdminUser_ReturnsAllRecords()
{ {
// Admin role bypasses site scoping even with PermittedSiteIds set. // Admin role bypasses site scoping even with PermittedSiteIds set.
// (The user also holds Deployment so it passes the role gate.) // (The user also holds Deployment so it passes the role gate.)
// The UI's SiteScopeService now agrees — see SiteScopeServiceTests
// (register row 86).
var deployRepo = Substitute.For<IDeploymentManagerRepository>(); var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentSummaryQuery(deployRepo, [1, 2]); StubDeploymentPageQuery(deployRepo, (1, 1), (2, 2));
_services.AddScoped(_ => deployRepo); _services.AddScoped(_ => deployRepo);
var actor = CreateActor(); var actor = CreateActor();
@@ -1448,6 +1466,119 @@ public class ManagementActorTests : TestKit, IDisposable
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5)); var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Contains("deploy-1", response.JsonData); Assert.Contains("deploy-1", response.JsonData);
Assert.Contains("deploy-2", response.JsonData); Assert.Contains("deploy-2", response.JsonData);
Assert.Null(CapturedDeploymentFilter(deployRepo).SiteIdScope);
}
// ── Loud status parsing + bounded page size (register rows 87 / 88) ────────
[Fact]
public void QueryDeployments_UnknownStatus_FailsLoudlyNamingTheValidValues()
{
// Register row 88: `--status Sucess` used to fall back to "no filter" and
// return EVERY status — a plausible, complete-looking answer to a question
// the caller did not ask.
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentPageQuery(deployRepo, (1, 1));
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
actor.Tell(Envelope(new QueryDeploymentsCommand(Status: "Sucess"), "Deployer"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("Sucess", response.Error);
Assert.Contains("Success", response.Error);
Assert.Contains("Failed", response.Error);
deployRepo.DidNotReceiveWithAnyArgs().QueryDeploymentListPageAsync(default!, default, default);
}
[Theory]
[InlineData("success")]
[InlineData("SUCCESS")]
public void QueryDeployments_KnownStatus_IsCaseInsensitive(string status)
{
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentPageQuery(deployRepo, (1, 1));
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
actor.Tell(Envelope(new QueryDeploymentsCommand(Status: status), "Deployer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(DeploymentStatus.Success, CapturedDeploymentFilter(deployRepo).Status);
}
[Fact]
public void QueryDeployments_NumericStatus_IsRejected()
{
// Enum.TryParse happily accepts "7" and yields an undefined enum value, so
// the parse needs the IsDefined guard as well as the TryParse.
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentPageQuery(deployRepo, (1, 1));
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
actor.Tell(Envelope(new QueryDeploymentsCommand(Status: "7"), "Deployer"));
ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void QueryDeployments_NonPositivePageSize_TakesTheDefault_NotUnlimited(int requested)
{
// Register row 87: a non-positive page size used to mean "unlimited" all the
// way down to the repository, materializing the whole retention window.
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentPageQuery(deployRepo, Enumerable.Range(1, 200).Select(i => (i, 1)).ToArray());
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
actor.Tell(Envelope(new QueryDeploymentsCommand(PageSize: requested), "Deployer"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
var call = deployRepo.ReceivedCalls()
.Single(c => c.GetMethodInfo().Name == nameof(IDeploymentManagerRepository.QueryDeploymentListPageAsync));
Assert.Equal(DeploymentListPage.DefaultPageSize, (int)call.GetArguments()[2]!);
// The total travels with the bounded page, so a caller can tell a truncated
// page from a complete one.
var result = JsonSerializer.Deserialize<JsonElement>(response.JsonData);
Assert.Equal(200, result.GetProperty("totalCount").GetInt32());
Assert.Equal(DeploymentListPage.DefaultPageSize, result.GetProperty("deployments").GetArrayLength());
Assert.Equal(DeploymentListPage.DefaultPageSize, result.GetProperty("pageSize").GetInt32());
}
[Fact]
public void QueryDeployments_OversizedPageSize_IsCappedAtTheMaximum()
{
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentPageQuery(deployRepo, (1, 1));
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
actor.Tell(Envelope(new QueryDeploymentsCommand(PageSize: int.MaxValue), "Deployer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
var call = deployRepo.ReceivedCalls()
.Single(c => c.GetMethodInfo().Name == nameof(IDeploymentManagerRepository.QueryDeploymentListPageAsync));
Assert.Equal(DeploymentListPage.MaxPageSize, (int)call.GetArguments()[2]!);
}
[Fact]
public void QueryDeployments_PageIsFlooredAtOne()
{
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
StubDeploymentPageQuery(deployRepo, (1, 1));
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
actor.Tell(Envelope(new QueryDeploymentsCommand(Page: 0), "Deployer"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Contains("deploy-1", response.JsonData);
var result = JsonSerializer.Deserialize<JsonElement>(response.JsonData);
Assert.Equal(1, result.GetProperty("page").GetInt32());
} }
// ======================================================================== // ========================================================================