From 433c6db4ecee5015effb3a408091f71037e33031 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 10 Jul 2026 05:51:49 -0400 Subject: [PATCH] =?UTF-8?q?feat(cli):=20browse/search/verify-endpoint/cert?= =?UTF-8?q?-trust=20commands=20=E2=80=94=20actor=20parity=20(arch-review?= =?UTF-8?q?=20C4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj --- docs/requirements/Component-CLI.md | 17 ++ .../Commands/ConnectionBrowseCommands.cs | 221 ++++++++++++++++++ src/ZB.MOM.WW.ScadaBridge.CLI/Program.cs | 5 +- src/ZB.MOM.WW.ScadaBridge.CLI/README.md | 87 +++++++ .../ConnectionBrowseCommandsTests.cs | 193 +++++++++++++++ 5 files changed, 522 insertions(+), 1 deletion(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.CLI/Commands/ConnectionBrowseCommands.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ConnectionBrowseCommandsTests.cs diff --git a/docs/requirements/Component-CLI.md b/docs/requirements/Component-CLI.md index e0b67a04..77cdf04f 100644 --- a/docs/requirements/Component-CLI.md +++ b/docs/requirements/Component-CLI.md @@ -173,6 +173,23 @@ scadabridge data-connection update --id --name --protocol scadabridge data-connection delete --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 --connection [--node-id ] +scadabridge data-connection search --site --connection --query [--max-depth ] [--max-results ] +scadabridge data-connection verify-endpoint --site --protocol --config-file [--connection ] +scadabridge data-connection certs list --site [--connection ] +scadabridge data-connection certs trust --site --connection --der-file --thumbprint +scadabridge data-connection certs remove --site --thumbprint [--connection ] +``` + ### External System Commands ``` scadabridge external-system list diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/ConnectionBrowseCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/ConnectionBrowseCommands.cs new file mode 100644 index 00000000..d9268e69 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/ConnectionBrowseCommands.cs @@ -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; + +/// +/// CLI parity for the site-routed OPC UA data-connection operations that the +/// ManagementActor dispatches (arch-review C4): live address-space browse and +/// search, endpoint verify-endpoint probing, and server-certificate trust +/// management (certs list|trust|remove). +/// +/// +/// These subcommands attach under the existing data-connection group. Every +/// command carries an explicit --site (mapped to the command record's +/// SiteIdentifier) so the management API can route it to the target site cluster +/// — the site side ignores it once routing has happened. browse/search and +/// verify-endpoint are Designer operations; certs are Admin operations, +/// enforced server-side by the ManagementActor. +/// +public static class ConnectionBrowseCommands +{ + /// + /// Attaches the browse / search / verify-endpoint / certs subcommands to the + /// data-connection command group. + /// + /// The existing data-connection group to extend. + /// Global management URL option. + /// Global output format option. + /// Global username option. + /// Global password option. + /// + /// 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. + /// + public static void AddTo( + Command dataConnection, + Option urlOption, + Option formatOption, + Option usernameOption, + Option passwordOption, + Func? 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)); + } + + /// + /// Sends to the management API, or — when a test + /// interceptor is present — hands it to the interceptor instead. + /// + private static Task DispatchAsync( + ParseResult result, + Option urlOption, + Option formatOption, + Option usernameOption, + Option passwordOption, + object command, + Func? 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 urlOption, Option formatOption, Option usernameOption, Option passwordOption, Func? interceptor) + { + var siteOption = new Option("--site") { Description = "Target site identifier", Required = true }; + var connectionOption = new Option("--connection") { Description = "Data connection name", Required = true }; + var nodeIdOption = new Option("--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 urlOption, Option formatOption, Option usernameOption, Option passwordOption, Func? interceptor) + { + var siteOption = new Option("--site") { Description = "Target site identifier", Required = true }; + var connectionOption = new Option("--connection") { Description = "Data connection name", Required = true }; + var queryOption = new Option("--query") { Description = "Case-insensitive substring matched on DisplayName and root-relative path", Required = true }; + var maxDepthOption = new Option("--max-depth") { Description = "Maximum levels below the root to descend", DefaultValueFactory = _ => 5 }; + var maxResultsOption = new Option("--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 urlOption, Option formatOption, Option usernameOption, Option passwordOption, Func? interceptor) + { + var siteOption = new Option("--site") { Description = "Target site identifier", Required = true }; + var protocolOption = new Option("--protocol") { Description = "Protocol type (e.g. OpcUa)", Required = true }; + var configFileOption = new Option("--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("--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 urlOption, Option formatOption, Option usernameOption, Option passwordOption, Func? 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 urlOption, Option formatOption, Option usernameOption, Option passwordOption, Func? interceptor) + { + var siteOption = new Option("--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("--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 urlOption, Option formatOption, Option usernameOption, Option passwordOption, Func? interceptor) + { + var siteOption = new Option("--site") { Description = "Target site identifier", Required = true }; + var connectionOption = new Option("--connection") { Description = "Data connection the certificate was captured from (correlation)", Required = true }; + var derFileOption = new Option("--der-file") { Description = "Path to the DER-encoded certificate file (base64-encoded into the request)", Required = true }; + var thumbprintOption = new Option("--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 urlOption, Option formatOption, Option usernameOption, Option passwordOption, Func? interceptor) + { + var siteOption = new Option("--site") { Description = "Target site identifier", Required = true }; + var thumbprintOption = new Option("--thumbprint") { Description = "Thumbprint of the certificate to remove", Required = true }; + // Contextual only — RemoveServerCertCommand is keyed by thumbprint + site, not connection. + var connectionOption = new Option("--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; + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Program.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Program.cs index fc49cac4..088f73cf 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Program.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Program.cs @@ -22,7 +22,10 @@ rootCommand.Add(TemplateCommands.Build(urlOption, formatOption, usernameOption, rootCommand.Add(InstanceCommands.Build(urlOption, formatOption, usernameOption, passwordOption)); rootCommand.Add(SiteCommands.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(NotificationCommands.Build(urlOption, formatOption, usernameOption, passwordOption)); rootCommand.Add(SecurityCommands.Build(urlOption, formatOption, usernameOption, passwordOption)); diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md index 5b6505c0..6090a9f4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md @@ -936,6 +936,93 @@ scadabridge --url data-connection delete --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 data-connection browse --site --connection [--node-id ] +``` + +| 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 data-connection search --site --connection --query [--max-depth ] [--max-results ] +``` + +| 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 data-connection verify-endpoint --site --protocol --config-file [--connection ] +``` + +| 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 data-connection certs list --site [--connection ] +``` + +| 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 data-connection certs trust --site --connection --der-file --thumbprint +``` + +| 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 data-connection certs remove --site --thumbprint [--connection ] +``` + +| 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 diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ConnectionBrowseCommandsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ConnectionBrowseCommandsTests.cs new file mode 100644 index 00000000..67d6a922 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ConnectionBrowseCommandsTests.cs @@ -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; + +/// +/// Parse tests for the site-routed data-connection subcommands added for actor parity +/// (arch-review C4): browse, search, verify-endpoint, and +/// certs list|trust|remove. Each test parses a real command line through +/// System.CommandLine and asserts the exact management command record produced, +/// including the routed --siteSiteIdentifier mapping. +/// +public class ConnectionBrowseCommandsTests +{ + /// + /// Builds a root command with the browse/search/verify/certs subcommands attached + /// under a bare data-connection group. The interceptor captures the built + /// command record instead of sending it over HTTP, so the tests never need a live + /// management server. + /// + private static (RootCommand root, List captured) BuildHarness() + { + var url = new Option("--url") { Recursive = true }; + var username = new Option("--username") { Recursive = true }; + var password = new Option("--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(); + 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(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(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(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(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(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(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(Assert.Single(captured)); + Assert.Equal("SITE1", cmd.SiteIdentifier); + Assert.Equal("AABBCC", cmd.Thumbprint); + } +}