Merge branch 'feature/audit-log-m8-cli': Audit Log #23 M8 CLI
M8 — the final milestone — ships the operator CLI surface:
- ManagementService /api/audit/query + /api/audit/export endpoints
(minimal-API, HTTP Basic + LDAP auth, OperationalAudit / AuditExport
role gates reusing M7's AuthorizationPolicies role sets).
- scadalink audit command group:
- audit query: full UI-parity filter set, keyset-cursor paging
(--all follows nextCursor), JSON (default) / table output,
AcceptOnlyFromAmong fast-fail validation on channel/kind/status.
- audit export: streams server CSV / JSONL to --output (parquet → 501
per v1.x deferral), required-flag enforcement.
- audit verify-chain: v1 no-op stub (hash-chain deferred to v1.x);
validates --month, prints the deferral message, exits 0.
- Table output formatter for audit events.
- Pre-existing audit-log config-change command renamed audit-config;
audit-log retained as a deprecation alias (stderr warning).
- CLI README documents the audit group, the rename, permissions.
Review fix: corrected --channel/--kind/--status help + README to the
real enum names (ApiOutbound/DbOutbound/Notification/ApiInbound, etc.)
and added AcceptOnlyFromAmong so a bad value fails fast instead of
silently returning unfiltered results; removed the dead --instance flag
(AuditLogQueryFilter has no instance column).
Shipped: 10 commits, ~62 net new tests.
=== Audit Log #23 — implementation complete (M1-M8) ===
Full solution: 24 test projects, 2,993 tests, 0 failures, 0 skipped.
dotnet build ScadaLink.slnx clean. infra/* never touched on any of the
8 milestone branches. alog.md changed exactly once (M1 vocabulary
reconciliation, committed before the dependent code merge per the
ordering invariant).
This commit is contained in:
21
docs/plans/2026-05-20-auditlog-m8-cli.md
Normal file
21
docs/plans/2026-05-20-auditlog-m8-cli.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Audit Log #23 — M8 CLI Implementation Plan
|
||||
|
||||
> **For Claude:** subagent-driven-development with bundled cadence. FINAL milestone.
|
||||
|
||||
**Goal:** Operator CLI surface — `scadalink audit query | export | verify-chain` — plus the ManagementService HTTP endpoints they call, output formatters, and renaming the pre-existing `audit-log` config-change command to `audit-config` with a deprecation alias.
|
||||
|
||||
**M7 realities baked in:**
|
||||
- `OperationalAudit` + `AuditExport` are role-claim policies (M7 Bundle G). The Management endpoints reuse them.
|
||||
- `IAuditLogRepository.QueryAsync` (keyset paging) + `GetKpiSnapshotAsync` exist.
|
||||
- `AuditLogQueryFilter` is single-value per dimension — the CLI's `--channel` etc. flags collapse to single values like the UI chips do (documented limitation).
|
||||
- `verify-chain` is a v1 no-op stub (hash-chain deferred to v1.x per alog.md locked decisions). Do NOT implement hash chains.
|
||||
- ManagementService surface: confirm controllers vs minimal API by reading the project (M7 found CentralUI uses minimal API; ManagementService may differ).
|
||||
|
||||
**CLI conventions:** System.CommandLine; JSON default + `--format table` opt-in. The CLI connects via the HTTP Management API (per CLAUDE.md). Mirror `src/ScadaLink.CLI/Commands/AuditLogCommands.cs` for the System.CommandLine pattern.
|
||||
|
||||
**Bundles:**
|
||||
- Bundle A — CLI `audit` command group: scaffold + query + export + verify-chain (T1, T2, T3, T4).
|
||||
- Bundle B — ManagementService /api/audit/{query,export} endpoints (T5).
|
||||
- Bundle C — Output formatters + audit-config rename + README (T6, T7, T8).
|
||||
|
||||
Final cross-bundle review + merge + roadmap closeout.
|
||||
72
src/ScadaLink.CLI/Commands/AuditCommandHelpers.cs
Normal file
72
src/ScadaLink.CLI/Commands/AuditCommandHelpers.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
|
||||
namespace ScadaLink.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Resolved Management API connection details for an <c>audit</c> subcommand, or an
|
||||
/// error describing why resolution failed.
|
||||
/// </summary>
|
||||
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 };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connection/format resolution shared by the <c>audit</c> subcommands. Mirrors the URL
|
||||
/// and credential precedence used by <see cref="CommandHelpers"/> (command line → config
|
||||
/// file / environment), but produces a raw <see cref="ManagementHttpClient"/> target
|
||||
/// because the audit endpoints are plain REST resources rather than <c>POST /management</c>
|
||||
/// command-envelope calls.
|
||||
/// </summary>
|
||||
public static class AuditCommandHelpers
|
||||
{
|
||||
public static AuditConnection ResolveConnection(
|
||||
ParseResult result,
|
||||
Option<string> urlOption,
|
||||
Option<string> usernameOption,
|
||||
Option<string> 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<string> formatOption)
|
||||
=> CommandHelpers.ResolveFormat(result, formatOption, CliConfig.Load());
|
||||
}
|
||||
190
src/ScadaLink.CLI/Commands/AuditCommands.cs
Normal file
190
src/ScadaLink.CLI/Commands/AuditCommands.cs
Normal file
@@ -0,0 +1,190 @@
|
||||
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 sinceOption = new Option<string?>("--since") { Description = "Start time: relative (1h, 24h, 7d) or ISO-8601" };
|
||||
var untilOption = new Option<string?>("--until") { Description = "End time: relative (1h, 24h, 7d) or ISO-8601" };
|
||||
var channelOption = new Option<string?>("--channel") { Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound)" };
|
||||
channelOption.AcceptOnlyFromAmong("ApiOutbound", "DbOutbound", "Notification", "ApiInbound");
|
||||
var kindOption = new Option<string?>("--kind") { Description = "Filter by event kind (ApiCall, ApiCallCached, DbWrite, DbWriteCached, NotifySend, NotifyDeliver, InboundRequest, InboundAuthFailure, CachedSubmit, CachedResolve)" };
|
||||
kindOption.AcceptOnlyFromAmong(
|
||||
"ApiCall", "ApiCallCached", "DbWrite", "DbWriteCached", "NotifySend",
|
||||
"NotifyDeliver", "InboundRequest", "InboundAuthFailure", "CachedSubmit", "CachedResolve");
|
||||
var statusOption = new Option<string?>("--status") { Description = "Filter by status (Submitted, Forwarded, Attempted, Delivered, Failed, Parked, Discarded, Skipped)" };
|
||||
statusOption.AcceptOnlyFromAmong(
|
||||
"Submitted", "Forwarded", "Attempted", "Delivered", "Failed", "Parked", "Discarded", "Skipped");
|
||||
var siteOption = new Option<string?>("--site") { Description = "Filter by source site ID" };
|
||||
var targetOption = new Option<string?>("--target") { Description = "Filter by target (external system, DB connection, notification list)" };
|
||||
var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
|
||||
var correlationIdOption = new Option<string?>("--correlation-id") { Description = "Filter by correlation ID" };
|
||||
var errorsOnlyOption = new Option<bool>("--errors-only") { Description = "Show only failed events (status=Failed; overrides --status)" };
|
||||
var pageSizeOption = new Option<int>("--page-size") { Description = "Events per page (1-1000)" };
|
||||
pageSizeOption.DefaultValueFactory = _ => 100;
|
||||
var allOption = new Option<bool>("--all") { Description = "Fetch every page, following the keyset cursor" };
|
||||
|
||||
var cmd = new Command("query") { Description = "Query audit log events" };
|
||||
cmd.Add(sinceOption);
|
||||
cmd.Add(untilOption);
|
||||
cmd.Add(channelOption);
|
||||
cmd.Add(kindOption);
|
||||
cmd.Add(statusOption);
|
||||
cmd.Add(siteOption);
|
||||
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),
|
||||
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<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var sinceOption = new Option<string>("--since") { Description = "Start time: relative (1h, 24h, 7d) or ISO-8601", Required = true };
|
||||
var untilOption = new Option<string>("--until") { Description = "End time: relative (1h, 24h, 7d) or ISO-8601", Required = true };
|
||||
var formatExportOption = new Option<string>("--format") { Description = "Export format", Required = true };
|
||||
formatExportOption.AcceptOnlyFromAmong("csv", "jsonl", "parquet");
|
||||
var outputOption = new Option<string>("--output") { Description = "Destination file path", Required = true };
|
||||
var channelOption = new Option<string?>("--channel") { Description = "Filter by channel" };
|
||||
var kindOption = new Option<string?>("--kind") { Description = "Filter by event kind" };
|
||||
var statusOption = new Option<string?>("--status") { Description = "Filter by status" };
|
||||
var siteOption = new Option<string?>("--site") { Description = "Filter by source site ID" };
|
||||
var targetOption = new Option<string?>("--target") { Description = "Filter by target" };
|
||||
var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
|
||||
|
||||
var cmd = new Command("export") { Description = "Export audit log events to a file" };
|
||||
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<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var monthOption = new Option<string>("--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.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;
|
||||
}
|
||||
}
|
||||
114
src/ScadaLink.CLI/Commands/AuditExportHelpers.cs
Normal file
114
src/ScadaLink.CLI/Commands/AuditExportHelpers.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
|
||||
namespace ScadaLink.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Filter + destination arguments for an <c>audit export</c> invocation. Mirrors the
|
||||
/// Bundle B <c>GET /api/audit/export</c> parameters.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for the <c>audit export</c> 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.
|
||||
/// </summary>
|
||||
public static class AuditExportHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the <c>?...</c> query string for <c>GET /api/audit/export</c>: the required
|
||||
/// time window + format, plus optional filters. Time-specs are resolved via
|
||||
/// <see cref="AuditQueryHelpers.ResolveTimeSpec"/>.
|
||||
/// </summary>
|
||||
public static string BuildQueryString(AuditExportArgs args, DateTimeOffset now)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the export: GETs <c>/api/audit/export</c> and copies the response body
|
||||
/// stream directly to <see cref="AuditExportArgs.Output"/>. The body is never fully
|
||||
/// buffered — <see cref="Stream.CopyToAsync(Stream)"/> streams in fixed-size chunks.
|
||||
/// A <c>501 Not Implemented</c> (parquet not yet supported server-side) prints the
|
||||
/// server message and returns a non-zero exit code.
|
||||
/// </summary>
|
||||
public static async Task<int> 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;
|
||||
}
|
||||
}
|
||||
45
src/ScadaLink.CLI/Commands/AuditFormatter.cs
Normal file
45
src/ScadaLink.CLI/Commands/AuditFormatter.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ScadaLink.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Renders a page of audit-log events to a writer. The <c>audit query</c> command picks
|
||||
/// a formatter from the <c>--format</c> option. The default JSONL formatter is defined
|
||||
/// here; the human-readable table formatter is supplied by Bundle C.
|
||||
/// </summary>
|
||||
public interface IAuditFormatter
|
||||
{
|
||||
/// <summary>Renders one page of events. Called once per fetched page.</summary>
|
||||
void WritePage(IReadOnlyList<JsonElement> events, TextWriter output);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default formatter: one JSON object per line (JSONL). Streamable — each page's events
|
||||
/// are flushed as they arrive, so <c>--all</c> over many pages does not accumulate.
|
||||
/// </summary>
|
||||
public sealed class JsonLinesAuditFormatter : IAuditFormatter
|
||||
{
|
||||
private static readonly JsonSerializerOptions Compact = new() { WriteIndented = false };
|
||||
|
||||
public void WritePage(IReadOnlyList<JsonElement> events, TextWriter output)
|
||||
{
|
||||
foreach (var evt in events)
|
||||
output.WriteLine(JsonSerializer.Serialize(evt, Compact));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an <see cref="IAuditFormatter"/> for a given <c>--format</c> value:
|
||||
/// <c>table</c> renders a column-aligned text table (<see cref="TableAuditFormatter"/>),
|
||||
/// any other value (including <c>json</c>) renders JSONL.
|
||||
/// </summary>
|
||||
public static class AuditFormatterFactory
|
||||
{
|
||||
public static IAuditFormatter Create(string format, TextWriter notices)
|
||||
{
|
||||
if (string.Equals(format, "table", StringComparison.OrdinalIgnoreCase))
|
||||
return new TableAuditFormatter();
|
||||
|
||||
return new JsonLinesAuditFormatter();
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,50 @@ using ScadaLink.Commons.Messages.Management;
|
||||
|
||||
namespace ScadaLink.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>scadalink audit-config</c> command group: views the configuration-change
|
||||
/// audit log (the <c>IAuditService</c> trail of admin edits — distinct from the
|
||||
/// centralized append-only Audit Log served by <see cref="AuditCommands"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Renamed from <c>audit-log</c> in #23 M8-T7 to avoid confusion with the new
|
||||
/// <c>scadalink audit</c> group. The old <c>audit-log</c> name is retained as a
|
||||
/// deprecated alias; <see cref="DeprecatedAlias"/> still resolves the full subcommand
|
||||
/// tree, and <c>Program.cs</c> prints a deprecation warning when it is used.
|
||||
/// </remarks>
|
||||
public static class AuditLogCommands
|
||||
{
|
||||
/// <summary>The deprecated alias kept for backward compatibility with the old command name.</summary>
|
||||
public const string DeprecatedAlias = "audit-log";
|
||||
|
||||
/// <summary>The deprecation warning emitted when the old <c>audit-log</c> name is used.</summary>
|
||||
public const string DeprecationWarning =
|
||||
"Warning: 'audit-log' is deprecated and will be removed in a future release. "
|
||||
+ "Use 'audit-config' instead.";
|
||||
|
||||
/// <summary>
|
||||
/// Writes the <see cref="DeprecationWarning"/> to <paramref name="stderr"/> when the
|
||||
/// CLI was invoked via the deprecated <c>audit-log</c> command name (i.e. the first
|
||||
/// argument is <see cref="DeprecatedAlias"/>). The command itself still works — it is
|
||||
/// an alias of <c>audit-config</c> — so this only adds the migration warning.
|
||||
/// Factored out of <c>Program.cs</c> so it is unit-testable without spawning a process.
|
||||
/// </summary>
|
||||
public static void WriteDeprecationWarningIfNeeded(string[] args, TextWriter stderr)
|
||||
{
|
||||
if (args.Length > 0
|
||||
&& string.Equals(args[0], DeprecatedAlias, StringComparison.Ordinal))
|
||||
{
|
||||
stderr.WriteLine(DeprecationWarning);
|
||||
}
|
||||
}
|
||||
|
||||
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var command = new Command("audit-log") { Description = "Query audit logs" };
|
||||
var command = new Command("audit-config") { Description = "Query the configuration-change audit log" };
|
||||
// Backward-compatible alias for the pre-M8 `audit-log` name. The alias keeps
|
||||
// full subcommand parity automatically; the deprecation warning is emitted by
|
||||
// the args[0] check in Program.cs.
|
||||
command.Aliases.Add(DeprecatedAlias);
|
||||
|
||||
command.Add(BuildQuery(urlOption, formatOption, usernameOption, passwordOption));
|
||||
|
||||
|
||||
179
src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs
Normal file
179
src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs
Normal file
@@ -0,0 +1,179 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ScadaLink.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Filter arguments for an <c>audit query</c> invocation. Mirrors the Bundle B
|
||||
/// <c>GET /api/audit/query</c> filter parameters; <see cref="Since"/>/<see cref="Until"/>
|
||||
/// are time-specs (relative like <c>1h</c>/<c>7d</c>, or absolute ISO-8601).
|
||||
/// </summary>
|
||||
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? Target { get; set; }
|
||||
public string? Actor { get; set; }
|
||||
public string? CorrelationId { get; set; }
|
||||
public bool ErrorsOnly { get; set; }
|
||||
public int PageSize { get; set; } = 100;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure helpers for the <c>audit query</c> 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.
|
||||
/// </summary>
|
||||
public static class AuditQueryHelpers
|
||||
{
|
||||
// <number><unit> where unit is s/m/h/d — a relative offset back from "now".
|
||||
private static readonly Regex RelativeSpec = new(@"^(\d+)([smhd])$", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a time-spec to an absolute <see cref="DateTimeOffset"/>. Accepts a
|
||||
/// relative offset (<c>30s</c>, <c>15m</c>, <c>1h</c>, <c>7d</c>) interpreted as
|
||||
/// <paramref name="now"/> minus the offset, or an absolute ISO-8601 timestamp.
|
||||
/// </summary>
|
||||
/// <exception cref="FormatException">The spec is neither a known relative form nor a parseable ISO-8601 timestamp.</exception>
|
||||
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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <c>?...</c> query string for <c>GET /api/audit/query</c> from the filter
|
||||
/// args plus an optional keyset cursor. Unset filters are omitted. <c>--errors-only</c>
|
||||
/// maps to <c>status=Failed</c> (the server takes a single status value).
|
||||
/// </summary>
|
||||
public static string BuildQueryString(
|
||||
AuditQueryArgs args, DateTimeOffset now, DateTimeOffset? afterOccurredAtUtc, string? afterEventId)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
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("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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the query: GETs <c>/api/audit/query</c>, renders each page with
|
||||
/// <paramref name="formatter"/>, and — when <paramref name="fetchAll"/> is set —
|
||||
/// follows <c>nextCursor</c> until the server returns a null cursor. Returns the
|
||||
/// process exit code (0 success, non-zero on HTTP/transport error).
|
||||
/// </summary>
|
||||
public static async Task<int> 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<JsonElement>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/ScadaLink.CLI/Commands/AuditVerifyChainHelpers.cs
Normal file
20
src/ScadaLink.CLI/Commands/AuditVerifyChainHelpers.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace ScadaLink.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for the <c>audit verify-chain</c> subcommand. v1 is a no-op: hash-chain
|
||||
/// tamper-evidence is deferred to v1.x (see Component-AuditLog.md). The command still
|
||||
/// validates its <c>--month</c> argument so the surface is stable for v1.x.
|
||||
/// </summary>
|
||||
public static class AuditVerifyChainHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true if <paramref name="month"/> is a well-formed <c>YYYY-MM</c> value
|
||||
/// with a real month (01-12). A malformed month (e.g. <c>2026-13</c>) is rejected.
|
||||
/// </summary>
|
||||
public static bool IsValidMonth(string? month)
|
||||
=> !string.IsNullOrWhiteSpace(month)
|
||||
&& DateTime.TryParseExact(month, "yyyy-MM", CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None, out _);
|
||||
}
|
||||
96
src/ScadaLink.CLI/Commands/TableAuditFormatter.cs
Normal file
96
src/ScadaLink.CLI/Commands/TableAuditFormatter.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ScadaLink.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable table formatter for <c>audit query --format table</c> (Audit Log
|
||||
/// #23 M8-T6). Renders each fetched page as a column-aligned text table with a fixed
|
||||
/// column set (<see cref="Columns"/>). Long free-text fields (Target, Actor) are
|
||||
/// truncated with an ellipsis so columns stay aligned regardless of payload size.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A header row is emitted once per page (matching the streamable, page-at-a-time
|
||||
/// contract of <see cref="IAuditFormatter"/>). An empty page emits the header only,
|
||||
/// so the column shape is visible even with zero results.
|
||||
/// </remarks>
|
||||
public sealed class TableAuditFormatter : IAuditFormatter
|
||||
{
|
||||
/// <summary>JSON property name (camelCase, as the server serializes it) → column header.</summary>
|
||||
private static readonly (string Property, string Header, int MaxWidth)[] Columns =
|
||||
{
|
||||
("occurredAtUtc", "OccurredAtUtc", 24),
|
||||
("channel", "Channel", 14),
|
||||
("kind", "Kind", 18),
|
||||
("status", "Status", 12),
|
||||
("target", "Target", 32),
|
||||
("actor", "Actor", 20),
|
||||
("durationMs", "DurationMs", 10),
|
||||
("httpStatus", "HttpStatus", 10),
|
||||
};
|
||||
|
||||
public void WritePage(IReadOnlyList<JsonElement> events, TextWriter output)
|
||||
{
|
||||
// Build every cell first so column widths account for the actual data.
|
||||
var rows = new List<string[]>(events.Count);
|
||||
foreach (var evt in events)
|
||||
{
|
||||
var cells = new string[Columns.Length];
|
||||
for (var i = 0; i < Columns.Length; i++)
|
||||
cells[i] = Truncate(CellValue(evt, Columns[i].Property), Columns[i].MaxWidth);
|
||||
rows.Add(cells);
|
||||
}
|
||||
|
||||
var widths = new int[Columns.Length];
|
||||
for (var i = 0; i < Columns.Length; i++)
|
||||
widths[i] = Columns[i].Header.Length;
|
||||
foreach (var row in rows)
|
||||
for (var i = 0; i < Columns.Length; i++)
|
||||
widths[i] = Math.Max(widths[i], row[i].Length);
|
||||
|
||||
WriteRow(output, Columns.Select(c => c.Header).ToArray(), widths);
|
||||
foreach (var row in rows)
|
||||
WriteRow(output, row, widths);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a cell value for <paramref name="property"/> from an audit event.
|
||||
/// A missing property or a JSON <c>null</c> renders as an empty string (never
|
||||
/// the literal text "null").
|
||||
/// </summary>
|
||||
private static string CellValue(JsonElement evt, string property)
|
||||
{
|
||||
if (evt.ValueKind != JsonValueKind.Object
|
||||
|| !evt.TryGetProperty(property, out var value)
|
||||
|| value.ValueKind == JsonValueKind.Null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return value.ValueKind == JsonValueKind.String
|
||||
? value.GetString() ?? string.Empty
|
||||
: value.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Truncates <paramref name="value"/> to <paramref name="maxWidth"/> characters,
|
||||
/// replacing the tail with a single-character ellipsis so the column stays aligned.
|
||||
/// </summary>
|
||||
private static string Truncate(string value, int maxWidth)
|
||||
{
|
||||
if (maxWidth <= 0 || value.Length <= maxWidth)
|
||||
return value;
|
||||
if (maxWidth == 1)
|
||||
return "…";
|
||||
return value.Substring(0, maxWidth - 1) + "…";
|
||||
}
|
||||
|
||||
private static void WriteRow(TextWriter output, IReadOnlyList<string> cells, int[] widths)
|
||||
{
|
||||
for (var i = 0; i < cells.Count; i++)
|
||||
{
|
||||
// Last column is not padded — avoids trailing whitespace at line end.
|
||||
output.Write(i == cells.Count - 1 ? cells[i] : cells[i].PadRight(widths[i] + 2));
|
||||
}
|
||||
output.WriteLine();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
@@ -38,5 +39,10 @@ rootCommand.SetAction(_ =>
|
||||
Console.WriteLine("Use --help to see available commands.");
|
||||
});
|
||||
|
||||
// Deprecation notice for the pre-M8 `audit-log` command name. The command itself
|
||||
// still works (it is an alias of `audit-config`), but using the old name emits a
|
||||
// warning to stderr so scripts can be migrated.
|
||||
AuditLogCommands.WriteDeprecationWarningIfNeeded(args, Console.Error);
|
||||
|
||||
var parseResult = CommandLineParser.Parse(rootCommand, args);
|
||||
return await parseResult.InvokeAsync();
|
||||
|
||||
@@ -1049,14 +1049,118 @@ Features:
|
||||
|
||||
---
|
||||
|
||||
### `audit-log` — Audit log queries
|
||||
### `audit` — Centralized Audit Log
|
||||
|
||||
#### `audit-log query`
|
||||
Read access to the central append-only **Audit Log** (#23) — the record of every
|
||||
script-trust-boundary action: outbound API calls (sync + cached), outbound DB
|
||||
operations (sync + cached), notifications, and inbound API calls. This is distinct
|
||||
from the configuration-change audit trail exposed by [`audit-config`](#audit-config--configuration-change-audit-log).
|
||||
|
||||
Query the central audit log with optional filters and pagination.
|
||||
The subcommands map directly onto the `GET /api/audit/query` and
|
||||
`GET /api/audit/export` management endpoints. Filters and the result columns mirror
|
||||
the Central UI **Audit** page, so a CLI query and a UI query with the same filters
|
||||
return the same rows — CLI ↔ UI filter parity is intentional.
|
||||
|
||||
**Permissions.** Querying requires the `OperationalAudit` permission (roles `Admin`,
|
||||
`Audit`, or `AuditReadOnly`). Exporting requires the stricter `AuditExport` permission
|
||||
(roles `Admin` or `Audit`) — read access does *not* imply export access. A request
|
||||
without the required role returns exit code `2`.
|
||||
|
||||
#### `audit query`
|
||||
|
||||
Query audit log events with optional filters and keyset pagination.
|
||||
|
||||
```sh
|
||||
scadalink --url <url> audit-log query [options]
|
||||
scadalink --url <url> audit query [options]
|
||||
```
|
||||
|
||||
| Option | Required | Default | Description |
|
||||
|--------|----------|---------|-------------|
|
||||
| `--since` | no | — | Start time: relative (`1h`, `24h`, `7d`) or ISO-8601 |
|
||||
| `--until` | no | — | End time: relative (`1h`, `24h`, `7d`) or ISO-8601 |
|
||||
| `--channel` | no | — | Filter by channel (`ApiOutbound`, `DbOutbound`, `Notification`, `ApiInbound`) |
|
||||
| `--kind` | no | — | Filter by event kind (`ApiCall`, `ApiCallCached`, `DbWrite`, `DbWriteCached`, `NotifySend`, `NotifyDeliver`, `InboundRequest`, `InboundAuthFailure`, `CachedSubmit`, `CachedResolve`) |
|
||||
| `--status` | no | — | Filter by status (`Submitted`, `Forwarded`, `Attempted`, `Delivered`, `Failed`, `Parked`, `Discarded`, `Skipped`) |
|
||||
| `--site` | no | — | Filter by source site ID |
|
||||
| `--target` | no | — | Filter by target (external system, DB connection, notification list) |
|
||||
| `--actor` | no | — | Filter by actor |
|
||||
| `--correlation-id` | no | — | Filter by correlation ID |
|
||||
| `--errors-only` | no | `false` | Show only failed events (`status=Failed`; overrides `--status`) |
|
||||
| `--page-size` | no | `100` | Events per page (1–1000) |
|
||||
| `--all` | no | `false` | Fetch every page, following the keyset cursor |
|
||||
| `--format` | no | `json` | Output format: `json` (JSONL, one event per line) or `table` |
|
||||
|
||||
With `--format table`, events render as an aligned text table with columns
|
||||
`OccurredAtUtc`, `Channel`, `Kind`, `Status`, `Target`, `Actor`, `DurationMs`,
|
||||
`HttpStatus`; long `Target`/`Actor` values are truncated with an ellipsis. With
|
||||
`--format json` (the default), each page is emitted as JSONL — one JSON object per
|
||||
line — which streams cleanly under `--all` across many pages.
|
||||
|
||||
#### `audit export`
|
||||
|
||||
Export audit log events to a file. The export streams from the server, so it is not
|
||||
bounded by the query page size.
|
||||
|
||||
```sh
|
||||
scadalink --url <url> audit export --since <time> --until <time> --format <fmt> --output <path> [filters]
|
||||
```
|
||||
|
||||
| Option | Required | Default | Description |
|
||||
|--------|----------|---------|-------------|
|
||||
| `--since` | yes | — | Start time: relative (`1h`, `24h`, `7d`) or ISO-8601 |
|
||||
| `--until` | yes | — | End time: relative (`1h`, `24h`, `7d`) or ISO-8601 |
|
||||
| `--format` | yes | — | Export format: `csv`, `jsonl`, or `parquet` |
|
||||
| `--output` | yes | — | Destination file path |
|
||||
| `--channel` | no | — | Filter by channel |
|
||||
| `--kind` | no | — | Filter by event kind |
|
||||
| `--status` | no | — | Filter by status |
|
||||
| `--site` | no | — | Filter by source site ID |
|
||||
| `--target` | no | — | Filter by target |
|
||||
| `--actor` | no | — | Filter by actor |
|
||||
|
||||
> `--format parquet` is accepted by the CLI but the server returns `501 Not
|
||||
> Implemented` — Parquet archival is deferred to v1.x (see `Component-AuditLog.md`).
|
||||
> Use `csv` or `jsonl`.
|
||||
|
||||
#### `audit verify-chain`
|
||||
|
||||
Verify the audit log hash chain for a given month.
|
||||
|
||||
```sh
|
||||
scadalink --url <url> audit verify-chain --month <YYYY-MM>
|
||||
```
|
||||
|
||||
| Option | Required | Default | Description |
|
||||
|--------|----------|---------|-------------|
|
||||
| `--month` | yes | — | Month to verify, `YYYY-MM` (e.g. `2026-05`) |
|
||||
|
||||
> **v1 no-op.** Hash-chain tamper-evidence is not enabled in this release. The
|
||||
> subcommand validates the `--month` argument and prints a notice pointing at the
|
||||
> v1.x roadmap in `Component-AuditLog.md`; it exits `0` without contacting the server.
|
||||
> The command exists now so scripts and operator habits do not need to change when
|
||||
> tamper-evidence ships.
|
||||
|
||||
---
|
||||
|
||||
### `audit-config` — Configuration-change audit log
|
||||
|
||||
Query the configuration-change audit trail (the `IAuditService` record of admin
|
||||
edits to templates, instances, sites, etc.). This is **separate** from the
|
||||
centralized [`audit`](#audit--centralized-audit-log) group above.
|
||||
|
||||
> **Renamed.** This group was named `audit-log` before #23 M8. The old name still
|
||||
> works as a deprecated alias — `scadalink audit-log query ...` resolves the full
|
||||
> subcommand tree unchanged — but prints a deprecation warning to stderr:
|
||||
> `Warning: 'audit-log' is deprecated and will be removed in a future release. Use 'audit-config' instead.`
|
||||
> Migrate scripts to `audit-config`; the `audit-log` alias will be removed in a
|
||||
> future release.
|
||||
|
||||
#### `audit-config query`
|
||||
|
||||
Query the configuration-change audit log with optional filters and pagination.
|
||||
|
||||
```sh
|
||||
scadalink --url <url> audit-config query [options]
|
||||
```
|
||||
|
||||
| Option | Required | Default | Description |
|
||||
|
||||
@@ -201,6 +201,10 @@ try
|
||||
app.MapCentralUI<ScadaLink.Host.Components.App>();
|
||||
app.MapInboundAPI();
|
||||
app.MapManagementAPI();
|
||||
// Audit Log #23 (M8): CLI-facing /api/audit/{query,export} routes. Same
|
||||
// Basic-Auth + LDAP mechanism as /management; gated on the OperationalAudit
|
||||
// / AuditExport role sets.
|
||||
app.MapAuditAPI();
|
||||
app.MapHub<ScadaLink.ManagementService.DebugStreamHub>("/hubs/debug-stream");
|
||||
|
||||
// Compile and register all Inbound API method scripts at startup
|
||||
|
||||
554
src/ScadaLink.ManagementService/AuditEndpoints.cs
Normal file
554
src/ScadaLink.ManagementService/AuditEndpoints.cs
Normal file
@@ -0,0 +1,554 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Messages.Management;
|
||||
using ScadaLink.Commons.Types.Audit;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.Security;
|
||||
|
||||
namespace ScadaLink.ManagementService;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal-API endpoints exposing the central Audit Log (#23) over HTTP for the
|
||||
/// ScadaLink CLI (M8). Two routes:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>GET /api/audit/query</c> — keyset-paged JSON page, gated on the
|
||||
/// <see cref="AuthorizationPolicies.OperationalAudit"/> permission.</item>
|
||||
/// <item><c>GET /api/audit/export</c> — streamed bulk export (csv / jsonl;
|
||||
/// parquet returns HTTP 501), gated on the
|
||||
/// <see cref="AuthorizationPolicies.AuditExport"/> permission.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Auth mechanism.</b> ManagementService ships NO ASP.NET authorization-policy
|
||||
/// pipeline — the existing <c>/management</c> endpoint
|
||||
/// (<see cref="ManagementEndpoints"/>) authenticates each request by hand:
|
||||
/// decode HTTP Basic Auth, bind against LDAP via <see cref="LdapAuthService"/>,
|
||||
/// then map LDAP groups to roles via <see cref="RoleMapper"/>. These audit
|
||||
/// endpoints follow that exact mechanism rather than <c>.RequireAuthorization()</c>
|
||||
/// so the CLI authenticates the same way it does for every other management
|
||||
/// call (HTTP Basic). Permission gating is then a role-set membership check
|
||||
/// against <see cref="AuthorizationPolicies.OperationalAuditRoles"/> /
|
||||
/// <see cref="AuthorizationPolicies.AuditExportRoles"/> — the very role sets the
|
||||
/// CentralUI's <see cref="AuthorizationPolicies"/> enforces, so the two surfaces
|
||||
/// stay in lockstep.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Parquet.</b> No Parquet library is referenced by the solution and
|
||||
/// <c>Component-AuditLog.md</c> explicitly defers Parquet archival to v1.x, so
|
||||
/// <c>format=parquet</c> returns <c>501 Not Implemented</c> with a guidance
|
||||
/// message rather than a half-built binary stream. csv + jsonl are fully
|
||||
/// implemented.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class AuditEndpoints
|
||||
{
|
||||
/// <summary>Default rows per page for <c>/api/audit/query</c>.</summary>
|
||||
public const int DefaultPageSize = 100;
|
||||
|
||||
/// <summary>Hard ceiling on a single query page — keeps one page bounded in memory.</summary>
|
||||
public const int MaxPageSize = 1000;
|
||||
|
||||
/// <summary>Repository round-trip size used while streaming an export.</summary>
|
||||
public const int ExportPageSize = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Serializer for individual <see cref="AuditEvent"/> rows (query results +
|
||||
/// jsonl export lines). Drops null columns so a sparse audit row stays
|
||||
/// compact on the wire.
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Serializer for the <c>/api/audit/query</c> response envelope. Unlike
|
||||
/// <see cref="JsonOptions"/> it does NOT ignore nulls — the contract
|
||||
/// requires <c>nextCursor</c> to be present as an explicit <c>null</c> when
|
||||
/// there is no further page, so the CLI can rely on the key always existing.
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerOptions EnvelopeJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
public static IEndpointRouteBuilder MapAuditAPI(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapGet("/api/audit/query", (Delegate)HandleQuery);
|
||||
endpoints.MapGet("/api/audit/export", (Delegate)HandleExport);
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// GET /api/audit/query
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
internal static async Task<IResult> HandleQuery(HttpContext context)
|
||||
{
|
||||
var auth = await AuthenticateAsync(context);
|
||||
if (auth.Failure is not null)
|
||||
{
|
||||
return auth.Failure;
|
||||
}
|
||||
|
||||
if (!HasAnyRole(auth.User!, AuthorizationPolicies.OperationalAuditRoles))
|
||||
{
|
||||
return Forbidden("OperationalAudit");
|
||||
}
|
||||
|
||||
var filter = ParseFilter(context.Request.Query);
|
||||
var paging = ParsePaging(context.Request.Query);
|
||||
|
||||
var repo = context.RequestServices.GetRequiredService<IAuditLogRepository>();
|
||||
var events = await repo.QueryAsync(filter, paging, context.RequestAborted);
|
||||
|
||||
// The cursor for the next page is the last row of this page — but only
|
||||
// when the page came back FULL. A short page means there is no next
|
||||
// page, so nextCursor is null and the CLI stops paging.
|
||||
object? nextCursor = null;
|
||||
if (events.Count == paging.PageSize && events.Count > 0)
|
||||
{
|
||||
var last = events[^1];
|
||||
nextCursor = new
|
||||
{
|
||||
afterOccurredAtUtc = last.OccurredAtUtc,
|
||||
afterEventId = last.EventId,
|
||||
};
|
||||
}
|
||||
|
||||
var payload = new { events, nextCursor };
|
||||
// EnvelopeJsonOptions keeps an explicit null nextCursor on the wire so
|
||||
// the CLI can always read the key. AuditEvent rows render with their
|
||||
// full (null-inclusive) shape — a stable schema for the consumer.
|
||||
return Results.Text(
|
||||
JsonSerializer.Serialize(payload, EnvelopeJsonOptions), "application/json", statusCode: 200);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// GET /api/audit/export
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
internal static async Task<IResult> HandleExport(HttpContext context)
|
||||
{
|
||||
var auth = await AuthenticateAsync(context);
|
||||
if (auth.Failure is not null)
|
||||
{
|
||||
return auth.Failure;
|
||||
}
|
||||
|
||||
if (!HasAnyRole(auth.User!, AuthorizationPolicies.AuditExportRoles))
|
||||
{
|
||||
return Forbidden("AuditExport");
|
||||
}
|
||||
|
||||
var format = (context.Request.Query["format"].ToString() ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(format))
|
||||
{
|
||||
format = "csv";
|
||||
}
|
||||
|
||||
if (format == "parquet")
|
||||
{
|
||||
// Deferred to v1.x (Component-AuditLog.md §"Deferred"): no Parquet
|
||||
// library is in the solution. Return a 501 with actionable guidance
|
||||
// rather than a partial binary stream.
|
||||
return Results.Json(
|
||||
new { error = "Parquet export deferred to v1.x; use csv or jsonl.", code = "NOT_IMPLEMENTED" },
|
||||
statusCode: 501);
|
||||
}
|
||||
|
||||
if (format != "csv" && format != "jsonl")
|
||||
{
|
||||
return Results.Json(
|
||||
new { error = $"Unsupported export format '{format}'. Use csv, jsonl, or parquet.", code = "BAD_REQUEST" },
|
||||
statusCode: 400);
|
||||
}
|
||||
|
||||
var filter = ParseFilter(context.Request.Query);
|
||||
var repo = context.RequestServices.GetRequiredService<IAuditLogRepository>();
|
||||
|
||||
var contentType = format == "csv" ? "text/csv; charset=utf-8" : "application/x-ndjson";
|
||||
var extension = format == "csv" ? "csv" : "jsonl";
|
||||
var fileName = $"audit-log-{DateTime.UtcNow:yyyyMMdd-HHmmss}.{extension}";
|
||||
|
||||
context.Response.ContentType = contentType;
|
||||
context.Response.Headers["Content-Disposition"] = $"attachment; filename=\"{fileName}\"";
|
||||
// Defeat intermediate proxy buffering so rows stream as each page flushes.
|
||||
context.Response.Headers["Cache-Control"] = "no-store";
|
||||
|
||||
if (format == "csv")
|
||||
{
|
||||
await StreamCsvAsync(repo, filter, context.Response.Body, context.RequestAborted);
|
||||
}
|
||||
else
|
||||
{
|
||||
await StreamJsonlAsync(repo, filter, context.Response.Body, context.RequestAborted);
|
||||
}
|
||||
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Streams every matching row as RFC 4180 CSV, paging the repository with its
|
||||
/// keyset cursor and flushing after each page so a large export starts
|
||||
/// arriving immediately. Column order matches <see cref="AuditEvent"/>.
|
||||
/// </summary>
|
||||
private static async Task StreamCsvAsync(
|
||||
IAuditLogRepository repo, AuditLogQueryFilter filter, Stream output, CancellationToken ct)
|
||||
{
|
||||
await using var writer = new StreamWriter(
|
||||
output,
|
||||
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
|
||||
bufferSize: 4096,
|
||||
leaveOpen: true)
|
||||
{
|
||||
NewLine = "\r\n",
|
||||
};
|
||||
|
||||
await writer.WriteLineAsync(CsvHeader);
|
||||
|
||||
await foreach (var page in PageAsync(repo, filter, ct))
|
||||
{
|
||||
foreach (var evt in page)
|
||||
{
|
||||
await writer.WriteLineAsync(FormatCsvRow(evt));
|
||||
}
|
||||
await writer.FlushAsync(ct);
|
||||
await output.FlushAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Streams every matching row as newline-delimited JSON (one compact object
|
||||
/// per line), flushing after each page.
|
||||
/// </summary>
|
||||
private static async Task StreamJsonlAsync(
|
||||
IAuditLogRepository repo, AuditLogQueryFilter filter, Stream output, CancellationToken ct)
|
||||
{
|
||||
await using var writer = new StreamWriter(
|
||||
output,
|
||||
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
|
||||
bufferSize: 4096,
|
||||
leaveOpen: true)
|
||||
{
|
||||
NewLine = "\n",
|
||||
};
|
||||
|
||||
await foreach (var page in PageAsync(repo, filter, ct))
|
||||
{
|
||||
foreach (var evt in page)
|
||||
{
|
||||
await writer.WriteLineAsync(JsonSerializer.Serialize(evt, JsonOptions));
|
||||
}
|
||||
await writer.FlushAsync(ct);
|
||||
await output.FlushAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lazily yields full repository pages, advancing the keyset cursor until a
|
||||
/// short page signals the end. Shared by the csv + jsonl streamers.
|
||||
/// </summary>
|
||||
private static async IAsyncEnumerable<IReadOnlyList<AuditEvent>> PageAsync(
|
||||
IAuditLogRepository repo,
|
||||
AuditLogQueryFilter filter,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
|
||||
{
|
||||
var cursor = new AuditLogPaging(ExportPageSize);
|
||||
while (true)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var page = await repo.QueryAsync(filter, cursor, ct);
|
||||
if (page.Count == 0)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return page;
|
||||
|
||||
if (page.Count < cursor.PageSize)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var last = page[^1];
|
||||
cursor = new AuditLogPaging(ExportPageSize, last.OccurredAtUtc, last.EventId);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Authentication — Basic Auth → LDAP → roles (mirrors ManagementEndpoints)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Outcome of <see cref="AuthenticateAsync"/>: exactly one of the two fields is set.</summary>
|
||||
private readonly record struct AuthOutcome(AuthenticatedUser? User, IResult? Failure);
|
||||
|
||||
/// <summary>
|
||||
/// Decodes HTTP Basic Auth, binds against LDAP, and resolves roles — the same
|
||||
/// flow <see cref="ManagementEndpoints"/> uses. Returns a populated
|
||||
/// <see cref="AuthenticatedUser"/> on success, or an <see cref="IResult"/>
|
||||
/// carrying the 401 response on any failure.
|
||||
/// </summary>
|
||||
private static async Task<AuthOutcome> AuthenticateAsync(HttpContext context)
|
||||
{
|
||||
var authHeader = context.Request.Headers.Authorization.ToString();
|
||||
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new AuthOutcome(null, Results.Json(
|
||||
new { error = "Authorization header required (Basic scheme).", code = "AUTH_FAILED" }, statusCode: 401));
|
||||
}
|
||||
|
||||
string username, password;
|
||||
try
|
||||
{
|
||||
var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(authHeader["Basic ".Length..]));
|
||||
var colon = decoded.IndexOf(':');
|
||||
if (colon < 0) throw new FormatException();
|
||||
username = decoded[..colon];
|
||||
password = decoded[(colon + 1)..];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new AuthOutcome(null, Results.Json(
|
||||
new { error = "Malformed Basic Auth header.", code = "AUTH_FAILED" }, statusCode: 401));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
return new AuthOutcome(null, Results.Json(
|
||||
new { error = "Username and password are required.", code = "AUTH_FAILED" }, statusCode: 401));
|
||||
}
|
||||
|
||||
var ldapAuth = context.RequestServices.GetRequiredService<LdapAuthService>();
|
||||
var authResult = await ldapAuth.AuthenticateAsync(username, password);
|
||||
if (!authResult.Success)
|
||||
{
|
||||
return new AuthOutcome(null, Results.Json(
|
||||
new { error = authResult.ErrorMessage ?? "Authentication failed.", code = "AUTH_FAILED" }, statusCode: 401));
|
||||
}
|
||||
|
||||
var roleMapper = context.RequestServices.GetRequiredService<RoleMapper>();
|
||||
var mappingResult = await roleMapper.MapGroupsToRolesAsync(
|
||||
authResult.Groups ?? (IReadOnlyList<string>)Array.Empty<string>());
|
||||
|
||||
var permittedSiteIds = mappingResult.IsSystemWideDeployment
|
||||
? Array.Empty<string>()
|
||||
: mappingResult.PermittedSiteIds.ToArray();
|
||||
|
||||
var user = new AuthenticatedUser(
|
||||
authResult.Username!,
|
||||
authResult.DisplayName!,
|
||||
mappingResult.Roles.ToArray(),
|
||||
permittedSiteIds);
|
||||
|
||||
return new AuthOutcome(user, null);
|
||||
}
|
||||
|
||||
private static bool HasAnyRole(AuthenticatedUser user, string[] allowed) =>
|
||||
user.Roles.Any(r => allowed.Contains(r, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
private static IResult Forbidden(string permission) => Results.Json(
|
||||
new { error = $"Permission '{permission}' required.", code = "UNAUTHORIZED" }, statusCode: 403);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Query-string parsing
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Parses the query-string into an <see cref="AuditLogQueryFilter"/>. Unknown
|
||||
/// enum names / un-parseable Guids / dates are silently dropped (no 400) —
|
||||
/// the same lax contract the CentralUI export endpoint uses.
|
||||
/// </summary>
|
||||
public static AuditLogQueryFilter ParseFilter(IQueryCollection query)
|
||||
{
|
||||
AuditChannel? channel = null;
|
||||
if (query.TryGetValue("channel", out var channelValues)
|
||||
&& Enum.TryParse<AuditChannel>(channelValues.ToString(), ignoreCase: true, out var parsedChannel))
|
||||
{
|
||||
channel = parsedChannel;
|
||||
}
|
||||
|
||||
AuditKind? kind = null;
|
||||
if (query.TryGetValue("kind", out var kindValues)
|
||||
&& Enum.TryParse<AuditKind>(kindValues.ToString(), ignoreCase: true, out var parsedKind))
|
||||
{
|
||||
kind = parsedKind;
|
||||
}
|
||||
|
||||
AuditStatus? status = null;
|
||||
if (query.TryGetValue("status", out var statusValues)
|
||||
&& Enum.TryParse<AuditStatus>(statusValues.ToString(), ignoreCase: true, out var parsedStatus))
|
||||
{
|
||||
status = parsedStatus;
|
||||
}
|
||||
|
||||
Guid? correlationId = null;
|
||||
if (query.TryGetValue("correlationId", out var corrValues)
|
||||
&& Guid.TryParse(corrValues.ToString(), out var parsedCorr))
|
||||
{
|
||||
correlationId = parsedCorr;
|
||||
}
|
||||
|
||||
return new AuditLogQueryFilter(
|
||||
Channel: channel,
|
||||
Kind: kind,
|
||||
Status: status,
|
||||
SourceSiteId: TrimToNullable(query, "sourceSiteId"),
|
||||
Target: TrimToNullable(query, "target"),
|
||||
Actor: TrimToNullable(query, "actor"),
|
||||
CorrelationId: correlationId,
|
||||
FromUtc: ParseUtcDate(query, "fromUtc"),
|
||||
ToUtc: ParseUtcDate(query, "toUtc"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the keyset-paging query parameters into an
|
||||
/// <see cref="AuditLogPaging"/>. <c>pageSize</c> is clamped to
|
||||
/// <c>[1, <see cref="MaxPageSize"/>]</c>; a missing / junk value falls back
|
||||
/// to <see cref="DefaultPageSize"/>. <c>afterOccurredAtUtc</c> +
|
||||
/// <c>afterEventId</c> are honoured only when BOTH parse (the repository's
|
||||
/// keyset cursor requires the pair together).
|
||||
/// </summary>
|
||||
public static AuditLogPaging ParsePaging(IQueryCollection query)
|
||||
{
|
||||
int pageSize = DefaultPageSize;
|
||||
if (query.TryGetValue("pageSize", out var pageSizeRaw)
|
||||
&& int.TryParse(pageSizeRaw.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedSize)
|
||||
&& parsedSize > 0)
|
||||
{
|
||||
pageSize = Math.Min(parsedSize, MaxPageSize);
|
||||
}
|
||||
|
||||
DateTime? afterOccurredAt = ParseUtcDate(query, "afterOccurredAtUtc");
|
||||
|
||||
Guid? afterEventId = null;
|
||||
if (query.TryGetValue("afterEventId", out var eventIdRaw)
|
||||
&& Guid.TryParse(eventIdRaw.ToString(), out var parsedEventId))
|
||||
{
|
||||
afterEventId = parsedEventId;
|
||||
}
|
||||
|
||||
// The cursor pair must be all-or-nothing — a half-supplied cursor would
|
||||
// produce an invalid keyset predicate. Drop both unless both are present.
|
||||
if (afterOccurredAt is null || afterEventId is null)
|
||||
{
|
||||
return new AuditLogPaging(pageSize);
|
||||
}
|
||||
|
||||
return new AuditLogPaging(pageSize, afterOccurredAt, afterEventId);
|
||||
}
|
||||
|
||||
private static string? TrimToNullable(IQueryCollection query, string key)
|
||||
{
|
||||
if (!query.TryGetValue(key, out var values))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var v = values.ToString();
|
||||
return string.IsNullOrWhiteSpace(v) ? null : v.Trim();
|
||||
}
|
||||
|
||||
private static DateTime? ParseUtcDate(IQueryCollection query, string key)
|
||||
{
|
||||
if (!query.TryGetValue(key, out var values))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (DateTime.TryParse(
|
||||
values.ToString(),
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||
out var parsed))
|
||||
{
|
||||
return DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// CSV helpers — 21 columns in AuditEvent declaration order
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
public const string CsvHeader =
|
||||
"EventId,OccurredAtUtc,IngestedAtUtc,Channel,Kind,CorrelationId," +
|
||||
"SourceSiteId,SourceInstanceId,SourceScript,Actor,Target,Status," +
|
||||
"HttpStatus,DurationMs,ErrorMessage,ErrorDetail,RequestSummary," +
|
||||
"ResponseSummary,PayloadTruncated,Extra,ForwardState";
|
||||
|
||||
public static string FormatCsvRow(AuditEvent evt)
|
||||
{
|
||||
var sb = new StringBuilder(256);
|
||||
AppendField(sb, evt.EventId.ToString(), first: true);
|
||||
AppendField(sb, FormatDate(evt.OccurredAtUtc));
|
||||
AppendField(sb, FormatDate(evt.IngestedAtUtc));
|
||||
AppendField(sb, evt.Channel.ToString());
|
||||
AppendField(sb, evt.Kind.ToString());
|
||||
AppendField(sb, evt.CorrelationId?.ToString());
|
||||
AppendField(sb, evt.SourceSiteId);
|
||||
AppendField(sb, evt.SourceInstanceId);
|
||||
AppendField(sb, evt.SourceScript);
|
||||
AppendField(sb, evt.Actor);
|
||||
AppendField(sb, evt.Target);
|
||||
AppendField(sb, evt.Status.ToString());
|
||||
AppendField(sb, evt.HttpStatus?.ToString(CultureInfo.InvariantCulture));
|
||||
AppendField(sb, evt.DurationMs?.ToString(CultureInfo.InvariantCulture));
|
||||
AppendField(sb, evt.ErrorMessage);
|
||||
AppendField(sb, evt.ErrorDetail);
|
||||
AppendField(sb, evt.RequestSummary);
|
||||
AppendField(sb, evt.ResponseSummary);
|
||||
AppendField(sb, evt.PayloadTruncated.ToString());
|
||||
AppendField(sb, evt.Extra);
|
||||
AppendField(sb, evt.ForwardState?.ToString());
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string? FormatDate(DateTime? value) =>
|
||||
value?.ToString("O", CultureInfo.InvariantCulture);
|
||||
|
||||
private static string FormatDate(DateTime value) =>
|
||||
value.ToString("O", CultureInfo.InvariantCulture);
|
||||
|
||||
private static void AppendField(StringBuilder sb, string? value, bool first = false)
|
||||
{
|
||||
if (!first) sb.Append(',');
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// RFC 4180: quote on comma / quote / CR / LF; double-up embedded quotes.
|
||||
if (value.IndexOfAny(s_quoteTriggers) < 0)
|
||||
{
|
||||
sb.Append(value);
|
||||
return;
|
||||
}
|
||||
|
||||
sb.Append('"');
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (ch == '"')
|
||||
{
|
||||
sb.Append('"').Append('"');
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(ch);
|
||||
}
|
||||
}
|
||||
sb.Append('"');
|
||||
}
|
||||
|
||||
private static readonly char[] s_quoteTriggers = { ',', '"', '\r', '\n' };
|
||||
}
|
||||
@@ -86,14 +86,25 @@ public static class AuthorizationPolicies
|
||||
/// Roles that satisfy <see cref="OperationalAudit"/>. Held in one place
|
||||
/// so the seed/docs and the policy stay in lockstep.
|
||||
/// </summary>
|
||||
internal static readonly string[] OperationalAuditRoles = { "Admin", "Audit", "AuditReadOnly" };
|
||||
/// <remarks>
|
||||
/// Public so the ManagementService HTTP API (#23 M8) — which gates the
|
||||
/// <c>/api/audit/*</c> routes with a manual Basic-Auth + LDAP role check
|
||||
/// rather than the ASP.NET authorization-policy pipeline — can reuse the
|
||||
/// exact same role set the <see cref="OperationalAudit"/> policy enforces.
|
||||
/// </remarks>
|
||||
public static readonly string[] OperationalAuditRoles = { "Admin", "Audit", "AuditReadOnly" };
|
||||
|
||||
/// <summary>
|
||||
/// Roles that satisfy <see cref="AuditExport"/>. A strict subset of
|
||||
/// <see cref="OperationalAuditRoles"/> — read access does NOT imply
|
||||
/// export permission.
|
||||
/// </summary>
|
||||
internal static readonly string[] AuditExportRoles = { "Admin", "Audit" };
|
||||
/// <remarks>
|
||||
/// Public for the same reason as <see cref="OperationalAuditRoles"/> —
|
||||
/// the ManagementService <c>/api/audit/export</c> route checks roles
|
||||
/// against this set directly.
|
||||
/// </remarks>
|
||||
public static readonly string[] AuditExportRoles = { "Admin", "Audit" };
|
||||
|
||||
public static IServiceCollection AddScadaLinkAuthorization(this IServiceCollection services)
|
||||
{
|
||||
|
||||
@@ -15,7 +15,9 @@ public class LdapAuthService
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
||||
// virtual: a test seam so HTTP-pipeline tests (e.g. the #23 M8 audit
|
||||
// endpoints) can substitute the LDAP bind without standing up a directory.
|
||||
public virtual async Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
return new LdapAuthResult(false, null, null, null, "Username is required.");
|
||||
|
||||
@@ -11,7 +11,9 @@ public class RoleMapper
|
||||
_securityRepository = securityRepository ?? throw new ArgumentNullException(nameof(securityRepository));
|
||||
}
|
||||
|
||||
public async Task<RoleMappingResult> MapGroupsToRolesAsync(
|
||||
// virtual: a test seam so HTTP-pipeline tests (e.g. the #23 M8 audit
|
||||
// endpoints) can substitute the LDAP-group→role resolution.
|
||||
public virtual async Task<RoleMappingResult> MapGroupsToRolesAsync(
|
||||
IReadOnlyList<string> ldapGroups,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.CommandLine;
|
||||
using ScadaLink.CLI.Commands;
|
||||
|
||||
namespace ScadaLink.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Shared helpers for invoking the <c>audit</c> command tree in tests and capturing
|
||||
/// stdout/stderr/exit code.
|
||||
/// </summary>
|
||||
internal static class AuditCommandTestHarness
|
||||
{
|
||||
public static RootCommand BuildRoot()
|
||||
{
|
||||
var url = new Option<string>("--url") { Recursive = true };
|
||||
var username = new Option<string>("--username") { Recursive = true };
|
||||
var password = new Option<string>("--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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses and invokes the command tree, capturing output from both channels the CLI
|
||||
/// uses: System.CommandLine's parser diagnostics flow through the
|
||||
/// <see cref="InvocationConfiguration"/> writers, while command actions write through
|
||||
/// <see cref="Console"/> (consistent with the rest of the CLI). Both are merged into
|
||||
/// the returned <c>Out</c>/<c>Err</c> strings. Callers must be in the <c>Console</c>
|
||||
/// xUnit collection so the global <see cref="Console"/> redirect is not racy.
|
||||
/// </summary>
|
||||
public static (int Exit, string Out, string Err) Invoke(RootCommand root, params string[] args)
|
||||
{
|
||||
var output = new StringWriter();
|
||||
var error = new StringWriter();
|
||||
|
||||
var originalOut = Console.Out;
|
||||
var originalErr = Console.Error;
|
||||
Console.SetOut(output);
|
||||
Console.SetError(error);
|
||||
int exit;
|
||||
try
|
||||
{
|
||||
exit = root.Parse(args).Invoke(new InvocationConfiguration
|
||||
{
|
||||
Output = output,
|
||||
Error = error,
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
Console.SetError(originalErr);
|
||||
}
|
||||
|
||||
return (exit, output.ToString(), error.ToString());
|
||||
}
|
||||
}
|
||||
@@ -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."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.CommandLine;
|
||||
using ScadaLink.CLI.Commands;
|
||||
|
||||
namespace ScadaLink.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <c>audit-log</c> → <c>audit-config</c> rename (Audit Log #23 M8-T7):
|
||||
/// the new name parses, the deprecated <c>audit-log</c> alias still resolves the full
|
||||
/// subcommand tree and emits a stderr deprecation warning, and the renamed group does
|
||||
/// not collide with the distinct <c>audit</c> group from Bundle A.
|
||||
/// </summary>
|
||||
public class AuditConfigDeprecationTests
|
||||
{
|
||||
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 RootCommand BuildRoot()
|
||||
{
|
||||
var root = new RootCommand();
|
||||
root.Add(Url);
|
||||
root.Add(Username);
|
||||
root.Add(Password);
|
||||
root.Add(Format);
|
||||
root.Add(AuditCommands.Build(Url, Format, Username, Password));
|
||||
root.Add(AuditLogCommands.Build(Url, Format, Username, Password));
|
||||
return root;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditConfig_Query_Works()
|
||||
{
|
||||
// The new `audit-config query` name parses cleanly with no errors.
|
||||
var root = BuildRoot();
|
||||
var parse = root.Parse(new[] { "audit-config", "query", "--user", "alice" });
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditLog_Query_StillWorks_ButEmitsDeprecationWarning_ToStderr()
|
||||
{
|
||||
// The deprecated `audit-log` alias still resolves the full subcommand tree...
|
||||
var root = BuildRoot();
|
||||
var parse = root.Parse(new[] { "audit-log", "query", "--user", "alice" });
|
||||
Assert.Empty(parse.Errors);
|
||||
|
||||
// ...and invoking via the old name emits the deprecation warning to stderr.
|
||||
var stderr = new StringWriter();
|
||||
AuditLogCommands.WriteDeprecationWarningIfNeeded(
|
||||
new[] { "audit-log", "query" }, stderr);
|
||||
var warning = stderr.ToString();
|
||||
Assert.Contains("deprecated", warning);
|
||||
Assert.Contains("audit-config", warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeprecationWarning_NotEmitted_ForNewName()
|
||||
{
|
||||
// The new `audit-config` name must not trigger the deprecation warning.
|
||||
var stderr = new StringWriter();
|
||||
AuditLogCommands.WriteDeprecationWarningIfNeeded(
|
||||
new[] { "audit-config", "query" }, stderr);
|
||||
Assert.Equal("", stderr.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeprecationWarning_NotEmitted_ForUnrelatedCommand()
|
||||
{
|
||||
var stderr = new StringWriter();
|
||||
AuditLogCommands.WriteDeprecationWarningIfNeeded(
|
||||
new[] { "audit", "query" }, stderr);
|
||||
Assert.Equal("", stderr.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Audit_And_AuditConfig_AreDistinctCommands_NoConflict()
|
||||
{
|
||||
var root = BuildRoot();
|
||||
|
||||
var auditNames = new[] { "audit", "audit-config" };
|
||||
foreach (var name in auditNames)
|
||||
{
|
||||
var match = root.Subcommands.SingleOrDefault(c => c.Name == name);
|
||||
Assert.NotNull(match);
|
||||
}
|
||||
|
||||
// The two groups are distinct objects with distinct subcommand sets:
|
||||
// `audit` has query/export/verify-chain; `audit-config` has only query.
|
||||
var audit = root.Subcommands.Single(c => c.Name == "audit");
|
||||
var auditConfig = root.Subcommands.Single(c => c.Name == "audit-config");
|
||||
Assert.NotSame(audit, auditConfig);
|
||||
Assert.Contains(audit.Subcommands, c => c.Name == "verify-chain");
|
||||
Assert.DoesNotContain(auditConfig.Subcommands, c => c.Name == "verify-chain");
|
||||
|
||||
// `audit-config` carries the deprecated `audit-log` alias; `audit` does not.
|
||||
Assert.Contains(AuditLogCommands.DeprecatedAlias, auditConfig.Aliases);
|
||||
Assert.DoesNotContain(AuditLogCommands.DeprecatedAlias, audit.Aliases);
|
||||
}
|
||||
}
|
||||
222
tests/ScadaLink.CLI.Tests/Commands/AuditExportCommandTests.cs
Normal file
222
tests/ScadaLink.CLI.Tests/Commands/AuditExportCommandTests.cs
Normal file
@@ -0,0 +1,222 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Web;
|
||||
using ScadaLink.CLI;
|
||||
using ScadaLink.CLI.Commands;
|
||||
|
||||
namespace ScadaLink.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <c>scadalink audit export</c> subcommand (Audit Log #23 M8-T3):
|
||||
/// required-flag enforcement, query-string construction, streaming the response body
|
||||
/// to the output file, and the parquet-not-implemented (501) path.
|
||||
/// </summary>
|
||||
[Collection("Console")]
|
||||
public class AuditExportCommandTests
|
||||
{
|
||||
// ---- CLI parsing: required flags --------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Export_MissingRequiredFlag_ProducesParseErrorAndNonZeroExit()
|
||||
{
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
// --output is omitted.
|
||||
var (exit, _, err) = AuditCommandTestHarness.Invoke(
|
||||
root, "audit", "export", "--since", "1h", "--until", "0h", "--format", "csv");
|
||||
Assert.NotEqual(0, exit);
|
||||
Assert.Contains("--output", err);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Export_AllRequiredFlagsPresent_ParsesWithoutError()
|
||||
{
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var parse = root.Parse(new[]
|
||||
{
|
||||
"audit", "export", "--since", "1h", "--until", "0h",
|
||||
"--format", "csv", "--output", "/tmp/out.csv",
|
||||
});
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Export_InvalidFormat_Rejected()
|
||||
{
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var parse = root.Parse(new[]
|
||||
{
|
||||
"audit", "export", "--since", "1h", "--until", "0h",
|
||||
"--format", "xml", "--output", "/tmp/out.xml",
|
||||
});
|
||||
Assert.NotEmpty(parse.Errors);
|
||||
}
|
||||
|
||||
// ---- Query string -----------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void BuildQueryString_IncludesWindowFormatAndFilters()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z");
|
||||
var args = new AuditExportArgs
|
||||
{
|
||||
Since = "1h",
|
||||
Until = "2026-05-20T12:00:00Z",
|
||||
Format = "jsonl",
|
||||
Output = "/tmp/x",
|
||||
Channel = "Notification",
|
||||
Site = "site-9",
|
||||
};
|
||||
var qs = AuditExportHelpers.BuildQueryString(args, now);
|
||||
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
|
||||
|
||||
Assert.Equal("jsonl", parsed["format"]);
|
||||
Assert.Equal("Notification", parsed["channel"]);
|
||||
Assert.Equal("site-9", parsed["sourceSiteId"]);
|
||||
Assert.Equal("2026-05-20T11:00:00.0000000+00:00", parsed["fromUtc"]);
|
||||
Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]);
|
||||
}
|
||||
|
||||
// ---- Streaming export to file -----------------------------------------
|
||||
|
||||
private sealed class BodyHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode _status;
|
||||
private readonly Func<HttpContent> _content;
|
||||
public string? RequestPathAndQuery { get; private set; }
|
||||
|
||||
public BodyHandler(HttpStatusCode status, Func<HttpContent> content)
|
||||
{
|
||||
_status = status;
|
||||
_content = content;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
RequestPathAndQuery = request.RequestUri!.PathAndQuery;
|
||||
return Task.FromResult(new HttpResponseMessage(_status) { Content = _content() });
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunExport_Success_StreamsResponseToOutputFile()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"audit-export-{Guid.NewGuid():N}.jsonl");
|
||||
try
|
||||
{
|
||||
var handler = new BodyHandler(HttpStatusCode.OK,
|
||||
() => new StringContent("{\"eventId\":\"e1\"}\n{\"eventId\":\"e2\"}\n",
|
||||
Encoding.UTF8, "application/x-ndjson"));
|
||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
var output = new StringWriter();
|
||||
|
||||
var exit = await AuditExportHelpers.RunExportAsync(
|
||||
client,
|
||||
new AuditExportArgs { Since = "1h", Until = "0h", Format = "jsonl", Output = path },
|
||||
output, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal(0, exit);
|
||||
Assert.True(File.Exists(path));
|
||||
var content = await File.ReadAllTextAsync(path);
|
||||
Assert.Contains("e1", content);
|
||||
Assert.Contains("e2", content);
|
||||
Assert.Contains("api/audit/export", handler.RequestPathAndQuery);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunExport_Parquet501_PrintsServerMessageAndReturnsNonZero()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"audit-export-{Guid.NewGuid():N}.parquet");
|
||||
try
|
||||
{
|
||||
var handler = new BodyHandler(HttpStatusCode.NotImplemented,
|
||||
() => new StringContent("Parquet export is not yet supported.", Encoding.UTF8, "text/plain"));
|
||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
var output = new StringWriter();
|
||||
|
||||
var exit = await AuditExportHelpers.RunExportAsync(
|
||||
client,
|
||||
new AuditExportArgs { Since = "1h", Until = "0h", Format = "parquet", Output = path },
|
||||
output, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.NotEqual(0, exit);
|
||||
// No file should be written on the 501 path.
|
||||
Assert.False(File.Exists(path));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunExport_LargeBody_IsStreamedNotFullyBuffered()
|
||||
{
|
||||
// A ~8 MB body delivered via a streaming HttpContent. The export must copy it to
|
||||
// disk via Stream.CopyToAsync (chunked) — assert the file is written in full and
|
||||
// matches, which proves the streaming copy path works for multi-MB payloads.
|
||||
var path = Path.Combine(Path.GetTempPath(), $"audit-export-big-{Guid.NewGuid():N}.csv");
|
||||
const int totalBytes = 8 * 1024 * 1024;
|
||||
try
|
||||
{
|
||||
var handler = new BodyHandler(HttpStatusCode.OK,
|
||||
() => new StreamContent(new RepeatingStream((byte)'a', totalBytes)));
|
||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
var output = new StringWriter();
|
||||
|
||||
var exit = await AuditExportHelpers.RunExportAsync(
|
||||
client,
|
||||
new AuditExportArgs { Since = "7d", Until = "0h", Format = "csv", Output = path },
|
||||
output, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal(0, exit);
|
||||
Assert.Equal(totalBytes, new FileInfo(path).Length);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A read-only stream that yields <paramref name="length"/> copies of a single byte
|
||||
/// without ever materialising the whole payload — used to simulate a large export
|
||||
/// body so the streaming copy can be exercised without an 8 MB literal.
|
||||
/// </summary>
|
||||
private sealed class RepeatingStream : Stream
|
||||
{
|
||||
private readonly byte _value;
|
||||
private long _remaining;
|
||||
|
||||
public RepeatingStream(byte value, long length)
|
||||
{
|
||||
_value = value;
|
||||
_remaining = length;
|
||||
}
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => throw new NotSupportedException();
|
||||
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
if (_remaining <= 0) return 0;
|
||||
var n = (int)Math.Min(count, _remaining);
|
||||
for (var i = 0; i < n; i++) buffer[offset + i] = _value;
|
||||
_remaining -= n;
|
||||
return n;
|
||||
}
|
||||
|
||||
public override void Flush() { }
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
285
tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs
Normal file
285
tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs
Normal file
@@ -0,0 +1,285 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <c>scadalink audit query</c> subcommand (Audit Log #23 M8-T2):
|
||||
/// time-spec resolution, query-string construction, formatter selection, error
|
||||
/// handling, and keyset-cursor paging via <c>--all</c>.
|
||||
/// </summary>
|
||||
[Collection("Console")]
|
||||
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<FormatException>(() => 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 = "ApiOutbound",
|
||||
Kind = "ApiCallCached",
|
||||
Status = "Delivered",
|
||||
Site = "site-1",
|
||||
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("ApiOutbound", parsed["channel"]);
|
||||
Assert.Equal("ApiCallCached", parsed["kind"]);
|
||||
Assert.Equal("Delivered", parsed["status"]);
|
||||
Assert.Equal("site-1", parsed["sourceSiteId"]);
|
||||
// --instance was dropped: AuditLogQueryFilter has no instance column.
|
||||
Assert.Null(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<string> _bodies;
|
||||
public List<string> RequestUris { get; } = new();
|
||||
|
||||
public RecordingHandler(params string[] bodies)
|
||||
{
|
||||
_bodies = new Queue<string>(bodies);
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> 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<HttpResponseMessage> 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);
|
||||
}
|
||||
|
||||
// ---- Enum-name validation (fast-fail) ----------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Query_ChannelWithRealEnumName_IsAccepted()
|
||||
{
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var parse = root.Parse(new[] { "audit", "query", "--channel", "ApiOutbound" });
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Query_ChannelWithInvalidName_FailsFast_NonZeroExit()
|
||||
{
|
||||
// "OutboundApi" is the old (non-existent) name; the real enum is "ApiOutbound".
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var (exit, _, err) = AuditCommandTestHarness.Invoke(root, "audit", "query", "--channel", "OutboundApi");
|
||||
Assert.NotEqual(0, exit);
|
||||
Assert.NotEqual("", err);
|
||||
Assert.Contains("OutboundApi", err);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Query_KindWithInvalidName_FailsFast_NonZeroExit()
|
||||
{
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var (exit, _, err) = AuditCommandTestHarness.Invoke(root, "audit", "query", "--kind", "CachedCall");
|
||||
Assert.NotEqual(0, exit);
|
||||
Assert.NotEqual("", err);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Query_StatusWithInvalidName_FailsFast_NonZeroExit()
|
||||
{
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var (exit, _, err) = AuditCommandTestHarness.Invoke(root, "audit", "query", "--status", "Bogus");
|
||||
Assert.NotEqual(0, exit);
|
||||
Assert.NotEqual("", err);
|
||||
}
|
||||
}
|
||||
117
tests/ScadaLink.CLI.Tests/Commands/AuditTableFormatterTests.cs
Normal file
117
tests/ScadaLink.CLI.Tests/Commands/AuditTableFormatterTests.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
using System.Text.Json;
|
||||
using ScadaLink.CLI.Commands;
|
||||
|
||||
namespace ScadaLink.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <c>table</c> output formatter of the <c>scadalink audit query</c>
|
||||
/// subcommand (Audit Log #23 M8-T6): header rendering, long-field truncation, the
|
||||
/// empty-result-set case, and null-actor handling.
|
||||
/// </summary>
|
||||
public class AuditTableFormatterTests
|
||||
{
|
||||
private static IReadOnlyList<JsonElement> Events(string json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return doc.RootElement.EnumerateArray()
|
||||
.Select(e => e.Clone())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Table_RendersHeaderRow_WithExpectedColumns()
|
||||
{
|
||||
var formatter = new TableAuditFormatter();
|
||||
var output = new StringWriter();
|
||||
|
||||
formatter.WritePage(Events("[]"), output);
|
||||
|
||||
var firstLine = output.ToString()
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)[0];
|
||||
foreach (var col in new[]
|
||||
{
|
||||
"OccurredAtUtc", "Channel", "Kind", "Status",
|
||||
"Target", "Actor", "DurationMs", "HttpStatus",
|
||||
})
|
||||
{
|
||||
Assert.Contains(col, firstLine);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Table_TruncatesLongTarget_WithEllipsis()
|
||||
{
|
||||
var formatter = new TableAuditFormatter();
|
||||
var output = new StringWriter();
|
||||
|
||||
var longTarget = new string('x', 200);
|
||||
formatter.WritePage(
|
||||
Events($"[{{\"occurredAtUtc\":\"2026-05-20T12:00:00Z\",\"channel\":\"OutboundApi\"," +
|
||||
$"\"kind\":\"SyncCall\",\"status\":\"Delivered\",\"target\":\"{longTarget}\"," +
|
||||
$"\"actor\":\"multi-role\"}}]"),
|
||||
output);
|
||||
|
||||
var text = output.ToString();
|
||||
Assert.Contains("…", text);
|
||||
// The full untruncated target must not appear verbatim.
|
||||
Assert.DoesNotContain(longTarget, text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Table_EmptyResultSet_RendersHeaderOnly_OrNoRowsMessage()
|
||||
{
|
||||
var formatter = new TableAuditFormatter();
|
||||
var output = new StringWriter();
|
||||
|
||||
formatter.WritePage(Events("[]"), output);
|
||||
|
||||
var lines = output.ToString()
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
// Header only — no data rows. (A header line is always emitted so the
|
||||
// column shape is visible even with zero results.)
|
||||
Assert.Single(lines);
|
||||
Assert.Contains("OccurredAtUtc", lines[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Table_NullActor_RendersBlank()
|
||||
{
|
||||
var formatter = new TableAuditFormatter();
|
||||
var output = new StringWriter();
|
||||
|
||||
formatter.WritePage(
|
||||
Events("[{\"occurredAtUtc\":\"2026-05-20T12:00:00Z\",\"channel\":\"InboundApi\"," +
|
||||
"\"kind\":\"ApiCall\",\"status\":\"Delivered\",\"target\":\"key-1\"," +
|
||||
"\"actor\":null}]"),
|
||||
output);
|
||||
|
||||
var lines = output.ToString()
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.Equal(2, lines.Length);
|
||||
// The data row must not contain the literal "null" for the actor column.
|
||||
Assert.DoesNotContain("null", lines[1]);
|
||||
Assert.Contains("InboundApi", lines[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Table_HeaderEmittedOncePerPage_DataRowsAligned()
|
||||
{
|
||||
var formatter = new TableAuditFormatter();
|
||||
var output = new StringWriter();
|
||||
|
||||
formatter.WritePage(
|
||||
Events("[{\"occurredAtUtc\":\"2026-05-20T12:00:00Z\",\"channel\":\"OutboundApi\"," +
|
||||
"\"kind\":\"SyncCall\",\"status\":\"Delivered\",\"target\":\"weather-api\"," +
|
||||
"\"actor\":\"multi-role\",\"durationMs\":42,\"httpStatus\":200}," +
|
||||
"{\"occurredAtUtc\":\"2026-05-20T12:01:00Z\",\"channel\":\"Notification\"," +
|
||||
"\"kind\":\"Send\",\"status\":\"Failed\",\"target\":\"ops-list\"," +
|
||||
"\"actor\":\"scheduler\",\"durationMs\":7}]"),
|
||||
output);
|
||||
|
||||
var lines = output.ToString()
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.Equal(3, lines.Length);
|
||||
Assert.Contains("weather-api", lines[1]);
|
||||
Assert.Contains("ops-list", lines[2]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using ScadaLink.CLI.Commands;
|
||||
|
||||
namespace ScadaLink.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <c>scadalink audit verify-chain</c> subcommand (Audit Log #23 M8-T4).
|
||||
/// v1 is a no-op stub: a valid <c>--month</c> prints the documented "not enabled"
|
||||
/// message and exits 0; a malformed month or a missing <c>--month</c> exits non-zero.
|
||||
/// </summary>
|
||||
[Collection("Console")]
|
||||
public class AuditVerifyChainCommandTests
|
||||
{
|
||||
[Fact]
|
||||
public void VerifyChain_ValidMonth_ExitsZeroWithDocumentedMessage()
|
||||
{
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var (exit, output, _) = AuditCommandTestHarness.Invoke(
|
||||
root, "audit", "verify-chain", "--month", "2026-05");
|
||||
|
||||
Assert.Equal(0, exit);
|
||||
Assert.Contains("Hash-chain tamper-evidence is not enabled", output);
|
||||
Assert.Contains("Component-AuditLog.md", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyChain_MalformedMonth_ExitsNonZero()
|
||||
{
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var (exit, _, _) = AuditCommandTestHarness.Invoke(
|
||||
root, "audit", "verify-chain", "--month", "2026-13");
|
||||
|
||||
Assert.NotEqual(0, exit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyChain_MissingMonth_ProducesRequiredFlagError()
|
||||
{
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var (exit, _, err) = AuditCommandTestHarness.Invoke(root, "audit", "verify-chain");
|
||||
|
||||
Assert.NotEqual(0, exit);
|
||||
Assert.Contains("--month", err);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("2026-05", true)]
|
||||
[InlineData("2026-01", true)]
|
||||
[InlineData("2026-12", true)]
|
||||
[InlineData("2026-13", false)]
|
||||
[InlineData("2026-00", false)]
|
||||
[InlineData("2026-5", false)]
|
||||
[InlineData("not-a-month", false)]
|
||||
[InlineData("", false)]
|
||||
public void IsValidMonth_ValidatesYyyyMm(string month, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, AuditVerifyChainHelpers.IsValidMonth(month));
|
||||
}
|
||||
}
|
||||
386
tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs
Normal file
386
tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs
Normal file
@@ -0,0 +1,386 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Types.Audit;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.ManagementService;
|
||||
using ScadaLink.Security;
|
||||
|
||||
namespace ScadaLink.ManagementService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-pipeline tests for the #23 M8 audit endpoints (<see cref="AuditEndpoints"/>).
|
||||
///
|
||||
/// <para>
|
||||
/// ManagementService authenticates each request by hand (HTTP Basic → LDAP →
|
||||
/// roles) rather than via the ASP.NET authorization-policy pipeline, so these
|
||||
/// tests substitute <see cref="LdapAuthService"/> + <see cref="RoleMapper"/>
|
||||
/// (both expose a virtual test seam) to drive the role outcome without standing
|
||||
/// up a directory. The repository is a stubbed <see cref="IAuditLogRepository"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class AuditEndpointsTests
|
||||
{
|
||||
private const string BasicCredential = "auditor:password";
|
||||
|
||||
private static AuditEvent SampleEvent(Guid? id = null, DateTime? occurredAt = null) => new()
|
||||
{
|
||||
EventId = id ?? Guid.Parse("11111111-1111-1111-1111-111111111111"),
|
||||
OccurredAtUtc = occurredAt ?? new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
SourceSiteId = "plant-a",
|
||||
Status = AuditStatus.Delivered,
|
||||
HttpStatus = 200,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds an in-process TestServer hosting the audit endpoints with stubbed
|
||||
/// auth + repository. <paramref name="roles"/> is the role set the
|
||||
/// substituted <see cref="RoleMapper"/> returns for the authenticated user;
|
||||
/// pass an empty array to simulate a user with no audit permission.
|
||||
/// </summary>
|
||||
private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildHostAsync(
|
||||
string[] roles,
|
||||
IReadOnlyList<AuditEvent>[]? queryPages = null,
|
||||
bool ldapSucceeds = true)
|
||||
{
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
if (queryPages is { Length: > 0 })
|
||||
{
|
||||
var returns = queryPages
|
||||
.Select(p => Task.FromResult<IReadOnlyList<AuditEvent>>(p))
|
||||
.ToArray();
|
||||
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
||||
.Returns(returns[0], returns.Skip(1).ToArray());
|
||||
}
|
||||
else
|
||||
{
|
||||
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||
}
|
||||
|
||||
// Substituted LDAP bind — AuthenticateAsync is virtual (test seam).
|
||||
var ldap = Substitute.For<LdapAuthService>(
|
||||
Options.Create(new SecurityOptions()),
|
||||
Substitute.For<ILogger<LdapAuthService>>());
|
||||
ldap.AuthenticateAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ldapSucceeds
|
||||
? new LdapAuthResult(true, "Auditor", "auditor", new[] { "cn=audit" }, null)
|
||||
: new LdapAuthResult(false, null, null, null, "Bad credentials."));
|
||||
|
||||
// Substituted role mapper — MapGroupsToRolesAsync is virtual (test seam).
|
||||
var roleMapper = Substitute.For<RoleMapper>(Substitute.For<ISecurityRepository>());
|
||||
roleMapper.MapGroupsToRolesAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new RoleMappingResult(roles, Array.Empty<string>(), IsSystemWideDeployment: true));
|
||||
|
||||
var hostBuilder = new HostBuilder()
|
||||
.ConfigureWebHost(web =>
|
||||
{
|
||||
web.UseTestServer();
|
||||
web.ConfigureServices(services =>
|
||||
{
|
||||
services.AddRouting();
|
||||
services.AddSingleton(repo);
|
||||
services.AddSingleton(ldap);
|
||||
services.AddSingleton(roleMapper);
|
||||
});
|
||||
web.Configure(app =>
|
||||
{
|
||||
app.UseRouting();
|
||||
app.UseEndpoints(endpoints => endpoints.MapAuditAPI());
|
||||
});
|
||||
});
|
||||
|
||||
var host = await hostBuilder.StartAsync();
|
||||
return (host.GetTestClient(), repo, host);
|
||||
}
|
||||
|
||||
private static HttpRequestMessage Get(string url, string credential = BasicCredential)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
if (credential.Length > 0)
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue(
|
||||
"Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes(credential)));
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// /api/audit/query
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Query_ValidParams_ReturnsJsonPage()
|
||||
{
|
||||
var (client, _, host) = await BuildHostAsync(
|
||||
roles: new[] { "Audit" },
|
||||
queryPages: new[] { (IReadOnlyList<AuditEvent>)new[] { SampleEvent() } });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get(
|
||||
"/api/audit/query?channel=ApiOutbound&status=Delivered&pageSize=100"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("application/json", response.Content.Headers.ContentType!.MediaType);
|
||||
|
||||
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
var events = doc.RootElement.GetProperty("events");
|
||||
Assert.Equal(1, events.GetArrayLength());
|
||||
Assert.Equal("11111111-1111-1111-1111-111111111111",
|
||||
events[0].GetProperty("eventId").GetString());
|
||||
|
||||
// A short page (1 row < pageSize 100) means no further pages.
|
||||
Assert.Equal(JsonValueKind.Null, doc.RootElement.GetProperty("nextCursor").ValueKind);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Query_WithCursor_ReturnsNextPage()
|
||||
{
|
||||
// First page is FULL (pageSize=2 → 2 rows) so the response carries a
|
||||
// non-null nextCursor; the test then replays that cursor as the next
|
||||
// request and asserts the repo saw a keyset-paged AuditLogPaging.
|
||||
var pageOne = (IReadOnlyList<AuditEvent>)new[]
|
||||
{
|
||||
SampleEvent(Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001"),
|
||||
new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc)),
|
||||
SampleEvent(Guid.Parse("aaaaaaaa-0000-0000-0000-000000000002"),
|
||||
new DateTime(2026, 5, 20, 11, 0, 0, DateTimeKind.Utc)),
|
||||
};
|
||||
var (client, repo, host) = await BuildHostAsync(
|
||||
roles: new[] { "Audit" },
|
||||
queryPages: new[] { pageOne });
|
||||
using (host)
|
||||
{
|
||||
var first = await client.SendAsync(Get("/api/audit/query?pageSize=2"));
|
||||
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||
|
||||
using var firstDoc = JsonDocument.Parse(await first.Content.ReadAsStringAsync());
|
||||
var cursor = firstDoc.RootElement.GetProperty("nextCursor");
|
||||
Assert.Equal(JsonValueKind.Object, cursor.ValueKind);
|
||||
|
||||
var afterEventId = cursor.GetProperty("afterEventId").GetString()!;
|
||||
var afterOccurredAt = cursor.GetProperty("afterOccurredAtUtc").GetString()!;
|
||||
Assert.Equal("aaaaaaaa-0000-0000-0000-000000000002", afterEventId);
|
||||
|
||||
// Replay the cursor — the endpoint must thread it into AuditLogPaging.
|
||||
var second = await client.SendAsync(Get(
|
||||
$"/api/audit/query?pageSize=2&afterEventId={afterEventId}&afterOccurredAtUtc={Uri.EscapeDataString(afterOccurredAt)}"));
|
||||
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
|
||||
|
||||
await repo.Received().QueryAsync(
|
||||
Arg.Any<AuditLogQueryFilter>(),
|
||||
Arg.Is<AuditLogPaging>(p =>
|
||||
p.PageSize == 2 &&
|
||||
p.AfterEventId == Guid.Parse("aaaaaaaa-0000-0000-0000-000000000002") &&
|
||||
p.AfterOccurredAtUtc != null),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Query_WithoutOperationalAudit_Returns403()
|
||||
{
|
||||
// A user whose only role is Design holds neither OperationalAudit nor
|
||||
// AuditExport — the query endpoint must 403.
|
||||
var (client, _, host) = await BuildHostAsync(roles: new[] { "Design" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get("/api/audit/query"));
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Query_WithoutCredentials_Returns401()
|
||||
{
|
||||
var (client, _, host) = await BuildHostAsync(roles: new[] { "Audit" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get("/api/audit/query", credential: ""));
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Query_AuditReadOnlyRole_IsAllowed()
|
||||
{
|
||||
// AuditReadOnly satisfies OperationalAudit (read) — query must succeed.
|
||||
var (client, _, host) = await BuildHostAsync(roles: new[] { "AuditReadOnly" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get("/api/audit/query"));
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// /api/audit/export
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Export_Csv_StreamsContent_WithCsvContentType()
|
||||
{
|
||||
var (client, _, host) = await BuildHostAsync(
|
||||
roles: new[] { "Audit" },
|
||||
queryPages: new[]
|
||||
{
|
||||
(IReadOnlyList<AuditEvent>)new[] { SampleEvent() },
|
||||
(IReadOnlyList<AuditEvent>)Array.Empty<AuditEvent>(),
|
||||
});
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get("/api/audit/export?format=csv"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("text/csv", response.Content.Headers.ContentType!.MediaType);
|
||||
|
||||
var disposition = response.Content.Headers.ContentDisposition;
|
||||
Assert.NotNull(disposition);
|
||||
Assert.Equal("attachment", disposition!.DispositionType);
|
||||
Assert.EndsWith(".csv", disposition.FileName, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.StartsWith("EventId,OccurredAtUtc,IngestedAtUtc,", body);
|
||||
Assert.Contains("11111111-1111-1111-1111-111111111111", body);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_Csv_DefaultsWhenFormatOmitted()
|
||||
{
|
||||
// No format= param → csv default.
|
||||
var (client, _, host) = await BuildHostAsync(
|
||||
roles: new[] { "Audit" },
|
||||
queryPages: new[] { (IReadOnlyList<AuditEvent>)Array.Empty<AuditEvent>() });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get("/api/audit/export"));
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("text/csv", response.Content.Headers.ContentType!.MediaType);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_Jsonl_StreamsOnePerLine()
|
||||
{
|
||||
var (client, _, host) = await BuildHostAsync(
|
||||
roles: new[] { "Audit" },
|
||||
queryPages: new[]
|
||||
{
|
||||
(IReadOnlyList<AuditEvent>)new[]
|
||||
{
|
||||
SampleEvent(Guid.Parse("bbbbbbbb-0000-0000-0000-000000000001")),
|
||||
SampleEvent(Guid.Parse("bbbbbbbb-0000-0000-0000-000000000002")),
|
||||
},
|
||||
(IReadOnlyList<AuditEvent>)Array.Empty<AuditEvent>(),
|
||||
});
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get("/api/audit/export?format=jsonl"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("application/x-ndjson", response.Content.Headers.ContentType!.MediaType);
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
var lines = body.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.Equal(2, lines.Length);
|
||||
|
||||
// Each line must be a standalone, parseable JSON object.
|
||||
foreach (var line in lines)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(line);
|
||||
Assert.Equal(JsonValueKind.Object, doc.RootElement.ValueKind);
|
||||
Assert.True(doc.RootElement.TryGetProperty("eventId", out _));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_Parquet_Returns501()
|
||||
{
|
||||
// Parquet archival is deferred to v1.x (Component-AuditLog.md) — no
|
||||
// library is referenced, so the endpoint returns 501 with guidance.
|
||||
var (client, _, host) = await BuildHostAsync(roles: new[] { "Audit" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get("/api/audit/export?format=parquet"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotImplemented, response.StatusCode);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("Parquet export deferred to v1.x", body);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_WithoutAuditExport_Returns403()
|
||||
{
|
||||
// AuditReadOnly grants read (OperationalAudit) but NOT bulk export
|
||||
// (AuditExport) — the export endpoint must 403.
|
||||
var (client, _, host) = await BuildHostAsync(roles: new[] { "AuditReadOnly" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get("/api/audit/export?format=csv"));
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_UnsupportedFormat_Returns400()
|
||||
{
|
||||
var (client, _, host) = await BuildHostAsync(roles: new[] { "Audit" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get("/api/audit/export?format=xml"));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Query-string parsing units
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ParsePaging_ClampsPageSizeToMax()
|
||||
{
|
||||
var query = new Microsoft.AspNetCore.Http.QueryCollection(
|
||||
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
|
||||
{
|
||||
["pageSize"] = "999999",
|
||||
});
|
||||
|
||||
var paging = AuditEndpoints.ParsePaging(query);
|
||||
|
||||
Assert.Equal(AuditEndpoints.MaxPageSize, paging.PageSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsePaging_HalfSuppliedCursor_IsDropped()
|
||||
{
|
||||
// afterEventId without afterOccurredAtUtc is an invalid keyset cursor —
|
||||
// both must be dropped so the repository gets a first-page request.
|
||||
var query = new Microsoft.AspNetCore.Http.QueryCollection(
|
||||
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
|
||||
{
|
||||
["afterEventId"] = Guid.NewGuid().ToString(),
|
||||
});
|
||||
|
||||
var paging = AuditEndpoints.ParsePaging(query);
|
||||
|
||||
Assert.Null(paging.AfterEventId);
|
||||
Assert.Null(paging.AfterOccurredAtUtc);
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,13 @@
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Akka.TestKit.Xunit2" />
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="xunit" />
|
||||
@@ -20,5 +24,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/ScadaLink.ManagementService/ScadaLink.ManagementService.csproj" />
|
||||
<ProjectReference Include="../../src/ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
||||
<ProjectReference Include="../../src/ScadaLink.Security/ScadaLink.Security.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user