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:
@@ -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.
|
||||||
--
|
--
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 & Auth") makes the
|
/// The design (Component-CentralUI, CLAUDE.md "Security & 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)
|
||||||
{
|
{
|
||||||
if (int.TryParse(claim.Value, out var id))
|
// An Administrator's SiteId claims are not merely overridden, they are
|
||||||
ids.Add(id);
|
// 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))
|
||||||
|
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
-28
@@ -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;
|
|
||||||
}
|
|
||||||
+3
-2
@@ -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
|
||||||
|
|||||||
+1
-53
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-1
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user