feat(cli): browse/search/verify-endpoint/cert-trust commands — actor parity (arch-review C4)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -173,6 +173,23 @@ scadabridge data-connection update --id <id> --name <name> --protocol <protocol>
|
|||||||
scadabridge data-connection delete --id <id>
|
scadabridge data-connection delete --id <id>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Site-routed OPC UA operations (browse / search / verify-endpoint / certs)
|
||||||
|
|
||||||
|
CLI parity for the site-routed data-connection operations the ManagementActor dispatches
|
||||||
|
(arch-review C4). Each carries `--site` so the management API routes it to the target
|
||||||
|
site cluster. `browse`/`search`/`verify-endpoint` require the **Designer** role; `certs`
|
||||||
|
require **Admin**. `verify-endpoint` reads `ConfigJson` verbatim from `--config-file`;
|
||||||
|
`certs trust` base64-encodes `--der-file`.
|
||||||
|
|
||||||
|
```
|
||||||
|
scadabridge data-connection browse --site <identifier> --connection <name> [--node-id <nodeId>]
|
||||||
|
scadabridge data-connection search --site <identifier> --connection <name> --query <text> [--max-depth <n>] [--max-results <n>]
|
||||||
|
scadabridge data-connection verify-endpoint --site <identifier> --protocol <protocol> --config-file <path> [--connection <name>]
|
||||||
|
scadabridge data-connection certs list --site <identifier> [--connection <name>]
|
||||||
|
scadabridge data-connection certs trust --site <identifier> --connection <name> --der-file <path> --thumbprint <hex>
|
||||||
|
scadabridge data-connection certs remove --site <identifier> --thumbprint <hex> [--connection <name>]
|
||||||
|
```
|
||||||
|
|
||||||
### External System Commands
|
### External System Commands
|
||||||
```
|
```
|
||||||
scadabridge external-system list
|
scadabridge external-system list
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
using System.CommandLine;
|
||||||
|
using System.CommandLine.Parsing;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CLI parity for the site-routed OPC UA data-connection operations that the
|
||||||
|
/// ManagementActor dispatches (arch-review C4): live address-space <c>browse</c> and
|
||||||
|
/// <c>search</c>, endpoint <c>verify-endpoint</c> probing, and server-certificate trust
|
||||||
|
/// management (<c>certs list|trust|remove</c>).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// These subcommands attach under the existing <c>data-connection</c> group. Every
|
||||||
|
/// command carries an explicit <c>--site</c> (mapped to the command record's
|
||||||
|
/// <c>SiteIdentifier</c>) so the management API can route it to the target site cluster
|
||||||
|
/// — the site side ignores it once routing has happened. <c>browse</c>/<c>search</c> and
|
||||||
|
/// <c>verify-endpoint</c> are Designer operations; <c>certs</c> are Admin operations,
|
||||||
|
/// enforced server-side by the ManagementActor.
|
||||||
|
/// </remarks>
|
||||||
|
public static class ConnectionBrowseCommands
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Attaches the browse / search / verify-endpoint / certs subcommands to the
|
||||||
|
/// <c>data-connection</c> command group.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dataConnection">The existing <c>data-connection</c> group to extend.</param>
|
||||||
|
/// <param name="urlOption">Global management URL option.</param>
|
||||||
|
/// <param name="formatOption">Global output format option.</param>
|
||||||
|
/// <param name="usernameOption">Global username option.</param>
|
||||||
|
/// <param name="passwordOption">Global password option.</param>
|
||||||
|
/// <param name="commandInterceptor">
|
||||||
|
/// Test seam: when supplied, the built command record is handed to this delegate
|
||||||
|
/// (which returns the process exit code) instead of being sent over HTTP. Lets parse
|
||||||
|
/// tests assert the exact record produced from a given command line without a live
|
||||||
|
/// management server. Null in production (Program.cs), where the real HTTP path runs.
|
||||||
|
/// </param>
|
||||||
|
public static void AddTo(
|
||||||
|
Command dataConnection,
|
||||||
|
Option<string> urlOption,
|
||||||
|
Option<string> formatOption,
|
||||||
|
Option<string> usernameOption,
|
||||||
|
Option<string> passwordOption,
|
||||||
|
Func<object, int>? commandInterceptor = null)
|
||||||
|
{
|
||||||
|
dataConnection.Add(BuildBrowse(urlOption, formatOption, usernameOption, passwordOption, commandInterceptor));
|
||||||
|
dataConnection.Add(BuildSearch(urlOption, formatOption, usernameOption, passwordOption, commandInterceptor));
|
||||||
|
dataConnection.Add(BuildVerifyEndpoint(urlOption, formatOption, usernameOption, passwordOption, commandInterceptor));
|
||||||
|
dataConnection.Add(BuildCerts(urlOption, formatOption, usernameOption, passwordOption, commandInterceptor));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends <paramref name="command"/> to the management API, or — when a test
|
||||||
|
/// interceptor is present — hands it to the interceptor instead.
|
||||||
|
/// </summary>
|
||||||
|
private static Task<int> DispatchAsync(
|
||||||
|
ParseResult result,
|
||||||
|
Option<string> urlOption,
|
||||||
|
Option<string> formatOption,
|
||||||
|
Option<string> usernameOption,
|
||||||
|
Option<string> passwordOption,
|
||||||
|
object command,
|
||||||
|
Func<object, int>? commandInterceptor)
|
||||||
|
{
|
||||||
|
if (commandInterceptor is not null)
|
||||||
|
return Task.FromResult(commandInterceptor(command));
|
||||||
|
|
||||||
|
return CommandHelpers.ExecuteCommandAsync(
|
||||||
|
result, urlOption, formatOption, usernameOption, passwordOption, command);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command BuildBrowse(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption, Func<object, int>? interceptor)
|
||||||
|
{
|
||||||
|
var siteOption = new Option<string>("--site") { Description = "Target site identifier", Required = true };
|
||||||
|
var connectionOption = new Option<string>("--connection") { Description = "Data connection name", Required = true };
|
||||||
|
var nodeIdOption = new Option<string?>("--node-id") { Description = "Parent node id to browse (server root / ObjectsFolder if omitted)" };
|
||||||
|
|
||||||
|
var cmd = new Command("browse") { Description = "Browse the immediate children of an OPC UA node on a site data connection" };
|
||||||
|
cmd.Add(siteOption);
|
||||||
|
cmd.Add(connectionOption);
|
||||||
|
cmd.Add(nodeIdOption);
|
||||||
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
|
{
|
||||||
|
var site = result.GetValue(siteOption)!;
|
||||||
|
var connection = result.GetValue(connectionOption)!;
|
||||||
|
var nodeId = result.GetValue(nodeIdOption);
|
||||||
|
return await DispatchAsync(result, urlOption, formatOption, usernameOption, passwordOption,
|
||||||
|
new BrowseNodeCommand(connection, nodeId, null, site), interceptor);
|
||||||
|
});
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command BuildSearch(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption, Func<object, int>? interceptor)
|
||||||
|
{
|
||||||
|
var siteOption = new Option<string>("--site") { Description = "Target site identifier", Required = true };
|
||||||
|
var connectionOption = new Option<string>("--connection") { Description = "Data connection name", Required = true };
|
||||||
|
var queryOption = new Option<string>("--query") { Description = "Case-insensitive substring matched on DisplayName and root-relative path", Required = true };
|
||||||
|
var maxDepthOption = new Option<int>("--max-depth") { Description = "Maximum levels below the root to descend", DefaultValueFactory = _ => 5 };
|
||||||
|
var maxResultsOption = new Option<int>("--max-results") { Description = "Maximum number of matches to return", DefaultValueFactory = _ => 100 };
|
||||||
|
|
||||||
|
var cmd = new Command("search") { Description = "Bounded recursive search of an OPC UA address space on a site data connection" };
|
||||||
|
cmd.Add(siteOption);
|
||||||
|
cmd.Add(connectionOption);
|
||||||
|
cmd.Add(queryOption);
|
||||||
|
cmd.Add(maxDepthOption);
|
||||||
|
cmd.Add(maxResultsOption);
|
||||||
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
|
{
|
||||||
|
var site = result.GetValue(siteOption)!;
|
||||||
|
var connection = result.GetValue(connectionOption)!;
|
||||||
|
var query = result.GetValue(queryOption)!;
|
||||||
|
var maxDepth = result.GetValue(maxDepthOption);
|
||||||
|
var maxResults = result.GetValue(maxResultsOption);
|
||||||
|
return await DispatchAsync(result, urlOption, formatOption, usernameOption, passwordOption,
|
||||||
|
new SearchAddressSpaceCommand(connection, query, maxDepth, maxResults, site), interceptor);
|
||||||
|
});
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command BuildVerifyEndpoint(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption, Func<object, int>? interceptor)
|
||||||
|
{
|
||||||
|
var siteOption = new Option<string>("--site") { Description = "Target site identifier", Required = true };
|
||||||
|
var protocolOption = new Option<string>("--protocol") { Description = "Protocol type (e.g. OpcUa)", Required = true };
|
||||||
|
var configFileOption = new Option<string>("--config-file") { Description = "Path to the endpoint configuration JSON file (read verbatim into ConfigJson)", Required = true };
|
||||||
|
// Correlation only — a verify probe runs against an endpoint config that may not
|
||||||
|
// yet correspond to a saved connection, so the name is optional.
|
||||||
|
var connectionOption = new Option<string?>("--connection") { Description = "Connection name for logging/correlation (optional)" };
|
||||||
|
|
||||||
|
var cmd = new Command("verify-endpoint") { Description = "Probe a data-connection endpoint config on a site without persisting it (read-only; never trusts the server cert)" };
|
||||||
|
cmd.Add(siteOption);
|
||||||
|
cmd.Add(protocolOption);
|
||||||
|
cmd.Add(configFileOption);
|
||||||
|
cmd.Add(connectionOption);
|
||||||
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
|
{
|
||||||
|
var site = result.GetValue(siteOption)!;
|
||||||
|
var protocol = result.GetValue(protocolOption)!;
|
||||||
|
var configFile = result.GetValue(configFileOption)!;
|
||||||
|
var connection = result.GetValue(connectionOption);
|
||||||
|
var configJson = await File.ReadAllTextAsync(configFile);
|
||||||
|
return await DispatchAsync(result, urlOption, formatOption, usernameOption, passwordOption,
|
||||||
|
new VerifyEndpointCommand(connection ?? string.Empty, protocol, configJson, site), interceptor);
|
||||||
|
});
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command BuildCerts(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption, Func<object, int>? interceptor)
|
||||||
|
{
|
||||||
|
var group = new Command("certs") { Description = "Manage trusted OPC UA server certificates for a site" };
|
||||||
|
group.Add(BuildCertsList(urlOption, formatOption, usernameOption, passwordOption, interceptor));
|
||||||
|
group.Add(BuildCertsTrust(urlOption, formatOption, usernameOption, passwordOption, interceptor));
|
||||||
|
group.Add(BuildCertsRemove(urlOption, formatOption, usernameOption, passwordOption, interceptor));
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command BuildCertsList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption, Func<object, int>? interceptor)
|
||||||
|
{
|
||||||
|
var siteOption = new Option<string>("--site") { Description = "Target site identifier", Required = true };
|
||||||
|
// Contextual only — the trusted-peer PKI store is site-scoped (not per-connection),
|
||||||
|
// so the connection is not part of the ListServerCertsCommand payload. Accepted for
|
||||||
|
// symmetry with the connection-scoped UI navigation.
|
||||||
|
var connectionOption = new Option<string?>("--connection") { Description = "Data connection name (contextual; not part of the request)" };
|
||||||
|
|
||||||
|
var cmd = new Command("list") { Description = "List the certificates in a site's trusted-peer and rejected PKI stores" };
|
||||||
|
cmd.Add(siteOption);
|
||||||
|
cmd.Add(connectionOption);
|
||||||
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
|
{
|
||||||
|
var site = result.GetValue(siteOption)!;
|
||||||
|
return await DispatchAsync(result, urlOption, formatOption, usernameOption, passwordOption,
|
||||||
|
new ListServerCertsCommand(site), interceptor);
|
||||||
|
});
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command BuildCertsTrust(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption, Func<object, int>? interceptor)
|
||||||
|
{
|
||||||
|
var siteOption = new Option<string>("--site") { Description = "Target site identifier", Required = true };
|
||||||
|
var connectionOption = new Option<string>("--connection") { Description = "Data connection the certificate was captured from (correlation)", Required = true };
|
||||||
|
var derFileOption = new Option<string>("--der-file") { Description = "Path to the DER-encoded certificate file (base64-encoded into the request)", Required = true };
|
||||||
|
var thumbprintOption = new Option<string>("--thumbprint") { Description = "Certificate thumbprint (used as the store filename key)", Required = true };
|
||||||
|
|
||||||
|
var cmd = new Command("trust") { Description = "Trust an OPC UA server certificate on every node of a site" };
|
||||||
|
cmd.Add(siteOption);
|
||||||
|
cmd.Add(connectionOption);
|
||||||
|
cmd.Add(derFileOption);
|
||||||
|
cmd.Add(thumbprintOption);
|
||||||
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
|
{
|
||||||
|
var site = result.GetValue(siteOption)!;
|
||||||
|
var connection = result.GetValue(connectionOption)!;
|
||||||
|
var derFile = result.GetValue(derFileOption)!;
|
||||||
|
var thumbprint = result.GetValue(thumbprintOption)!;
|
||||||
|
var derBase64 = Convert.ToBase64String(await File.ReadAllBytesAsync(derFile));
|
||||||
|
return await DispatchAsync(result, urlOption, formatOption, usernameOption, passwordOption,
|
||||||
|
new TrustServerCertCommand(connection, derBase64, thumbprint, site), interceptor);
|
||||||
|
});
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Command BuildCertsRemove(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption, Func<object, int>? interceptor)
|
||||||
|
{
|
||||||
|
var siteOption = new Option<string>("--site") { Description = "Target site identifier", Required = true };
|
||||||
|
var thumbprintOption = new Option<string>("--thumbprint") { Description = "Thumbprint of the certificate to remove", Required = true };
|
||||||
|
// Contextual only — RemoveServerCertCommand is keyed by thumbprint + site, not connection.
|
||||||
|
var connectionOption = new Option<string?>("--connection") { Description = "Data connection name (contextual; not part of the request)" };
|
||||||
|
|
||||||
|
var cmd = new Command("remove") { Description = "Remove a trusted OPC UA server certificate from every node of a site" };
|
||||||
|
cmd.Add(siteOption);
|
||||||
|
cmd.Add(thumbprintOption);
|
||||||
|
cmd.Add(connectionOption);
|
||||||
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
|
{
|
||||||
|
var site = result.GetValue(siteOption)!;
|
||||||
|
var thumbprint = result.GetValue(thumbprintOption)!;
|
||||||
|
return await DispatchAsync(result, urlOption, formatOption, usernameOption, passwordOption,
|
||||||
|
new RemoveServerCertCommand(thumbprint, site), interceptor);
|
||||||
|
});
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,10 @@ rootCommand.Add(TemplateCommands.Build(urlOption, formatOption, usernameOption,
|
|||||||
rootCommand.Add(InstanceCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
rootCommand.Add(InstanceCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
rootCommand.Add(SiteCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
rootCommand.Add(SiteCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
rootCommand.Add(DeployCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
rootCommand.Add(DeployCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
rootCommand.Add(DataConnectionCommands.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(ExternalSystemCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
rootCommand.Add(NotificationCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
rootCommand.Add(NotificationCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
rootCommand.Add(SecurityCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
rootCommand.Add(SecurityCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
|
|||||||
@@ -936,6 +936,93 @@ scadabridge --url <url> data-connection delete --id <int>
|
|||||||
|--------|----------|-------------|
|
|--------|----------|-------------|
|
||||||
| `--id` | yes | Data connection ID |
|
| `--id` | yes | Data connection ID |
|
||||||
|
|
||||||
|
#### `data-connection browse`
|
||||||
|
|
||||||
|
Browse the immediate children of an OPC UA node on the live server backing a site data connection. `--site` routes the command to the target site. Designer role.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scadabridge --url <url> data-connection browse --site <identifier> --connection <name> [--node-id <nodeId>]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Required | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `--site` | yes | Target site identifier |
|
||||||
|
| `--connection` | yes | Data connection name |
|
||||||
|
| `--node-id` | no | Parent node id to browse (server root / ObjectsFolder if omitted) |
|
||||||
|
|
||||||
|
#### `data-connection search`
|
||||||
|
|
||||||
|
Bounded recursive search of an OPC UA address space (substring on DisplayName / root-relative path). Designer role.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scadabridge --url <url> data-connection search --site <identifier> --connection <name> --query <text> [--max-depth <int>] [--max-results <int>]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Required | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `--site` | yes | Target site identifier |
|
||||||
|
| `--connection` | yes | Data connection name |
|
||||||
|
| `--query` | yes | Case-insensitive substring matched on DisplayName and root-relative path |
|
||||||
|
| `--max-depth` | no | Maximum levels below the root to descend (default 5) |
|
||||||
|
| `--max-results` | no | Maximum number of matches to return (default 100) |
|
||||||
|
|
||||||
|
#### `data-connection verify-endpoint`
|
||||||
|
|
||||||
|
Probe an endpoint configuration on a site without persisting it (read-only; never trusts the server certificate). The endpoint JSON is read verbatim from `--config-file`. Designer role.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scadabridge --url <url> data-connection verify-endpoint --site <identifier> --protocol <string> --config-file <path> [--connection <name>]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Required | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `--site` | yes | Target site identifier |
|
||||||
|
| `--protocol` | yes | Protocol type (e.g. `OpcUa`) |
|
||||||
|
| `--config-file` | yes | Path to the endpoint configuration JSON file (read into `ConfigJson`) |
|
||||||
|
| `--connection` | no | Connection name for logging/correlation |
|
||||||
|
|
||||||
|
#### `data-connection certs list`
|
||||||
|
|
||||||
|
List the certificates in a site's trusted-peer and rejected PKI stores. Admin role.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scadabridge --url <url> data-connection certs list --site <identifier> [--connection <name>]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Required | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `--site` | yes | Target site identifier |
|
||||||
|
| `--connection` | no | Data connection name (contextual; not part of the request) |
|
||||||
|
|
||||||
|
#### `data-connection certs trust`
|
||||||
|
|
||||||
|
Trust an OPC UA server certificate on every node of a site. The DER file is base64-encoded into the request. Admin role.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scadabridge --url <url> data-connection certs trust --site <identifier> --connection <name> --der-file <path> --thumbprint <hex>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Required | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `--site` | yes | Target site identifier |
|
||||||
|
| `--connection` | yes | Data connection the certificate was captured from (correlation) |
|
||||||
|
| `--der-file` | yes | Path to the DER-encoded certificate file (base64-encoded into the request) |
|
||||||
|
| `--thumbprint` | yes | Certificate thumbprint (store filename key) |
|
||||||
|
|
||||||
|
#### `data-connection certs remove`
|
||||||
|
|
||||||
|
Remove a trusted OPC UA server certificate from every node of a site, identified by thumbprint. Admin role.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scadabridge --url <url> data-connection certs remove --site <identifier> --thumbprint <hex> [--connection <name>]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Required | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `--site` | yes | Target site identifier |
|
||||||
|
| `--thumbprint` | yes | Thumbprint of the certificate to remove |
|
||||||
|
| `--connection` | no | Data connection name (contextual; not part of the request) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `external-system` — Manage external HTTP systems
|
### `external-system` — Manage external HTTP systems
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
using System.CommandLine;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CLI;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse tests for the site-routed data-connection subcommands added for actor parity
|
||||||
|
/// (arch-review C4): <c>browse</c>, <c>search</c>, <c>verify-endpoint</c>, and
|
||||||
|
/// <c>certs list|trust|remove</c>. Each test parses a real command line through
|
||||||
|
/// System.CommandLine and asserts the exact management command record produced,
|
||||||
|
/// including the routed <c>--site</c> → <c>SiteIdentifier</c> mapping.
|
||||||
|
/// </summary>
|
||||||
|
public class ConnectionBrowseCommandsTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a root command with the browse/search/verify/certs subcommands attached
|
||||||
|
/// under a bare <c>data-connection</c> group. The interceptor captures the built
|
||||||
|
/// command record instead of sending it over HTTP, so the tests never need a live
|
||||||
|
/// management server.
|
||||||
|
/// </summary>
|
||||||
|
private static (RootCommand root, List<object> captured) BuildHarness()
|
||||||
|
{
|
||||||
|
var url = new Option<string>("--url") { Recursive = true };
|
||||||
|
var username = new Option<string>("--username") { Recursive = true };
|
||||||
|
var password = new Option<string>("--password") { Recursive = true };
|
||||||
|
var format = CliOptions.CreateFormatOption();
|
||||||
|
|
||||||
|
var root = new RootCommand();
|
||||||
|
root.Add(url);
|
||||||
|
root.Add(username);
|
||||||
|
root.Add(password);
|
||||||
|
root.Add(format);
|
||||||
|
|
||||||
|
var captured = new List<object>();
|
||||||
|
var dataConnection = new Command("data-connection");
|
||||||
|
ConnectionBrowseCommands.AddTo(dataConnection, url, format, username, password, cmd =>
|
||||||
|
{
|
||||||
|
captured.Add(cmd);
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
root.Add(dataConnection);
|
||||||
|
|
||||||
|
return (root, captured);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Browse_BuildsBrowseNodeCommand_WithSiteIdentifier()
|
||||||
|
{
|
||||||
|
var (root, captured) = BuildHarness();
|
||||||
|
|
||||||
|
var exit = await root
|
||||||
|
.Parse(new[] { "data-connection", "browse", "--site", "SITE1", "--connection", "conn1", "--node-id", "ns=2;s=X" })
|
||||||
|
.InvokeAsync();
|
||||||
|
|
||||||
|
Assert.Equal(0, exit);
|
||||||
|
var cmd = Assert.IsType<BrowseNodeCommand>(Assert.Single(captured));
|
||||||
|
Assert.Equal("SITE1", cmd.SiteIdentifier);
|
||||||
|
Assert.Equal("conn1", cmd.ConnectionName);
|
||||||
|
Assert.Equal("ns=2;s=X", cmd.ParentNodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Browse_WithoutNodeId_BrowsesFromRoot()
|
||||||
|
{
|
||||||
|
var (root, captured) = BuildHarness();
|
||||||
|
|
||||||
|
var exit = await root
|
||||||
|
.Parse(new[] { "data-connection", "browse", "--site", "SITE1", "--connection", "conn1" })
|
||||||
|
.InvokeAsync();
|
||||||
|
|
||||||
|
Assert.Equal(0, exit);
|
||||||
|
var cmd = Assert.IsType<BrowseNodeCommand>(Assert.Single(captured));
|
||||||
|
Assert.Equal("SITE1", cmd.SiteIdentifier);
|
||||||
|
Assert.Null(cmd.ParentNodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Search_BuildsSearchAddressSpaceCommand_WithSiteIdentifier()
|
||||||
|
{
|
||||||
|
var (root, captured) = BuildHarness();
|
||||||
|
|
||||||
|
var exit = await root
|
||||||
|
.Parse(new[]
|
||||||
|
{
|
||||||
|
"data-connection", "search", "--site", "SITE1", "--connection", "conn1",
|
||||||
|
"--query", "Motor", "--max-depth", "3", "--max-results", "50"
|
||||||
|
})
|
||||||
|
.InvokeAsync();
|
||||||
|
|
||||||
|
Assert.Equal(0, exit);
|
||||||
|
var cmd = Assert.IsType<SearchAddressSpaceCommand>(Assert.Single(captured));
|
||||||
|
Assert.Equal("SITE1", cmd.SiteIdentifier);
|
||||||
|
Assert.Equal("conn1", cmd.ConnectionName);
|
||||||
|
Assert.Equal("Motor", cmd.Query);
|
||||||
|
Assert.Equal(3, cmd.MaxDepth);
|
||||||
|
Assert.Equal(50, cmd.MaxResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task VerifyEndpoint_ReadsConfigJsonFromFile_WithSiteIdentifier()
|
||||||
|
{
|
||||||
|
var (root, captured) = BuildHarness();
|
||||||
|
|
||||||
|
var configPath = Path.GetTempFileName();
|
||||||
|
const string configJson = "{\"endpointUrl\":\"opc.tcp://server:4840\"}";
|
||||||
|
await File.WriteAllTextAsync(configPath, configJson);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var exit = await root
|
||||||
|
.Parse(new[]
|
||||||
|
{
|
||||||
|
"data-connection", "verify-endpoint", "--site", "SITE1",
|
||||||
|
"--protocol", "OpcUa", "--config-file", configPath
|
||||||
|
})
|
||||||
|
.InvokeAsync();
|
||||||
|
|
||||||
|
Assert.Equal(0, exit);
|
||||||
|
var cmd = Assert.IsType<VerifyEndpointCommand>(Assert.Single(captured));
|
||||||
|
Assert.Equal("SITE1", cmd.SiteIdentifier);
|
||||||
|
Assert.Equal("OpcUa", cmd.Protocol);
|
||||||
|
Assert.Equal(configJson, cmd.ConfigJson);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(configPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CertsList_BuildsListServerCertsCommand_WithSiteIdentifier()
|
||||||
|
{
|
||||||
|
var (root, captured) = BuildHarness();
|
||||||
|
|
||||||
|
var exit = await root
|
||||||
|
.Parse(new[] { "data-connection", "certs", "list", "--site", "SITE1", "--connection", "conn1" })
|
||||||
|
.InvokeAsync();
|
||||||
|
|
||||||
|
Assert.Equal(0, exit);
|
||||||
|
var cmd = Assert.IsType<ListServerCertsCommand>(Assert.Single(captured));
|
||||||
|
Assert.Equal("SITE1", cmd.SiteIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CertsTrust_Base64EncodesDerFile_WithSiteIdentifier()
|
||||||
|
{
|
||||||
|
var (root, captured) = BuildHarness();
|
||||||
|
|
||||||
|
var derPath = Path.GetTempFileName();
|
||||||
|
var derBytes = new byte[] { 0x30, 0x82, 0x01, 0x0A, 0xDE, 0xAD, 0xBE, 0xEF };
|
||||||
|
await File.WriteAllBytesAsync(derPath, derBytes);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var exit = await root
|
||||||
|
.Parse(new[]
|
||||||
|
{
|
||||||
|
"data-connection", "certs", "trust", "--site", "SITE1", "--connection", "conn1",
|
||||||
|
"--der-file", derPath, "--thumbprint", "AABBCC"
|
||||||
|
})
|
||||||
|
.InvokeAsync();
|
||||||
|
|
||||||
|
Assert.Equal(0, exit);
|
||||||
|
var cmd = Assert.IsType<TrustServerCertCommand>(Assert.Single(captured));
|
||||||
|
Assert.Equal("SITE1", cmd.SiteIdentifier);
|
||||||
|
Assert.Equal("conn1", cmd.ConnectionName);
|
||||||
|
Assert.Equal("AABBCC", cmd.Thumbprint);
|
||||||
|
Assert.Equal(Convert.ToBase64String(derBytes), cmd.DerBase64);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(derPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CertsRemove_BuildsRemoveServerCertCommand_WithSiteIdentifier()
|
||||||
|
{
|
||||||
|
var (root, captured) = BuildHarness();
|
||||||
|
|
||||||
|
var exit = await root
|
||||||
|
.Parse(new[]
|
||||||
|
{
|
||||||
|
"data-connection", "certs", "remove", "--site", "SITE1", "--connection", "conn1", "--thumbprint", "AABBCC"
|
||||||
|
})
|
||||||
|
.InvokeAsync();
|
||||||
|
|
||||||
|
Assert.Equal(0, exit);
|
||||||
|
var cmd = Assert.IsType<RemoveServerCertCommand>(Assert.Single(captured));
|
||||||
|
Assert.Equal("SITE1", cmd.SiteIdentifier);
|
||||||
|
Assert.Equal("AABBCC", cmd.Thumbprint);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user