From 2fa46ed400b957bc63893798862c0954cd159055 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 21:55:38 -0400 Subject: [PATCH] feat(cli): scadalink audit query subcommand (#23 M8) --- .../Commands/AuditCommandHelpers.cs | 72 +++++ src/ScadaLink.CLI/Commands/AuditCommands.cs | 148 ++++++++++- .../Commands/AuditExportHelpers.cs | 114 ++++++++ src/ScadaLink.CLI/Commands/AuditFormatter.cs | 48 ++++ .../Commands/AuditQueryHelpers.cs | 181 +++++++++++++ .../Commands/AuditVerifyChainHelpers.cs | 20 ++ .../Commands/AuditCommandTestHarness.cs | 39 +++ .../Commands/AuditQueryCommandTests.cs | 245 ++++++++++++++++++ 8 files changed, 864 insertions(+), 3 deletions(-) create mode 100644 src/ScadaLink.CLI/Commands/AuditCommandHelpers.cs create mode 100644 src/ScadaLink.CLI/Commands/AuditExportHelpers.cs create mode 100644 src/ScadaLink.CLI/Commands/AuditFormatter.cs create mode 100644 src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs create mode 100644 src/ScadaLink.CLI/Commands/AuditVerifyChainHelpers.cs create mode 100644 tests/ScadaLink.CLI.Tests/Commands/AuditCommandTestHarness.cs create mode 100644 tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs diff --git a/src/ScadaLink.CLI/Commands/AuditCommandHelpers.cs b/src/ScadaLink.CLI/Commands/AuditCommandHelpers.cs new file mode 100644 index 0000000..876db62 --- /dev/null +++ b/src/ScadaLink.CLI/Commands/AuditCommandHelpers.cs @@ -0,0 +1,72 @@ +using System.CommandLine; +using System.CommandLine.Parsing; + +namespace ScadaLink.CLI.Commands; + +/// +/// Resolved Management API connection details for an audit subcommand, or an +/// error describing why resolution failed. +/// +public sealed class AuditConnection +{ + public string? Url { get; init; } + public string? Username { get; init; } + public string? Password { get; init; } + public string? Error { get; init; } + public string? ErrorCode { get; init; } + + public static AuditConnection Fail(string error, string code) + => new() { Error = error, ErrorCode = code }; +} + +/// +/// Connection/format resolution shared by the audit subcommands. Mirrors the URL +/// and credential precedence used by (command line → config +/// file / environment), but produces a raw target +/// because the audit endpoints are plain REST resources rather than POST /management +/// command-envelope calls. +/// +public static class AuditCommandHelpers +{ + public static AuditConnection ResolveConnection( + ParseResult result, + Option urlOption, + Option usernameOption, + Option passwordOption) + { + var config = CliConfig.Load(); + + var url = result.GetValue(urlOption); + if (string.IsNullOrWhiteSpace(url)) + url = config.ManagementUrl; + + if (string.IsNullOrWhiteSpace(url)) + { + return AuditConnection.Fail( + "No management URL specified. Use --url, set SCADALINK_MANAGEMENT_URL, or add 'managementUrl' to ~/.scadalink/config.json.", + "NO_URL"); + } + + if (!CommandHelpers.IsValidManagementUrl(url)) + { + return AuditConnection.Fail( + $"Invalid management URL '{url}'. Expected an absolute http/https URL (e.g. http://localhost:9001).", + "INVALID_URL"); + } + + var username = CommandHelpers.ResolveCredential(result.GetValue(usernameOption), config.Username); + var password = CommandHelpers.ResolveCredential(result.GetValue(passwordOption), config.Password); + + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) + { + return AuditConnection.Fail( + "Credentials required. Use --username/--password or set SCADALINK_USERNAME/SCADALINK_PASSWORD.", + "NO_CREDENTIALS"); + } + + return new AuditConnection { Url = url, Username = username, Password = password }; + } + + public static string ResolveFormat(ParseResult result, Option formatOption) + => CommandHelpers.ResolveFormat(result, formatOption, CliConfig.Load()); +} diff --git a/src/ScadaLink.CLI/Commands/AuditCommands.cs b/src/ScadaLink.CLI/Commands/AuditCommands.cs index 23000fb..9a953e3 100644 --- a/src/ScadaLink.CLI/Commands/AuditCommands.cs +++ b/src/ScadaLink.CLI/Commands/AuditCommands.cs @@ -24,22 +24,164 @@ public static class AuditCommands private static Command BuildQuery(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { + var sinceOption = new Option("--since") { Description = "Start time: relative (1h, 24h, 7d) or ISO-8601" }; + var untilOption = new Option("--until") { Description = "End time: relative (1h, 24h, 7d) or ISO-8601" }; + var channelOption = new Option("--channel") { Description = "Filter by channel (OutboundApi, OutboundDb, Notification, InboundApi)" }; + var kindOption = new Option("--kind") { Description = "Filter by event kind" }; + var statusOption = new Option("--status") { Description = "Filter by status (single value)" }; + var siteOption = new Option("--site") { Description = "Filter by source site ID" }; + var instanceOption = new Option("--instance") { Description = "Filter by instance" }; + var targetOption = new Option("--target") { Description = "Filter by target (external system, DB connection, notification list)" }; + var actorOption = new Option("--actor") { Description = "Filter by actor" }; + var correlationIdOption = new Option("--correlation-id") { Description = "Filter by correlation ID" }; + var errorsOnlyOption = new Option("--errors-only") { Description = "Show only failed events (status=Failed; overrides --status)" }; + var pageSizeOption = new Option("--page-size") { Description = "Events per page (1-1000)" }; + pageSizeOption.DefaultValueFactory = _ => 100; + var allOption = new Option("--all") { Description = "Fetch every page, following the keyset cursor" }; + var cmd = new Command("query") { Description = "Query audit log events" }; - cmd.SetAction((ParseResult result) => 0); + cmd.Add(sinceOption); + cmd.Add(untilOption); + cmd.Add(channelOption); + cmd.Add(kindOption); + cmd.Add(statusOption); + cmd.Add(siteOption); + cmd.Add(instanceOption); + cmd.Add(targetOption); + cmd.Add(actorOption); + cmd.Add(correlationIdOption); + cmd.Add(errorsOnlyOption); + cmd.Add(pageSizeOption); + cmd.Add(allOption); + + cmd.SetAction(async (ParseResult result) => + { + var connection = AuditCommandHelpers.ResolveConnection(result, urlOption, usernameOption, passwordOption); + if (connection.Error != null) + { + OutputFormatter.WriteError(connection.Error, connection.ErrorCode!); + return 1; + } + + var format = AuditCommandHelpers.ResolveFormat(result, formatOption); + var formatter = AuditFormatterFactory.Create(format, Console.Error); + + var args = new AuditQueryArgs + { + Since = result.GetValue(sinceOption), + Until = result.GetValue(untilOption), + Channel = result.GetValue(channelOption), + Kind = result.GetValue(kindOption), + Status = result.GetValue(statusOption), + Site = result.GetValue(siteOption), + Instance = result.GetValue(instanceOption), + Target = result.GetValue(targetOption), + Actor = result.GetValue(actorOption), + CorrelationId = result.GetValue(correlationIdOption), + ErrorsOnly = result.GetValue(errorsOnlyOption), + PageSize = result.GetValue(pageSizeOption), + }; + var fetchAll = result.GetValue(allOption); + + try + { + using var client = new ManagementHttpClient(connection.Url!, connection.Username!, connection.Password!); + return await AuditQueryHelpers.RunQueryAsync( + client, args, fetchAll, formatter, Console.Out, DateTimeOffset.UtcNow); + } + catch (FormatException ex) + { + OutputFormatter.WriteError(ex.Message, "INVALID_ARGUMENT"); + return 1; + } + }); return cmd; } private static Command BuildExport(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { + var sinceOption = new Option("--since") { Description = "Start time: relative (1h, 24h, 7d) or ISO-8601", Required = true }; + var untilOption = new Option("--until") { Description = "End time: relative (1h, 24h, 7d) or ISO-8601", Required = true }; + var formatExportOption = new Option("--format") { Description = "Export format", Required = true }; + formatExportOption.AcceptOnlyFromAmong("csv", "jsonl", "parquet"); + var outputOption = new Option("--output") { Description = "Destination file path", Required = true }; + var channelOption = new Option("--channel") { Description = "Filter by channel" }; + var kindOption = new Option("--kind") { Description = "Filter by event kind" }; + var statusOption = new Option("--status") { Description = "Filter by status" }; + var siteOption = new Option("--site") { Description = "Filter by source site ID" }; + var targetOption = new Option("--target") { Description = "Filter by target" }; + var actorOption = new Option("--actor") { Description = "Filter by actor" }; + var cmd = new Command("export") { Description = "Export audit log events to a file" }; - cmd.SetAction((ParseResult result) => 0); + cmd.Add(sinceOption); + cmd.Add(untilOption); + cmd.Add(formatExportOption); + cmd.Add(outputOption); + cmd.Add(channelOption); + cmd.Add(kindOption); + cmd.Add(statusOption); + cmd.Add(siteOption); + cmd.Add(targetOption); + cmd.Add(actorOption); + + cmd.SetAction(async (ParseResult result) => + { + var connection = AuditCommandHelpers.ResolveConnection(result, urlOption, usernameOption, passwordOption); + if (connection.Error != null) + { + OutputFormatter.WriteError(connection.Error, connection.ErrorCode!); + return 1; + } + + var args = new AuditExportArgs + { + Since = result.GetValue(sinceOption)!, + Until = result.GetValue(untilOption)!, + Format = result.GetValue(formatExportOption)!, + Output = result.GetValue(outputOption)!, + Channel = result.GetValue(channelOption), + Kind = result.GetValue(kindOption), + Status = result.GetValue(statusOption), + Site = result.GetValue(siteOption), + Target = result.GetValue(targetOption), + Actor = result.GetValue(actorOption), + }; + + try + { + using var client = new ManagementHttpClient(connection.Url!, connection.Username!, connection.Password!); + return await AuditExportHelpers.RunExportAsync(client, args, Console.Out, DateTimeOffset.UtcNow); + } + catch (FormatException ex) + { + OutputFormatter.WriteError(ex.Message, "INVALID_ARGUMENT"); + return 1; + } + }); return cmd; } private static Command BuildVerifyChain(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { + var monthOption = new Option("--month") { Description = "Month to verify (YYYY-MM)", Required = true }; + var cmd = new Command("verify-chain") { Description = "Verify the audit log hash chain for a month" }; - cmd.SetAction((ParseResult result) => 0); + cmd.Add(monthOption); + cmd.SetAction((ParseResult result) => + { + var month = result.GetValue(monthOption)!; + if (!AuditVerifyChainHelpers.IsValidMonth(month)) + { + OutputFormatter.WriteError( + $"Invalid month '{month}'. Expected YYYY-MM (e.g. 2026-05).", "INVALID_ARGUMENT"); + return 1; + } + + Console.WriteLine( + "Hash-chain tamper-evidence is not enabled in this release. " + + "See Component-AuditLog.md (Security & Tamper-Evidence) for the v1.x roadmap."); + return 0; + }); return cmd; } } diff --git a/src/ScadaLink.CLI/Commands/AuditExportHelpers.cs b/src/ScadaLink.CLI/Commands/AuditExportHelpers.cs new file mode 100644 index 0000000..01d702b --- /dev/null +++ b/src/ScadaLink.CLI/Commands/AuditExportHelpers.cs @@ -0,0 +1,114 @@ +using System.Globalization; +using System.Net; + +namespace ScadaLink.CLI.Commands; + +/// +/// Filter + destination arguments for an audit export invocation. Mirrors the +/// Bundle B GET /api/audit/export parameters. +/// +public sealed class AuditExportArgs +{ + public string Since { get; set; } = string.Empty; + public string Until { get; set; } = string.Empty; + public string Format { get; set; } = string.Empty; + public string Output { get; set; } = string.Empty; + public string? Channel { get; set; } + public string? Kind { get; set; } + public string? Status { get; set; } + public string? Site { get; set; } + public string? Target { get; set; } + public string? Actor { get; set; } +} + +/// +/// Helpers for the audit export subcommand: builds the export query string and +/// streams the HTTP response body straight to the destination file without buffering +/// the (potentially multi-megabyte) export in memory. +/// +public static class AuditExportHelpers +{ + /// + /// Builds the ?... query string for GET /api/audit/export: the required + /// time window + format, plus optional filters. Time-specs are resolved via + /// . + /// + public static string BuildQueryString(AuditExportArgs args, DateTimeOffset now) + { + var parts = new List(); + + void Add(string key, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + parts.Add($"{key}={Uri.EscapeDataString(value)}"); + } + + Add("fromUtc", AuditQueryHelpers.ResolveTimeSpec(args.Since, now).ToString("o", CultureInfo.InvariantCulture)); + Add("toUtc", AuditQueryHelpers.ResolveTimeSpec(args.Until, now).ToString("o", CultureInfo.InvariantCulture)); + Add("format", args.Format); + Add("channel", args.Channel); + Add("kind", args.Kind); + Add("status", args.Status); + Add("sourceSiteId", args.Site); + Add("target", args.Target); + Add("actor", args.Actor); + + return "?" + string.Join("&", parts); + } + + /// + /// Executes the export: GETs /api/audit/export and copies the response body + /// stream directly to . The body is never fully + /// buffered — streams in fixed-size chunks. + /// A 501 Not Implemented (parquet not yet supported server-side) prints the + /// server message and returns a non-zero exit code. + /// + public static async Task RunExportAsync( + ManagementHttpClient client, AuditExportArgs args, TextWriter output, DateTimeOffset now) + { + var qs = BuildQueryString(args, now); + + HttpResponseMessage response; + try + { + response = await client.SendGetStreamAsync("api/audit/export" + qs, CancellationToken.None); + } + catch (HttpRequestException ex) + { + OutputFormatter.WriteError($"Connection failed: {ex.Message}", "CONNECTION_FAILED"); + return 1; + } + + using (response) + { + if (response.StatusCode == HttpStatusCode.NotImplemented) + { + var message = await response.Content.ReadAsStringAsync(); + OutputFormatter.WriteError( + string.IsNullOrWhiteSpace(message) + ? "Export format not implemented by the server." + : message, + "NOT_IMPLEMENTED"); + return 1; + } + + if (!response.IsSuccessStatusCode) + { + var message = await response.Content.ReadAsStringAsync(); + OutputFormatter.WriteError( + string.IsNullOrWhiteSpace(message) ? $"Export failed (HTTP {(int)response.StatusCode})." : message, + "ERROR"); + return 1; + } + + await using var source = await response.Content.ReadAsStreamAsync(); + await using var destination = new FileStream( + args.Output, FileMode.Create, FileAccess.Write, FileShare.None, + bufferSize: 81920, useAsync: true); + await source.CopyToAsync(destination); + } + + output.WriteLine($"Exported audit log to {args.Output}"); + return 0; + } +} diff --git a/src/ScadaLink.CLI/Commands/AuditFormatter.cs b/src/ScadaLink.CLI/Commands/AuditFormatter.cs new file mode 100644 index 0000000..ba0d959 --- /dev/null +++ b/src/ScadaLink.CLI/Commands/AuditFormatter.cs @@ -0,0 +1,48 @@ +using System.Text.Json; + +namespace ScadaLink.CLI.Commands; + +/// +/// Renders a page of audit-log events to a writer. The audit query command picks +/// a formatter from the --format option. The default JSONL formatter is defined +/// here; the human-readable table formatter is supplied by Bundle C. +/// +public interface IAuditFormatter +{ + /// Renders one page of events. Called once per fetched page. + void WritePage(IReadOnlyList events, TextWriter output); +} + +/// +/// Default formatter: one JSON object per line (JSONL). Streamable — each page's events +/// are flushed as they arrive, so --all over many pages does not accumulate. +/// +public sealed class JsonLinesAuditFormatter : IAuditFormatter +{ + private static readonly JsonSerializerOptions Compact = new() { WriteIndented = false }; + + public void WritePage(IReadOnlyList events, TextWriter output) + { + foreach (var evt in events) + output.WriteLine(JsonSerializer.Serialize(evt, Compact)); + } +} + +/// +/// Resolves an for a given --format value. The +/// table formatter is filled in by Bundle C; until then --format table falls back +/// to JSONL with a one-time notice so the flag is wired but not silently broken. +/// +public static class AuditFormatterFactory +{ + public static IAuditFormatter Create(string format, TextWriter notices) + { + if (string.Equals(format, "table", StringComparison.OrdinalIgnoreCase)) + { + notices.WriteLine("note: 'table' output is not yet available; using json. (Bundle C)"); + return new JsonLinesAuditFormatter(); + } + + return new JsonLinesAuditFormatter(); + } +} diff --git a/src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs b/src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs new file mode 100644 index 0000000..0066dbf --- /dev/null +++ b/src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs @@ -0,0 +1,181 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace ScadaLink.CLI.Commands; + +/// +/// Filter arguments for an audit query invocation. Mirrors the Bundle B +/// GET /api/audit/query filter parameters; / +/// are time-specs (relative like 1h/7d, or absolute ISO-8601). +/// +public sealed class AuditQueryArgs +{ + public string? Since { get; set; } + public string? Until { get; set; } + public string? Channel { get; set; } + public string? Kind { get; set; } + public string? Status { get; set; } + public string? Site { get; set; } + public string? Instance { get; set; } + public string? Target { get; set; } + public string? Actor { get; set; } + public string? CorrelationId { get; set; } + public bool ErrorsOnly { get; set; } + public int PageSize { get; set; } = 100; +} + +/// +/// Pure helpers for the audit query subcommand: time-spec resolution, query-string +/// construction, and the keyset-cursor paging loop. Kept separate from the command wiring +/// so each piece is unit-testable without standing up the command tree. +/// +public static class AuditQueryHelpers +{ + // where unit is s/m/h/d — a relative offset back from "now". + private static readonly Regex RelativeSpec = new(@"^(\d+)([smhd])$", RegexOptions.Compiled); + + /// + /// Resolves a time-spec to an absolute . Accepts a + /// relative offset (30s, 15m, 1h, 7d) interpreted as + /// minus the offset, or an absolute ISO-8601 timestamp. + /// + /// The spec is neither a known relative form nor a parseable ISO-8601 timestamp. + public static DateTimeOffset ResolveTimeSpec(string spec, DateTimeOffset now) + { + if (string.IsNullOrWhiteSpace(spec)) + throw new FormatException("Empty time value."); + + var match = RelativeSpec.Match(spec.Trim()); + if (match.Success) + { + var amount = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); + var offset = match.Groups[2].Value switch + { + "s" => TimeSpan.FromSeconds(amount), + "m" => TimeSpan.FromMinutes(amount), + "h" => TimeSpan.FromHours(amount), + "d" => TimeSpan.FromDays(amount), + _ => throw new FormatException($"Unknown time unit in '{spec}'."), + }; + return now - offset; + } + + if (DateTimeOffset.TryParse(spec, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var absolute)) + { + return absolute; + } + + throw new FormatException( + $"Invalid time value '{spec}'. Use a relative offset (e.g. 1h, 24h, 7d) or an ISO-8601 timestamp."); + } + + /// + /// Builds the ?... query string for GET /api/audit/query from the filter + /// args plus an optional keyset cursor. Unset filters are omitted. --errors-only + /// maps to status=Failed (the server takes a single status value). + /// + public static string BuildQueryString( + AuditQueryArgs args, DateTimeOffset now, DateTimeOffset? afterOccurredAtUtc, string? afterEventId) + { + var parts = new List(); + + void Add(string key, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + parts.Add($"{key}={Uri.EscapeDataString(value)}"); + } + + if (!string.IsNullOrWhiteSpace(args.Since)) + Add("fromUtc", ResolveTimeSpec(args.Since!, now).ToString("o", CultureInfo.InvariantCulture)); + if (!string.IsNullOrWhiteSpace(args.Until)) + Add("toUtc", ResolveTimeSpec(args.Until!, now).ToString("o", CultureInfo.InvariantCulture)); + + Add("channel", args.Channel); + Add("kind", args.Kind); + + // --errors-only is a convenience shorthand for the single-value Failed status + // filter. The server's status filter accepts one value, so --errors-only and an + // explicit --status are mutually exclusive in effect; --errors-only wins. + Add("status", args.ErrorsOnly ? "Failed" : args.Status); + + Add("sourceSiteId", args.Site); + Add("instance", args.Instance); + Add("target", args.Target); + Add("actor", args.Actor); + Add("correlationId", args.CorrelationId); + Add("pageSize", args.PageSize.ToString(CultureInfo.InvariantCulture)); + + if (afterOccurredAtUtc.HasValue) + Add("afterOccurredAtUtc", afterOccurredAtUtc.Value.ToString("o", CultureInfo.InvariantCulture)); + Add("afterEventId", afterEventId); + + return parts.Count == 0 ? string.Empty : "?" + string.Join("&", parts); + } + + /// + /// Executes the query: GETs /api/audit/query, renders each page with + /// , and — when is set — + /// follows nextCursor until the server returns a null cursor. Returns the + /// process exit code (0 success, non-zero on HTTP/transport error). + /// + public static async Task RunQueryAsync( + ManagementHttpClient client, + AuditQueryArgs args, + bool fetchAll, + IAuditFormatter formatter, + TextWriter output, + DateTimeOffset now) + { + DateTimeOffset? afterOccurredAtUtc = null; + string? afterEventId = null; + + while (true) + { + var qs = BuildQueryString(args, now, afterOccurredAtUtc, afterEventId); + var response = await client.SendGetAsync("api/audit/query" + qs, TimeSpan.FromSeconds(30)); + + if (response.JsonData == null) + { + OutputFormatter.WriteError( + response.Error ?? "Audit query failed.", response.ErrorCode ?? "ERROR"); + return 1; + } + + using var doc = JsonDocument.Parse(response.JsonData); + var root = doc.RootElement; + + var events = root.TryGetProperty("events", out var evts) && evts.ValueKind == JsonValueKind.Array + ? evts.EnumerateArray().ToList() + : new List(); + formatter.WritePage(events, output); + output.Flush(); + + if (!fetchAll) + return 0; + + if (!root.TryGetProperty("nextCursor", out var cursor) + || cursor.ValueKind != JsonValueKind.Object) + { + return 0; + } + + afterOccurredAtUtc = cursor.TryGetProperty("afterOccurredAtUtc", out var c1) + && c1.ValueKind == JsonValueKind.String + ? DateTimeOffset.Parse(c1.GetString()!, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal) + : null; + afterEventId = cursor.TryGetProperty("afterEventId", out var c2) + && c2.ValueKind == JsonValueKind.String + ? c2.GetString() + : null; + + // A malformed cursor (object present but missing both keys) would loop + // forever — treat it as the end of results. + if (afterOccurredAtUtc == null && afterEventId == null) + return 0; + } + } +} diff --git a/src/ScadaLink.CLI/Commands/AuditVerifyChainHelpers.cs b/src/ScadaLink.CLI/Commands/AuditVerifyChainHelpers.cs new file mode 100644 index 0000000..45aa964 --- /dev/null +++ b/src/ScadaLink.CLI/Commands/AuditVerifyChainHelpers.cs @@ -0,0 +1,20 @@ +using System.Globalization; + +namespace ScadaLink.CLI.Commands; + +/// +/// Helpers for the audit verify-chain subcommand. v1 is a no-op: hash-chain +/// tamper-evidence is deferred to v1.x (see Component-AuditLog.md). The command still +/// validates its --month argument so the surface is stable for v1.x. +/// +public static class AuditVerifyChainHelpers +{ + /// + /// Returns true if is a well-formed YYYY-MM value + /// with a real month (01-12). A malformed month (e.g. 2026-13) is rejected. + /// + public static bool IsValidMonth(string? month) + => !string.IsNullOrWhiteSpace(month) + && DateTime.TryParseExact(month, "yyyy-MM", CultureInfo.InvariantCulture, + DateTimeStyles.None, out _); +} diff --git a/tests/ScadaLink.CLI.Tests/Commands/AuditCommandTestHarness.cs b/tests/ScadaLink.CLI.Tests/Commands/AuditCommandTestHarness.cs new file mode 100644 index 0000000..56ad835 --- /dev/null +++ b/tests/ScadaLink.CLI.Tests/Commands/AuditCommandTestHarness.cs @@ -0,0 +1,39 @@ +using System.CommandLine; +using ScadaLink.CLI.Commands; + +namespace ScadaLink.CLI.Tests.Commands; + +/// +/// Shared helpers for invoking the audit command tree in tests and capturing +/// stdout/stderr/exit code. +/// +internal static class AuditCommandTestHarness +{ + public static RootCommand BuildRoot() + { + 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); + root.Add(AuditCommands.Build(url, format, username, password)); + return root; + } + + public static (int Exit, string Out, string Err) Invoke(RootCommand root, params string[] args) + { + var output = new StringWriter(); + var error = new StringWriter(); + var exit = root.Parse(args).Invoke(new InvocationConfiguration + { + Output = output, + Error = error, + }); + return (exit, output.ToString(), error.ToString()); + } +} diff --git a/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs b/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs new file mode 100644 index 0000000..7773a90 --- /dev/null +++ b/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs @@ -0,0 +1,245 @@ +using System.Collections.Specialized; +using System.Net; +using System.Text; +using System.Web; +using ScadaLink.CLI; +using ScadaLink.CLI.Commands; + +namespace ScadaLink.CLI.Tests.Commands; + +/// +/// Tests for the scadalink audit query subcommand (Audit Log #23 M8-T2): +/// time-spec resolution, query-string construction, formatter selection, error +/// handling, and keyset-cursor paging via --all. +/// +public class AuditQueryCommandTests +{ + // ---- Time-spec parsing ------------------------------------------------- + + [Fact] + public void ResolveTimeSpec_RelativeHours_ResolvesToNowMinusOffset() + { + var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z"); + var resolved = AuditQueryHelpers.ResolveTimeSpec("1h", now); + Assert.Equal(DateTimeOffset.Parse("2026-05-20T11:00:00Z"), resolved); + } + + [Fact] + public void ResolveTimeSpec_RelativeDays_ResolvesToNowMinusOffset() + { + var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z"); + var resolved = AuditQueryHelpers.ResolveTimeSpec("7d", now); + Assert.Equal(DateTimeOffset.Parse("2026-05-13T12:00:00Z"), resolved); + } + + [Fact] + public void ResolveTimeSpec_AbsoluteIso8601_ParsedVerbatim() + { + var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z"); + var resolved = AuditQueryHelpers.ResolveTimeSpec("2026-01-02T03:04:05Z", now); + Assert.Equal(DateTimeOffset.Parse("2026-01-02T03:04:05Z"), resolved); + } + + [Fact] + public void ResolveTimeSpec_Garbage_Throws() + { + var now = DateTimeOffset.UtcNow; + Assert.Throws(() => AuditQueryHelpers.ResolveTimeSpec("not-a-time", now)); + } + + // ---- Query string construction ---------------------------------------- + + [Fact] + public void BuildQueryString_FullFlagSet_ProducesExpectedParameters() + { + var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z"); + var args = new AuditQueryArgs + { + Since = "1h", + Until = "2026-05-20T12:00:00Z", + Channel = "OutboundApi", + Kind = "CachedCall", + Status = "Delivered", + Site = "site-1", + Instance = "pump-7", + Target = "weather-api", + Actor = "multi-role", + CorrelationId = "abc-123", + ErrorsOnly = false, + PageSize = 250, + }; + + var qs = AuditQueryHelpers.BuildQueryString(args, now, afterOccurredAtUtc: null, afterEventId: null); + var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?')); + + Assert.Equal("OutboundApi", parsed["channel"]); + Assert.Equal("CachedCall", parsed["kind"]); + Assert.Equal("Delivered", parsed["status"]); + Assert.Equal("site-1", parsed["sourceSiteId"]); + Assert.Equal("pump-7", parsed["instance"]); + Assert.Equal("weather-api", parsed["target"]); + Assert.Equal("multi-role", parsed["actor"]); + Assert.Equal("abc-123", parsed["correlationId"]); + Assert.Equal("250", parsed["pageSize"]); + Assert.Equal("2026-05-20T11:00:00.0000000+00:00", parsed["fromUtc"]); + Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]); + } + + [Fact] + public void BuildQueryString_ErrorsOnly_MapsToFailedStatus() + { + var now = DateTimeOffset.UtcNow; + var args = new AuditQueryArgs { ErrorsOnly = true }; + var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null); + var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?')); + Assert.Equal("Failed", parsed["status"]); + } + + [Fact] + public void BuildQueryString_Cursor_AppendsAfterParameters() + { + var now = DateTimeOffset.UtcNow; + var args = new AuditQueryArgs(); + var after = DateTimeOffset.Parse("2026-05-20T10:00:00Z"); + var qs = AuditQueryHelpers.BuildQueryString(args, now, after, "evt-99"); + var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?')); + Assert.Equal("evt-99", parsed["afterEventId"]); + Assert.Equal("2026-05-20T10:00:00.0000000+00:00", parsed["afterOccurredAtUtc"]); + } + + [Fact] + public void BuildQueryString_OmitsUnsetFilters() + { + var now = DateTimeOffset.UtcNow; + var args = new AuditQueryArgs { PageSize = 100 }; + var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null); + var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?')); + Assert.Null(parsed["channel"]); + Assert.Null(parsed["status"]); + Assert.Null(parsed["fromUtc"]); + Assert.Equal("100", parsed["pageSize"]); + } + + // ---- HTTP execution / paging ------------------------------------------ + + private sealed class RecordingHandler : HttpMessageHandler + { + private readonly Queue _bodies; + public List RequestUris { get; } = new(); + + public RecordingHandler(params string[] bodies) + { + _bodies = new Queue(bodies); + } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestUris.Add(request.RequestUri!.PathAndQuery); + var body = _bodies.Count > 0 ? _bodies.Dequeue() : "{\"events\":[],\"nextCursor\":null}"; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }); + } + } + + [Fact] + public async Task RunQuery_SinglePage_WritesEventsAsJsonLines() + { + var handler = new RecordingHandler( + "{\"events\":[{\"eventId\":\"e1\"},{\"eventId\":\"e2\"}],\"nextCursor\":null}"); + var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); + var output = new StringWriter(); + + var exit = await AuditQueryHelpers.RunQueryAsync( + client, new AuditQueryArgs { PageSize = 100 }, fetchAll: false, + new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow); + + Assert.Equal(0, exit); + var lines = output.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(2, lines.Length); + Assert.Contains("e1", lines[0]); + Assert.Contains("e2", lines[1]); + Assert.Single(handler.RequestUris); + } + + [Fact] + public async Task RunQuery_WithAll_FollowsNextCursorAcrossPages() + { + var handler = new RecordingHandler( + "{\"events\":[{\"eventId\":\"e1\"}],\"nextCursor\":{\"afterOccurredAtUtc\":\"2026-05-20T10:00:00Z\",\"afterEventId\":\"e1\"}}", + "{\"events\":[{\"eventId\":\"e2\"}],\"nextCursor\":null}"); + var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); + var output = new StringWriter(); + + var exit = await AuditQueryHelpers.RunQueryAsync( + client, new AuditQueryArgs { PageSize = 100 }, fetchAll: true, + new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow); + + Assert.Equal(0, exit); + Assert.Equal(2, handler.RequestUris.Count); + Assert.Contains("afterEventId=e1", handler.RequestUris[1]); + var lines = output.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(2, lines.Length); + } + + [Fact] + public async Task RunQuery_WithoutAll_StopsAfterFirstPageEvenWhenCursorPresent() + { + var handler = new RecordingHandler( + "{\"events\":[{\"eventId\":\"e1\"}],\"nextCursor\":{\"afterOccurredAtUtc\":\"2026-05-20T10:00:00Z\",\"afterEventId\":\"e1\"}}"); + var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); + var output = new StringWriter(); + + var exit = await AuditQueryHelpers.RunQueryAsync( + client, new AuditQueryArgs { PageSize = 100 }, fetchAll: false, + new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow); + + Assert.Equal(0, exit); + Assert.Single(handler.RequestUris); + } + + [Fact] + public async Task RunQuery_ServerError_ReturnsNonZeroExit() + { + var handler = new ErrorHandler(); + var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); + var output = new StringWriter(); + + var exit = await AuditQueryHelpers.RunQueryAsync( + client, new AuditQueryArgs(), fetchAll: false, + new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow); + + Assert.NotEqual(0, exit); + } + + private sealed class ErrorHandler : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("{\"error\":\"boom\",\"code\":\"INTERNAL\"}"), + }); + } + + // ---- CLI parsing ------------------------------------------------------- + + [Fact] + public void Query_UnknownFlag_ProducesParseErrorAndNonZeroExit() + { + var root = AuditCommandTestHarness.BuildRoot(); + var (exit, _, err) = AuditCommandTestHarness.Invoke(root, "audit", "query", "--bogus", "x"); + Assert.NotEqual(0, exit); + Assert.NotEqual("", err); + } + + [Fact] + public void Query_FormatTable_IsAccepted() + { + var root = AuditCommandTestHarness.BuildRoot(); + var parse = root.Parse(new[] { "audit", "query", "--format", "table" }); + Assert.Empty(parse.Errors); + } +}