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);
+ }
+}