From 3263b39477e75b4076c1cceffe97664ca056b0aa Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 21:52:37 -0400 Subject: [PATCH] feat(cli): scaffold scadalink audit command group (#23 M8) --- src/ScadaLink.CLI/Commands/AuditCommands.cs | 45 ++++++++++++++ src/ScadaLink.CLI/ManagementHttpClient.cs | 59 ++++++++++++++++++ src/ScadaLink.CLI/Program.cs | 1 + .../Commands/AuditCommandsScaffoldTests.cs | 61 +++++++++++++++++++ 4 files changed, 166 insertions(+) create mode 100644 src/ScadaLink.CLI/Commands/AuditCommands.cs create mode 100644 tests/ScadaLink.CLI.Tests/Commands/AuditCommandsScaffoldTests.cs diff --git a/src/ScadaLink.CLI/Commands/AuditCommands.cs b/src/ScadaLink.CLI/Commands/AuditCommands.cs new file mode 100644 index 0000000..23000fb --- /dev/null +++ b/src/ScadaLink.CLI/Commands/AuditCommands.cs @@ -0,0 +1,45 @@ +using System.CommandLine; +using System.CommandLine.Parsing; + +namespace ScadaLink.CLI.Commands; + +/// +/// The scadalink audit command group (Audit Log #23 M8). Provides read access to +/// the centralized append-only Audit Log via the Bundle B REST endpoints +/// (GET /api/audit/query, GET /api/audit/export), plus a v1 no-op +/// verify-chain placeholder for the deferred hash-chain tamper-evidence feature. +/// +public static class AuditCommands +{ + public static Command Build(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) + { + var command = new Command("audit") { Description = "Query and export the centralized audit log" }; + + command.Add(BuildQuery(urlOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildExport(urlOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildVerifyChain(urlOption, formatOption, usernameOption, passwordOption)); + + return command; + } + + private static Command BuildQuery(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) + { + var cmd = new Command("query") { Description = "Query audit log events" }; + cmd.SetAction((ParseResult result) => 0); + return cmd; + } + + private static Command BuildExport(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) + { + var cmd = new Command("export") { Description = "Export audit log events to a file" }; + cmd.SetAction((ParseResult result) => 0); + return cmd; + } + + private static Command BuildVerifyChain(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) + { + var cmd = new Command("verify-chain") { Description = "Verify the audit log hash chain for a month" }; + cmd.SetAction((ParseResult result) => 0); + return cmd; + } +} diff --git a/src/ScadaLink.CLI/ManagementHttpClient.cs b/src/ScadaLink.CLI/ManagementHttpClient.cs index 515bf42..ce8e02b 100644 --- a/src/ScadaLink.CLI/ManagementHttpClient.cs +++ b/src/ScadaLink.CLI/ManagementHttpClient.cs @@ -74,6 +74,65 @@ public class ManagementHttpClient : IDisposable return new ManagementResponse((int)httpResponse.StatusCode, null, error, code); } + /// + /// Issues a plain HTTP GET against a REST endpoint (e.g. the audit + /// /api/audit/query endpoint introduced by Audit Log #23 M8) and returns the + /// response body. Unlike , this does not wrap the call + /// in the POST /management command envelope — the audit endpoints are plain + /// REST resources. Authentication (HTTP Basic) and the base address are shared. + /// + /// Path relative to the base URL, with query string. + public async Task SendGetAsync(string relativePath, TimeSpan timeout) + { + using var cts = new CancellationTokenSource(timeout); + + HttpResponseMessage httpResponse; + try + { + httpResponse = await _httpClient.GetAsync(relativePath, cts.Token); + } + catch (TaskCanceledException) + { + return new ManagementResponse(504, null, "Request timed out.", "TIMEOUT"); + } + catch (HttpRequestException ex) + { + return new ManagementResponse(0, null, $"Connection failed: {ex.Message}", "CONNECTION_FAILED"); + } + + var responseBody = await httpResponse.Content.ReadAsStringAsync(cts.Token); + + if (httpResponse.IsSuccessStatusCode) + { + return new ManagementResponse((int)httpResponse.StatusCode, responseBody, null, null); + } + + string? error = null; + string? code = null; + try + { + using var doc = JsonDocument.Parse(responseBody); + error = doc.RootElement.TryGetProperty("error", out var e) ? e.GetString() : responseBody; + code = doc.RootElement.TryGetProperty("code", out var c) ? c.GetString() : null; + } + catch + { + error = responseBody; + } + + return new ManagementResponse((int)httpResponse.StatusCode, null, error, code); + } + + /// + /// Issues a plain HTTP GET and returns the raw + /// so the caller can stream the response body without buffering it in memory — used + /// by audit export, where the response can be many megabytes. The caller owns + /// disposing the returned message. The + /// option ensures the body is not pre-buffered. + /// + public async Task SendGetStreamAsync(string relativePath, CancellationToken cancellationToken) + => await _httpClient.GetAsync(relativePath, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + public void Dispose() => _httpClient.Dispose(); } diff --git a/src/ScadaLink.CLI/Program.cs b/src/ScadaLink.CLI/Program.cs index 08da7d7..a79f678 100644 --- a/src/ScadaLink.CLI/Program.cs +++ b/src/ScadaLink.CLI/Program.cs @@ -27,6 +27,7 @@ rootCommand.Add(ExternalSystemCommands.Build(urlOption, formatOption, usernameOp 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)); diff --git a/tests/ScadaLink.CLI.Tests/Commands/AuditCommandsScaffoldTests.cs b/tests/ScadaLink.CLI.Tests/Commands/AuditCommandsScaffoldTests.cs new file mode 100644 index 0000000..e75c552 --- /dev/null +++ b/tests/ScadaLink.CLI.Tests/Commands/AuditCommandsScaffoldTests.cs @@ -0,0 +1,61 @@ +using System.CommandLine; +using ScadaLink.CLI.Commands; + +namespace ScadaLink.CLI.Tests.Commands; + +/// +/// Scaffold tests for the scadalink audit command group (Audit Log #23 M8-T1). +/// Verifies the parent command exists with its three subcommands and that every leaf +/// has an action wired. +/// +public class AuditCommandsScaffoldTests +{ + private static readonly Option Url = new("--url") { Recursive = true }; + private static readonly Option Username = new("--username") { Recursive = true }; + private static readonly Option Password = new("--password") { Recursive = true }; + private static readonly Option Format = CliOptions.CreateFormatOption(); + + private static Command BuildAudit() + => AuditCommands.Build(Url, Format, Username, Password); + + [Fact] + public void Audit_Command_IsNamedAudit() + { + var audit = BuildAudit(); + Assert.Equal("audit", audit.Name); + Assert.False(string.IsNullOrWhiteSpace(audit.Description)); + } + + [Fact] + public void Audit_HasThreeSubcommands_QueryExportVerifyChain() + { + var audit = BuildAudit(); + var names = audit.Subcommands.Select(c => c.Name).OrderBy(n => n).ToArray(); + Assert.Equal(new[] { "export", "query", "verify-chain" }, names); + } + + [Fact] + public void Audit_HelpText_ListsAllSubcommands() + { + var root = new RootCommand(); + root.Add(BuildAudit()); + + var output = new StringWriter(); + var exit = root.Parse(new[] { "audit", "--help" }) + .Invoke(new InvocationConfiguration { Output = output }); + + Assert.Equal(0, exit); + var text = output.ToString(); + Assert.Contains("query", text); + Assert.Contains("export", text); + Assert.Contains("verify-chain", text); + } + + [Fact] + public void Audit_EveryLeafCommand_HasAnAction() + { + var audit = BuildAudit(); + Assert.All(audit.Subcommands, sub => + Assert.True(sub.Action != null, $"Leaf command '{sub.Name}' has no action.")); + } +}