diff --git a/docs/components/CLI.md b/docs/components/CLI.md index c8e38d96..137972eb 100644 --- a/docs/components/CLI.md +++ b/docs/components/CLI.md @@ -44,7 +44,7 @@ Every command accepts `--format json` (default) or `--format table`. For JSON ou ## 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 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(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)); ``` 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 | |-------|-------------|---------------| -| `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` | -| `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` | +| `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/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` | | `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` | -| `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` | +| `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` | | `db-connection` | `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) - [Security](./Security.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. diff --git a/docs/components/ManagementService.md b/docs/components/ManagementService.md index aad989b8..834ebdb2 100644 --- a/docs/components/ManagementService.md +++ b/docs/components/ManagementService.md @@ -241,3 +241,14 @@ Any exception that is not a `ManagementCommandException` or `SiteScopeViolationE - [Security](./Security.md) - [Audit Log](./AuditLog.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. diff --git a/docs/plans/2026-09-10-management-api-cli-parity-review.md b/docs/plans/2026-09-10-management-api-cli-parity-review.md index 11af2f03..a88b3f1a 100644 --- a/docs/plans/2026-09-10-management-api-cli-parity-review.md +++ b/docs/plans/2026-09-10-management-api-cli-parity-review.md @@ -2,17 +2,17 @@ **Date:** 2026-09-10 **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 registration, ManagementActor dispatch (including inherited record types), CLI command construction and registration, and management audit REST endpoints. 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 -not establish complete actor/API parity. The inventory found: +At the reviewed revision, 19 registered command groups and the passing 475-test +suite did not establish complete actor/API parity. The baseline inventory found: | Inventory | Count | |---|---:| @@ -31,24 +31,24 @@ is not counted as a missing operator command. ## Missing CLI operations 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; -the secured-write limitation is already documented and may be retained as an -explicit product decision rather than implemented. +`POST /management` API, subject to server-side authorization. Remediation adds +all 19, including the full secured-write lifecycle. The descriptions below retain +the original findings; status cells and completion evidence track the fixes. | ID | Capability | Missing command contracts | Status | |---|---|---|---| -| GAP-01 | Shared-schema library (5) | `ListSharedSchemasCommand`, `GetSharedSchemaCommand`, `CreateSharedSchemaCommand`, `UpdateSharedSchemaCommand`, `DeleteSharedSchemaCommand` | Open | -| GAP-02 | Secured-write lifecycle (3) | `SubmitSecuredWriteCommand`, `ApproveSecuredWriteCommand`, `RejectSecuredWriteCommand` | Open; documented listing-only CLI scope | -| GAP-03 | Individual notification editing (3) | `RenameNotificationListCommand`, `AddNotificationRecipientCommand`, `DeleteNotificationRecipientCommand` | Open | -| GAP-04 | Provider configuration creation (2) | `CreateSmtpConfigCommand`, `CreateSmsConfigCommand` | Open | -| GAP-05 | Template inspection and editing (3) | `GetResolvedTemplateMembersCommand`, `MoveTemplateToFolderCommand`, `UpdateTemplateNativeAlarmSourceCommand` | Open | -| GAP-06 | Instance override management (2) | `DeleteInstanceAttributeOverrideCommand`, `ListInstanceNativeAlarmSourceOverridesCommand` | Open | -| GAP-07 | Data-connection relocation (1) | `MoveDataConnectionCommand` | Open | +| GAP-01 | Shared-schema library (5) | `ListSharedSchemasCommand`, `GetSharedSchemaCommand`, `CreateSharedSchemaCommand`, `UpdateSharedSchemaCommand`, `DeleteSharedSchemaCommand` | Completed | +| GAP-02 | Secured-write lifecycle (3) | `SubmitSecuredWriteCommand`, `ApproveSecuredWriteCommand`, `RejectSecuredWriteCommand` | Completed; full lifecycle exposed | +| GAP-03 | Individual notification editing (3) | `RenameNotificationListCommand`, `AddNotificationRecipientCommand`, `DeleteNotificationRecipientCommand` | Completed | +| GAP-04 | Provider configuration creation (2) | `CreateSmtpConfigCommand`, `CreateSmsConfigCommand` | Completed | +| GAP-05 | Template inspection and editing (3) | `GetResolvedTemplateMembersCommand`, `MoveTemplateToFolderCommand`, `UpdateTemplateNativeAlarmSourceCommand` | Completed | +| GAP-06 | Instance override management (2) | `DeleteInstanceAttributeOverrideCommand`, `ListInstanceNativeAlarmSourceOverridesCommand` | Completed | +| GAP-07 | Data-connection relocation (1) | `MoveDataConnectionCommand` | Completed | Evidence: - [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/) - [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; 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; do not add the row counts together as unique missing commands. ### PAR-01 — Deployment deadlines differ between API and CLI -**Severity:** Medium · **Status:** Open +**Severity:** Medium · **Status:** Completed `deploy instance`, `instance deploy`, `deploy artifacts`, and `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 -**Severity:** Medium · **Status:** Open · **Related:** GAP-03 +**Severity:** Medium · **Status:** Completed · **Related:** GAP-03 The existing CLI whole-list update reaches `HandleUpdateNotificationList`, which 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 -**Severity:** Low · **Status:** Open +**Severity:** Low · **Status:** Completed The actor/API accept `ConnectionTimeoutSeconds`, `MaxRetries`, and `RetryDelay` 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 -**Severity:** Low · **Status:** Open +**Severity:** Low · **Status:** Completed The registry automatically includes every concrete `*Command` type in the management namespace. `ReadTagValuesCommand` and `ResolveRolesCommand` therefore @@ -140,7 +140,7 @@ flow's security restriction. ### 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 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 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. diff --git a/docs/requirements/Component-CLI.md b/docs/requirements/Component-CLI.md index 269d7fba..4c0a175d 100644 --- a/docs/requirements/Component-CLI.md +++ b/docs/requirements/Component-CLI.md @@ -447,7 +447,7 @@ Configuration is resolved in the following priority order (highest wins): ## 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. -- **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. - **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 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. + + +## 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. diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/CliApplication.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/CliApplication.cs new file mode 100644 index 00000000..7bd134b1 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/CliApplication.cs @@ -0,0 +1,60 @@ +using System.CommandLine; +using ZB.MOM.WW.ScadaBridge.CLI.Commands; + +namespace ZB.MOM.WW.ScadaBridge.CLI; + +/// Constructs the production CLI command tree for execution and surface verification. +public static class CliApplication +{ + /// Builds an independent root command with every supported operation. + /// The configured CLI root. + public static RootCommand BuildRoot() + { + var rootCommand = new RootCommand("ScadaBridge CLI — manage the ScadaBridge SCADA system"); + + var urlOption = new Option("--url") { Description = "Management API URL", Recursive = true }; + var usernameOption = new Option("--username") { Description = "LDAP username", Recursive = true }; + var passwordOption = new Option("--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; + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/CommandHelpers.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/CommandHelpers.cs index 71aa2098..a104b2fe 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/CommandHelpers.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/CommandHelpers.cs @@ -18,9 +18,9 @@ internal static class CommandHelpers /// Option that supplies the password override. /// The management command object to send. /// - /// Optional per-command HTTP timeout. Defaults to 30s, matching the management API's - /// own request timeout. Larger payloads (e.g. Transport bundles) should supply a - /// longer value. + /// Optional per-command HTTP timeout. Deploy operations defer to the server-owned + /// deadline; ordinary commands default to 30 seconds. An explicit timeout + /// overrides this policy (for example, bundle and Flow Debugger requests). /// /// /// Optional success handler. When supplied, the helper invokes it with the success @@ -90,7 +90,12 @@ internal static class CommandHelpers // Send via HTTP 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 // the error path still routes through IsAuthorizationFailure so the documented diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/DataConnectionCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/DataConnectionCommands.cs index 74ef08f0..0f3ff907 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/DataConnectionCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/DataConnectionCommands.cs @@ -17,6 +17,7 @@ public static class DataConnectionCommands public static Command Build(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { 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(BuildGet(urlOption, formatOption, usernameOption, passwordOption)); @@ -27,6 +28,22 @@ public static class DataConnectionCommands return command; } + private static Command BuildMove(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) + { + var idOption = new Option("--id") { Description = "Data connection ID", Required = true }; + var siteOption = new Option("--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 urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Data connection ID", Required = true }; diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/InstanceCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/InstanceCommands.cs index 47c2e600..9c00f3f5 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/InstanceCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/InstanceCommands.cs @@ -28,6 +28,7 @@ public static class InstanceCommands command.Add(BuildImportOverrides(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildAlarmOverride(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(BuildDiff(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildDeploy(urlOption, formatOption, usernameOption, passwordOption)); @@ -596,6 +597,43 @@ public static class InstanceCommands return group; } + private static Command BuildDeleteAttributeOverride(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) + { + var idOption = new Option("--instance-id") { Description = "Instance ID", Required = true }; + var attributeOption = new Option("--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 urlOption, Option formatOption, Option usernameOption, Option 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 urlOption, Option formatOption, Option usernameOption, Option passwordOption) + { + var idOption = new Option("--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 urlOption, Option formatOption, Option usernameOption, Option passwordOption) { 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)" }; + group.Add(BuildListNativeAlarmSourceOverrides(urlOption, formatOption, usernameOption, passwordOption)); + // set var setIdOption = new Option("--instance-id") { Description = "Instance ID", Required = true }; var setSourceOption = new Option("--source") { Description = "Source binding canonical name (e.g. 'Pressure' or 'Module.Pressure')", Required = true }; diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/NotificationCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/NotificationCommands.cs index 913655ce..42069215 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/NotificationCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/NotificationCommands.cs @@ -23,6 +23,8 @@ public static class NotificationCommands command.Add(BuildGet(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildCreate(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(BuildSmtp(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildSms(urlOption, formatOption, usernameOption, passwordOption)); @@ -30,6 +32,49 @@ public static class NotificationCommands return command; } + private static Option RequiredId(string name, string description) + { + var option = new Option(name) { Description = description, Required = true }; + option.Validators.Add(result => { if (result.GetValueOrDefault() <= 0) result.AddError($"{name} must be positive."); }); + return option; + } + + private static Command BuildRename(Option urlOption, Option formatOption, Option usernameOption, Option 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("--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 urlOption, Option formatOption, Option usernameOption, Option 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("--name") { Required = true, Description = "Recipient name" }; + var email = new Option("--email") { Description = "Email address" }; + var phone = new Option("--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 // be read back both from the SetAction and from the testable Build* helpers). @@ -134,6 +179,15 @@ public static class NotificationCommands }); 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" }; updateCmd.Add(SmtpIdOption); updateCmd.Add(SmtpServerOption); @@ -247,6 +301,21 @@ public static class NotificationCommands }); 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" }; updateCmd.Add(SmsIdOption); updateCmd.Add(SmsAccountSidOption); @@ -254,6 +323,9 @@ public static class NotificationCommands updateCmd.Add(SmsMessagingServiceSidOption); updateCmd.Add(SmsApiBaseUrlOption); updateCmd.Add(SmsAuthTokenOption); + updateCmd.Add(SmsTimeoutOption); + updateCmd.Add(SmsMaxRetriesOption); + updateCmd.Add(SmsRetryDelayOption); updateCmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( @@ -265,6 +337,24 @@ public static class NotificationCommands return group; } + private static readonly Option SmsTimeoutOption = SmsPolicyOption("--connection-timeout-seconds", "Connection timeout in seconds", 1); + private static readonly Option SmsMaxRetriesOption = SmsPolicyOption("--max-retries", "Retry count (zero uses the outbox fallback)", 0); + private static readonly Option SmsRetryDelayOption = SmsPolicyOption("--retry-delay-seconds", "Retry delay in seconds (zero uses the outbox fallback)", 0); + + private static Option SmsPolicyOption(string name, string description, int minimum) + { + var option = new Option(name) { Description = description + "; omission preserves updates or uses create defaults" }; + option.Validators.Add(result => + { + if (result.GetValueOrDefault() 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 // from the SetAction and from BuildUpdateSmsConfigCommand (used by tests). private static readonly Option SmsIdOption = @@ -317,7 +407,8 @@ public static class NotificationCommands throw new ArgumentException( "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 urlOption, Option formatOption, Option usernameOption, Option passwordOption) diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/SecuredWriteCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/SecuredWriteCommands.cs index 3a65e059..1cac1a0e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/SecuredWriteCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/SecuredWriteCommands.cs @@ -1,15 +1,14 @@ 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; /// -/// Builds the secured-write command group. Currently exposes the read-only -/// list sub-command, which relays a to the -/// central ManagementActor (gated any-of Operator / Verifier / -/// Administrator). The list is offset-paged via --skip / --take -/// (arch-review P3); omitting them preserves the historical unpaged default (first 200 rows). +/// Builds the two-person secured-write workflow. Submission requires an Operator; +/// approve/reject require a distinct Verifier. Roles, site scope, expiry, and +/// separation of duties are enforced by the management server. /// public static class SecuredWriteCommands { @@ -24,7 +23,7 @@ public static class SecuredWriteCommands private static readonly Option TakeOption = new("--take") { Description = "Maximum rows to return (clamped to 1..500 server-side)", DefaultValueFactory = _ => 200 }; - /// Builds the secured-write command group with its list sub-command. + /// Builds the secured-write command group with list, submit, approve, and reject subcommands. /// Global --url option for the management API endpoint. /// Global --format option for output format. /// Global --username option for authentication. @@ -32,8 +31,11 @@ public static class SecuredWriteCommands /// The configured secured-write command. public static Command Build(Option urlOption, Option formatOption, Option usernameOption, Option 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(BuildSubmit(urlOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildDecision("approve", urlOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildDecision("reject", urlOption, formatOption, usernameOption, passwordOption)); return command; } @@ -53,6 +55,84 @@ public static class SecuredWriteCommands return cmd; } + private static Command BuildSubmit(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) + { + var site = new Option("--site") { Description = "Target site identifier", Required = true }; + var connection = new Option("--connection") { Description = "MxGateway connection name", Required = true }; + var tag = new Option("--tag-path") { Description = "Fully-qualified tag path", Required = true }; + var value = new Option("--value-json") { Description = "JSON-serialized value (quote JSON strings)", Required = true }; + var type = new Option("--value-type") { Description = "Target data type name, e.g. Boolean or Double", Required = true }; + var comment = new Option("--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 urlOption, Option formatOption, Option usernameOption, Option passwordOption) + { + var id = new Option("--id") { Description = "Pending secured-write ID", Required = true }; + var comment = new Option("--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; + } + /// /// Builds a from a parsed secured-write list /// invocation. Exposed internally so command-construction tests can assert field mapping diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/SharedSchemaCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/SharedSchemaCommands.cs new file mode 100644 index 00000000..d721d1aa --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/SharedSchemaCommands.cs @@ -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; + +/// CLI adapters for the shared JSON schema library management contracts. +public static class SharedSchemaCommands +{ + /// Builds the shared-schema list/get/create/update/delete command group. + /// Management URL option. + /// Output format option. + /// Authentication username option. + /// Authentication password option. + /// The configured shared-schema group. + public static Command Build(Option urlOption, Option formatOption, Option usernameOption, Option 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 Send(ParseResult result, object command) => CommandHelpers.ExecuteCommandAsync( + result, urlOption, formatOption, usernameOption, passwordOption, command); + + Command BuildIdentity(string verb, Func makeCommand) + { + var id = new Option("--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("--id") { Description = "Shared schema ID", Required = true }; + var name = new Option("--name") { Description = "Schema library name", Required = true }; + var scope = new Option("--scope") { Description = "Optional schema scope; omission clears scope on update" }; + var file = new Option("--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; + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs index 598c3bbc..e13e46e3 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs @@ -19,6 +19,8 @@ public static class TemplateCommands var command = new Command("template") { Description = "Manage templates" }; 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(BuildCreate(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption)); @@ -139,6 +141,65 @@ public static class TemplateCommands return group; } + private static Command BuildResolvedMembers(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) + { + var idOption = new Option("--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 urlOption, Option formatOption, Option usernameOption, Option passwordOption) + { + var idOption = new Option("--id") { Description = "Template ID", Required = true }; + var folderOption = new Option("--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 urlOption, Option formatOption, Option usernameOption, Option passwordOption) + { + var idOption = new Option("--id") { Description = "Native alarm source ID", Required = true }; + var nameOption = new Option("--name") { Description = "Source binding name", Required = true }; + var connectionOption = new Option("--connection") { Description = "Alarm-capable data connection name", Required = true }; + var sourceOption = new Option("--source-ref") { Description = "Source reference (OPC UA SourceNode nodeId, or MxAccess object/area)", Required = true }; + var filterOption = new Option("--filter") { Description = "Condition filter; omit to clear and mirror all conditions" }; + var descriptionOption = new Option("--description") { Description = "Description; omit to clear" }; + var lockedOption = new Option("--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 urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--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)" }; + group.Add(BuildUpdateNativeAlarmSource(urlOption, formatOption, usernameOption, passwordOption)); + // add var templateIdOption = new Option("--template-id") { Description = "Template ID", Required = true }; var nameOption = new Option("--name") { Description = "Source binding name", Required = true }; diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Program.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Program.cs index 02322d06..6b03a5fc 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Program.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Program.cs @@ -1,50 +1,8 @@ -using System.CommandLine; using System.CommandLine.Parsing; +using ZB.MOM.WW.ScadaBridge.CLI; using ZB.MOM.WW.ScadaBridge.CLI.Commands; -var rootCommand = new RootCommand("ScadaBridge CLI — manage the ScadaBridge SCADA system"); - -var urlOption = new Option("--url") { Description = "Management API URL", Recursive = true }; -var usernameOption = new Option("--username") { Description = "LDAP username", Recursive = true }; -var passwordOption = new Option("--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."); -}); +var rootCommand = CliApplication.BuildRoot(); // 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 diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md index 9fd90a64..5a09083c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md @@ -2272,7 +2272,7 @@ scadabridge --url cached-call discard --site-id --tracked-operati ### `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 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 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 "" +``` + +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. diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/ManagementCommandRegistry.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/ManagementCommandRegistry.cs index 9653bbed..249c7bda 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/ManagementCommandRegistry.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/ManagementCommandRegistry.cs @@ -4,9 +4,10 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; /// /// Bidirectional name registry for management command records. The registry contains -/// exactly the non-abstract *Command types declared in the +/// the HTTP-eligible non-abstract *Command types declared in the /// ZB.MOM.WW.ScadaBridge.Commons.Messages.Management 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. /// /// /// and are symmetric: @@ -41,7 +42,7 @@ public static class ManagementCommandRegistry /// The CLR type of the management command to look up. /// /// Thrown when is not a registered management - /// command — i.e. not a non-abstract *Command type in the + /// command — i.e. not an HTTP-eligible non-abstract *Command type in the /// ZB.MOM.WW.ScadaBridge.Commons.Messages.Management namespace. This keeps the method /// symmetric with : it never yields a name that /// cannot turn back into the same type. @@ -56,7 +57,7 @@ public static class ManagementCommandRegistry throw new ArgumentException( $"'{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.", nameof(commandType)); } @@ -69,7 +70,12 @@ public static class ManagementCommandRegistry return assembly.GetTypes() .Where(t => t.Namespace == ns && 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( t => t.Name[..^"Command".Length], t => t, diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CommandTreeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CommandTreeTests.cs index 31793d11..d239d753 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CommandTreeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CommandTreeTests.cs @@ -18,46 +18,15 @@ public class CommandTreeTests private static readonly Option Password = new("--password") { Recursive = true }; private static readonly Option Format = CliOptions.CreateFormatOption(); - // NOTE: this list MUST stay in sync with the rootCommand.Add(...) calls in - // src/ZB.MOM.WW.ScadaBridge.CLI/Program.cs. When a new command group is added (or one is - // removed/renamed), update this array and bump the count assertion in - // AllCommandGroups_Build_WithoutThrowing accordingly. - private static IEnumerable 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; - } + // Exercise the production root: a separately maintained group list can drift. + private static IEnumerable AllCommandGroups() => CliApplication.BuildRoot().Subcommands; [Fact] public void RegisteredOperationalGroups_IncludeSecuredWritesAndConnectionTools() { var groups = AllCommandGroups().ToList(); 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"); foreach (var name in new[] { "browse", "search", "verify-endpoint", "certs" }) Assert.Contains(connection.Subcommands, c => c.Name == name); @@ -82,12 +51,7 @@ public class CommandTreeTests public void AllCommandGroups_Build_WithoutThrowing() { var groups = AllCommandGroups().ToList(); - // CLI-022: bump this count whenever a new top-level command group is - // 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.Equal(20, groups.Count); Assert.All(groups, g => Assert.False(string.IsNullOrWhiteSpace(g.Name))); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/DeploymentDeadlineParityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/DeploymentDeadlineParityTests.cs new file mode 100644 index 00000000..4c5bbc11 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/DeploymentDeadlineParityTests.cs @@ -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("--url") { Recursive = true }; + var format = new Option("--format") { Recursive = true }; + var user = new Option("--username") { Recursive = true }; + var password = new Option("--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(); } + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/EditingParityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/EditingParityTests.cs new file mode 100644 index 00000000..4329d85f --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/EditingParityTests.cs @@ -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("--url") { Recursive = true }; + var format = new Option("--format") { Recursive = true }; + var user = new Option("--username") { Recursive = true }; + var password = new Option("--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); +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ManagementSurfaceParityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ManagementSurfaceParityTests.cs new file mode 100644 index 00000000..c706123a --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ManagementSurfaceParityTests.cs @@ -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; + +/// +/// 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. +/// +[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> ProbeProductionTree(string? omitGroup = null) + { + var root = CliApplication.BuildRoot(); + var recorded = new ConcurrentDictionary(); + 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(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 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"]); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/NotificationParityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/NotificationParityTests.cs new file mode 100644 index 00000000..3c5a1526 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/NotificationParityTests.cs @@ -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("--url") { Recursive = true }; + var format = new Option("--format") { Recursive = true }; + var user = new Option("--username") { Recursive = true }; + var password = new Option("--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 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)); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ProductionRootTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ProductionRootTests.cs new file mode 100644 index 00000000..7144a871 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ProductionRootTests.cs @@ -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(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()); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/SchemaSecuredParityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/SchemaSecuredParityTests.cs new file mode 100644 index 00000000..76e5760b --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/SchemaSecuredParityTests.cs @@ -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("--url") { Recursive = true }; + var format = new Option("--format") { Recursive = true }; + var user = new Option("--username") { Recursive = true }; + var password = new Option("--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(); + 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); } + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs index 62c17ec3..2bf4a1e0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs @@ -355,6 +355,7 @@ public class AuditDrilldownDrawerTests : BunitContext invocation => invocation.Arguments.Count == 1 && invocation.Arguments[0] is string s && s.StartsWith("curl ", StringComparison.Ordinal)); + clipboardCall.SetVoidResult(); // Build an event with a {headers, body} RequestSummary so the cURL // builder has material to fold in. diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditEventDetailTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditEventDetailTests.cs index 61f853d4..e0ff9f06 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditEventDetailTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditEventDetailTests.cs @@ -301,6 +301,7 @@ public class AuditEventDetailTests : BunitContext invocation => invocation.Arguments.Count == 1 && invocation.Arguments[0] is string s && s.StartsWith("curl ", StringComparison.Ordinal)); + clipboardCall.SetVoidResult(); var ev = MakeEvent( channel: AuditChannel.ApiOutbound, diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionsPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionsPageTests.cs index c2cea3a9..d00773fc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionsPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionsPageTests.cs @@ -1,3 +1,4 @@ +using Microsoft.JSInterop; using System.Security.Claims; using ZB.MOM.WW.ScadaBridge.Security; using Bunit; @@ -20,6 +21,13 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests; /// 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(); // M9-T25: the design page now injects IConnectionHealthQueryService to render // per-connection live health badges. A substitute defaulting to an empty map @@ -51,7 +59,7 @@ public class DataConnectionsPageTests : BunitContext new Dictionary())); JSInterop.Setup("treeviewStorage.load", _ => true).SetResult(null); - JSInterop.SetupVoid("treeviewStorage.save", _ => true); + JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult(); } private void AddTestAuth() diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/JsInteropLoggingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/JsInteropLoggingTests.cs index eea0ddc6..23dab610 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/JsInteropLoggingTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/JsInteropLoggingTests.cs @@ -61,7 +61,7 @@ public class JsInteropLoggingTests : BunitContext JSInterop.SetupVoid("document.body.classList.add", "modal-open") .SetException(new JSException("body lock failed")); // 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(); cut.InvokeAsync(() => cut.Instance.ShowAsync("Compare", "a", "b")); @@ -84,7 +84,7 @@ public class JsInteropLoggingTests : BunitContext JSInterop.Mode = JSRuntimeMode.Strict; JSInterop.SetupVoid("document.body.classList.add", "modal-open") .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(); cut.InvokeAsync(() => cut.Instance.ShowAsync("Compare", "a", "b")); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TreeViewKeyboardNavigationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TreeViewKeyboardNavigationTests.cs index 05736411..88d22131 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TreeViewKeyboardNavigationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TreeViewKeyboardNavigationTests.cs @@ -53,7 +53,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext { // 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. - JSInterop.SetupVoid("treeviewStorage.setIndeterminate", _ => true); + JSInterop.SetupVoid("treeviewStorage.setIndeterminate", _ => true).SetVoidResult(); } return Render>(parameters => diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TreeViewStorageResilienceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TreeViewStorageResilienceTests.cs index e99bd34f..812d15f2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TreeViewStorageResilienceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TreeViewStorageResilienceTests.cs @@ -37,7 +37,7 @@ public class TreeViewStorageResilienceTests : BunitContext // A garbage payload that is not valid JSON for a List. JSInterop.Setup("treeviewStorage.load", _ => true) .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 // Deserialize call. Post-fix: the corrupt payload is ignored. @@ -53,7 +53,7 @@ public class TreeViewStorageResilienceTests : BunitContext // Valid JSON, but not a List — an object, not an array. JSInterop.Setup("treeviewStorage.load", _ => true) .SetResult("{\"unexpected\": true}"); - JSInterop.SetupVoid("treeviewStorage.save", _ => true); + JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult(); var cut = BuildTree(); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TemplatesPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TemplatesPageTests.cs index b9e10c51..b7dbe3f0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TemplatesPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TemplatesPageTests.cs @@ -44,7 +44,7 @@ public class TemplatesPageTests : BunitContext // against sessionStorage (`templates-tree` key). bUnit requires explicit // stubs for all JS interop calls, otherwise rendering throws. JSInterop.Setup("treeviewStorage.load", _ => true).SetResult(null); - JSInterop.SetupVoid("treeviewStorage.save", _ => true); + JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult(); } private void AddTestAuth(params string[] roles) diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyLiveUpdatesTimerDisposeRaceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyLiveUpdatesTimerDisposeRaceTests.cs index 7cd9d0db..0f90b80e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyLiveUpdatesTimerDisposeRaceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyLiveUpdatesTimerDisposeRaceTests.cs @@ -87,7 +87,7 @@ public class TopologyLiveUpdatesTimerDisposeRaceTests : BunitContext Services.AddScoped(); JSInterop.Setup("treeviewStorage.load", _ => true).SetResult(null); - JSInterop.SetupVoid("treeviewStorage.save", _ => true); + JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult(); SeedEmptyRepos(); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyPageTests.cs index ad52f59e..a0a83e1e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyPageTests.cs @@ -1,3 +1,4 @@ +using Microsoft.JSInterop; using System.Security.Claims; using System.Text.Json; using ZB.MOM.WW.ScadaBridge.Security; @@ -34,6 +35,13 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests; /// 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(); private readonly ISiteRepository _siteRepo = Substitute.For(); private readonly IDeploymentManagerRepository _deployRepo = Substitute.For(); @@ -97,7 +105,7 @@ public class TopologyPageTests : BunitContext // TreeView persists expansion state via JS interop. Stub the calls so render doesn't throw. JSInterop.Setup("treeviewStorage.load", _ => true).SetResult(null); - JSInterop.SetupVoid("treeviewStorage.save", _ => true); + JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult(); } private void AddTestAuth() diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TreeViewMultiSelectTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TreeViewMultiSelectTests.cs index 9de26daa..cfccb55d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TreeViewMultiSelectTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TreeViewMultiSelectTests.cs @@ -38,7 +38,7 @@ public class TreeViewMultiSelectTests : BunitContext { // 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. - JSInterop.SetupVoid("treeviewStorage.setIndeterminate", _ => true); + JSInterop.SetupVoid("treeviewStorage.setIndeterminate", _ => true).SetVoidResult(); return Render>(parameters => { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TreeViewTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TreeViewTests.cs index cb119483..d87f4c34 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TreeViewTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TreeViewTests.cs @@ -348,7 +348,7 @@ public class TreeViewTests : BunitContext public void SessionStorage_Set_ExpandWritesToStorage() { JSInterop.Setup("treeviewStorage.load", _ => true).SetResult(null); - JSInterop.SetupVoid("treeviewStorage.save", _ => true); + JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult(); var cut = RenderTreeView(storageKey: "test-tree"); @@ -366,7 +366,7 @@ public class TreeViewTests : BunitContext public void SessionStorage_RestoresExpandedOnMount() { JSInterop.Setup("treeviewStorage.load", _ => true).SetResult("[\"a\"]"); - JSInterop.SetupVoid("treeviewStorage.save", _ => true); + JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult(); var cut = RenderTreeView(storageKey: "test-tree"); @@ -381,7 +381,7 @@ public class TreeViewTests : BunitContext { // Storage returns empty array — meaning user explicitly collapsed everything JSInterop.Setup("treeviewStorage.load", _ => true).SetResult("[]"); - JSInterop.SetupVoid("treeviewStorage.save", _ => true); + JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult(); var cut = RenderTreeView( storageKey: "test-tree", diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ManagementCommandRegistryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ManagementCommandRegistryTests.cs index ac9fc060..dccccedc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ManagementCommandRegistryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ManagementCommandRegistryTests.cs @@ -15,7 +15,9 @@ public class ManagementCommandRegistryTests typeof(ManagementEnvelope).Assembly.GetTypes() .Where(t => t.Namespace == typeof(ManagementEnvelope).Namespace && t.Name.EndsWith("Command", StringComparison.Ordinal) - && !t.IsAbstract); + && !t.IsAbstract + && t != typeof(ReadTagValuesCommand) + && t != typeof(ResolveRolesCommand)); [Fact] public void GetCommandName_Resolve_RoundTrips_ForEveryRegisteredCommand() diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ReadTagValuesCommandRegistryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ReadTagValuesCommandRegistryTests.cs index a25d8181..600da98f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ReadTagValuesCommandRegistryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ReadTagValuesCommandRegistryTests.cs @@ -2,23 +2,14 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages; -/// -/// Verifies that is discovered by -/// so it travels over the management -/// boundary as a known command (resolvable by wire name and round-trippable -/// through GetCommandName / Resolve). Mirrors -/// . -/// public class ReadTagValuesCommandRegistryTests { - [Fact] - public void Registry_discovers_ReadTagValuesCommand() + [Theory] + [InlineData(typeof(ReadTagValuesCommand), "ReadTagValues")] + [InlineData(typeof(ResolveRolesCommand), "ResolveRoles")] + public void SiteOnlyAndRetiredCommands_AreNotHttpCommands(Type type, string wireName) { - // GetCommandName throws ArgumentException for any type the registry - // does not contain, so a successful call here is proof of discovery. - var name = ManagementCommandRegistry.GetCommandName(typeof(ReadTagValuesCommand)); - - Assert.Equal("ReadTagValues", name); - Assert.Equal(typeof(ReadTagValuesCommand), ManagementCommandRegistry.Resolve(name)); + Assert.Null(ManagementCommandRegistry.Resolve(wireName)); + Assert.Throws(() => ManagementCommandRegistry.GetCommandName(type)); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DispatchCoverageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DispatchCoverageTests.cs index 184cf116..9b329579 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DispatchCoverageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DispatchCoverageTests.cs @@ -6,8 +6,8 @@ using Akka.TestKit.Xunit2; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using NSubstitute; -using NSubstitute.ExceptionExtensions; 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.Security; @@ -19,7 +19,7 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; /// in . A command with no arm falls through to the /// registry's default case, which throws /// ("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. /// /// @@ -27,7 +27,8 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; /// instance (all fields default — /// 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 -/// scoped throws a sentinel exception, so +/// scoped 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 /// exists). Only a command with NO arm faults with . /// A recording logger captures the exception maps in @@ -37,36 +38,12 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; /// public class DispatchCoverageTests : TestKit, IDisposable { - /// - /// Commands that are registered in (so they - /// carry a stable wire name across the management boundary) but are intentionally - /// NOT dispatched by because they are serviced by a - /// different actor path. Each entry is a deliberate design decision, not a missing arm. - /// - private static readonly HashSet 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 RegisteredCommandTypes() => typeof(ManagementEnvelope).Assembly.GetTypes() .Where(t => t.Namespace == typeof(ManagementEnvelope).Namespace && t.Name.EndsWith("Command", StringComparison.Ordinal) - && !t.IsAbstract); + && !t.IsAbstract + && ManagementCommandRegistry.Resolve(t.Name[..^"Command".Length]) == t); void IDisposable.Dispose() => Shutdown(); @@ -74,19 +51,12 @@ public class DispatchCoverageTests : TestKit, IDisposable public void EveryRegisteredCommand_ReachesADispatchArm() { var logger = new RecordingLogger(); - var actor = CreateSentinelActor(logger); + var actor = CreateSentinelActor(logger, out var transaction); var commandTypes = RegisteredCommandTypes() - .Where(t => !Excluded.Contains(t)) .OrderBy(t => t.Name, StringComparer.Ordinal) .ToList(); - // Sanity: the exclusion list must reference real, registered command types. - foreach (var excluded in Excluded) - { - Assert.Contains(excluded, RegisteredCommandTypes()); - } - var noDispatchArm = new List(); 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( noDispatchArm.Count == 0, $"{noDispatchArm.Count} registered command(s) have no dispatch arm in ManagementActor " + - $"(fell through to the NotSupportedException default). Add the missing arm, or — with " + - $"justification — an entry to the exclusion list: {string.Join(", ", noDispatchArm)}"); + $"(fell through to the NotSupportedException default). Add the missing arm or correct HTTP eligibility. " + + $"Commands: {string.Join(", ", noDispatchArm)}"); } /// @@ -124,10 +95,15 @@ public class DispatchCoverageTests : TestKit, IDisposable /// CreateScope() call succeeds and the fault surfaces only when a handler /// actually resolves a service. /// - private IActorRef CreateSentinelActor(ILogger logger) + private IActorRef CreateSentinelActor(ILogger logger, out PassthroughTransaction transaction) { var scopedProvider = Substitute.For(); - scopedProvider.GetService(Arg.Any()).Throws(_ => new SentinelException()); + var passthrough = new PassthroughTransaction(scopedProvider); + transaction = passthrough; + scopedProvider.GetService(Arg.Any()).Returns(call => + call.Arg() == typeof(IConfigurationCommandTransaction) + ? passthrough + : throw new SentinelException()); var scope = Substitute.For(); scope.ServiceProvider.Returns(scopedProvider); @@ -141,6 +117,32 @@ public class DispatchCoverageTests : TestKit, IDisposable return Sys.ActorOf(Props.Create(() => new ManagementActor(rootProvider, logger))); } + [Fact] + public void Probe_DetectsACommandWithoutADispatchArm() + { + var logger = new RecordingLogger(); + 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(TimeSpan.FromSeconds(10)); + Assert.IsType(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 ExecuteAsync(Func> operation) + { + DispatchCount++; + return operation(provider); + } + } + /// Marker fault thrown by the scoped provider — distinct from the /// "no dispatch arm" signature. private sealed class SentinelException : Exception diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/HttpCommandEligibilityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/HttpCommandEligibilityTests.cs new file mode 100644 index 00000000..67068b7e --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/HttpCommandEligibilityTests.cs @@ -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); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/NotificationRecipientPreservationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/NotificationRecipientPreservationTests.cs new file mode 100644 index 00000000..9566c903 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/NotificationRecipientPreservationTests.cs @@ -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(); + repo.GetNotificationListByIdAsync(7, Arg.Any()).Returns(list); + repo.GetRecipientsByListIdAsync(7, Arg.Any()) + .Returns(_ => list.Recipients.ToList()); + repo.GetRecipientByIdAsync(Arg.Any(), Arg.Any()) + .Returns(call => list.Recipients.SingleOrDefault(r => r.Id == call.Arg())); + // 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(), Arg.Any())) + .Do(call => + { + var row = call.Arg(); + row.Id = 42; + list.Recipients.Add(row); + }); + repo.When(r => r.DeleteRecipientAsync(Arg.Any(), Arg.Any())) + .Do(call => list.Recipients.Remove(list.Recipients.Single(r => r.Id == call.Arg()))); + using var services = new ServiceCollection().AddTestConfigurationTransaction() + .AddSingleton(repo).AddSingleton(Substitute.For()).BuildServiceProvider(); + var user = new AuthenticatedUser("designer", "designer", new[] { Roles.Designer }, Array.Empty()); + + 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(), Arg.Any()); + await repo.Received(1).DeleteRecipientAsync(42, Arg.Any()); + await repo.DidNotReceive().DeleteRecipientAsync(41, Arg.Any()); + await repo.DidNotReceiveWithAnyArgs().UpdateRecipientAsync(default!, default); + await repo.Received(3).SaveChangesAsync(Arg.Any()); + + 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); + } + } +}