Complete management API and CLI command parity

This commit is contained in:
Joseph Doherty
2026-09-10 00:51:45 -04:00
parent 477e2abe97
commit 2750bd2809
38 changed files with 1637 additions and 199 deletions
+33 -5
View File
@@ -44,7 +44,7 @@ Every command accepts `--format json` (default) or `--format table`. For JSON ou
## Architecture ## Architecture
`Program.cs` builds the `RootCommand` tree, attaches the four global options (`--url`, `--username`, `--password`, `--format`), and registers each command group: `Program.cs` invokes `CliApplication.BuildRoot()`, which builds the `RootCommand` tree, attaches the four global options (`--url`, `--username`, `--password`, `--format`), and registers each command group:
```csharp ```csharp
rootCommand.Add(TemplateCommands.Build(urlOption, formatOption, usernameOption, passwordOption)); rootCommand.Add(TemplateCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
@@ -59,10 +59,14 @@ rootCommand.Add(AuditLogCommands.Build(urlOption, formatOption, usernameOption,
rootCommand.Add(AuditCommands.Build(urlOption, formatOption, usernameOption, passwordOption)); rootCommand.Add(AuditCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(HealthCommands.Build(urlOption, formatOption, usernameOption, passwordOption)); rootCommand.Add(HealthCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(DebugCommands.Build(urlOption, formatOption, usernameOption, passwordOption)); rootCommand.Add(DebugCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(SharedSchemaCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(SharedScriptCommands.Build(urlOption, formatOption, usernameOption, passwordOption)); rootCommand.Add(SharedScriptCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(DbConnectionCommands.Build(urlOption, formatOption, usernameOption, passwordOption)); rootCommand.Add(DbConnectionCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(ApiMethodCommands.Build(urlOption, formatOption, usernameOption, passwordOption)); rootCommand.Add(ApiMethodCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(BundleCommands.Build(urlOption, formatOption, usernameOption, passwordOption)); rootCommand.Add(BundleCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(CachedCallCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(SecuredWriteCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(FlowDebugCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
``` ```
Every command's action delegates to `CommandHelpers.ExecuteCommandAsync`, which owns the URL/credential resolution, `ManagementHttpClient` lifetime, and exit-code mapping. This consolidation means the authorization-failure exit code (`2`) is enforced uniformly — including for the `bundle` group, which uses `ExecuteCommandAsync` with a longer 5-minute timeout and a per-command `onSuccess` handler rather than a separate HTTP path. Every command's action delegates to `CommandHelpers.ExecuteCommandAsync`, which owns the URL/credential resolution, `ManagementHttpClient` lifetime, and exit-code mapping. This consolidation means the authorization-failure exit code (`2`) is enforced uniformly — including for the `bundle` group, which uses `ExecuteCommandAsync` with a longer 5-minute timeout and a per-command `onSuccess` handler rather than a separate HTTP path.
@@ -105,14 +109,18 @@ scadabridge instance deploy --id 7
| Group | Subcommands | Role required | | Group | Subcommands | Role required |
|-------|-------------|---------------| |-------|-------------|---------------|
| `template` | `list`, `get`, `create`, `update`, `delete`, `validate`; `attribute add/update/delete`; `alarm add/update/delete`; `script add/update/delete`; `composition add/delete`; `native-alarm-source add/list/remove` | `Design` | | `template` | `list`, `get`, `create`, `update`, `delete`, `validate`, `resolved-members`, `move`; `attribute add/update/delete`; `alarm add/update/delete`; `script add/update/delete`; `composition add/delete`; `native-alarm-source add/list/update/remove` | `Design` |
| `instance` | `list`, `get`, `create`, `set-bindings`, `set-overrides`, `alarm-override set/delete/list`, `native-alarm-source set/clear`, `set-area`, `diff`, `deploy`, `enable`, `disable`, `delete` | `Deployment` | | `instance` | `list`, `get`, `create`, `set-bindings`, `set-overrides`, `alarm-override set/delete/list`, `native-alarm-source set/clear/list/import`, `attribute-override delete`, `set-area`, `diff`, `deploy`, `enable`, `disable`, `delete` | `Deployment` |
| `site` | `list`, `get`, `create`, `delete`, `deploy-artifacts`; `area list/create/update/delete` | `Admin` | | `site` | `list`, `get`, `create`, `delete`, `deploy-artifacts`; `area list/create/update/delete` | `Admin` |
| `deploy` | `instance`, `artifacts`, `status` | `Deployment` | | `deploy` | `instance`, `artifacts`, `status` | `Deployment` |
| `data-connection` | `list`, `get`, `create`, `update`, `delete` | `Design` / `Deployment` | | `data-connection` | `list`, `get`, `create`, `update`, `delete`, `move`; `browse`, `search`, `verify-endpoint`, `certs` | `Design` / `Deployment` |
| `external-system` | `list`, `get`, `create`, `update`, `delete` | `Design` | | `external-system` | `list`, `get`, `create`, `update`, `delete` | `Design` |
| `notification` | `list`, `get`, `create`, `update`, `delete`; `smtp list/update` | `Design` / `Admin` | | `notification` | `list`, `get`, `create`, `update`, `delete`, `rename`; `recipient add/delete`; `smtp list/create/update`; `sms list/create/update` | `Design` / `Admin` |
| `security` | `api-key list/create/update/delete/set-methods`; `role-mapping list/create/update/delete`; `scope-rule list/add/delete` | `Admin` | | `security` | `api-key list/create/update/delete/set-methods`; `role-mapping list/create/update/delete`; `scope-rule list/add/delete` | `Admin` |
| `shared-schema` | `list`, `get`, `create`, `update`, `delete` | Read: authenticated; mutations: Designer |
| `secured-write` | `list`, `submit`, `approve`, `reject` | Operator / Verifier / Administrator, enforced per operation |
| `cached-call` | `list`, `retry`, `discard` | Server-enforced operational roles and site scope |
| `flow-debug` | `run-method`, `run-script`, `run-value-change`, `replay` | Deployer; replay also accepts OperationalAudit roles |
| `shared-script` | `list`, `get`, `create`, `update`, `delete` | `Design` | | `shared-script` | `list`, `get`, `create`, `update`, `delete` | `Design` |
| `db-connection` | `list`, `get`, `create`, `update`, `delete` | `Design` | | `db-connection` | `list`, `get`, `create`, `update`, `delete` | `Design` |
| `api-method` | `list`, `get`, `create`, `update`, `delete` | `Design` | | `api-method` | `list`, `get`, `create`, `update`, `delete` | `Design` |
@@ -248,3 +256,23 @@ The `audit-log` command group was renamed to `audit-config` in M8 of Audit Log (
- [Audit Log](./AuditLog.md) - [Audit Log](./AuditLog.md)
- [Security](./Security.md) - [Security](./Security.md)
- [Commons](./Commons.md) - [Commons](./Commons.md)
## Management parity and deadlines
All 149 HTTP-eligible management contracts are exposed across 20 production CLI
groups, including the `shared-schema` library and the secured-write lifecycle.
The [parity additions](../../src/ZB.MOM.WW.ScadaBridge.CLI/README.md#management-parity-additions-2026-09-10)
document the individual notification edits, provider creation, template and
instance operations, connection relocation, and SMS policy flags.
The production-root loopback test requires actual HTTP dispatch for every
eligible contract. A separate actor test verifies every registered contract has
a handler. `ReadTagValues` remains site-only and `ResolveRoles` remains retired;
both are excluded from HTTP name resolution.
Every instance/artifact/site deployment alias lets the server own the overall
deadline, while retaining a bounded connect timeout. This prevents the ordinary
30-second client timer from abandoning a deployment still applying at the server.
Secured-write approval interprets execution status as well as HTTP success:
only `Executed` exits zero; failed execution or an invalid response exits one.
+11
View File
@@ -241,3 +241,14 @@ Any exception that is not a `ManagementCommandException` or `SiteScopeViolationE
- [Security](./Security.md) - [Security](./Security.md)
- [Audit Log](./AuditLog.md) - [Audit Log](./AuditLog.md)
- [Host](./Host.md) - [Host](./Host.md)
## HTTP command eligibility
`ManagementCommandRegistry` includes 149 executable HTTP contracts. The site-only
`ReadTagValuesCommand` and retired `ResolveRolesCommand` remain internal message
types but are excluded from the HTTP registry, so `/management` rejects those
names as unknown commands instead of accepting an undispatchable request. Site
gRPC tag-read serialization and server-side LDAP role resolution are unchanged.
The dispatch-coverage test now checks every HTTP-registered contract without
exemptions, including create records inherited from update records.
@@ -2,17 +2,17 @@
**Date:** 2026-09-10 **Date:** 2026-09-10
**Reviewed revision:** `9ee402bb` **Reviewed revision:** `9ee402bb`
**Status:** Findings recorded; remediation not started. **Status:** Remediation in progress on `codex/management-cli-parity`.
## Scope and method ## Original review scope and method
Source-level comparison of management command records, automatic HTTP command Source-level comparison of management command records, automatic HTTP command
registration, ManagementActor dispatch (including inherited record types), CLI registration, ManagementActor dispatch (including inherited record types), CLI
command construction and registration, and management audit REST endpoints. command construction and registration, and management audit REST endpoints.
No live mutations were performed and no tests were rerun for this review. No live mutations were performed and no tests were rerun for this review.
The CLI's 19 registered command groups and previously passing 475-test suite do At the reviewed revision, 19 registered command groups and the passing 475-test
not establish complete actor/API parity. The inventory found: suite did not establish complete actor/API parity. The baseline inventory found:
| Inventory | Count | | Inventory | Count |
|---|---:| |---|---:|
@@ -31,24 +31,24 @@ is not counted as a missing operator command.
## Missing CLI operations ## Missing CLI operations
All 19 commands below are handled by the actor and reachable through the generic All 19 commands below are handled by the actor and reachable through the generic
`POST /management` API, subject to server-side authorization. Each remains open; `POST /management` API, subject to server-side authorization. Remediation adds
the secured-write limitation is already documented and may be retained as an all 19, including the full secured-write lifecycle. The descriptions below retain
explicit product decision rather than implemented. the original findings; status cells and completion evidence track the fixes.
| ID | Capability | Missing command contracts | Status | | ID | Capability | Missing command contracts | Status |
|---|---|---|---| |---|---|---|---|
| GAP-01 | Shared-schema library (5) | `ListSharedSchemasCommand`, `GetSharedSchemaCommand`, `CreateSharedSchemaCommand`, `UpdateSharedSchemaCommand`, `DeleteSharedSchemaCommand` | Open | | GAP-01 | Shared-schema library (5) | `ListSharedSchemasCommand`, `GetSharedSchemaCommand`, `CreateSharedSchemaCommand`, `UpdateSharedSchemaCommand`, `DeleteSharedSchemaCommand` | Completed |
| GAP-02 | Secured-write lifecycle (3) | `SubmitSecuredWriteCommand`, `ApproveSecuredWriteCommand`, `RejectSecuredWriteCommand` | Open; documented listing-only CLI scope | | GAP-02 | Secured-write lifecycle (3) | `SubmitSecuredWriteCommand`, `ApproveSecuredWriteCommand`, `RejectSecuredWriteCommand` | Completed; full lifecycle exposed |
| GAP-03 | Individual notification editing (3) | `RenameNotificationListCommand`, `AddNotificationRecipientCommand`, `DeleteNotificationRecipientCommand` | Open | | GAP-03 | Individual notification editing (3) | `RenameNotificationListCommand`, `AddNotificationRecipientCommand`, `DeleteNotificationRecipientCommand` | Completed |
| GAP-04 | Provider configuration creation (2) | `CreateSmtpConfigCommand`, `CreateSmsConfigCommand` | Open | | GAP-04 | Provider configuration creation (2) | `CreateSmtpConfigCommand`, `CreateSmsConfigCommand` | Completed |
| GAP-05 | Template inspection and editing (3) | `GetResolvedTemplateMembersCommand`, `MoveTemplateToFolderCommand`, `UpdateTemplateNativeAlarmSourceCommand` | Open | | GAP-05 | Template inspection and editing (3) | `GetResolvedTemplateMembersCommand`, `MoveTemplateToFolderCommand`, `UpdateTemplateNativeAlarmSourceCommand` | Completed |
| GAP-06 | Instance override management (2) | `DeleteInstanceAttributeOverrideCommand`, `ListInstanceNativeAlarmSourceOverridesCommand` | Open | | GAP-06 | Instance override management (2) | `DeleteInstanceAttributeOverrideCommand`, `ListInstanceNativeAlarmSourceOverridesCommand` | Completed |
| GAP-07 | Data-connection relocation (1) | `MoveDataConnectionCommand` | Open | | GAP-07 | Data-connection relocation (1) | `MoveDataConnectionCommand` | Completed |
Evidence: Evidence:
- [ManagementActor dispatch](../../src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs) - [ManagementActor dispatch](../../src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs)
- [CLI root registration](../../src/ZB.MOM.WW.ScadaBridge.CLI/Program.cs) - [CLI root registration](../../src/ZB.MOM.WW.ScadaBridge.CLI/CliApplication.cs)
- [CLI command implementations](../../src/ZB.MOM.WW.ScadaBridge.CLI/Commands/) - [CLI command implementations](../../src/ZB.MOM.WW.ScadaBridge.CLI/Commands/)
- [Management command contracts](../../src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/) - [Management command contracts](../../src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/)
@@ -57,14 +57,14 @@ contracts and are dispatched through the corresponding update handlers. They
are not unhandled API contracts. The handlers distinguish the create subtype; are not unhandled API contracts. The handlers distinguish the create subtype;
sending an ordinary update with ID zero is not equivalent. sending an ordinary update with ID zero is not equivalent.
## Behavioral and contract findings ## Original behavioral and contract findings
Severity is a review estimate. These findings overlap the operation inventory; Severity is a review estimate. These findings overlap the operation inventory;
do not add the row counts together as unique missing commands. do not add the row counts together as unique missing commands.
### PAR-01 — Deployment deadlines differ between API and CLI ### PAR-01 — Deployment deadlines differ between API and CLI
**Severity:** Medium · **Status:** Open **Severity:** Medium · **Status:** Completed
`deploy instance`, `instance deploy`, `deploy artifacts`, and `deploy instance`, `instance deploy`, `deploy artifacts`, and
`site deploy-artifacts` use the default 30-second CLI request deadline. The API `site deploy-artifacts` use the default 30-second CLI request deadline. The API
@@ -84,7 +84,7 @@ policy and add delayed-response regression coverage for every CLI alias.
### PAR-02 — Notification replacement is not equivalent to individual editing ### PAR-02 — Notification replacement is not equivalent to individual editing
**Severity:** Medium · **Status:** Open · **Related:** GAP-03 **Severity:** Medium · **Status:** Completed · **Related:** GAP-03
The existing CLI whole-list update reaches `HandleUpdateNotificationList`, which The existing CLI whole-list update reaches `HandleUpdateNotificationList`, which
deletes existing recipients and recreates them. It cannot faithfully replace deletes existing recipients and recreates them. It cannot faithfully replace
@@ -99,7 +99,7 @@ verify unrelated recipients retain their identities and names.
### PAR-03 — SMS retry and timeout options are missing ### PAR-03 — SMS retry and timeout options are missing
**Severity:** Low · **Status:** Open **Severity:** Low · **Status:** Completed
The actor/API accept `ConnectionTimeoutSeconds`, `MaxRetries`, and `RetryDelay` The actor/API accept `ConnectionTimeoutSeconds`, `MaxRetries`, and `RetryDelay`
on `UpdateSmsConfigCommand`. CLI payload construction supplies none of these on `UpdateSmsConfigCommand`. CLI payload construction supplies none of these
@@ -116,7 +116,7 @@ explicit values and omission behavior against the API contract.
### PAR-04 — Registry accepts two contracts the actor cannot dispatch ### PAR-04 — Registry accepts two contracts the actor cannot dispatch
**Severity:** Low · **Status:** Open **Severity:** Low · **Status:** Completed
The registry automatically includes every concrete `*Command` type in the The registry automatically includes every concrete `*Command` type in the
management namespace. `ReadTagValuesCommand` and `ResolveRolesCommand` therefore management namespace. `ReadTagValuesCommand` and `ResolveRolesCommand` therefore
@@ -140,7 +140,7 @@ flow's security restriction.
### PAR-05 — Tests do not enforce complete surface parity ### PAR-05 — Tests do not enforce complete surface parity
**Severity:** Low · **Status:** Open **Severity:** Low · **Status:** Completed
Command-tree tests verify existing groups and selected operations, but do not Command-tree tests verify existing groups and selected operations, but do not
require every supported actor command to map to a CLI command or an explicit require every supported actor command to map to a CLI command or an explicit
@@ -163,3 +163,77 @@ inheritance, and test representative payload fields and deadline policies.
This is a bounded surface comparison, not an exhaustive security audit or a This is a bounded surface comparison, not an exhaustive security audit or a
field-by-field review of all 130 CLI-referenced contracts. field-by-field review of all 130 CLI-referenced contracts.
## Implementation plan and progress
The requested remediation implements all 19 CLI mappings, including secured-write
submit/approve/reject through existing server authorization. The HTTP registry
will exclude site-only ReadTagValues and retired ResolveRoles contracts; their
internal types remain intact. Existing update semantics remain compatible.
| Work package | Files / scope | Verification | Status |
|---|---|---|---|
| Notifications | CLI NotificationCommands; focused notification tests; GAP-03/04, PAR-02/03 | Capture real command payloads, preserve named recipients, create and optional SMS settings | Completed |
| Template/instance/connection commands | CLI TemplateCommands, InstanceCommands, DataConnectionCommands; GAP-05/06/07 | Parse and HTTP-envelope regressions for every added operation | Completed |
| Schema and secured writes | New SharedSchemaCommands, SecuredWriteCommands; GAP-01/02 | Real command payloads, role enforcement remains server-side | Completed |
| Deadline and registry policy | CommandHelpers, Commons registry, API tests; PAR-01/04 | Delayed responses for all deploy aliases; rejected HTTP-ineligible contracts | Completed |
| Parity gate and integration | CLI root, command mapping tests, CLI docs; PAR-05 | Exhaustive registry/dispatch/CLI mapping with explicit internal exclusions | Completed |
| Completion | Full solution build/tests, local deployment/read-only smoke, report updates, merge/commit/push | Fresh verification evidence recorded below | In progress |
Each package follows failing regression tests, minimal implementation, focused
verification, and code review. Builds/tests use a shared lock in this worktree to
avoid concurrent dotnet output races. The final merge and git operations are
owned by the primary agent.
### Progress checkpoint
- Notification/Smtp/Sms focused suite: 52 passed, including 19 new parse/HTTP cases.
- Editing parity: 15 passed, covering all six added operations and an existing alias.
- Deployment deadline regression: all four aliases now accept the server's response
after 31 seconds; the same test failed with the old 30-second client deadline.
- HTTP registry regressions: 11 passed; ReadTagValues and ResolveRoles no longer resolve.
- Production-root parity probe: all 149 HTTP-eligible contracts actually dispatched
to a loopback recorder. Removing the schema group produces missing coverage.
- Independent review approved the editing and notification mappings. Additional
actor preservation coverage and secured-write execution-failure exit handling
are being verified before final completion.
### Independent review follow-ups
- Added email and SMS actor-dispatch preservation tests: rename, add, and delete
leave the existing named recipient and ID untouched (2 passed).
- Secured-write approval now treats HTTP 200 with `status: Failed` as CLI failure,
and rejects malformed or unexpected approval outcomes. Schema/secured focused
tests: 33 passed, including existing listing behavior and new outcome checks.
- Fixed a pre-existing blind spot in the actor dispatch gate: its sentinel now
passes through the configuration transaction before throwing on handler
dependencies. A negative control detects an unsupported command (2 passed).
- New CLI tests that invoke Console-writing actions share the Console collection
to avoid racing tests that redirect process-global output.
All implementation packages are present. Final solution verification, canonical
local deployment, and integration remain in progress.
### Final pre-integration verification
- Full solution build: zero warnings and errors.
- Full solution run (excluding deployed Playwright): 9,551 passed, one failure in
the UI toast unobserved-exception test. Root cause was unrelated incomplete
JavaScript success stubs in UI fixtures; no product UI behavior changed.
- Fixed 15 successful void stubs across 11 fixtures to complete explicitly. Two
direct regression tests failed before and passed after. Full CentralUI rerun:
**1,418/1,418 passed**; the original toast assertion remains unchanged.
- Replacing the initial UI result with the corrected full UI suite gives
**9,554 passed, zero outstanding failures or skips** across 29 test suites.
Key suites: CLI **543**, ManagementService **665**, Commons **852**,
Communication **766**, Integration **118**, Host **598**.
- Documentation links and `git diff --check` passed. All GAP-01 through GAP-07 and
PAR-01 through PAR-05 are complete. Canonical deployment and final merge/push
evidence will be added after integration.
Local verification logs: `/tmp/parity-full-build.log`, `/tmp/parity-full-tests.log`,
`/tmp/parity-ui-red.log`, `/tmp/parity-ui-green.log`, `/tmp/parity-ui-full.log`.
These logs are local evidence, not committed artifacts.
+22 -1
View File
@@ -447,7 +447,7 @@ Configuration is resolved in the following priority order (highest wins):
## Error Handling ## Error Handling
- **Connection failure**: If the CLI cannot connect to the management URL (e.g., DNS failure, connection refused), it exits with code 1 and a descriptive error message. - **Connection failure**: If the CLI cannot connect to the management URL (e.g., DNS failure, connection refused), it exits with code 1 and a descriptive error message.
- **Command timeout**: If the server does not respond within 30 seconds, the command fails with a timeout error (HTTP 504). - **Command timeout**: Ordinary CLI commands use a 30-second client deadline. All instance/artifact/site deployment aliases defer the overall deadline to the server, retaining a bounded socket-connect timeout. Server Ask expiry returns HTTP 504; a client deadline is reported locally. Bundle and Flow Debugger commands retain their explicit timeout policies.
- **Authentication failure**: If the server returns HTTP 401 (LDAP bind failed), the CLI exits with code 1. - **Authentication failure**: If the server returns HTTP 401 (LDAP bind failed), the CLI exits with code 1.
- **Authorization failure**: If the server returns HTTP 403, the CLI exits with code 2. - **Authorization failure**: If the server returns HTTP 403, the CLI exits with code 2.
@@ -472,3 +472,24 @@ deploy site preserves the returned summary in JSON/table output and exits 1 when
is nonzero or the summary is malformed, even when HTTP returned 2xx. A zero failure count exits is nonzero or the summary is malformed, even when HTTP returned 2xx. A zero failure count exits
0; HTTP authorization failures retain exit 2. The server owns the bounded bulk operation deadline; 0; HTTP authorization failures retain exit 2. The server owns the bounded bulk operation deadline;
the client does not impose a shorter total timer on this command. the client does not impose a shorter total timer on this command.
## Management parity (2026-09-10)
The CLI exposes all 149 HTTP-eligible management contracts. The production root
is constructed by `CliApplication.BuildRoot`; the test suite invokes that tree
against a loopback HTTP recorder and requires every registered contract to be
sent, with a negative control for a removed group. Actor dispatch coverage uses
the same HTTP eligibility boundary with no unhandled-command exemptions.
Added operations include the five `shared-schema` CRUD commands, secured-write
submit/approve/reject, notification rename and individual recipient add/delete,
SMTP/SMS create, effective template member inspection, template folder moves,
native alarm source updates, individual attribute-override deletion, native alarm
override listing, and data-connection moves. SMS timeout/retry settings are also
exposed. Their exact syntax and omission semantics are maintained in the
[CLI command reference](../../src/ZB.MOM.WW.ScadaBridge.CLI/README.md#management-parity-additions-2026-09-10).
All additions retain server-owned roles, scope restrictions, validation, and audit
handling. In particular, secured-write approval requires a different verifier
identity and exits with failure when the returned device execution failed.
@@ -0,0 +1,60 @@
using System.CommandLine;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI;
/// <summary>Constructs the production CLI command tree for execution and surface verification.</summary>
public static class CliApplication
{
/// <summary>Builds an independent root command with every supported operation.</summary>
/// <returns>The configured CLI root.</returns>
public static RootCommand BuildRoot()
{
var rootCommand = new RootCommand("ScadaBridge CLI — manage the ScadaBridge SCADA system");
var urlOption = new Option<string>("--url") { Description = "Management API URL", Recursive = true };
var usernameOption = new Option<string>("--username") { Description = "LDAP username", Recursive = true };
var passwordOption = new Option<string>("--password") { Description = "LDAP password", Recursive = true };
// No DefaultValueFactory: format precedence (explicit --format -> config/env -> "json")
// is resolved by CommandHelpers.ResolveFormat, which needs to distinguish an absent flag.
// CliOptions.CreateFormatOption also constrains the accepted values (json/table).
var formatOption = CliOptions.CreateFormatOption();
rootCommand.Add(urlOption);
rootCommand.Add(usernameOption);
rootCommand.Add(passwordOption);
rootCommand.Add(formatOption);
// Register command groups
rootCommand.Add(TemplateCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(InstanceCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(SiteCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(DeployCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
var dataConnectionCommand = DataConnectionCommands.Build(urlOption, formatOption, usernameOption, passwordOption);
// Attach the site-routed browse/search/verify-endpoint/certs subcommands (actor parity, arch-review C4).
ConnectionBrowseCommands.AddTo(dataConnectionCommand, urlOption, formatOption, usernameOption, passwordOption);
rootCommand.Add(dataConnectionCommand);
rootCommand.Add(ExternalSystemCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(NotificationCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(SecurityCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(AuditLogCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(AuditCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(HealthCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(DebugCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(SharedSchemaCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(SharedScriptCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(DbConnectionCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(ApiMethodCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(BundleCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(CachedCallCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(SecuredWriteCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(FlowDebugCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.SetAction(_ =>
{
Console.WriteLine("Use --help to see available commands.");
});
return rootCommand;
}
}
@@ -18,9 +18,9 @@ internal static class CommandHelpers
/// <param name="passwordOption">Option that supplies the password override.</param> /// <param name="passwordOption">Option that supplies the password override.</param>
/// <param name="command">The management command object to send.</param> /// <param name="command">The management command object to send.</param>
/// <param name="timeout"> /// <param name="timeout">
/// Optional per-command HTTP timeout. Defaults to 30s, matching the management API's /// Optional per-command HTTP timeout. Deploy operations defer to the server-owned
/// own request timeout. Larger payloads (e.g. Transport bundles) should supply a /// deadline; ordinary commands default to 30 seconds. An explicit timeout
/// longer value. /// overrides this policy (for example, bundle and Flow Debugger requests).
/// </param> /// </param>
/// <param name="onSuccess"> /// <param name="onSuccess">
/// Optional success handler. When supplied, the helper invokes it with the success /// Optional success handler. When supplied, the helper invokes it with the success
@@ -90,7 +90,12 @@ internal static class CommandHelpers
// Send via HTTP // Send via HTTP
using var client = new ManagementHttpClient(url, username, password); using var client = new ManagementHttpClient(url, username, password);
var response = await client.SendCommandAsync(commandName, command, timeout ?? TimeSpan.FromSeconds(30)); // Every alias of a deploy contract shares the same policy. The server bounds
// the operation; a shorter client deadline can report failure while it applies.
var requestTimeout = timeout ?? (command is MgmtDeployInstanceCommand or MgmtDeployArtifactsCommand or MgmtDeploySiteCommand
? Timeout.InfiniteTimeSpan
: TimeSpan.FromSeconds(30));
var response = await client.SendCommandAsync(commandName, command, requestTimeout);
// Caller-supplied success handler short-circuits the default rendering — but // Caller-supplied success handler short-circuits the default rendering — but
// the error path still routes through IsAuthorizationFailure so the documented // the error path still routes through IsAuthorizationFailure so the documented
@@ -17,6 +17,7 @@ public static class DataConnectionCommands
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption) public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{ {
var command = new Command("data-connection") { Description = "Manage data connections" }; var command = new Command("data-connection") { Description = "Manage data connections" };
command.Add(BuildMove(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildList(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildList(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildGet(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildGet(urlOption, formatOption, usernameOption, passwordOption));
@@ -27,6 +28,22 @@ public static class DataConnectionCommands
return command; return command;
} }
private static Command BuildMove(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Data connection ID", Required = true };
var siteOption = new Option<int>("--site-id") { Description = "Destination site ID", Required = true };
var cmd = new Command("move") { Description = "Move a data connection to another site" };
cmd.Add(idOption);
cmd.Add(siteOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new MoveDataConnectionCommand(result.GetValue(idOption), result.GetValue(siteOption)));
});
return cmd;
}
private static Command BuildGet(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption) private static Command BuildGet(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{ {
var idOption = new Option<int>("--id") { Description = "Data connection ID", Required = true }; var idOption = new Option<int>("--id") { Description = "Data connection ID", Required = true };
@@ -28,6 +28,7 @@ public static class InstanceCommands
command.Add(BuildImportOverrides(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildImportOverrides(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildAlarmOverride(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildAlarmOverride(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildNativeAlarmSourceOverride(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildNativeAlarmSourceOverride(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildAttributeOverride(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildSetArea(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildSetArea(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildDiff(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildDiff(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildDeploy(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildDeploy(urlOption, formatOption, usernameOption, passwordOption));
@@ -596,6 +597,43 @@ public static class InstanceCommands
return group; return group;
} }
private static Command BuildDeleteAttributeOverride(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
var attributeOption = new Option<string>("--attribute") { Description = "Canonical attribute name (for example Module.Value)", Required = true };
var cmd = new Command("delete") { Description = "Remove one attribute override and restore the inherited value" };
cmd.Add(idOption);
cmd.Add(attributeOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new DeleteInstanceAttributeOverrideCommand(result.GetValue(idOption), result.GetValue(attributeOption)!));
});
return cmd;
}
private static Command BuildAttributeOverride(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var group = new Command("attribute-override") { Description = "Manage individual instance attribute overrides" };
group.Add(BuildDeleteAttributeOverride(urlOption, formatOption, usernameOption, passwordOption));
return group;
}
private static Command BuildListNativeAlarmSourceOverrides(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
var cmd = new Command("list") { Description = "List native alarm source overrides for an instance" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new ListInstanceNativeAlarmSourceOverridesCommand(result.GetValue(idOption)));
});
return cmd;
}
private static Command BuildNativeAlarmSourceOverride(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption) private static Command BuildNativeAlarmSourceOverride(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{ {
var group = new Command("native-alarm-source") var group = new Command("native-alarm-source")
@@ -603,6 +641,8 @@ public static class InstanceCommands
Description = "Manage per-instance native alarm source overrides (retarget an inherited binding; blank = inherited)" Description = "Manage per-instance native alarm source overrides (retarget an inherited binding; blank = inherited)"
}; };
group.Add(BuildListNativeAlarmSourceOverrides(urlOption, formatOption, usernameOption, passwordOption));
// set // set
var setIdOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true }; var setIdOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
var setSourceOption = new Option<string>("--source") { Description = "Source binding canonical name (e.g. 'Pressure' or 'Module.Pressure')", Required = true }; var setSourceOption = new Option<string>("--source") { Description = "Source binding canonical name (e.g. 'Pressure' or 'Module.Pressure')", Required = true };
@@ -23,6 +23,8 @@ public static class NotificationCommands
command.Add(BuildGet(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildGet(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildRename(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildRecipient(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildSmtp(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildSmtp(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildSms(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildSms(urlOption, formatOption, usernameOption, passwordOption));
@@ -30,6 +32,49 @@ public static class NotificationCommands
return command; return command;
} }
private static Option<int> RequiredId(string name, string description)
{
var option = new Option<int>(name) { Description = description, Required = true };
option.Validators.Add(result => { if (result.GetValueOrDefault<int>() <= 0) result.AddError($"{name} must be positive."); });
return option;
}
private static Command BuildRename(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var cmd = new Command("rename") { Description = "Rename a list, preserving all recipients and their IDs" };
var id = RequiredId("--id", "Notification list ID");
var name = new Option<string>("--name") { Required = true, Description = "New list name" };
cmd.Add(id); cmd.Add(name);
cmd.SetAction(async result => await CommandHelpers.ExecuteCommandAsync(result, urlOption, formatOption, usernameOption, passwordOption,
new RenameNotificationListCommand(result.GetValue(id), result.GetValue(name)!)));
return cmd;
}
private static Command BuildRecipient(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var group = new Command("recipient") { Description = "Manage individual named recipients without replacing the list" };
var add = new Command("add") { Description = "Add a named recipient (address must match the list delivery channel)" };
var listId = RequiredId("--list-id", "Notification list ID");
var name = new Option<string>("--name") { Required = true, Description = "Recipient name" };
var email = new Option<string?>("--email") { Description = "Email address" };
var phone = new Option<string?>("--phone") { Description = "Phone number in E.164 format" };
add.Add(listId); add.Add(name); add.Add(email); add.Add(phone);
add.Validators.Add(result =>
{
if (string.IsNullOrWhiteSpace(result.GetValue(email)) && string.IsNullOrWhiteSpace(result.GetValue(phone)))
result.AddError("Provide --email and/or --phone for the recipient.");
});
add.SetAction(async result => await CommandHelpers.ExecuteCommandAsync(result, urlOption, formatOption, usernameOption, passwordOption,
new AddNotificationRecipientCommand(result.GetValue(listId), result.GetValue(name)!, result.GetValue(email), result.GetValue(phone))));
var delete = new Command("delete") { Description = "Delete one recipient by its recipient ID" };
var id = RequiredId("--id", "Recipient ID");
delete.Add(id);
delete.SetAction(async result => await CommandHelpers.ExecuteCommandAsync(result, urlOption, formatOption, usernameOption, passwordOption,
new DeleteNotificationRecipientCommand(result.GetValue(id))));
group.Add(add); group.Add(delete);
return group;
}
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
// Notification list create/update options (static so the parsed values can // Notification list create/update options (static so the parsed values can
// be read back both from the SetAction and from the testable Build* helpers). // be read back both from the SetAction and from the testable Build* helpers).
@@ -134,6 +179,15 @@ public static class NotificationCommands
}); });
group.Add(listCmd); group.Add(listCmd);
var createCmd = new Command("create") { Description = "Create SMTP configuration" };
foreach (var option in new Option[] { SmtpServerOption, SmtpPortOption, SmtpAuthModeOption, SmtpFromOption, SmtpTlsModeOption,
SmtpCredentialsOption, SmtpOAuth2AuthorityOption, SmtpOAuth2ScopeOption, SmtpTransportOption }) createCmd.Add(option);
createCmd.SetAction(async result => await CommandHelpers.ExecuteCommandAsync(result, urlOption, formatOption, usernameOption, passwordOption,
new CreateSmtpConfigCommand(result.GetValue(SmtpServerOption)!, result.GetValue(SmtpPortOption), result.GetValue(SmtpAuthModeOption)!,
result.GetValue(SmtpFromOption)!, result.GetValue(SmtpTlsModeOption), result.GetValue(SmtpCredentialsOption),
result.GetValue(SmtpOAuth2AuthorityOption), result.GetValue(SmtpOAuth2ScopeOption), result.GetValue(SmtpTransportOption))));
group.Add(createCmd);
var updateCmd = new Command("update") { Description = "Update SMTP configuration" }; var updateCmd = new Command("update") { Description = "Update SMTP configuration" };
updateCmd.Add(SmtpIdOption); updateCmd.Add(SmtpIdOption);
updateCmd.Add(SmtpServerOption); updateCmd.Add(SmtpServerOption);
@@ -247,6 +301,21 @@ public static class NotificationCommands
}); });
group.Add(listCmd); group.Add(listCmd);
var createCmd = new Command("create") { Description = "Create SMS configuration (Auth Token required)" };
foreach (var option in new Option[] { SmsAccountSidOption, SmsFromNumberOption, SmsMessagingServiceSidOption, SmsApiBaseUrlOption,
SmsAuthTokenOption, SmsTimeoutOption, SmsMaxRetriesOption, SmsRetryDelayOption }) createCmd.Add(option);
createCmd.Validators.Add(result =>
{
if (string.IsNullOrWhiteSpace(result.GetValue(SmsAuthTokenOption))) result.AddError("--auth-token is required when creating SMS configuration.");
if (string.IsNullOrWhiteSpace(result.GetValue(SmsFromNumberOption)) && string.IsNullOrWhiteSpace(result.GetValue(SmsMessagingServiceSidOption)))
result.AddError("Provide --from-number and/or --messaging-service-sid.");
});
createCmd.SetAction(async result => await CommandHelpers.ExecuteCommandAsync(result, urlOption, formatOption, usernameOption, passwordOption,
new CreateSmsConfigCommand(result.GetValue(SmsAccountSidOption)!, result.GetValue(SmsFromNumberOption), result.GetValue(SmsMessagingServiceSidOption),
result.GetValue(SmsApiBaseUrlOption), result.GetValue(SmsAuthTokenOption), result.GetValue(SmsTimeoutOption),
result.GetValue(SmsMaxRetriesOption), SmsRetryDelay(result))));
group.Add(createCmd);
var updateCmd = new Command("update") { Description = "Update SMS configuration" }; var updateCmd = new Command("update") { Description = "Update SMS configuration" };
updateCmd.Add(SmsIdOption); updateCmd.Add(SmsIdOption);
updateCmd.Add(SmsAccountSidOption); updateCmd.Add(SmsAccountSidOption);
@@ -254,6 +323,9 @@ public static class NotificationCommands
updateCmd.Add(SmsMessagingServiceSidOption); updateCmd.Add(SmsMessagingServiceSidOption);
updateCmd.Add(SmsApiBaseUrlOption); updateCmd.Add(SmsApiBaseUrlOption);
updateCmd.Add(SmsAuthTokenOption); updateCmd.Add(SmsAuthTokenOption);
updateCmd.Add(SmsTimeoutOption);
updateCmd.Add(SmsMaxRetriesOption);
updateCmd.Add(SmsRetryDelayOption);
updateCmd.SetAction(async (ParseResult result) => updateCmd.SetAction(async (ParseResult result) =>
{ {
return await CommandHelpers.ExecuteCommandAsync( return await CommandHelpers.ExecuteCommandAsync(
@@ -265,6 +337,24 @@ public static class NotificationCommands
return group; return group;
} }
private static readonly Option<int?> SmsTimeoutOption = SmsPolicyOption("--connection-timeout-seconds", "Connection timeout in seconds", 1);
private static readonly Option<int?> SmsMaxRetriesOption = SmsPolicyOption("--max-retries", "Retry count (zero uses the outbox fallback)", 0);
private static readonly Option<int?> SmsRetryDelayOption = SmsPolicyOption("--retry-delay-seconds", "Retry delay in seconds (zero uses the outbox fallback)", 0);
private static Option<int?> SmsPolicyOption(string name, string description, int minimum)
{
var option = new Option<int?>(name) { Description = description + "; omission preserves updates or uses create defaults" };
option.Validators.Add(result =>
{
if (result.GetValueOrDefault<int?>() is { } value && value < minimum)
result.AddError($"{name} must be at least {minimum}.");
});
return option;
}
private static TimeSpan? SmsRetryDelay(ParseResult result) =>
result.GetValue(SmsRetryDelayOption) is { } seconds ? TimeSpan.FromSeconds(seconds) : null;
// SMS update options are static so the parsed values can be read back both // SMS update options are static so the parsed values can be read back both
// from the SetAction and from BuildUpdateSmsConfigCommand (used by tests). // from the SetAction and from BuildUpdateSmsConfigCommand (used by tests).
private static readonly Option<int> SmsIdOption = private static readonly Option<int> SmsIdOption =
@@ -317,7 +407,8 @@ public static class NotificationCommands
throw new ArgumentException( throw new ArgumentException(
"Provide --from-number and/or --messaging-service-sid (at least one sender identity is required)."); "Provide --from-number and/or --messaging-service-sid (at least one sender identity is required).");
} }
return new UpdateSmsConfigCommand(id, accountSid, fromNumber, messagingServiceSid, apiBaseUrl, authToken); return new UpdateSmsConfigCommand(id, accountSid, fromNumber, messagingServiceSid, apiBaseUrl, authToken,
result.GetValue(SmsTimeoutOption), result.GetValue(SmsMaxRetriesOption), SmsRetryDelay(result));
} }
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption) private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
@@ -1,15 +1,14 @@
using System.CommandLine; using System.CommandLine;
using System.CommandLine.Parsing; using System.CommandLine.Parsing;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary> /// <summary>
/// Builds the <c>secured-write</c> command group. Currently exposes the read-only /// Builds the two-person secured-write workflow. Submission requires an Operator;
/// <c>list</c> sub-command, which relays a <see cref="ListSecuredWritesCommand"/> to the /// approve/reject require a distinct Verifier. Roles, site scope, expiry, and
/// central <c>ManagementActor</c> (gated any-of <c>Operator</c> / <c>Verifier</c> / /// separation of duties are enforced by the management server.
/// <c>Administrator</c>). The list is offset-paged via <c>--skip</c> / <c>--take</c>
/// (arch-review P3); omitting them preserves the historical unpaged default (first 200 rows).
/// </summary> /// </summary>
public static class SecuredWriteCommands public static class SecuredWriteCommands
{ {
@@ -24,7 +23,7 @@ public static class SecuredWriteCommands
private static readonly Option<int> TakeOption = private static readonly Option<int> TakeOption =
new("--take") { Description = "Maximum rows to return (clamped to 1..500 server-side)", DefaultValueFactory = _ => 200 }; new("--take") { Description = "Maximum rows to return (clamped to 1..500 server-side)", DefaultValueFactory = _ => 200 };
/// <summary>Builds the <c>secured-write</c> command group with its <c>list</c> sub-command.</summary> /// <summary>Builds the <c>secured-write</c> command group with list, submit, approve, and reject subcommands.</summary>
/// <param name="urlOption">Global <c>--url</c> option for the management API endpoint.</param> /// <param name="urlOption">Global <c>--url</c> option for the management API endpoint.</param>
/// <param name="formatOption">Global <c>--format</c> option for output format.</param> /// <param name="formatOption">Global <c>--format</c> option for output format.</param>
/// <param name="usernameOption">Global <c>--username</c> option for authentication.</param> /// <param name="usernameOption">Global <c>--username</c> option for authentication.</param>
@@ -32,8 +31,11 @@ public static class SecuredWriteCommands
/// <returns>The configured <c>secured-write</c> command.</returns> /// <returns>The configured <c>secured-write</c> command.</returns>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption) public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{ {
var command = new Command("secured-write") { Description = "Query two-person (MxGateway) secured writes" }; var command = new Command("secured-write") { Description = "Manage two-person (MxGateway) secured writes" };
command.Add(BuildList(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildList(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildSubmit(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildDecision("approve", urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildDecision("reject", urlOption, formatOption, usernameOption, passwordOption));
return command; return command;
} }
@@ -53,6 +55,84 @@ public static class SecuredWriteCommands
return cmd; return cmd;
} }
private static Command BuildSubmit(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var site = new Option<string>("--site") { Description = "Target site identifier", Required = true };
var connection = new Option<string>("--connection") { Description = "MxGateway connection name", Required = true };
var tag = new Option<string>("--tag-path") { Description = "Fully-qualified tag path", Required = true };
var value = new Option<string>("--value-json") { Description = "JSON-serialized value (quote JSON strings)", Required = true };
var type = new Option<string>("--value-type") { Description = "Target data type name, e.g. Boolean or Double", Required = true };
var comment = new Option<string?>("--comment") { Description = "Operator comment" };
var command = new Command("submit") { Description = "Submit a pending secured write as an Operator for a distinct Verifier" };
command.Add(site); command.Add(connection); command.Add(tag); command.Add(value); command.Add(type); command.Add(comment);
command.SetAction(async (ParseResult result) =>
{
var json = result.GetValue(value)!;
try
{
using var parsed = JsonDocument.Parse(json);
}
catch (JsonException ex)
{
OutputFormatter.WriteError($"Invalid value JSON: {ex.Message}", "INVALID_JSON");
return 1;
}
return await CommandHelpers.ExecuteCommandAsync(result, urlOption, formatOption, usernameOption, passwordOption,
new SubmitSecuredWriteCommand(result.GetValue(site)!, result.GetValue(connection)!, result.GetValue(tag)!, json,
result.GetValue(type)!, result.GetValue(comment)));
});
return command;
}
private static Command BuildDecision(string verb, Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var id = new Option<long>("--id") { Description = "Pending secured-write ID", Required = true };
var comment = new Option<string?>("--comment") { Description = "Verifier comment" };
var command = new Command(verb)
{
Description = verb == "approve"
? "Approve and execute a pending write as a distinct Verifier"
: "Reject a pending write as a distinct Verifier"
};
command.Add(id); command.Add(comment);
command.SetAction((ParseResult result) =>
{
object request = verb == "approve"
? new ApproveSecuredWriteCommand(result.GetValue(id), result.GetValue(comment))
: new RejectSecuredWriteCommand(result.GetValue(id), result.GetValue(comment));
return CommandHelpers.ExecuteCommandAsync(result, urlOption, formatOption, usernameOption, passwordOption, request,
onSuccess: verb == "approve"
? json => HandleApprovalResponse(json, CommandHelpers.ResolveFormat(result, formatOption, CliConfig.Load()))
: null);
});
return command;
}
private static int HandleApprovalResponse(string json, string format)
{
CommandHelpers.HandleResponse(new ManagementResponse(200, json, null, null), format);
try
{
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("status", out var status)
&& status.ValueKind == JsonValueKind.String)
{
if (status.GetString() == "Executed") return 0;
if (status.GetString() == "Failed")
{
var detail = root.TryGetProperty("executionError", out var error) && error.ValueKind == JsonValueKind.String
? error.GetString() : null;
OutputFormatter.WriteError(detail ?? "The server reported that the secured write failed.", "SECURED_WRITE_FAILED");
return 1;
}
}
}
catch (JsonException) { }
OutputFormatter.WriteError("The server returned an invalid secured-write approval outcome.", "INVALID_RESPONSE");
return 1;
}
/// <summary> /// <summary>
/// Builds a <see cref="ListSecuredWritesCommand"/> from a parsed <c>secured-write list</c> /// Builds a <see cref="ListSecuredWritesCommand"/> from a parsed <c>secured-write list</c>
/// invocation. Exposed internally so command-construction tests can assert field mapping /// invocation. Exposed internally so command-construction tests can assert field mapping
@@ -0,0 +1,81 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary>CLI adapters for the shared JSON schema library management contracts.</summary>
public static class SharedSchemaCommands
{
/// <summary>Builds the shared-schema list/get/create/update/delete command group.</summary>
/// <param name="urlOption">Management URL option.</param>
/// <param name="formatOption">Output format option.</param>
/// <param name="usernameOption">Authentication username option.</param>
/// <param name="passwordOption">Authentication password option.</param>
/// <returns>The configured shared-schema group.</returns>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var group = new Command("shared-schema") { Description = "Manage the shared JSON schema library" };
var list = new Command("list") { Description = "List shared schemas" };
list.SetAction((ParseResult result) => Send(result, new ListSharedSchemasCommand()));
group.Add(list);
group.Add(BuildIdentity("get", id => new GetSharedSchemaCommand(id)));
group.Add(BuildEdit(update: false));
group.Add(BuildEdit(update: true));
group.Add(BuildIdentity("delete", id => new DeleteSharedSchemaCommand(id)));
return group;
Task<int> Send(ParseResult result, object command) => CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, command);
Command BuildIdentity(string verb, Func<int, object> makeCommand)
{
var id = new Option<int>("--id") { Description = "Shared schema ID", Required = true };
var command = new Command(verb) { Description = $"{(verb == "get" ? "Get" : "Delete")} a shared schema by ID" };
command.Add(id);
command.SetAction((ParseResult result) => Send(result, makeCommand(result.GetValue(id))));
return command;
}
Command BuildEdit(bool update)
{
var id = new Option<int>("--id") { Description = "Shared schema ID", Required = true };
var name = new Option<string>("--name") { Description = "Schema library name", Required = true };
var scope = new Option<string?>("--scope") { Description = "Optional schema scope; omission clears scope on update" };
var file = new Option<string>("--schema-file") { Description = "Path to the JSON schema document", Required = true };
var command = new Command(update ? "update" : "create")
{
Description = update ? "Replace a shared schema's name, scope, and JSON document" : "Create a shared schema from a JSON file"
};
if (update) command.Add(id);
command.Add(name); command.Add(scope); command.Add(file);
command.SetAction(async (ParseResult result) =>
{
string json;
try
{
json = await File.ReadAllTextAsync(result.GetValue(file)!);
using var parsed = JsonDocument.Parse(json);
}
catch (JsonException ex)
{
OutputFormatter.WriteError($"Invalid schema JSON: {ex.Message}", "INVALID_JSON");
return 1;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
{
OutputFormatter.WriteError($"Could not read schema file: {ex.Message}", "FILE_ERROR");
return 1;
}
// Structural schema validation, uniqueness, and authorization remain server-owned.
object request = update
? new UpdateSharedSchemaCommand(result.GetValue(id), result.GetValue(name)!, result.GetValue(scope), json)
: new CreateSharedSchemaCommand(result.GetValue(name)!, result.GetValue(scope), json);
return await Send(result, request);
});
return command;
}
}
}
@@ -19,6 +19,8 @@ public static class TemplateCommands
var command = new Command("template") { Description = "Manage templates" }; var command = new Command("template") { Description = "Manage templates" };
command.Add(BuildList(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildList(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildResolvedMembers(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildMove(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildGet(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildGet(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption));
@@ -139,6 +141,65 @@ public static class TemplateCommands
return group; return group;
} }
private static Command BuildResolvedMembers(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Template ID", Required = true };
var cmd = new Command("resolved-members") { Description = "Inspect resolved template members including inherited members" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new GetResolvedTemplateMembersCommand(result.GetValue(idOption)));
});
return cmd;
}
private static Command BuildMove(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Template ID", Required = true };
var folderOption = new Option<int?>("--folder-id") { Description = "Destination folder ID; omit to move to the tree root" };
var cmd = new Command("move") { Description = "Move a template into a folder or to the tree root" };
cmd.Add(idOption);
cmd.Add(folderOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new MoveTemplateToFolderCommand(result.GetValue(idOption), result.GetValue(folderOption)));
});
return cmd;
}
private static Command BuildUpdateNativeAlarmSource(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Native alarm source ID", Required = true };
var nameOption = new Option<string>("--name") { Description = "Source binding name", Required = true };
var connectionOption = new Option<string>("--connection") { Description = "Alarm-capable data connection name", Required = true };
var sourceOption = new Option<string>("--source-ref") { Description = "Source reference (OPC UA SourceNode nodeId, or MxAccess object/area)", Required = true };
var filterOption = new Option<string?>("--filter") { Description = "Condition filter; omit to clear and mirror all conditions" };
var descriptionOption = new Option<string?>("--description") { Description = "Description; omit to clear" };
var lockedOption = new Option<bool>("--locked") { Description = "Lock status; omit for unlocked" };
var cmd = new Command("update") { Description = "Replace the fields of a native alarm source binding" };
cmd.Add(idOption);
cmd.Add(nameOption);
cmd.Add(connectionOption);
cmd.Add(sourceOption);
cmd.Add(filterOption);
cmd.Add(descriptionOption);
cmd.Add(lockedOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new UpdateTemplateNativeAlarmSourceCommand(
result.GetValue(idOption), result.GetValue(nameOption)!,
result.GetValue(connectionOption)!, result.GetValue(sourceOption)!,
result.GetValue(filterOption), result.GetValue(descriptionOption), result.GetValue(lockedOption)));
});
return cmd;
}
private static Command BuildResyncMembers(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption) private static Command BuildResyncMembers(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{ {
var idOption = new Option<int>("--id") { Description = "Template ID (its derived subtree is included)", Required = true }; var idOption = new Option<int>("--id") { Description = "Template ID (its derived subtree is included)", Required = true };
@@ -758,6 +819,8 @@ public static class TemplateCommands
Description = "Manage template native alarm source bindings (read-only mirror of OPC UA A&C / MxGateway alarms)" Description = "Manage template native alarm source bindings (read-only mirror of OPC UA A&C / MxGateway alarms)"
}; };
group.Add(BuildUpdateNativeAlarmSource(urlOption, formatOption, usernameOption, passwordOption));
// add // add
var templateIdOption = new Option<int>("--template-id") { Description = "Template ID", Required = true }; var templateIdOption = new Option<int>("--template-id") { Description = "Template ID", Required = true };
var nameOption = new Option<string>("--name") { Description = "Source binding name", Required = true }; var nameOption = new Option<string>("--name") { Description = "Source binding name", Required = true };
+2 -44
View File
@@ -1,50 +1,8 @@
using System.CommandLine;
using System.CommandLine.Parsing; using System.CommandLine.Parsing;
using ZB.MOM.WW.ScadaBridge.CLI;
using ZB.MOM.WW.ScadaBridge.CLI.Commands; using ZB.MOM.WW.ScadaBridge.CLI.Commands;
var rootCommand = new RootCommand("ScadaBridge CLI — manage the ScadaBridge SCADA system"); var rootCommand = CliApplication.BuildRoot();
var urlOption = new Option<string>("--url") { Description = "Management API URL", Recursive = true };
var usernameOption = new Option<string>("--username") { Description = "LDAP username", Recursive = true };
var passwordOption = new Option<string>("--password") { Description = "LDAP password", Recursive = true };
// No DefaultValueFactory: format precedence (explicit --format -> config/env -> "json")
// is resolved by CommandHelpers.ResolveFormat, which needs to distinguish an absent flag.
// CliOptions.CreateFormatOption also constrains the accepted values (json/table).
var formatOption = CliOptions.CreateFormatOption();
rootCommand.Add(urlOption);
rootCommand.Add(usernameOption);
rootCommand.Add(passwordOption);
rootCommand.Add(formatOption);
// Register command groups
rootCommand.Add(TemplateCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(InstanceCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(SiteCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(DeployCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
var dataConnectionCommand = DataConnectionCommands.Build(urlOption, formatOption, usernameOption, passwordOption);
// Attach the site-routed browse/search/verify-endpoint/certs subcommands (actor parity, arch-review C4).
ConnectionBrowseCommands.AddTo(dataConnectionCommand, urlOption, formatOption, usernameOption, passwordOption);
rootCommand.Add(dataConnectionCommand);
rootCommand.Add(ExternalSystemCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(NotificationCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(SecurityCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(AuditLogCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(AuditCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(HealthCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(DebugCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(SharedScriptCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(DbConnectionCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(ApiMethodCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(BundleCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(CachedCallCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(SecuredWriteCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(FlowDebugCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.SetAction(_ =>
{
Console.WriteLine("Use --help to see available commands.");
});
// Deprecation notice for the earlier `audit-log` command name. The command itself // Deprecation notice for the earlier `audit-log` command name. The command itself
// still works (it is an alias of `audit-config`), but using the old name emits a // still works (it is an alias of `audit-config`), but using the old name emits a
+102 -1
View File
@@ -2272,7 +2272,7 @@ scadabridge --url <url> cached-call discard --site-id <string> --tracked-operati
### `secured-write` — Query two-person write requests ### `secured-write` — Query two-person write requests
`secured-write list` reads the MxGateway secured-write queue and terminal history. It requires any of Operator, Verifier or Administrator and applies the caller's site scope. This CLI group exposes listing only; submitting, approving and rejecting are management/UI operations. `secured-write list` reads the MxGateway secured-write queue and terminal history. It requires any of Operator, Verifier or Administrator and applies the caller's site scope. This group also exposes `submit`, `approve`, and `reject` through the same server-side authorization and separation-of-duties checks as the UI; see the parity additions below.
```sh ```sh
scadabridge secured-write list --status Pending --site site-a --skip 0 --take 50 scadabridge secured-write list --status Pending --site site-a --skip 0 --take 50
@@ -2424,3 +2424,104 @@ Role enforcement is applied by the ManagementActor on the server side. The CLI a
## Issues & Missing Features ## Issues & Missing Features
If you encounter bugs, unexpected behavior, or missing features in the CLI, log them in [`cli_issues.md`](../../cli_issues.md) in the project root. Include a brief description, the command involved, and any relevant error output. If you encounter bugs, unexpected behavior, or missing features in the CLI, log them in [`cli_issues.md`](../../cli_issues.md) in the project root. Include a brief description, the command involved, and any relevant error output.
## Management parity additions (2026-09-10)
The production CLI exposes all 149 HTTP-eligible management contracts across 20
command groups. `CliApplication.BuildRoot()` is used both by the executable and
by the HTTP-dispatch parity test. Site-only `ReadTagValues` and the retired
`ResolveRoles` flow are not accepted by `POST /management`; binding tests continue
using the site's existing transport.
### Shared schema library
```sh
scadabridge shared-schema list
scadabridge shared-schema get --id 7
scadabridge shared-schema create --name PumpPayload --schema-file ./pump.schema.json --scope Pump
scadabridge shared-schema update --id 7 --name PumpPayload --schema-file ./pump.schema.json
scadabridge shared-schema delete --id 7
```
Create/update read UTF-8 JSON from `--schema-file`; the server validates schema
structure and uniqueness. Update replaces the name, schema, and scope; omitting
`--scope` clears it. Mutations require Designer access.
### Individual notification edits and provider creation
```sh
scadabridge notification rename --id 4 --name "Shift engineers"
scadabridge notification recipient add --list-id 4 --name "On-call engineer" --email engineer@example.com
scadabridge notification recipient delete --id 18
scadabridge notification smtp create --server smtp.example.com --port 587 --auth-mode None --from-address alerts@example.com
scadabridge notification sms create --account-sid AC0123456789abcdef0123456789abcdef --from-number +15551234567 --auth-token "<token>"
```
Rename and individual recipient edits preserve unrelated recipient IDs and names.
Recipient add accepts `--email` and/or `--phone`; the server checks them against
its owning list's channel. Existing `notification update` retains its whole-list
recipient replacement semantics.
SMTP/SMS create accept their update options except `--id` and send distinct create
contracts. SMS create requires an auth token and a sender (`--from-number` and/or
`--messaging-service-sid`). Provider creation/update requires Administrator access.
SMS create and update also accept:
| Option | Meaning |
|---|---|
| `--connection-timeout-seconds` | Positive connection timeout |
| `--max-retries` | Nonnegative retry setting |
| `--retry-delay-seconds` | Nonnegative retry delay |
Omission preserves existing update values or uses creation defaults. Explicit
zero retry settings retain the existing outbox fallback semantics; they do not
promise that delivery will never retry. Existing secret-preservation and
messaging-service-ID clearing behavior on update is unchanged.
### Template, instance, and connection edits
```sh
scadabridge template resolved-members --id 3
scadabridge template move --id 3 --folder-id 8
scadabridge template native-alarm-source update --id 5 --name Pressure --connection Plc --source-ref Pressure --locked
scadabridge instance attribute-override delete --instance-id 7 --attribute Module.Speed
scadabridge instance native-alarm-source list --instance-id 7
scadabridge data-connection move --id 9 --site-id 2
```
`resolved-members` inspects effective inherited template members, without applying
composition or instance overrides. Omit `--folder-id` to move a template to the
root. Native-alarm-source update replaces all contract fields: optional `--filter`
and `--description` clear when omitted, and omitted `--locked` means false.
Individual attribute-override deletion preserves other overrides. Server-side
role, site-scope, locking, and dependency checks remain authoritative.
### Secured-write lifecycle
```sh
scadabridge secured-write submit --site site-a --connection Plc --tag-path Pump.Setpoint --value-json 42 --value-type Int32 --comment "Approved operating procedure"
scadabridge secured-write approve --id 123 --comment "Verified setpoint"
scadabridge secured-write reject --id 124 --comment "Incorrect target"
```
Submit requires Operator access; approve/reject require Verifier access. The
server derives identity from authentication and enforces a different principal
for the verification decision. Approval can execute a real device write.
`--value-json` must be valid JSON; the server validates the value/type pairing.
IDs for approve/reject are 64-bit integers; `--comment` is optional.
Approval exits zero only for an `Executed` response. A server-reported execution
failure exits one while retaining the response details; malformed or unexpected
approval responses also fail. Authorization denial exits two. JSON/table output
continues to use the standard formatter.
### Deployment deadline policy
`deploy instance`, `instance deploy`, `deploy artifacts`, `site deploy-artifacts`,
and `deploy site` defer the overall deadline to the Management API. They keep the
bounded socket-connect timeout but do not impose the ordinary 30-second client
request deadline on a deployment that may still be applying. The API's configured
long-running timeout remains authoritative. Ordinary commands retain a 30-second
client deadline; explicit bundle/Flow Debugger timeouts retain their own policies.
@@ -4,9 +4,10 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
/// <summary> /// <summary>
/// Bidirectional name registry for management command records. The registry contains /// Bidirectional name registry for management command records. The registry contains
/// exactly the non-abstract <c>*Command</c> types declared in the /// the HTTP-eligible non-abstract <c>*Command</c> types declared in the
/// <c>ZB.MOM.WW.ScadaBridge.Commons.Messages.Management</c> namespace; these are the commands that /// <c>ZB.MOM.WW.ScadaBridge.Commons.Messages.Management</c> namespace; these are the commands that
/// travel over the HTTP management boundary. /// travel over the HTTP management boundary. Site-only tag reads and the retired
/// role-resolution flow are excluded; their internal message types remain available.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <see cref="Resolve"/> and <see cref="GetCommandName"/> are symmetric: /// <see cref="Resolve"/> and <see cref="GetCommandName"/> are symmetric:
@@ -41,7 +42,7 @@ public static class ManagementCommandRegistry
/// <param name="commandType">The CLR type of the management command to look up.</param> /// <param name="commandType">The CLR type of the management command to look up.</param>
/// <exception cref="ArgumentException"> /// <exception cref="ArgumentException">
/// Thrown when <paramref name="commandType"/> is not a registered management /// Thrown when <paramref name="commandType"/> is not a registered management
/// command — i.e. not a non-abstract <c>*Command</c> type in the /// command — i.e. not an HTTP-eligible non-abstract <c>*Command</c> type in the
/// <c>ZB.MOM.WW.ScadaBridge.Commons.Messages.Management</c> namespace. This keeps the method /// <c>ZB.MOM.WW.ScadaBridge.Commons.Messages.Management</c> namespace. This keeps the method
/// symmetric with <see cref="Resolve"/>: it never yields a name that /// symmetric with <see cref="Resolve"/>: it never yields a name that
/// <see cref="Resolve"/> cannot turn back into the same type. /// <see cref="Resolve"/> cannot turn back into the same type.
@@ -56,7 +57,7 @@ public static class ManagementCommandRegistry
throw new ArgumentException( throw new ArgumentException(
$"'{commandType.FullName}' is not a registered management command. " + $"'{commandType.FullName}' is not a registered management command. " +
$"Management commands must be non-abstract '*Command' records declared in " + $"Management commands must be HTTP-eligible, non-abstract '*Command' records declared in " +
$"the '{typeof(ManagementEnvelope).Namespace}' namespace.", $"the '{typeof(ManagementEnvelope).Namespace}' namespace.",
nameof(commandType)); nameof(commandType));
} }
@@ -69,7 +70,12 @@ public static class ManagementCommandRegistry
return assembly.GetTypes() return assembly.GetTypes()
.Where(t => t.Namespace == ns .Where(t => t.Namespace == ns
&& t.Name.EndsWith("Command", StringComparison.Ordinal) && t.Name.EndsWith("Command", StringComparison.Ordinal)
&& !t.IsAbstract) && !t.IsAbstract
// These records are internal/retired, not central HTTP operations.
// Keep the types for site gRPC and compatibility, without advertising
// names that ManagementActor cannot dispatch.
&& t != typeof(ReadTagValuesCommand)
&& t != typeof(ResolveRolesCommand))
.ToFrozenDictionary( .ToFrozenDictionary(
t => t.Name[..^"Command".Length], t => t.Name[..^"Command".Length],
t => t, t => t,
@@ -18,46 +18,15 @@ public class CommandTreeTests
private static readonly Option<string> Password = new("--password") { Recursive = true }; private static readonly Option<string> Password = new("--password") { Recursive = true };
private static readonly Option<string> Format = CliOptions.CreateFormatOption(); private static readonly Option<string> Format = CliOptions.CreateFormatOption();
// NOTE: this list MUST stay in sync with the rootCommand.Add(...) calls in // Exercise the production root: a separately maintained group list can drift.
// src/ZB.MOM.WW.ScadaBridge.CLI/Program.cs. When a new command group is added (or one is private static IEnumerable<Command> AllCommandGroups() => CliApplication.BuildRoot().Subcommands;
// removed/renamed), update this array and bump the count assertion in
// AllCommandGroups_Build_WithoutThrowing accordingly.
private static IEnumerable<Command> AllCommandGroups() => new[]
{
TemplateCommands.Build(Url, Format, Username, Password),
InstanceCommands.Build(Url, Format, Username, Password),
SiteCommands.Build(Url, Format, Username, Password),
DeployCommands.Build(Url, Format, Username, Password),
BuildDataConnectionGroup(),
ExternalSystemCommands.Build(Url, Format, Username, Password),
NotificationCommands.Build(Url, Format, Username, Password),
SecurityCommands.Build(Url, Format, Username, Password),
AuditLogCommands.Build(Url, Format, Username, Password),
AuditCommands.Build(Url, Format, Username, Password),
HealthCommands.Build(Url, Format, Username, Password),
DebugCommands.Build(Url, Format, Username, Password),
SharedScriptCommands.Build(Url, Format, Username, Password),
DbConnectionCommands.Build(Url, Format, Username, Password),
ApiMethodCommands.Build(Url, Format, Username, Password),
BundleCommands.Build(Url, Format, Username, Password),
CachedCallCommands.Build(Url, Format, Username, Password),
SecuredWriteCommands.Build(Url, Format, Username, Password),
FlowDebugCommands.Build(Url, Format, Username, Password),
};
private static Command BuildDataConnectionGroup()
{
var command = DataConnectionCommands.Build(Url, Format, Username, Password);
ConnectionBrowseCommands.AddTo(command, Url, Format, Username, Password);
return command;
}
[Fact] [Fact]
public void RegisteredOperationalGroups_IncludeSecuredWritesAndConnectionTools() public void RegisteredOperationalGroups_IncludeSecuredWritesAndConnectionTools()
{ {
var groups = AllCommandGroups().ToList(); var groups = AllCommandGroups().ToList();
var secured = groups.Single(g => g.Name == "secured-write"); var secured = groups.Single(g => g.Name == "secured-write");
Assert.Equal("list", Assert.Single(secured.Subcommands).Name); Assert.Equal(new[] { "approve", "list", "reject", "submit" }, secured.Subcommands.Select(c => c.Name).Order());
var connection = groups.Single(g => g.Name == "data-connection"); var connection = groups.Single(g => g.Name == "data-connection");
foreach (var name in new[] { "browse", "search", "verify-endpoint", "certs" }) foreach (var name in new[] { "browse", "search", "verify-endpoint", "certs" })
Assert.Contains(connection.Subcommands, c => c.Name == name); Assert.Contains(connection.Subcommands, c => c.Name == name);
@@ -82,12 +51,7 @@ public class CommandTreeTests
public void AllCommandGroups_Build_WithoutThrowing() public void AllCommandGroups_Build_WithoutThrowing()
{ {
var groups = AllCommandGroups().ToList(); var groups = AllCommandGroups().ToList();
// CLI-022: bump this count whenever a new top-level command group is Assert.Equal(20, groups.Count);
// registered in Program.cs. Current registered groups (19):
// template, instance, site, deploy, data-connection, external-system,
// notification, security, audit-config, audit, health, debug,
// shared-script, db-connection, api-method, bundle, cached-call, secured-write, flow-debug.
Assert.Equal(19, groups.Count);
Assert.All(groups, g => Assert.False(string.IsNullOrWhiteSpace(g.Name))); Assert.All(groups, g => Assert.False(string.IsNullOrWhiteSpace(g.Name)));
} }
@@ -0,0 +1,75 @@
using System.CommandLine;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
[Collection("Console")]
public class DeploymentDeadlineParityTests
{
[Fact]
public async Task EverySingleDeploymentAlias_WaitsForServerBeyondOrdinaryDeadline()
{
// Run the four independent loopback requests together: one 31-second window,
// exercising the real commands and socket transport past the old 30-second cap.
await Task.WhenAll(
InvokeDelayed(["deploy", "instance", "--id", "1"], "MgmtDeployInstance"),
InvokeDelayed(["instance", "deploy", "--id", "1"], "MgmtDeployInstance"),
InvokeDelayed(["deploy", "artifacts", "--site-id", "1"], "MgmtDeployArtifacts"),
InvokeDelayed(["site", "deploy-artifacts", "--site-id", "1"], "MgmtDeployArtifacts"));
}
private static async Task InvokeDelayed(string[] args, string expectedCommand)
{
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(45));
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
try
{
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
var server = Task.Run(async () =>
{
using var client = await listener.AcceptTcpClientAsync(deadline.Token);
await using var stream = client.GetStream();
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
var length = 0;
while (await reader.ReadLineAsync(deadline.Token) is { Length: > 0 } line)
if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase))
length = int.Parse(line[15..].Trim());
var request = new char[length];
var read = 0;
while (read < length)
{
var count = await reader.ReadAsync(request.AsMemory(read), deadline.Token);
if (count == 0) throw new EndOfStreamException();
read += count;
}
using var payload = JsonDocument.Parse(new string(request));
Assert.Equal(expectedCommand, payload.RootElement.GetProperty("command").GetString());
await Task.Delay(TimeSpan.FromSeconds(31), deadline.Token);
var reply = Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}");
try { await stream.WriteAsync(reply, deadline.Token); }
catch (IOException) { /* Old client closes at 30s; exit assertion diagnoses it. */ }
}, deadline.Token);
var root = new RootCommand();
var url = new Option<string>("--url") { Recursive = true };
var format = new Option<string>("--format") { Recursive = true };
var user = new Option<string>("--username") { Recursive = true };
var password = new Option<string>("--password") { Recursive = true };
root.Add(url); root.Add(format); root.Add(user); root.Add(password);
root.Add(DeployCommands.Build(url, format, user, password));
root.Add(InstanceCommands.Build(url, format, user, password));
root.Add(SiteCommands.Build(url, format, user, password));
var result = root.Parse([.. args, "--url", $"http://127.0.0.1:{port}",
"--username", "test", "--password", "test", "--format", "json"]);
Assert.Empty(result.Errors);
var exit = await result.InvokeAsync().WaitAsync(deadline.Token);
await server;
Assert.Equal(0, exit);
}
finally { listener.Stop(); }
}
}
@@ -0,0 +1,82 @@
using System.CommandLine;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
[Collection("Console")]
public class EditingParityTests
{
private static RootCommand BuildRoot()
{
var root = new RootCommand();
var url = new Option<string>("--url") { Recursive = true };
var format = new Option<string>("--format") { Recursive = true };
var user = new Option<string>("--username") { Recursive = true };
var password = new Option<string>("--password") { Recursive = true };
root.Add(url); root.Add(format); root.Add(user); root.Add(password);
root.Add(TemplateCommands.Build(url, format, user, password));
root.Add(InstanceCommands.Build(url, format, user, password));
root.Add(DataConnectionCommands.Build(url, format, user, password));
return root;
}
[Theory]
[InlineData("template resolved-members --id 12", "GetResolvedTemplateMembers", "{\"templateId\":12}")]
[InlineData("template move --id 12 --folder-id 3", "MoveTemplateToFolder", "{\"templateId\":12,\"newFolderId\":3}")]
[InlineData("template move --id 12", "MoveTemplateToFolder", "{\"templateId\":12,\"newFolderId\":null}")]
[InlineData("template native-alarm-source update --id 9 --name Pressure --connection OPC --source-ref ns=2;s=Source --filter Active --description Updated --locked", "UpdateTemplateNativeAlarmSource", "{\"nativeAlarmSourceId\":9,\"name\":\"Pressure\",\"connectionName\":\"OPC\",\"sourceReference\":\"ns=2;s=Source\",\"conditionFilter\":\"Active\",\"description\":\"Updated\",\"isLocked\":true}")]
[InlineData("template native-alarm-source update --id 9 --name Pressure --connection OPC --source-ref ns=2;s=Source", "UpdateTemplateNativeAlarmSource", "{\"nativeAlarmSourceId\":9,\"name\":\"Pressure\",\"connectionName\":\"OPC\",\"sourceReference\":\"ns=2;s=Source\",\"conditionFilter\":null,\"description\":null,\"isLocked\":false}")]
[InlineData("instance attribute-override delete --instance-id 6 --attribute Module.Value", "DeleteInstanceAttributeOverride", "{\"instanceId\":6,\"attributeName\":\"Module.Value\"}")]
[InlineData("instance native-alarm-source list --instance-id 6", "ListInstanceNativeAlarmSourceOverrides", "{\"instanceId\":6}")]
[InlineData("instance alarm-override list --instance-id 6", "ListInstanceAlarmOverrides", "{\"instanceId\":6}")]
[InlineData("data-connection move --id 4 --site-id 7", "MoveDataConnection", "{\"dataConnectionId\":4,\"targetSiteId\":7}")]
public async Task Command_SendsExactManagementEnvelope(string arguments, string command, string payload)
{
var root = BuildRoot();
Assert.Empty(root.Parse(arguments).Errors);
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
try
{
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
var server = Task.Run(async () =>
{
using var client = await listener.AcceptTcpClientAsync();
await using var stream = client.GetStream();
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
var requestLine = await reader.ReadLineAsync();
var length = 0;
while (await reader.ReadLineAsync() is { Length: > 0 } header)
if (header.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase))
length = int.Parse(header.Split(':')[1]);
var buffer = new char[length];
Assert.Equal(length, await reader.ReadBlockAsync(buffer, 0, length));
var response = Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}");
await stream.WriteAsync(response);
return (requestLine, body: new string(buffer));
});
var args = arguments.Split(' ').Concat(new[] { "--url", $"http://127.0.0.1:{port}", "--username", "test", "--password", "test", "--format", "json" }).ToArray();
Assert.Equal(0, await root.Parse(args).InvokeAsync());
var request = await server.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal("POST /management HTTP/1.1", request.requestLine);
using var actual = JsonDocument.Parse(request.body);
Assert.Equal(command, actual.RootElement.GetProperty("command").GetString());
Assert.True(JsonElement.DeepEquals(JsonDocument.Parse(payload).RootElement, actual.RootElement.GetProperty("payload")), request.body);
}
finally { listener.Stop(); }
}
[Theory]
[InlineData("template resolved-members")]
[InlineData("template move --id 12 --folder-id invalid")]
[InlineData("template native-alarm-source update --id 9 --name Pressure --connection OPC")]
[InlineData("instance attribute-override delete --instance-id 6")]
[InlineData("instance native-alarm-source list")]
[InlineData("data-connection move --id 4")]
public void Command_RejectsMissingOrMalformedArguments(string arguments)
=> Assert.NotEmpty(BuildRoot().Parse(arguments).Errors);
}
@@ -0,0 +1,168 @@
using System.Collections.Concurrent;
using System.CommandLine;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.CLI;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// <summary>
/// Drives the production command tree against a loopback recorder. Every public
/// management contract must actually cross HTTP from a CLI action. A constructor
/// reference, an unregistered group, or a help-only leaf cannot satisfy this gate.
/// Server replies are deliberate errors so no local success handlers write exports.
/// </summary>
[Collection("Console")]
public class ManagementSurfaceParityTests
{
[Fact]
public async Task EveryHttpManagementContract_IsSentByTheProductionCli()
{
var sent = await ProbeProductionTree();
var registered = typeof(ManagementEnvelope).Assembly.GetTypes()
.Where(t => t.Namespace == typeof(ManagementEnvelope).Namespace && !t.IsAbstract
&& t.Name.EndsWith("Command", StringComparison.Ordinal))
.Where(t => ManagementCommandRegistry.Resolve(t.Name[..^7]) == t)
.Select(ManagementCommandRegistry.GetCommandName).ToHashSet();
var missing = registered.Except(sent.Keys).Order().ToArray();
Assert.True(missing.Length == 0, "No CLI dispatch for: " + string.Join(", ", missing));
Assert.Empty(sent.Keys.Except(registered));
}
[Fact]
public async Task RemovingProductionSchemaGroup_IsDetectedByParityProbe()
{
var sent = await ProbeProductionTree(omitGroup: "shared-schema");
Assert.DoesNotContain("ListSharedSchemas", sent.Keys);
Assert.Contains("ListSharedScripts", sent.Keys);
}
private static async Task<ConcurrentDictionary<string, string>> ProbeProductionTree(string? omitGroup = null)
{
var root = CliApplication.BuildRoot();
var recorded = new ConcurrentDictionary<string, string>();
var directory = Directory.CreateTempSubdirectory("cli-parity-");
var file = Path.Combine(directory.FullName, "input.json");
await File.WriteAllTextAsync(file, "{}");
var csv = Path.Combine(directory.FullName, "native.csv");
await File.WriteAllTextAsync(csv, "SourceName,Connection,SourceReference,Filter\nSource,Connection,Path,\n");
using var stop = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var url = $"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}";
var currentPath = "";
var server = Task.Run(async () =>
{
try
{
while (!stop.IsCancellationRequested)
{
using var client = await listener.AcceptTcpClientAsync(stop.Token);
await using var stream = client.GetStream();
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
var length = 0;
while (await reader.ReadLineAsync(stop.Token) is { Length: > 0 } line)
if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase))
length = int.Parse(line[15..].Trim());
var buffer = new char[length];
var read = 0;
while (read < length)
{
var count = await reader.ReadAsync(buffer.AsMemory(read), stop.Token);
if (count == 0) throw new EndOfStreamException();
read += count;
}
using var body = JsonDocument.Parse(new string(buffer));
recorded[body.RootElement.GetProperty("command").GetString()!] = currentPath;
const string response = "{\"error\":\"parity probe\",\"code\":\"PROBE\"}";
var bytes = Encoding.UTF8.GetBytes($"HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {response.Length}\r\nConnection: close\r\n\r\n{response}");
await stream.WriteAsync(bytes, stop.Token);
}
}
catch (OperationCanceledException) when (stop.IsCancellationRequested) { }
}, stop.Token);
try
{
foreach (var (leaf, path) in Leaves(root, []))
{
// Separate REST and persistent SignalR surfaces, not management contracts.
if (path.Length == 0 || path[0] == "audit" || path[0] == omitGroup
|| string.Join(' ', path) == "debug stream") continue;
currentPath = string.Join(' ', path);
var args = new List<string>(path);
foreach (var option in leaf.Options.Where(o => o.Required))
{
args.Add(option.Name);
args.Add(ValueFor(option, currentPath, file, csv));
}
AddConditionalOptions(args, currentPath);
args.AddRange(["--url", url, "--username", "probe", "--password", "probe", "--format", "json"]);
var parse = root.Parse(args.ToArray());
// Some convenience aliases require richer local files/preflight replies;
// other leaves must still emit every contract. Missing coverage fails above.
if (parse.Errors.Count > 0) continue;
await parse.InvokeAsync().WaitAsync(stop.Token);
}
}
finally
{
await stop.CancelAsync();
listener.Stop();
await server;
directory.Delete(recursive: true);
}
return recorded;
}
private static IEnumerable<(Command Leaf, string[] Path)> Leaves(Command command, string[] path)
{
if (command.Subcommands.Count == 0) yield return (command, path);
foreach (var child in command.Subcommands)
foreach (var entry in Leaves(child, [.. path, child.Name])) yield return entry;
}
private static string ValueFor(Option option, string path, string file, string csv)
{
var type = option.GetType().GetGenericArguments().FirstOrDefault();
type = Nullable.GetUnderlyingType(type ?? typeof(string)) ?? type;
if (type == typeof(int) || type == typeof(long) || type == typeof(double)) return "1";
if (type == typeof(Guid)) return "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
if (type == typeof(bool)) return "true";
if (type?.IsEnum == true) return Enum.GetNames(type)[0];
return option.Name switch
{
"--schema-file" or "--input" or "--config-file" or "--der-file" => file,
"--output" => file + ".out",
"--file" => csv,
"--bindings" => "[[\"Value\",1]]",
"--direction" => "up",
"--overrides" or "--config" => "{}",
"--value-json" => "1",
"--data-type" or "--value-type" => "Int32",
"--trigger-type" => "Periodic",
"--auth-type" or "--auth-mode" => "None",
"--http-method" => "GET",
"--type" => "OpcUa",
"--endpoint-url" => "http://localhost",
"--account-sid" => "AC" + new string('a', 32),
"--from-address" => "sender@example.test",
"--thumbprint" => new string('A', 40),
"--role" => "Designer",
"--code" or "--script" => "return null;",
_ => "fixture"
};
}
private static void AddConditionalOptions(List<string> args, string path)
{
if (path is "notification create" or "notification update")
args.AddRange(["--emails", "recipient@example.test"]);
if (path is "notification sms create" or "notification sms update")
args.AddRange(["--from-number", "+15551234567"]);
if (path == "notification sms create") args.AddRange(["--auth-token", "test-token"]);
if (path == "notification recipient add") args.AddRange(["--email", "recipient@example.test"]);
}
}
@@ -0,0 +1,130 @@
using System.CommandLine;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
[Collection("Console")]
public class NotificationParityTests
{
private static RootCommand Root()
{
var root = new RootCommand();
var url = new Option<string>("--url") { Recursive = true };
var format = new Option<string>("--format") { Recursive = true };
var user = new Option<string>("--username") { Recursive = true };
var password = new Option<string>("--password") { Recursive = true };
root.Add(url); root.Add(format); root.Add(user); root.Add(password);
root.Add(NotificationCommands.Build(url, format, user, password));
return root;
}
[Theory]
[InlineData("rename --id 7 --name Operators", "RenameNotificationListCommand", "notificationListId", "7")]
[InlineData("recipient add --list-id 7 --name Jane --email jane@example.com", "AddNotificationRecipientCommand", "name", "Jane")]
[InlineData("recipient add --list-id 7 --name Jane --phone +15551234567", "AddNotificationRecipientCommand", "phoneNumber", "+15551234567")]
[InlineData("recipient delete --id 42", "DeleteNotificationRecipientCommand", "recipientId", "42")]
[InlineData("smtp create --server mail.example.com --port 587 --auth-mode basic --from-address ops@example.com --tls-mode StartTLS --credentials test:secret", "CreateSmtpConfigCommand", "tlsMode", "StartTLS")]
[InlineData("sms create --account-sid AC00000000000000000000000000000000 --from-number +15551234567 --auth-token secret", "CreateSmsConfigCommand", "authToken", "secret")]
[InlineData("sms update --id 8 --account-sid AC00000000000000000000000000000000 --from-number +15551234567 --connection-timeout-seconds 15 --max-retries 4 --retry-delay-seconds 90", "UpdateSmsConfigCommand", "retryDelay", "00:01:30")]
public async Task InvocationSendsDedicatedContract(string arguments, string command, string property, string expected)
{
var body = await Capture(arguments);
Assert.Equal(command.Replace("Command", ""), body.GetProperty("command").GetString());
var payload = body.GetProperty("payload");
Assert.Equal(expected, payload.GetProperty(property).ToString());
if (command == "AddNotificationRecipientCommand")
{
Assert.Equal(7, payload.GetProperty("notificationListId").GetInt32());
Assert.Equal("Jane", payload.GetProperty("name").GetString());
Assert.Equal(arguments.Contains("--email") ? "jane@example.com" : null, payload.GetProperty("emailAddress").GetString());
Assert.Equal(arguments.Contains("--phone") ? "+15551234567" : null, payload.GetProperty("phoneNumber").GetString());
}
if (command == "RenameNotificationListCommand")
{
Assert.Equal("Operators", payload.GetProperty("name").GetString());
Assert.False(payload.TryGetProperty("recipientEmails", out _));
}
}
[Theory]
[InlineData("create --auth-token secret")]
[InlineData("update --id 8")]
public async Task SmsPolicyExplicitValuesIncludingZeroAreForwarded(string verb)
{
var payload = (await Capture($"sms {verb} --account-sid AC00000000000000000000000000000000 --messaging-service-sid MG123 --connection-timeout-seconds 15 --max-retries 0 --retry-delay-seconds 0")).GetProperty("payload");
Assert.Equal(15, payload.GetProperty("connectionTimeoutSeconds").GetInt32());
Assert.Equal(0, payload.GetProperty("maxRetries").GetInt32());
Assert.Equal("00:00:00", payload.GetProperty("retryDelay").GetString());
Assert.Equal(JsonValueKind.Null, payload.GetProperty("fromNumber").ValueKind);
}
[Fact]
public async Task SmtpCreateForwardsAllOptionalFields()
{
var payload = (await Capture("smtp create --server mail.example.com --port 587 --auth-mode oauth2 --from-address ops@example.com --credentials secret --oauth2-authority https://login.example.com/token --oauth2-scope scope --transport Smtp --tls-mode StartTLS")).GetProperty("payload");
Assert.Equal("secret", payload.GetProperty("credentials").GetString());
Assert.Equal("https://login.example.com/token", payload.GetProperty("oAuth2Authority").GetString());
Assert.Equal("scope", payload.GetProperty("oAuth2Scope").GetString());
Assert.Equal("Smtp", payload.GetProperty("transport").GetString());
}
[Fact]
public async Task SmtpCreateOmittedOptionsUseServerDefaults()
{
var payload = (await Capture("smtp create --server mail.example.com --port 25 --auth-mode none --from-address ops@example.com")).GetProperty("payload");
foreach (var name in new[] { "tlsMode", "credentials", "oAuth2Authority", "oAuth2Scope", "transport" })
Assert.Equal(JsonValueKind.Null, payload.GetProperty(name).ValueKind);
}
[Theory]
[InlineData("create --auth-token secret")]
[InlineData("update --id 8")]
public async Task OmittedSmsPolicyAndSecretsRemainNull(string verb)
{
var payload = (await Capture($"sms {verb} --account-sid AC00000000000000000000000000000000 --from-number +15551234567")).GetProperty("payload");
foreach (var name in new[] { "connectionTimeoutSeconds", "maxRetries", "retryDelay", "apiBaseUrl", "messagingServiceSid" })
Assert.Equal(JsonValueKind.Null, payload.GetProperty(name).ValueKind);
if (verb.StartsWith("update")) Assert.Equal(JsonValueKind.Null, payload.GetProperty("authToken").ValueKind);
}
[Theory]
[InlineData("sms create --account-sid AC00000000000000000000000000000000 --from-number +15551234567")]
[InlineData("sms update --id 8 --account-sid AC00000000000000000000000000000000 --from-number +15551234567 --max-retries -1")]
[InlineData("sms update --id 8 --account-sid AC00000000000000000000000000000000 --from-number +15551234567 --connection-timeout-seconds 0")]
[InlineData("sms update --id 8 --account-sid AC00000000000000000000000000000000 --from-number +15551234567 --retry-delay-seconds -1")]
[InlineData("recipient add --list-id 7 --name Jane")]
[InlineData("recipient delete --id 0")]
public void InvalidInputFailsParsing(string arguments)
{
Assert.NotEmpty(Root().Parse(("notification " + arguments).Split(' ')).Errors);
}
private static async Task<JsonElement> Capture(string arguments)
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
var parse = Root().Parse(("notification " + arguments + $" --url http://127.0.0.1:{port} --username test --password test --format json").Split(' '));
Assert.Empty(parse.Errors);
var server = Task.Run(async () =>
{
using var client = await listener.AcceptTcpClientAsync();
await using var stream = client.GetStream();
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
var length = 0;
while (await reader.ReadLineAsync() is { Length: > 0 } line)
if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase)) length = int.Parse(line.Split(':')[1]);
var buffer = new char[length];
await reader.ReadBlockAsync(buffer);
var body = JsonDocument.Parse(new string(buffer)).RootElement.Clone();
await stream.WriteAsync(Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}"));
return body;
});
Assert.Equal(0, await parse.InvokeAsync());
return await server.WaitAsync(TimeSpan.FromSeconds(5));
}
}
@@ -0,0 +1,20 @@
using System.CommandLine;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
public class ProductionRootTests
{
[Fact]
public void ProductionRoot_ExposesSchemaLibraryAndSecuredWriteLifecycle()
{
var rootType = typeof(SecuredWriteCommands).Assembly.GetType("ZB.MOM.WW.ScadaBridge.CLI.CliApplication");
Assert.NotNull(rootType);
var method = rootType.GetMethod("BuildRoot");
Assert.NotNull(method);
var root = Assert.IsType<RootCommand>(method.Invoke(null, null));
Assert.Contains(root.Subcommands, c => c.Name == "shared-schema");
var secured = root.Subcommands.Single(c => c.Name == "secured-write");
Assert.Equal(new[] { "approve", "list", "reject", "submit" }, secured.Subcommands.Select(c => c.Name).Order());
}
}
@@ -0,0 +1,244 @@
using System.CommandLine;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
[Collection("Console")]
public class SchemaSecuredParityTests
{
private static RootCommand BuildRoot()
{
var root = new RootCommand();
var url = new Option<string>("--url") { Recursive = true };
var format = new Option<string>("--format") { Recursive = true };
var user = new Option<string>("--username") { Recursive = true };
var password = new Option<string>("--password") { Recursive = true };
root.Add(url); root.Add(format); root.Add(user); root.Add(password);
root.Add(SharedSchemaCommands.Build(url, format, user, password));
root.Add(SecuredWriteCommands.Build(url, format, user, password));
return root;
}
private static async Task<(int Exit, JsonElement Envelope, string Headers)> InvokeHttp(
string[] args, int status = 200, string response = "{}", string format = "json")
{
var root = BuildRoot();
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
var parse = root.Parse([.. args, "--url", $"http://127.0.0.1:{port}", "--username", "operator", "--password", "test", "--format", format]);
Assert.Empty(parse.Errors);
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var server = Task.Run(async () =>
{
using var client = await listener.AcceptTcpClientAsync(deadline.Token);
await using var stream = client.GetStream();
var headerBytes = new List<byte>();
var one = new byte[1];
while (true)
{
await stream.ReadExactlyAsync(one, deadline.Token);
headerBytes.Add(one[0]);
if (headerBytes.Count >= 4 && headerBytes.TakeLast(4).SequenceEqual(new byte[] { 13, 10, 13, 10 })) break;
}
var headers = Encoding.ASCII.GetString(headerBytes.ToArray());
var length = int.Parse(headers.Split("\r\n").Single(h => h.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase)).Split(':')[1].Trim());
var request = new byte[length];
await stream.ReadExactlyAsync(request, deadline.Token);
var bytes = Encoding.UTF8.GetBytes(response);
await stream.WriteAsync(Encoding.ASCII.GetBytes($"HTTP/1.1 {status} Response\r\nContent-Type: application/json\r\nContent-Length: {bytes.Length}\r\nConnection: close\r\n\r\n"), deadline.Token);
await stream.WriteAsync(bytes, deadline.Token);
using var document = JsonDocument.Parse(request);
return (document.RootElement.Clone(), headers);
});
var exit = await parse.InvokeAsync().WaitAsync(deadline.Token);
var captured = await server;
Assert.StartsWith("POST /management HTTP/1.1", captured.headers);
Assert.Contains("Authorization: Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("operator:test")), captured.headers);
return (exit, captured.Item1, captured.headers);
}
[Theory]
[InlineData("list", "ListSharedSchemas")]
[InlineData("get", "GetSharedSchema")]
[InlineData("delete", "DeleteSharedSchema")]
public async Task SchemaReadDelete_UsesRegisteredContract(string verb, string command)
{
var args = verb == "list" ? new[] { "shared-schema", verb } : new[] { "shared-schema", verb, "--id", "42" };
var result = await InvokeHttp(args);
Assert.Equal(0, result.Exit);
Assert.Equal(command, result.Envelope.GetProperty("command").GetString());
if (verb != "list") Assert.Equal(42, result.Envelope.GetProperty("payload").GetProperty("sharedSchemaId").GetInt32());
}
[Theory]
[InlineData("create", "CreateSharedSchema", true)]
[InlineData("update", "UpdateSharedSchema", true)]
[InlineData("update", "UpdateSharedSchema", false)]
public async Task SchemaWrite_ReadsJsonFileAndPreservesContract(string verb, string command, bool includeScope)
{
var path = Path.GetTempFileName();
const string schema = "{\"type\":\"object\",\"properties\":{\"é\":{\"$ref\":\"lib:Address\"}}}";
await File.WriteAllTextAsync(path, schema);
try
{
string[] args = ["shared-schema", verb, "--name", "Order", "--schema-file", path];
if (includeScope) args = [.. args, "--scope", "ERP"];
if (verb == "update") args = [.. args, "--id", "42"];
var result = await InvokeHttp(args);
Assert.Equal(0, result.Exit);
Assert.Equal(command, result.Envelope.GetProperty("command").GetString());
var payload = result.Envelope.GetProperty("payload");
Assert.Equal("Order", payload.GetProperty("name").GetString());
Assert.Equal(includeScope ? "ERP" : null, payload.GetProperty("scope").GetString());
Assert.Equal(schema, payload.GetProperty("schemaJson").GetString());
if (verb == "update") Assert.Equal(42, payload.GetProperty("sharedSchemaId").GetInt32());
}
finally { File.Delete(path); }
}
[Fact]
public async Task Submit_UsesValueJsonAndServerOwnedIdentity()
{
var result = await InvokeHttp(["secured-write", "submit", "--site", "SITE1", "--connection", "mx", "--tag-path", "Tank.Level", "--value-json", "12.5", "--value-type", "Double", "--comment", "batch start"]);
Assert.Equal(0, result.Exit);
Assert.Equal("SubmitSecuredWrite", result.Envelope.GetProperty("command").GetString());
var payload = result.Envelope.GetProperty("payload");
Assert.Equal("SITE1", payload.GetProperty("siteId").GetString());
Assert.Equal("mx", payload.GetProperty("connectionName").GetString());
Assert.Equal("Tank.Level", payload.GetProperty("tagPath").GetString());
Assert.Equal("12.5", payload.GetProperty("valueJson").GetString());
Assert.Equal("Double", payload.GetProperty("valueType").GetString());
Assert.Equal("batch start", payload.GetProperty("comment").GetString());
Assert.False(payload.TryGetProperty("operatorUser", out _));
}
[Theory]
[InlineData("approve", "ApproveSecuredWrite")]
[InlineData("reject", "RejectSecuredWrite")]
public async Task Decision_Preserves64BitIdentityAndComment(string verb, string command)
{
var result = await InvokeHttp(["secured-write", verb, "--id", "5000000000", "--comment", "reviewed"],
response: JsonSerializer.Serialize(new { id = 5_000_000_000L, status = verb == "approve" ? "Executed" : "Rejected" }));
Assert.Equal(0, result.Exit);
Assert.Equal(command, result.Envelope.GetProperty("command").GetString());
var payload = result.Envelope.GetProperty("payload");
Assert.Equal(5_000_000_000L, payload.GetProperty("id").GetInt64());
Assert.Equal("reviewed", payload.GetProperty("comment").GetString());
Assert.False(payload.TryGetProperty("verifierUser", out _));
}
[Theory]
[InlineData("submit", 403, "Forbidden", 2)]
[InlineData("approve", 403, "Forbidden", 2)]
[InlineData("reject", 400, "MANAGEMENT_ERROR", 1)]
public async Task ServerDenials_AreReturnedWithoutLocalApproval(string verb, int status, string code, int expected)
{
string[] args = verb == "submit"
? ["secured-write", verb, "--site", "S", "--connection", "mx", "--tag-path", "tag", "--value-json", "true", "--value-type", "Boolean"]
: ["secured-write", verb, "--id", "123"];
var result = await InvokeHttp(args, status, JsonSerializer.Serialize(new { error = "Server rejected this decision", code }));
Assert.Equal(expected, result.Exit);
}
[Fact]
public async Task SecuredList_PreservesFiltersAndPagination()
{
var result = await InvokeHttp(["secured-write", "list", "--status", "Pending", "--site", "S1", "--skip", "7", "--take", "25"]);
Assert.Equal(0, result.Exit);
Assert.Equal("ListSecuredWrites", result.Envelope.GetProperty("command").GetString());
var payload = result.Envelope.GetProperty("payload");
Assert.Equal("Pending", payload.GetProperty("status").GetString());
Assert.Equal("S1", payload.GetProperty("siteId").GetString());
Assert.Equal(7, payload.GetProperty("skip").GetInt32());
Assert.Equal(25, payload.GetProperty("take").GetInt32());
}
[Theory]
[InlineData(403, 2)]
[InlineData(404, 1)]
public async Task SchemaServerErrors_PreserveExitContract(int status, int expected)
{
var result = await InvokeHttp(["shared-schema", "get", "--id", "42"], status,
"{\"error\":\"Server denied the schema request\",\"code\":\"REQUEST_FAILED\"}");
Assert.Equal(expected, result.Exit);
}
[Fact]
public async Task MalformedSubmitValue_ReturnsJsonErrorBeforeHttp()
{
var original = Console.Error;
using var error = new StringWriter();
Console.SetError(error);
try
{
var exit = await BuildRoot().Parse(["secured-write", "submit", "--site", "S", "--connection", "mx", "--tag-path", "tag", "--value-json", "{broken", "--value-type", "Double"]).InvokeAsync();
Assert.Equal(1, exit);
Assert.Contains("INVALID_JSON", error.ToString());
}
finally { Console.SetError(original); }
}
[Theory]
[InlineData("Executed", 0, "json")]
[InlineData("Failed", 1, "json")]
[InlineData("Executed", 0, "table")]
[InlineData("Failed", 1, "table")]
public async Task Approve_UsesServerExecutionOutcomeForExitCode(string status, int expected, string format)
{
var original = Console.Out;
using var output = new StringWriter();
Console.SetOut(output);
try
{
var result = await InvokeHttp(["secured-write", "approve", "--id", "123"], 200,
JsonSerializer.Serialize(new { id = 123L, status, executionError = status == "Failed" ? "device disconnected" : null }), format);
Assert.Equal(expected, result.Exit);
Assert.Contains(status, output.ToString());
if (status == "Failed") Assert.Contains("device disconnected", output.ToString());
}
finally { Console.SetOut(original); }
}
[Theory]
[InlineData("{}")]
[InlineData("{\"status\":\"Approved\"}")]
[InlineData("{\"status\":42}")]
[InlineData("not JSON")]
[InlineData("null")]
public async Task Approve_InvalidServerOutcomeIsAnError(string response)
{
var result = await InvokeHttp(["secured-write", "approve", "--id", "123"], response: response);
Assert.Equal(1, result.Exit);
}
[Theory]
[InlineData("shared-schema", "update")]
[InlineData("secured-write", "submit")]
[InlineData("secured-write", "approve")]
public void RequiredArguments_AreRejectedByParser(string group, string verb)
=> Assert.NotEmpty(BuildRoot().Parse([group, verb]).Errors);
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task SchemaFileFailures_ReturnErrorBeforeHttp(bool malformed)
{
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".json");
if (malformed) await File.WriteAllTextAsync(path, "{broken");
var original = Console.Error;
using var error = new StringWriter();
Console.SetError(error);
try
{
var exit = await BuildRoot().Parse(["shared-schema", "create", "--name", "bad", "--schema-file", path]).InvokeAsync();
Assert.Equal(1, exit);
Assert.Contains(malformed ? "INVALID_JSON" : "FILE_ERROR", error.ToString());
}
finally { Console.SetError(original); File.Delete(path); }
}
}
@@ -355,6 +355,7 @@ public class AuditDrilldownDrawerTests : BunitContext
invocation => invocation.Arguments.Count == 1 invocation => invocation.Arguments.Count == 1
&& invocation.Arguments[0] is string s && invocation.Arguments[0] is string s
&& s.StartsWith("curl ", StringComparison.Ordinal)); && s.StartsWith("curl ", StringComparison.Ordinal));
clipboardCall.SetVoidResult();
// Build an event with a {headers, body} RequestSummary so the cURL // Build an event with a {headers, body} RequestSummary so the cURL
// builder has material to fold in. // builder has material to fold in.
@@ -301,6 +301,7 @@ public class AuditEventDetailTests : BunitContext
invocation => invocation.Arguments.Count == 1 invocation => invocation.Arguments.Count == 1
&& invocation.Arguments[0] is string s && invocation.Arguments[0] is string s
&& s.StartsWith("curl ", StringComparison.Ordinal)); && s.StartsWith("curl ", StringComparison.Ordinal));
clipboardCall.SetVoidResult();
var ev = MakeEvent( var ev = MakeEvent(
channel: AuditChannel.ApiOutbound, channel: AuditChannel.ApiOutbound,
@@ -1,3 +1,4 @@
using Microsoft.JSInterop;
using System.Security.Claims; using System.Security.Claims;
using ZB.MOM.WW.ScadaBridge.Security; using ZB.MOM.WW.ScadaBridge.Security;
using Bunit; using Bunit;
@@ -20,6 +21,13 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests;
/// </summary> /// </summary>
public class DataConnectionsPageTests : BunitContext public class DataConnectionsPageTests : BunitContext
{ {
[Fact]
public void StorageSaveFixture_CompletesInvocationImmediately()
{
var invocation = JSInterop.JSRuntime.InvokeVoidAsync("treeviewStorage.save", "fixture-probe", "[]");
Assert.True(invocation.IsCompletedSuccessfully, "The storage-save fixture must complete JS calls before component disposal.");
}
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>(); private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
// M9-T25: the design page now injects IConnectionHealthQueryService to render // M9-T25: the design page now injects IConnectionHealthQueryService to render
// per-connection live health badges. A substitute defaulting to an empty map // per-connection live health badges. A substitute defaulting to an empty map
@@ -51,7 +59,7 @@ public class DataConnectionsPageTests : BunitContext
new Dictionary<int, ConnectionHealth>())); new Dictionary<int, ConnectionHealth>()));
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null); JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
JSInterop.SetupVoid("treeviewStorage.save", _ => true); JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult();
} }
private void AddTestAuth() private void AddTestAuth()
@@ -61,7 +61,7 @@ public class JsInteropLoggingTests : BunitContext
JSInterop.SetupVoid("document.body.classList.add", "modal-open") JSInterop.SetupVoid("document.body.classList.add", "modal-open")
.SetException(new JSException("body lock failed")); .SetException(new JSException("body lock failed"));
// Focus and any other interop is harmless here — allow it loosely. // Focus and any other interop is harmless here — allow it loosely.
JSInterop.SetupVoid("document.body.classList.remove", "modal-open"); JSInterop.SetupVoid("document.body.classList.remove", "modal-open").SetVoidResult();
var cut = Render<DiffDialog>(); var cut = Render<DiffDialog>();
cut.InvokeAsync(() => cut.Instance.ShowAsync("Compare", "a", "b")); cut.InvokeAsync(() => cut.Instance.ShowAsync("Compare", "a", "b"));
@@ -84,7 +84,7 @@ public class JsInteropLoggingTests : BunitContext
JSInterop.Mode = JSRuntimeMode.Strict; JSInterop.Mode = JSRuntimeMode.Strict;
JSInterop.SetupVoid("document.body.classList.add", "modal-open") JSInterop.SetupVoid("document.body.classList.add", "modal-open")
.SetException(new JSDisconnectedException("circuit gone")); .SetException(new JSDisconnectedException("circuit gone"));
JSInterop.SetupVoid("document.body.classList.remove", "modal-open"); JSInterop.SetupVoid("document.body.classList.remove", "modal-open").SetVoidResult();
var cut = Render<DiffDialog>(); var cut = Render<DiffDialog>();
cut.InvokeAsync(() => cut.Instance.ShowAsync("Compare", "a", "b")); cut.InvokeAsync(() => cut.Instance.ShowAsync("Compare", "a", "b"));
@@ -53,7 +53,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext
{ {
// Checkbox mode pushes the tri-state via JS interop after every render; // Checkbox mode pushes the tri-state via JS interop after every render;
// stub it so bUnit's strict mode doesn't blow up on the unmocked call. // stub it so bUnit's strict mode doesn't blow up on the unmocked call.
JSInterop.SetupVoid("treeviewStorage.setIndeterminate", _ => true); JSInterop.SetupVoid("treeviewStorage.setIndeterminate", _ => true).SetVoidResult();
} }
return Render<TreeView<TestNode>>(parameters => return Render<TreeView<TestNode>>(parameters =>
@@ -37,7 +37,7 @@ public class TreeViewStorageResilienceTests : BunitContext
// A garbage payload that is not valid JSON for a List<string>. // A garbage payload that is not valid JSON for a List<string>.
JSInterop.Setup<string?>("treeviewStorage.load", _ => true) JSInterop.Setup<string?>("treeviewStorage.load", _ => true)
.SetResult("{not json at all]"); .SetResult("{not json at all]");
JSInterop.SetupVoid("treeviewStorage.save", _ => true); JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult();
// Pre-fix: OnAfterRenderAsync threw JsonException out of the unguarded // Pre-fix: OnAfterRenderAsync threw JsonException out of the unguarded
// Deserialize call. Post-fix: the corrupt payload is ignored. // Deserialize call. Post-fix: the corrupt payload is ignored.
@@ -53,7 +53,7 @@ public class TreeViewStorageResilienceTests : BunitContext
// Valid JSON, but not a List<string> — an object, not an array. // Valid JSON, but not a List<string> — an object, not an array.
JSInterop.Setup<string?>("treeviewStorage.load", _ => true) JSInterop.Setup<string?>("treeviewStorage.load", _ => true)
.SetResult("{\"unexpected\": true}"); .SetResult("{\"unexpected\": true}");
JSInterop.SetupVoid("treeviewStorage.save", _ => true); JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult();
var cut = BuildTree(); var cut = BuildTree();
@@ -44,7 +44,7 @@ public class TemplatesPageTests : BunitContext
// against sessionStorage (`templates-tree` key). bUnit requires explicit // against sessionStorage (`templates-tree` key). bUnit requires explicit
// stubs for all JS interop calls, otherwise rendering throws. // stubs for all JS interop calls, otherwise rendering throws.
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null); JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
JSInterop.SetupVoid("treeviewStorage.save", _ => true); JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult();
} }
private void AddTestAuth(params string[] roles) private void AddTestAuth(params string[] roles)
@@ -87,7 +87,7 @@ public class TopologyLiveUpdatesTimerDisposeRaceTests : BunitContext
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>(); Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null); JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
JSInterop.SetupVoid("treeviewStorage.save", _ => true); JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult();
SeedEmptyRepos(); SeedEmptyRepos();
} }
@@ -1,3 +1,4 @@
using Microsoft.JSInterop;
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json; using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Security; using ZB.MOM.WW.ScadaBridge.Security;
@@ -34,6 +35,13 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests;
/// </summary> /// </summary>
public class TopologyPageTests : BunitContext public class TopologyPageTests : BunitContext
{ {
[Fact]
public void StorageSaveFixture_CompletesInvocationImmediately()
{
var invocation = JSInterop.JSRuntime.InvokeVoidAsync("treeviewStorage.save", "fixture-probe", "[]");
Assert.True(invocation.IsCompletedSuccessfully, "The storage-save fixture must complete JS calls before component disposal.");
}
private readonly ITemplateEngineRepository _repo = Substitute.For<ITemplateEngineRepository>(); private readonly ITemplateEngineRepository _repo = Substitute.For<ITemplateEngineRepository>();
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>(); private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private readonly IDeploymentManagerRepository _deployRepo = Substitute.For<IDeploymentManagerRepository>(); private readonly IDeploymentManagerRepository _deployRepo = Substitute.For<IDeploymentManagerRepository>();
@@ -97,7 +105,7 @@ public class TopologyPageTests : BunitContext
// TreeView persists expansion state via JS interop. Stub the calls so render doesn't throw. // TreeView persists expansion state via JS interop. Stub the calls so render doesn't throw.
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null); JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
JSInterop.SetupVoid("treeviewStorage.save", _ => true); JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult();
} }
private void AddTestAuth() private void AddTestAuth()
@@ -38,7 +38,7 @@ public class TreeViewMultiSelectTests : BunitContext
{ {
// Indeterminate is set via JS interop after each render. We stub it so // Indeterminate is set via JS interop after each render. We stub it so
// bUnit's strict mode doesn't blow up on the unmocked call. // bUnit's strict mode doesn't blow up on the unmocked call.
JSInterop.SetupVoid("treeviewStorage.setIndeterminate", _ => true); JSInterop.SetupVoid("treeviewStorage.setIndeterminate", _ => true).SetVoidResult();
return Render<TreeView<TestNode>>(parameters => return Render<TreeView<TestNode>>(parameters =>
{ {
@@ -348,7 +348,7 @@ public class TreeViewTests : BunitContext
public void SessionStorage_Set_ExpandWritesToStorage() public void SessionStorage_Set_ExpandWritesToStorage()
{ {
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null); JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
JSInterop.SetupVoid("treeviewStorage.save", _ => true); JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult();
var cut = RenderTreeView(storageKey: "test-tree"); var cut = RenderTreeView(storageKey: "test-tree");
@@ -366,7 +366,7 @@ public class TreeViewTests : BunitContext
public void SessionStorage_RestoresExpandedOnMount() public void SessionStorage_RestoresExpandedOnMount()
{ {
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult("[\"a\"]"); JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult("[\"a\"]");
JSInterop.SetupVoid("treeviewStorage.save", _ => true); JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult();
var cut = RenderTreeView(storageKey: "test-tree"); var cut = RenderTreeView(storageKey: "test-tree");
@@ -381,7 +381,7 @@ public class TreeViewTests : BunitContext
{ {
// Storage returns empty array — meaning user explicitly collapsed everything // Storage returns empty array — meaning user explicitly collapsed everything
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult("[]"); JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult("[]");
JSInterop.SetupVoid("treeviewStorage.save", _ => true); JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult();
var cut = RenderTreeView( var cut = RenderTreeView(
storageKey: "test-tree", storageKey: "test-tree",
@@ -15,7 +15,9 @@ public class ManagementCommandRegistryTests
typeof(ManagementEnvelope).Assembly.GetTypes() typeof(ManagementEnvelope).Assembly.GetTypes()
.Where(t => t.Namespace == typeof(ManagementEnvelope).Namespace .Where(t => t.Namespace == typeof(ManagementEnvelope).Namespace
&& t.Name.EndsWith("Command", StringComparison.Ordinal) && t.Name.EndsWith("Command", StringComparison.Ordinal)
&& !t.IsAbstract); && !t.IsAbstract
&& t != typeof(ReadTagValuesCommand)
&& t != typeof(ResolveRolesCommand));
[Fact] [Fact]
public void GetCommandName_Resolve_RoundTrips_ForEveryRegisteredCommand() public void GetCommandName_Resolve_RoundTrips_ForEveryRegisteredCommand()
@@ -2,23 +2,14 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
/// <summary>
/// Verifies that <see cref="ReadTagValuesCommand"/> is discovered by
/// <see cref="ManagementCommandRegistry"/> so it travels over the management
/// boundary as a known command (resolvable by wire name and round-trippable
/// through <c>GetCommandName</c> / <c>Resolve</c>). Mirrors
/// <see cref="BrowseCommandsRegistryTests"/>.
/// </summary>
public class ReadTagValuesCommandRegistryTests public class ReadTagValuesCommandRegistryTests
{ {
[Fact] [Theory]
public void Registry_discovers_ReadTagValuesCommand() [InlineData(typeof(ReadTagValuesCommand), "ReadTagValues")]
[InlineData(typeof(ResolveRolesCommand), "ResolveRoles")]
public void SiteOnlyAndRetiredCommands_AreNotHttpCommands(Type type, string wireName)
{ {
// GetCommandName throws ArgumentException for any type the registry Assert.Null(ManagementCommandRegistry.Resolve(wireName));
// does not contain, so a successful call here is proof of discovery. Assert.Throws<ArgumentException>(() => ManagementCommandRegistry.GetCommandName(type));
var name = ManagementCommandRegistry.GetCommandName(typeof(ReadTagValuesCommand));
Assert.Equal("ReadTagValues", name);
Assert.Equal(typeof(ReadTagValuesCommand), ManagementCommandRegistry.Resolve(name));
} }
} }
@@ -6,8 +6,8 @@ using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NSubstitute; using NSubstitute;
using NSubstitute.ExceptionExtensions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.ManagementService; using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.Security; using ZB.MOM.WW.ScadaBridge.Security;
@@ -19,7 +19,7 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// in <see cref="ManagementActor"/>. A command with no arm falls through to the /// in <see cref="ManagementActor"/>. A command with no arm falls through to the
/// registry's default case, which throws <see cref="NotSupportedException"/> /// registry's default case, which throws <see cref="NotSupportedException"/>
/// ("Unknown command type"). This test proves that signature never occurs for any /// ("Unknown command type"). This test proves that signature never occurs for any
/// registered command (bar the documented exclusion list), so a newly-added command /// HTTP-registered command, so a newly-added command
/// that someone forgot to wire up fails CI here rather than at runtime. /// that someone forgot to wire up fails CI here rather than at runtime.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
@@ -27,7 +27,8 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// <see cref="RuntimeHelpers.GetUninitializedObject"/> instance (all fields default — /// <see cref="RuntimeHelpers.GetUninitializedObject"/> instance (all fields default —
/// we never execute the handler for real) inside an envelope carrying ALL roles, so /// we never execute the handler for real) inside an envelope carrying ALL roles, so
/// the authorization gate always passes. The actor runs on a service provider whose /// the authorization gate always passes. The actor runs on a service provider whose
/// scoped <see cref="IServiceProvider.GetService"/> throws a sentinel exception, so /// scoped <see cref="IServiceProvider.GetService"/> passes through configuration transactions
/// and throws a sentinel exception for handler dependencies, so
/// any handler that touches the provider faults with the sentinel (a pass — the arm /// any handler that touches the provider faults with the sentinel (a pass — the arm
/// exists). Only a command with NO arm faults with <see cref="NotSupportedException"/>. /// exists). Only a command with NO arm faults with <see cref="NotSupportedException"/>.
/// A recording logger captures the exception <see cref="ManagementActor"/> maps in /// A recording logger captures the exception <see cref="ManagementActor"/> maps in
@@ -37,36 +38,12 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// </remarks> /// </remarks>
public class DispatchCoverageTests : TestKit, IDisposable public class DispatchCoverageTests : TestKit, IDisposable
{ {
/// <summary>
/// Commands that are registered in <see cref="ManagementCommandRegistry"/> (so they
/// carry a stable wire name across the management boundary) but are intentionally
/// NOT dispatched by <see cref="ManagementActor"/> because they are serviced by a
/// different actor path. Each entry is a deliberate design decision, not a missing arm.
/// </summary>
private static readonly HashSet<Type> Excluded = new()
{
// The two-step "ResolveRoles + command" flow is retired — the HTTP endpoint
// resolves roles itself and sends a single envelope. Leaving a handler would
// expose role-mapping data to a raw in-cluster sender with no role gate.
// See ManagementActor.DispatchCommand (the comment above the default arm, ~line 472).
typeof(ResolveRolesCommand),
// Site-routed, not a central ManagementActor command. Backs the CentralUI
// "Test Bindings" dialog, which sends it straight through
// CommunicationService.ReadTagValuesAsync (in-process) to the site's
// DataConnectionManagerActor / DataConnectionActor — never through the
// ManagementActor dispatch switch. It lives in the Messages.Management
// namespace (hence auto-registered) only to earn a stable wire name for the
// cross-cluster boundary; see ReadTagValuesCommandRegistryTests. A
// ManagementActor arm would be the wrong transport for this command.
typeof(ReadTagValuesCommand),
};
private static IEnumerable<Type> RegisteredCommandTypes() => private static IEnumerable<Type> RegisteredCommandTypes() =>
typeof(ManagementEnvelope).Assembly.GetTypes() typeof(ManagementEnvelope).Assembly.GetTypes()
.Where(t => t.Namespace == typeof(ManagementEnvelope).Namespace .Where(t => t.Namespace == typeof(ManagementEnvelope).Namespace
&& t.Name.EndsWith("Command", StringComparison.Ordinal) && t.Name.EndsWith("Command", StringComparison.Ordinal)
&& !t.IsAbstract); && !t.IsAbstract
&& ManagementCommandRegistry.Resolve(t.Name[..^"Command".Length]) == t);
void IDisposable.Dispose() => Shutdown(); void IDisposable.Dispose() => Shutdown();
@@ -74,19 +51,12 @@ public class DispatchCoverageTests : TestKit, IDisposable
public void EveryRegisteredCommand_ReachesADispatchArm() public void EveryRegisteredCommand_ReachesADispatchArm()
{ {
var logger = new RecordingLogger<ManagementActor>(); var logger = new RecordingLogger<ManagementActor>();
var actor = CreateSentinelActor(logger); var actor = CreateSentinelActor(logger, out var transaction);
var commandTypes = RegisteredCommandTypes() var commandTypes = RegisteredCommandTypes()
.Where(t => !Excluded.Contains(t))
.OrderBy(t => t.Name, StringComparer.Ordinal) .OrderBy(t => t.Name, StringComparer.Ordinal)
.ToList(); .ToList();
// Sanity: the exclusion list must reference real, registered command types.
foreach (var excluded in Excluded)
{
Assert.Contains(excluded, RegisteredCommandTypes());
}
var noDispatchArm = new List<string>(); var noDispatchArm = new List<string>();
foreach (var type in commandTypes) foreach (var type in commandTypes)
@@ -110,11 +80,12 @@ public class DispatchCoverageTests : TestKit, IDisposable
} }
} }
Assert.True(transaction.DispatchCount > 0, "Configuration commands must execute beyond the transaction wrapper.");
Assert.True( Assert.True(
noDispatchArm.Count == 0, noDispatchArm.Count == 0,
$"{noDispatchArm.Count} registered command(s) have no dispatch arm in ManagementActor " + $"{noDispatchArm.Count} registered command(s) have no dispatch arm in ManagementActor " +
$"(fell through to the NotSupportedException default). Add the missing arm, or — with " + $"(fell through to the NotSupportedException default). Add the missing arm or correct HTTP eligibility. " +
$"justification — an entry to the exclusion list: {string.Join(", ", noDispatchArm)}"); $"Commands: {string.Join(", ", noDispatchArm)}");
} }
/// <summary> /// <summary>
@@ -124,10 +95,15 @@ public class DispatchCoverageTests : TestKit, IDisposable
/// <c>CreateScope()</c> call succeeds and the fault surfaces only when a handler /// <c>CreateScope()</c> call succeeds and the fault surfaces only when a handler
/// actually resolves a service. /// actually resolves a service.
/// </summary> /// </summary>
private IActorRef CreateSentinelActor(ILogger<ManagementActor> logger) private IActorRef CreateSentinelActor(ILogger<ManagementActor> logger, out PassthroughTransaction transaction)
{ {
var scopedProvider = Substitute.For<IServiceProvider>(); var scopedProvider = Substitute.For<IServiceProvider>();
scopedProvider.GetService(Arg.Any<Type>()).Throws(_ => new SentinelException()); var passthrough = new PassthroughTransaction(scopedProvider);
transaction = passthrough;
scopedProvider.GetService(Arg.Any<Type>()).Returns(call =>
call.Arg<Type>() == typeof(IConfigurationCommandTransaction)
? passthrough
: throw new SentinelException());
var scope = Substitute.For<IServiceScope>(); var scope = Substitute.For<IServiceScope>();
scope.ServiceProvider.Returns(scopedProvider); scope.ServiceProvider.Returns(scopedProvider);
@@ -141,6 +117,32 @@ public class DispatchCoverageTests : TestKit, IDisposable
return Sys.ActorOf(Props.Create(() => new ManagementActor(rootProvider, logger))); return Sys.ActorOf(Props.Create(() => new ManagementActor(rootProvider, logger)));
} }
[Fact]
public void Probe_DetectsACommandWithoutADispatchArm()
{
var logger = new RecordingLogger<ManagementActor>();
var actor = CreateSentinelActor(logger, out _);
var correlationId = Guid.NewGuid().ToString("N");
actor.Tell(new ManagementEnvelope(
new AuthenticatedUser("probe", "Probe", Roles.All, []), new UnsupportedCommand(), correlationId));
ExpectMsg<object>(TimeSpan.FromSeconds(10));
Assert.IsType<NotSupportedException>(logger.Captured[correlationId]);
}
private sealed record UnsupportedCommand;
// Do not throw while resolving the transaction: that would pass configuration
// commands before DispatchCommand had a chance to select (or miss) their arm.
private sealed class PassthroughTransaction(IServiceProvider provider) : IConfigurationCommandTransaction
{
public int DispatchCount { get; private set; }
public Task<object?> ExecuteAsync(Func<IServiceProvider, Task<object?>> operation)
{
DispatchCount++;
return operation(provider);
}
}
/// <summary>Marker fault thrown by the scoped provider — distinct from the /// <summary>Marker fault thrown by the scoped provider — distinct from the
/// "no dispatch arm" <see cref="NotSupportedException"/> signature.</summary> /// "no dispatch arm" <see cref="NotSupportedException"/> signature.</summary>
private sealed class SentinelException : Exception private sealed class SentinelException : Exception
@@ -0,0 +1,17 @@
using ZB.MOM.WW.ScadaBridge.ManagementService;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
public class HttpCommandEligibilityTests
{
[Theory]
[InlineData("ReadTagValues")]
[InlineData("ResolveRoles")]
public void ParseCommand_RejectsInternalContracts(string command)
{
var result = ManagementEndpoints.ParseCommand($"{{\"command\":\"{command}\",\"payload\":{{}}}}");
Assert.False(result.Success);
Assert.Equal("BAD_REQUEST", result.ErrorCode);
Assert.Contains("Unknown command", result.ErrorMessage);
}
}
@@ -0,0 +1,89 @@
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
public class NotificationRecipientPreservationTests
{
[Theory]
[InlineData(NotificationType.Email)]
[InlineData(NotificationType.Sms)]
public async Task DedicatedEdits_PreserveUnrelatedRecipientIdentityAndName(NotificationType channel)
{
var list = new NotificationList("Original") { Id = 7, Type = channel };
var original = channel == NotificationType.Email
? NotificationRecipient.ForEmail("Existing Operator", "original@example.com")
: NotificationRecipient.ForSms("Existing Operator", "+15551234567");
original.Id = 41;
original.NotificationListId = list.Id;
list.Recipients.Add(original);
var originalEmail = original.EmailAddress;
var originalPhone = original.PhoneNumber;
var repo = Substitute.For<INotificationRepository>();
repo.GetNotificationListByIdAsync(7, Arg.Any<CancellationToken>()).Returns(list);
repo.GetRecipientsByListIdAsync(7, Arg.Any<CancellationToken>())
.Returns(_ => list.Recipients.ToList());
repo.GetRecipientByIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(call => list.Recipients.SingleOrDefault(r => r.Id == call.Arg<int>()));
// Model repository inserts/deletes in one persisted collection. Any accidental
// whole-list replacement would remove or renumber the original row and fail below.
repo.When(r => r.AddRecipientAsync(Arg.Any<NotificationRecipient>(), Arg.Any<CancellationToken>()))
.Do(call =>
{
var row = call.Arg<NotificationRecipient>();
row.Id = 42;
list.Recipients.Add(row);
});
repo.When(r => r.DeleteRecipientAsync(Arg.Any<int>(), Arg.Any<CancellationToken>()))
.Do(call => list.Recipients.Remove(list.Recipients.Single(r => r.Id == call.Arg<int>())));
using var services = new ServiceCollection().AddTestConfigurationTransaction()
.AddSingleton(repo).AddSingleton(Substitute.For<IAuditService>()).BuildServiceProvider();
var user = new AuthenticatedUser("designer", "designer", new[] { Roles.Designer }, Array.Empty<string>());
await ManagementActor.ExecuteConfigurationCommandAsync(services,
new RenameNotificationListCommand(7, "Renamed"), user);
Assert.Equal("Renamed", list.Name);
AssertOriginalUnchanged();
Assert.Single(list.Recipients);
await repo.DidNotReceiveWithAnyArgs().AddRecipientAsync(default!, default);
await repo.DidNotReceiveWithAnyArgs().DeleteRecipientAsync(default, default);
await ManagementActor.ExecuteConfigurationCommandAsync(services,
new AddNotificationRecipientCommand(7, "New Operator",
channel == NotificationType.Email ? "new@example.com" : null,
channel == NotificationType.Sms ? "+15557654321" : null), user);
AssertOriginalUnchanged();
Assert.Equal(2, list.Recipients.Count);
var added = Assert.Single(list.Recipients, r => r.Id != original.Id);
Assert.Equal(42, added.Id);
Assert.Equal("New Operator", added.Name);
Assert.Equal(7, added.NotificationListId);
await repo.DidNotReceiveWithAnyArgs().DeleteRecipientAsync(default, default);
await ManagementActor.ExecuteConfigurationCommandAsync(services,
new DeleteNotificationRecipientCommand(42), user);
AssertOriginalUnchanged();
Assert.Single(list.Recipients);
await repo.Received(1).AddRecipientAsync(Arg.Any<NotificationRecipient>(), Arg.Any<CancellationToken>());
await repo.Received(1).DeleteRecipientAsync(42, Arg.Any<CancellationToken>());
await repo.DidNotReceive().DeleteRecipientAsync(41, Arg.Any<CancellationToken>());
await repo.DidNotReceiveWithAnyArgs().UpdateRecipientAsync(default!, default);
await repo.Received(3).SaveChangesAsync(Arg.Any<CancellationToken>());
void AssertOriginalUnchanged()
{
var retained = Assert.Single(list.Recipients, r => r.Id == 41);
Assert.Same(original, retained);
Assert.Equal("Existing Operator", retained.Name);
Assert.Equal(7, retained.NotificationListId);
Assert.Equal(originalEmail, retained.EmailAddress);
Assert.Equal(originalPhone, retained.PhoneNumber);
}
}
}