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);
|
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();
|
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(NotificationCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
rootCommand.Add(SecurityCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
rootCommand.Add(SecurityCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
rootCommand.Add(AuditLogCommands.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(HealthCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
rootCommand.Add(DebugCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
rootCommand.Add(DebugCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
rootCommand.Add(SharedScriptCommands.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