Complete management API and CLI command parity
This commit is contained in:
+33
-5
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -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"));
|
||||||
|
|||||||
+1
-1
@@ -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 =>
|
||||||
|
|||||||
+2
-2
@@ -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)
|
||||||
|
|||||||
+1
-1
@@ -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",
|
||||||
|
|||||||
+3
-1
@@ -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()
|
||||||
|
|||||||
+6
-15
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user