feat(cli): scaffold scadalink audit command group (#23 M8)
This commit is contained in:
45
src/ScadaLink.CLI/Commands/AuditCommands.cs
Normal file
45
src/ScadaLink.CLI/Commands/AuditCommands.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
|
||||
namespace ScadaLink.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>scadalink audit</c> command group (Audit Log #23 M8). Provides read access to
|
||||
/// the centralized append-only Audit Log via the Bundle B REST endpoints
|
||||
/// (<c>GET /api/audit/query</c>, <c>GET /api/audit/export</c>), plus a v1 no-op
|
||||
/// <c>verify-chain</c> placeholder for the deferred hash-chain tamper-evidence feature.
|
||||
/// </summary>
|
||||
public static class AuditCommands
|
||||
{
|
||||
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var cmd = new Command("query") { Description = "Query audit log events" };
|
||||
cmd.SetAction((ParseResult result) => 0);
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildExport(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var cmd = new Command("verify-chain") { Description = "Verify the audit log hash chain for a month" };
|
||||
cmd.SetAction((ParseResult result) => 0);
|
||||
return cmd;
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,65 @@ public class ManagementHttpClient : IDisposable
|
||||
return new ManagementResponse((int)httpResponse.StatusCode, null, error, code);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues a plain HTTP <c>GET</c> against a REST endpoint (e.g. the audit
|
||||
/// <c>/api/audit/query</c> endpoint introduced by Audit Log #23 M8) and returns the
|
||||
/// response body. Unlike <see cref="SendCommandAsync"/>, this does not wrap the call
|
||||
/// in the <c>POST /management</c> command envelope — the audit endpoints are plain
|
||||
/// REST resources. Authentication (HTTP Basic) and the base address are shared.
|
||||
/// </summary>
|
||||
/// <param name="relativePath">Path relative to the base URL, with query string.</param>
|
||||
public async Task<ManagementResponse> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues a plain HTTP <c>GET</c> and returns the raw <see cref="HttpResponseMessage"/>
|
||||
/// so the caller can stream the response body without buffering it in memory — used
|
||||
/// by <c>audit export</c>, where the response can be many megabytes. The caller owns
|
||||
/// disposing the returned message. The <see cref="HttpCompletionOption.ResponseHeadersRead"/>
|
||||
/// option ensures the body is not pre-buffered.
|
||||
/// </summary>
|
||||
public async Task<HttpResponseMessage> SendGetStreamAsync(string relativePath, CancellationToken cancellationToken)
|
||||
=> await _httpClient.GetAsync(relativePath, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
|
||||
public void Dispose() => _httpClient.Dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.CommandLine;
|
||||
using ScadaLink.CLI.Commands;
|
||||
|
||||
namespace ScadaLink.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Scaffold tests for the <c>scadalink audit</c> command group (Audit Log #23 M8-T1).
|
||||
/// Verifies the parent command exists with its three subcommands and that every leaf
|
||||
/// has an action wired.
|
||||
/// </summary>
|
||||
public class AuditCommandsScaffoldTests
|
||||
{
|
||||
private static readonly Option<string> Url = new("--url") { Recursive = true };
|
||||
private static readonly Option<string> Username = new("--username") { Recursive = true };
|
||||
private static readonly Option<string> Password = new("--password") { Recursive = true };
|
||||
private static readonly Option<string> 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."));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user