From 263884fa633cbb191777a18a4bcdf2170f32424c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 21:39:29 -0400 Subject: [PATCH 01/10] docs(audit): add M8 CLI implementation plan (#23) 3 bundles: CLI audit command group (scaffold/query/export/verify-chain), ManagementService endpoints, formatters + audit-config rename + README. verify-chain is a v1 no-op stub; hash chain deferred to v1.x. --- docs/plans/2026-05-20-auditlog-m8-cli.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 docs/plans/2026-05-20-auditlog-m8-cli.md diff --git a/docs/plans/2026-05-20-auditlog-m8-cli.md b/docs/plans/2026-05-20-auditlog-m8-cli.md new file mode 100644 index 0000000..3e68f00 --- /dev/null +++ b/docs/plans/2026-05-20-auditlog-m8-cli.md @@ -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. From a1bdd94d4cdb087e910708593fcf042bb214a7d4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 21:49:14 -0400 Subject: [PATCH 02/10] feat(mgmt): /api/audit/{query,export} endpoints with permission gates (#23 M8) --- src/ScadaLink.Host/Program.cs | 4 + .../AuditEndpoints.cs | 554 ++++++++++++++++++ .../AuthorizationPolicies.cs | 15 +- src/ScadaLink.Security/LdapAuthService.cs | 4 +- src/ScadaLink.Security/RoleMapper.cs | 4 +- .../AuditEndpointsTests.cs | 386 ++++++++++++ .../ScadaLink.ManagementService.Tests.csproj | 5 + 7 files changed, 968 insertions(+), 4 deletions(-) create mode 100644 src/ScadaLink.ManagementService/AuditEndpoints.cs create mode 100644 tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs diff --git a/src/ScadaLink.Host/Program.cs b/src/ScadaLink.Host/Program.cs index 3632824..a0b44a6 100644 --- a/src/ScadaLink.Host/Program.cs +++ b/src/ScadaLink.Host/Program.cs @@ -201,6 +201,10 @@ try app.MapCentralUI(); 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("/hubs/debug-stream"); // Compile and register all Inbound API method scripts at startup diff --git a/src/ScadaLink.ManagementService/AuditEndpoints.cs b/src/ScadaLink.ManagementService/AuditEndpoints.cs new file mode 100644 index 0000000..18217eb --- /dev/null +++ b/src/ScadaLink.ManagementService/AuditEndpoints.cs @@ -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; + +/// +/// Minimal-API endpoints exposing the central Audit Log (#23) over HTTP for the +/// ScadaLink CLI (M8). Two routes: +/// +/// GET /api/audit/query — keyset-paged JSON page, gated on the +/// permission. +/// GET /api/audit/export — streamed bulk export (csv / jsonl; +/// parquet returns HTTP 501), gated on the +/// permission. +/// +/// +/// +/// Auth mechanism. ManagementService ships NO ASP.NET authorization-policy +/// pipeline — the existing /management endpoint +/// () authenticates each request by hand: +/// decode HTTP Basic Auth, bind against LDAP via , +/// then map LDAP groups to roles via . These audit +/// endpoints follow that exact mechanism rather than .RequireAuthorization() +/// 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 / +/// — the very role sets the +/// CentralUI's enforces, so the two surfaces +/// stay in lockstep. +/// +/// +/// +/// Parquet. No Parquet library is referenced by the solution and +/// Component-AuditLog.md explicitly defers Parquet archival to v1.x, so +/// format=parquet returns 501 Not Implemented with a guidance +/// message rather than a half-built binary stream. csv + jsonl are fully +/// implemented. +/// +/// +public static class AuditEndpoints +{ + /// Default rows per page for /api/audit/query. + public const int DefaultPageSize = 100; + + /// Hard ceiling on a single query page — keeps one page bounded in memory. + public const int MaxPageSize = 1000; + + /// Repository round-trip size used while streaming an export. + public const int ExportPageSize = 1000; + + /// + /// Serializer for individual rows (query results + + /// jsonl export lines). Drops null columns so a sparse audit row stays + /// compact on the wire. + /// + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() }, + }; + + /// + /// Serializer for the /api/audit/query response envelope. Unlike + /// it does NOT ignore nulls — the contract + /// requires nextCursor to be present as an explicit null when + /// there is no further page, so the CLI can rely on the key always existing. + /// + 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 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(); + 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 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(); + + 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; + } + + /// + /// 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 . + /// + 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); + } + } + + /// + /// Streams every matching row as newline-delimited JSON (one compact object + /// per line), flushing after each page. + /// + 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); + } + } + + /// + /// Lazily yields full repository pages, advancing the keyset cursor until a + /// short page signals the end. Shared by the csv + jsonl streamers. + /// + private static async IAsyncEnumerable> 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) + // ───────────────────────────────────────────────────────────────────── + + /// Outcome of : exactly one of the two fields is set. + private readonly record struct AuthOutcome(AuthenticatedUser? User, IResult? Failure); + + /// + /// Decodes HTTP Basic Auth, binds against LDAP, and resolves roles — the same + /// flow uses. Returns a populated + /// on success, or an + /// carrying the 401 response on any failure. + /// + private static async Task 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(); + 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(); + var mappingResult = await roleMapper.MapGroupsToRolesAsync( + authResult.Groups ?? (IReadOnlyList)Array.Empty()); + + var permittedSiteIds = mappingResult.IsSystemWideDeployment + ? Array.Empty() + : 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 + // ───────────────────────────────────────────────────────────────────── + + /// + /// Parses the query-string into an . Unknown + /// enum names / un-parseable Guids / dates are silently dropped (no 400) — + /// the same lax contract the CentralUI export endpoint uses. + /// + public static AuditLogQueryFilter ParseFilter(IQueryCollection query) + { + AuditChannel? channel = null; + if (query.TryGetValue("channel", out var channelValues) + && Enum.TryParse(channelValues.ToString(), ignoreCase: true, out var parsedChannel)) + { + channel = parsedChannel; + } + + AuditKind? kind = null; + if (query.TryGetValue("kind", out var kindValues) + && Enum.TryParse(kindValues.ToString(), ignoreCase: true, out var parsedKind)) + { + kind = parsedKind; + } + + AuditStatus? status = null; + if (query.TryGetValue("status", out var statusValues) + && Enum.TryParse(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")); + } + + /// + /// Parses the keyset-paging query parameters into an + /// . pageSize is clamped to + /// [1, ]; a missing / junk value falls back + /// to . afterOccurredAtUtc + + /// afterEventId are honoured only when BOTH parse (the repository's + /// keyset cursor requires the pair together). + /// + 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' }; +} diff --git a/src/ScadaLink.Security/AuthorizationPolicies.cs b/src/ScadaLink.Security/AuthorizationPolicies.cs index 200c778..9e19b4f 100644 --- a/src/ScadaLink.Security/AuthorizationPolicies.cs +++ b/src/ScadaLink.Security/AuthorizationPolicies.cs @@ -86,14 +86,25 @@ public static class AuthorizationPolicies /// Roles that satisfy . Held in one place /// so the seed/docs and the policy stay in lockstep. /// - internal static readonly string[] OperationalAuditRoles = { "Admin", "Audit", "AuditReadOnly" }; + /// + /// Public so the ManagementService HTTP API (#23 M8) — which gates the + /// /api/audit/* 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 policy enforces. + /// + public static readonly string[] OperationalAuditRoles = { "Admin", "Audit", "AuditReadOnly" }; /// /// Roles that satisfy . A strict subset of /// — read access does NOT imply /// export permission. /// - internal static readonly string[] AuditExportRoles = { "Admin", "Audit" }; + /// + /// Public for the same reason as — + /// the ManagementService /api/audit/export route checks roles + /// against this set directly. + /// + public static readonly string[] AuditExportRoles = { "Admin", "Audit" }; public static IServiceCollection AddScadaLinkAuthorization(this IServiceCollection services) { diff --git a/src/ScadaLink.Security/LdapAuthService.cs b/src/ScadaLink.Security/LdapAuthService.cs index 1b8b161..48ad10e 100644 --- a/src/ScadaLink.Security/LdapAuthService.cs +++ b/src/ScadaLink.Security/LdapAuthService.cs @@ -15,7 +15,9 @@ public class LdapAuthService _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public async Task 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 AuthenticateAsync(string username, string password, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(username)) return new LdapAuthResult(false, null, null, null, "Username is required."); diff --git a/src/ScadaLink.Security/RoleMapper.cs b/src/ScadaLink.Security/RoleMapper.cs index 4ab88b8..f59bc4e 100644 --- a/src/ScadaLink.Security/RoleMapper.cs +++ b/src/ScadaLink.Security/RoleMapper.cs @@ -11,7 +11,9 @@ public class RoleMapper _securityRepository = securityRepository ?? throw new ArgumentNullException(nameof(securityRepository)); } - public async Task 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 MapGroupsToRolesAsync( IReadOnlyList ldapGroups, CancellationToken ct = default) { diff --git a/tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs b/tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs new file mode 100644 index 0000000..ee2f0a3 --- /dev/null +++ b/tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs @@ -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; + +/// +/// HTTP-pipeline tests for the #23 M8 audit endpoints (). +/// +/// +/// ManagementService authenticates each request by hand (HTTP Basic → LDAP → +/// roles) rather than via the ASP.NET authorization-policy pipeline, so these +/// tests substitute + +/// (both expose a virtual test seam) to drive the role outcome without standing +/// up a directory. The repository is a stubbed . +/// +/// +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, + }; + + /// + /// Builds an in-process TestServer hosting the audit endpoints with stubbed + /// auth + repository. is the role set the + /// substituted returns for the authenticated user; + /// pass an empty array to simulate a user with no audit permission. + /// + private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildHostAsync( + string[] roles, + IReadOnlyList[]? queryPages = null, + bool ldapSucceeds = true) + { + var repo = Substitute.For(); + if (queryPages is { Length: > 0 }) + { + var returns = queryPages + .Select(p => Task.FromResult>(p)) + .ToArray(); + repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(returns[0], returns.Skip(1).ToArray()); + } + else + { + repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(Array.Empty())); + } + + // Substituted LDAP bind — AuthenticateAsync is virtual (test seam). + var ldap = Substitute.For( + Options.Create(new SecurityOptions()), + Substitute.For>()); + ldap.AuthenticateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .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(Substitute.For()); + roleMapper.MapGroupsToRolesAsync(Arg.Any>(), Arg.Any()) + .Returns(new RoleMappingResult(roles, Array.Empty(), 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)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)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(), + Arg.Is(p => + p.PageSize == 2 && + p.AfterEventId == Guid.Parse("aaaaaaaa-0000-0000-0000-000000000002") && + p.AfterOccurredAtUtc != null), + Arg.Any()); + } + } + + [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)new[] { SampleEvent() }, + (IReadOnlyList)Array.Empty(), + }); + 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)Array.Empty() }); + 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)new[] + { + SampleEvent(Guid.Parse("bbbbbbbb-0000-0000-0000-000000000001")), + SampleEvent(Guid.Parse("bbbbbbbb-0000-0000-0000-000000000002")), + }, + (IReadOnlyList)Array.Empty(), + }); + 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 + { + ["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 + { + ["afterEventId"] = Guid.NewGuid().ToString(), + }); + + var paging = AuditEndpoints.ParsePaging(query); + + Assert.Null(paging.AfterEventId); + Assert.Null(paging.AfterOccurredAtUtc); + } +} diff --git a/tests/ScadaLink.ManagementService.Tests/ScadaLink.ManagementService.Tests.csproj b/tests/ScadaLink.ManagementService.Tests/ScadaLink.ManagementService.Tests.csproj index 1df5a3c..f1f131b 100644 --- a/tests/ScadaLink.ManagementService.Tests/ScadaLink.ManagementService.Tests.csproj +++ b/tests/ScadaLink.ManagementService.Tests/ScadaLink.ManagementService.Tests.csproj @@ -6,9 +6,13 @@ true false + + + + @@ -20,5 +24,6 @@ + From 3263b39477e75b4076c1cceffe97664ca056b0aa Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 21:52:37 -0400 Subject: [PATCH 03/10] feat(cli): scaffold scadalink audit command group (#23 M8) --- src/ScadaLink.CLI/Commands/AuditCommands.cs | 45 ++++++++++++++ src/ScadaLink.CLI/ManagementHttpClient.cs | 59 ++++++++++++++++++ src/ScadaLink.CLI/Program.cs | 1 + .../Commands/AuditCommandsScaffoldTests.cs | 61 +++++++++++++++++++ 4 files changed, 166 insertions(+) create mode 100644 src/ScadaLink.CLI/Commands/AuditCommands.cs create mode 100644 tests/ScadaLink.CLI.Tests/Commands/AuditCommandsScaffoldTests.cs diff --git a/src/ScadaLink.CLI/Commands/AuditCommands.cs b/src/ScadaLink.CLI/Commands/AuditCommands.cs new file mode 100644 index 0000000..23000fb --- /dev/null +++ b/src/ScadaLink.CLI/Commands/AuditCommands.cs @@ -0,0 +1,45 @@ +using System.CommandLine; +using System.CommandLine.Parsing; + +namespace ScadaLink.CLI.Commands; + +/// +/// The scadalink audit command group (Audit Log #23 M8). Provides read access to +/// the centralized append-only Audit Log via the Bundle B REST endpoints +/// (GET /api/audit/query, GET /api/audit/export), plus a v1 no-op +/// verify-chain placeholder for the deferred hash-chain tamper-evidence feature. +/// +public static class AuditCommands +{ + public static Command Build(Option urlOption, Option formatOption, Option usernameOption, Option 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 urlOption, Option formatOption, Option usernameOption, Option passwordOption) + { + var cmd = new Command("query") { Description = "Query audit log events" }; + cmd.SetAction((ParseResult result) => 0); + return cmd; + } + + private static Command BuildExport(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) + { + var cmd = new Command("export") { Description = "Export audit log events to a file" }; + cmd.SetAction((ParseResult result) => 0); + return cmd; + } + + private static Command BuildVerifyChain(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) + { + var cmd = new Command("verify-chain") { Description = "Verify the audit log hash chain for a month" }; + cmd.SetAction((ParseResult result) => 0); + return cmd; + } +} diff --git a/src/ScadaLink.CLI/ManagementHttpClient.cs b/src/ScadaLink.CLI/ManagementHttpClient.cs index 515bf42..ce8e02b 100644 --- a/src/ScadaLink.CLI/ManagementHttpClient.cs +++ b/src/ScadaLink.CLI/ManagementHttpClient.cs @@ -74,6 +74,65 @@ public class ManagementHttpClient : IDisposable return new ManagementResponse((int)httpResponse.StatusCode, null, error, code); } + /// + /// Issues a plain HTTP GET against a REST endpoint (e.g. the audit + /// /api/audit/query endpoint introduced by Audit Log #23 M8) and returns the + /// response body. Unlike , this does not wrap the call + /// in the POST /management command envelope — the audit endpoints are plain + /// REST resources. Authentication (HTTP Basic) and the base address are shared. + /// + /// Path relative to the base URL, with query string. + public async Task 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); + } + + /// + /// Issues a plain HTTP GET and returns the raw + /// so the caller can stream the response body without buffering it in memory — used + /// by audit export, where the response can be many megabytes. The caller owns + /// disposing the returned message. The + /// option ensures the body is not pre-buffered. + /// + public async Task SendGetStreamAsync(string relativePath, CancellationToken cancellationToken) + => await _httpClient.GetAsync(relativePath, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + public void Dispose() => _httpClient.Dispose(); } diff --git a/src/ScadaLink.CLI/Program.cs b/src/ScadaLink.CLI/Program.cs index 08da7d7..a79f678 100644 --- a/src/ScadaLink.CLI/Program.cs +++ b/src/ScadaLink.CLI/Program.cs @@ -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)); diff --git a/tests/ScadaLink.CLI.Tests/Commands/AuditCommandsScaffoldTests.cs b/tests/ScadaLink.CLI.Tests/Commands/AuditCommandsScaffoldTests.cs new file mode 100644 index 0000000..e75c552 --- /dev/null +++ b/tests/ScadaLink.CLI.Tests/Commands/AuditCommandsScaffoldTests.cs @@ -0,0 +1,61 @@ +using System.CommandLine; +using ScadaLink.CLI.Commands; + +namespace ScadaLink.CLI.Tests.Commands; + +/// +/// Scaffold tests for the scadalink audit command group (Audit Log #23 M8-T1). +/// Verifies the parent command exists with its three subcommands and that every leaf +/// has an action wired. +/// +public class AuditCommandsScaffoldTests +{ + private static readonly Option Url = new("--url") { Recursive = true }; + private static readonly Option Username = new("--username") { Recursive = true }; + private static readonly Option Password = new("--password") { Recursive = true }; + private static readonly Option 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.")); + } +} From 2fa46ed400b957bc63893798862c0954cd159055 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 21:55:38 -0400 Subject: [PATCH 04/10] feat(cli): scadalink audit query subcommand (#23 M8) --- .../Commands/AuditCommandHelpers.cs | 72 +++++ src/ScadaLink.CLI/Commands/AuditCommands.cs | 148 ++++++++++- .../Commands/AuditExportHelpers.cs | 114 ++++++++ src/ScadaLink.CLI/Commands/AuditFormatter.cs | 48 ++++ .../Commands/AuditQueryHelpers.cs | 181 +++++++++++++ .../Commands/AuditVerifyChainHelpers.cs | 20 ++ .../Commands/AuditCommandTestHarness.cs | 39 +++ .../Commands/AuditQueryCommandTests.cs | 245 ++++++++++++++++++ 8 files changed, 864 insertions(+), 3 deletions(-) create mode 100644 src/ScadaLink.CLI/Commands/AuditCommandHelpers.cs create mode 100644 src/ScadaLink.CLI/Commands/AuditExportHelpers.cs create mode 100644 src/ScadaLink.CLI/Commands/AuditFormatter.cs create mode 100644 src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs create mode 100644 src/ScadaLink.CLI/Commands/AuditVerifyChainHelpers.cs create mode 100644 tests/ScadaLink.CLI.Tests/Commands/AuditCommandTestHarness.cs create mode 100644 tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs diff --git a/src/ScadaLink.CLI/Commands/AuditCommandHelpers.cs b/src/ScadaLink.CLI/Commands/AuditCommandHelpers.cs new file mode 100644 index 0000000..876db62 --- /dev/null +++ b/src/ScadaLink.CLI/Commands/AuditCommandHelpers.cs @@ -0,0 +1,72 @@ +using System.CommandLine; +using System.CommandLine.Parsing; + +namespace ScadaLink.CLI.Commands; + +/// +/// Resolved Management API connection details for an audit subcommand, or an +/// error describing why resolution failed. +/// +public sealed class AuditConnection +{ + public string? Url { get; init; } + public string? Username { get; init; } + public string? Password { get; init; } + public string? Error { get; init; } + public string? ErrorCode { get; init; } + + public static AuditConnection Fail(string error, string code) + => new() { Error = error, ErrorCode = code }; +} + +/// +/// Connection/format resolution shared by the audit subcommands. Mirrors the URL +/// and credential precedence used by (command line → config +/// file / environment), but produces a raw target +/// because the audit endpoints are plain REST resources rather than POST /management +/// command-envelope calls. +/// +public static class AuditCommandHelpers +{ + public static AuditConnection ResolveConnection( + ParseResult result, + Option urlOption, + Option usernameOption, + Option passwordOption) + { + var config = CliConfig.Load(); + + var url = result.GetValue(urlOption); + if (string.IsNullOrWhiteSpace(url)) + url = config.ManagementUrl; + + if (string.IsNullOrWhiteSpace(url)) + { + return AuditConnection.Fail( + "No management URL specified. Use --url, set SCADALINK_MANAGEMENT_URL, or add 'managementUrl' to ~/.scadalink/config.json.", + "NO_URL"); + } + + if (!CommandHelpers.IsValidManagementUrl(url)) + { + return AuditConnection.Fail( + $"Invalid management URL '{url}'. Expected an absolute http/https URL (e.g. http://localhost:9001).", + "INVALID_URL"); + } + + var username = CommandHelpers.ResolveCredential(result.GetValue(usernameOption), config.Username); + var password = CommandHelpers.ResolveCredential(result.GetValue(passwordOption), config.Password); + + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) + { + return AuditConnection.Fail( + "Credentials required. Use --username/--password or set SCADALINK_USERNAME/SCADALINK_PASSWORD.", + "NO_CREDENTIALS"); + } + + return new AuditConnection { Url = url, Username = username, Password = password }; + } + + public static string ResolveFormat(ParseResult result, Option formatOption) + => CommandHelpers.ResolveFormat(result, formatOption, CliConfig.Load()); +} diff --git a/src/ScadaLink.CLI/Commands/AuditCommands.cs b/src/ScadaLink.CLI/Commands/AuditCommands.cs index 23000fb..9a953e3 100644 --- a/src/ScadaLink.CLI/Commands/AuditCommands.cs +++ b/src/ScadaLink.CLI/Commands/AuditCommands.cs @@ -24,22 +24,164 @@ public static class AuditCommands private static Command BuildQuery(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { + var sinceOption = new Option("--since") { Description = "Start time: relative (1h, 24h, 7d) or ISO-8601" }; + var untilOption = new Option("--until") { Description = "End time: relative (1h, 24h, 7d) or ISO-8601" }; + var channelOption = new Option("--channel") { Description = "Filter by channel (OutboundApi, OutboundDb, Notification, InboundApi)" }; + var kindOption = new Option("--kind") { Description = "Filter by event kind" }; + var statusOption = new Option("--status") { Description = "Filter by status (single value)" }; + var siteOption = new Option("--site") { Description = "Filter by source site ID" }; + var instanceOption = new Option("--instance") { Description = "Filter by instance" }; + var targetOption = new Option("--target") { Description = "Filter by target (external system, DB connection, notification list)" }; + var actorOption = new Option("--actor") { Description = "Filter by actor" }; + var correlationIdOption = new Option("--correlation-id") { Description = "Filter by correlation ID" }; + var errorsOnlyOption = new Option("--errors-only") { Description = "Show only failed events (status=Failed; overrides --status)" }; + var pageSizeOption = new Option("--page-size") { Description = "Events per page (1-1000)" }; + pageSizeOption.DefaultValueFactory = _ => 100; + var allOption = new Option("--all") { Description = "Fetch every page, following the keyset cursor" }; + var cmd = new Command("query") { Description = "Query audit log events" }; - cmd.SetAction((ParseResult result) => 0); + cmd.Add(sinceOption); + cmd.Add(untilOption); + cmd.Add(channelOption); + cmd.Add(kindOption); + cmd.Add(statusOption); + cmd.Add(siteOption); + cmd.Add(instanceOption); + cmd.Add(targetOption); + cmd.Add(actorOption); + cmd.Add(correlationIdOption); + cmd.Add(errorsOnlyOption); + cmd.Add(pageSizeOption); + cmd.Add(allOption); + + cmd.SetAction(async (ParseResult result) => + { + var connection = AuditCommandHelpers.ResolveConnection(result, urlOption, usernameOption, passwordOption); + if (connection.Error != null) + { + OutputFormatter.WriteError(connection.Error, connection.ErrorCode!); + return 1; + } + + var format = AuditCommandHelpers.ResolveFormat(result, formatOption); + var formatter = AuditFormatterFactory.Create(format, Console.Error); + + var args = new AuditQueryArgs + { + Since = result.GetValue(sinceOption), + Until = result.GetValue(untilOption), + Channel = result.GetValue(channelOption), + Kind = result.GetValue(kindOption), + Status = result.GetValue(statusOption), + Site = result.GetValue(siteOption), + Instance = result.GetValue(instanceOption), + Target = result.GetValue(targetOption), + Actor = result.GetValue(actorOption), + CorrelationId = result.GetValue(correlationIdOption), + ErrorsOnly = result.GetValue(errorsOnlyOption), + PageSize = result.GetValue(pageSizeOption), + }; + var fetchAll = result.GetValue(allOption); + + try + { + using var client = new ManagementHttpClient(connection.Url!, connection.Username!, connection.Password!); + return await AuditQueryHelpers.RunQueryAsync( + client, args, fetchAll, formatter, Console.Out, DateTimeOffset.UtcNow); + } + catch (FormatException ex) + { + OutputFormatter.WriteError(ex.Message, "INVALID_ARGUMENT"); + return 1; + } + }); return cmd; } private static Command BuildExport(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { + var sinceOption = new Option("--since") { Description = "Start time: relative (1h, 24h, 7d) or ISO-8601", Required = true }; + var untilOption = new Option("--until") { Description = "End time: relative (1h, 24h, 7d) or ISO-8601", Required = true }; + var formatExportOption = new Option("--format") { Description = "Export format", Required = true }; + formatExportOption.AcceptOnlyFromAmong("csv", "jsonl", "parquet"); + var outputOption = new Option("--output") { Description = "Destination file path", Required = true }; + var channelOption = new Option("--channel") { Description = "Filter by channel" }; + var kindOption = new Option("--kind") { Description = "Filter by event kind" }; + var statusOption = new Option("--status") { Description = "Filter by status" }; + var siteOption = new Option("--site") { Description = "Filter by source site ID" }; + var targetOption = new Option("--target") { Description = "Filter by target" }; + var actorOption = new Option("--actor") { Description = "Filter by actor" }; + var cmd = new Command("export") { Description = "Export audit log events to a file" }; - cmd.SetAction((ParseResult result) => 0); + cmd.Add(sinceOption); + cmd.Add(untilOption); + cmd.Add(formatExportOption); + cmd.Add(outputOption); + cmd.Add(channelOption); + cmd.Add(kindOption); + cmd.Add(statusOption); + cmd.Add(siteOption); + cmd.Add(targetOption); + cmd.Add(actorOption); + + cmd.SetAction(async (ParseResult result) => + { + var connection = AuditCommandHelpers.ResolveConnection(result, urlOption, usernameOption, passwordOption); + if (connection.Error != null) + { + OutputFormatter.WriteError(connection.Error, connection.ErrorCode!); + return 1; + } + + var args = new AuditExportArgs + { + Since = result.GetValue(sinceOption)!, + Until = result.GetValue(untilOption)!, + Format = result.GetValue(formatExportOption)!, + Output = result.GetValue(outputOption)!, + Channel = result.GetValue(channelOption), + Kind = result.GetValue(kindOption), + Status = result.GetValue(statusOption), + Site = result.GetValue(siteOption), + Target = result.GetValue(targetOption), + Actor = result.GetValue(actorOption), + }; + + try + { + using var client = new ManagementHttpClient(connection.Url!, connection.Username!, connection.Password!); + return await AuditExportHelpers.RunExportAsync(client, args, Console.Out, DateTimeOffset.UtcNow); + } + catch (FormatException ex) + { + OutputFormatter.WriteError(ex.Message, "INVALID_ARGUMENT"); + return 1; + } + }); return cmd; } private static Command BuildVerifyChain(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { + var monthOption = new Option("--month") { Description = "Month to verify (YYYY-MM)", Required = true }; + var cmd = new Command("verify-chain") { Description = "Verify the audit log hash chain for a month" }; - cmd.SetAction((ParseResult result) => 0); + cmd.Add(monthOption); + cmd.SetAction((ParseResult result) => + { + var month = result.GetValue(monthOption)!; + if (!AuditVerifyChainHelpers.IsValidMonth(month)) + { + OutputFormatter.WriteError( + $"Invalid month '{month}'. Expected YYYY-MM (e.g. 2026-05).", "INVALID_ARGUMENT"); + return 1; + } + + Console.WriteLine( + "Hash-chain tamper-evidence is not enabled in this release. " + + "See Component-AuditLog.md (Security & Tamper-Evidence) for the v1.x roadmap."); + return 0; + }); return cmd; } } diff --git a/src/ScadaLink.CLI/Commands/AuditExportHelpers.cs b/src/ScadaLink.CLI/Commands/AuditExportHelpers.cs new file mode 100644 index 0000000..01d702b --- /dev/null +++ b/src/ScadaLink.CLI/Commands/AuditExportHelpers.cs @@ -0,0 +1,114 @@ +using System.Globalization; +using System.Net; + +namespace ScadaLink.CLI.Commands; + +/// +/// Filter + destination arguments for an audit export invocation. Mirrors the +/// Bundle B GET /api/audit/export parameters. +/// +public sealed class AuditExportArgs +{ + public string Since { get; set; } = string.Empty; + public string Until { get; set; } = string.Empty; + public string Format { get; set; } = string.Empty; + public string Output { get; set; } = string.Empty; + public string? Channel { get; set; } + public string? Kind { get; set; } + public string? Status { get; set; } + public string? Site { get; set; } + public string? Target { get; set; } + public string? Actor { get; set; } +} + +/// +/// Helpers for the audit export subcommand: builds the export query string and +/// streams the HTTP response body straight to the destination file without buffering +/// the (potentially multi-megabyte) export in memory. +/// +public static class AuditExportHelpers +{ + /// + /// Builds the ?... query string for GET /api/audit/export: the required + /// time window + format, plus optional filters. Time-specs are resolved via + /// . + /// + public static string BuildQueryString(AuditExportArgs args, DateTimeOffset now) + { + var parts = new List(); + + void Add(string key, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + parts.Add($"{key}={Uri.EscapeDataString(value)}"); + } + + Add("fromUtc", AuditQueryHelpers.ResolveTimeSpec(args.Since, now).ToString("o", CultureInfo.InvariantCulture)); + Add("toUtc", AuditQueryHelpers.ResolveTimeSpec(args.Until, now).ToString("o", CultureInfo.InvariantCulture)); + Add("format", args.Format); + Add("channel", args.Channel); + Add("kind", args.Kind); + Add("status", args.Status); + Add("sourceSiteId", args.Site); + Add("target", args.Target); + Add("actor", args.Actor); + + return "?" + string.Join("&", parts); + } + + /// + /// Executes the export: GETs /api/audit/export and copies the response body + /// stream directly to . The body is never fully + /// buffered — streams in fixed-size chunks. + /// A 501 Not Implemented (parquet not yet supported server-side) prints the + /// server message and returns a non-zero exit code. + /// + public static async Task RunExportAsync( + ManagementHttpClient client, AuditExportArgs args, TextWriter output, DateTimeOffset now) + { + var qs = BuildQueryString(args, now); + + HttpResponseMessage response; + try + { + response = await client.SendGetStreamAsync("api/audit/export" + qs, CancellationToken.None); + } + catch (HttpRequestException ex) + { + OutputFormatter.WriteError($"Connection failed: {ex.Message}", "CONNECTION_FAILED"); + return 1; + } + + using (response) + { + if (response.StatusCode == HttpStatusCode.NotImplemented) + { + var message = await response.Content.ReadAsStringAsync(); + OutputFormatter.WriteError( + string.IsNullOrWhiteSpace(message) + ? "Export format not implemented by the server." + : message, + "NOT_IMPLEMENTED"); + return 1; + } + + if (!response.IsSuccessStatusCode) + { + var message = await response.Content.ReadAsStringAsync(); + OutputFormatter.WriteError( + string.IsNullOrWhiteSpace(message) ? $"Export failed (HTTP {(int)response.StatusCode})." : message, + "ERROR"); + return 1; + } + + await using var source = await response.Content.ReadAsStreamAsync(); + await using var destination = new FileStream( + args.Output, FileMode.Create, FileAccess.Write, FileShare.None, + bufferSize: 81920, useAsync: true); + await source.CopyToAsync(destination); + } + + output.WriteLine($"Exported audit log to {args.Output}"); + return 0; + } +} diff --git a/src/ScadaLink.CLI/Commands/AuditFormatter.cs b/src/ScadaLink.CLI/Commands/AuditFormatter.cs new file mode 100644 index 0000000..ba0d959 --- /dev/null +++ b/src/ScadaLink.CLI/Commands/AuditFormatter.cs @@ -0,0 +1,48 @@ +using System.Text.Json; + +namespace ScadaLink.CLI.Commands; + +/// +/// Renders a page of audit-log events to a writer. The audit query command picks +/// a formatter from the --format option. The default JSONL formatter is defined +/// here; the human-readable table formatter is supplied by Bundle C. +/// +public interface IAuditFormatter +{ + /// Renders one page of events. Called once per fetched page. + void WritePage(IReadOnlyList events, TextWriter output); +} + +/// +/// Default formatter: one JSON object per line (JSONL). Streamable — each page's events +/// are flushed as they arrive, so --all over many pages does not accumulate. +/// +public sealed class JsonLinesAuditFormatter : IAuditFormatter +{ + private static readonly JsonSerializerOptions Compact = new() { WriteIndented = false }; + + public void WritePage(IReadOnlyList events, TextWriter output) + { + foreach (var evt in events) + output.WriteLine(JsonSerializer.Serialize(evt, Compact)); + } +} + +/// +/// Resolves an for a given --format value. The +/// table formatter is filled in by Bundle C; until then --format table falls back +/// to JSONL with a one-time notice so the flag is wired but not silently broken. +/// +public static class AuditFormatterFactory +{ + public static IAuditFormatter Create(string format, TextWriter notices) + { + if (string.Equals(format, "table", StringComparison.OrdinalIgnoreCase)) + { + notices.WriteLine("note: 'table' output is not yet available; using json. (Bundle C)"); + return new JsonLinesAuditFormatter(); + } + + return new JsonLinesAuditFormatter(); + } +} diff --git a/src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs b/src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs new file mode 100644 index 0000000..0066dbf --- /dev/null +++ b/src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs @@ -0,0 +1,181 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace ScadaLink.CLI.Commands; + +/// +/// Filter arguments for an audit query invocation. Mirrors the Bundle B +/// GET /api/audit/query filter parameters; / +/// are time-specs (relative like 1h/7d, or absolute ISO-8601). +/// +public sealed class AuditQueryArgs +{ + public string? Since { get; set; } + public string? Until { get; set; } + public string? Channel { get; set; } + public string? Kind { get; set; } + public string? Status { get; set; } + public string? Site { get; set; } + public string? Instance { get; set; } + public string? Target { get; set; } + public string? Actor { get; set; } + public string? CorrelationId { get; set; } + public bool ErrorsOnly { get; set; } + public int PageSize { get; set; } = 100; +} + +/// +/// Pure helpers for the audit query subcommand: time-spec resolution, query-string +/// construction, and the keyset-cursor paging loop. Kept separate from the command wiring +/// so each piece is unit-testable without standing up the command tree. +/// +public static class AuditQueryHelpers +{ + // where unit is s/m/h/d — a relative offset back from "now". + private static readonly Regex RelativeSpec = new(@"^(\d+)([smhd])$", RegexOptions.Compiled); + + /// + /// Resolves a time-spec to an absolute . Accepts a + /// relative offset (30s, 15m, 1h, 7d) interpreted as + /// minus the offset, or an absolute ISO-8601 timestamp. + /// + /// The spec is neither a known relative form nor a parseable ISO-8601 timestamp. + public static DateTimeOffset ResolveTimeSpec(string spec, DateTimeOffset now) + { + if (string.IsNullOrWhiteSpace(spec)) + throw new FormatException("Empty time value."); + + var match = RelativeSpec.Match(spec.Trim()); + if (match.Success) + { + var amount = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); + var offset = match.Groups[2].Value switch + { + "s" => TimeSpan.FromSeconds(amount), + "m" => TimeSpan.FromMinutes(amount), + "h" => TimeSpan.FromHours(amount), + "d" => TimeSpan.FromDays(amount), + _ => throw new FormatException($"Unknown time unit in '{spec}'."), + }; + return now - offset; + } + + if (DateTimeOffset.TryParse(spec, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var absolute)) + { + return absolute; + } + + throw new FormatException( + $"Invalid time value '{spec}'. Use a relative offset (e.g. 1h, 24h, 7d) or an ISO-8601 timestamp."); + } + + /// + /// Builds the ?... query string for GET /api/audit/query from the filter + /// args plus an optional keyset cursor. Unset filters are omitted. --errors-only + /// maps to status=Failed (the server takes a single status value). + /// + public static string BuildQueryString( + AuditQueryArgs args, DateTimeOffset now, DateTimeOffset? afterOccurredAtUtc, string? afterEventId) + { + var parts = new List(); + + void Add(string key, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + parts.Add($"{key}={Uri.EscapeDataString(value)}"); + } + + if (!string.IsNullOrWhiteSpace(args.Since)) + Add("fromUtc", ResolveTimeSpec(args.Since!, now).ToString("o", CultureInfo.InvariantCulture)); + if (!string.IsNullOrWhiteSpace(args.Until)) + Add("toUtc", ResolveTimeSpec(args.Until!, now).ToString("o", CultureInfo.InvariantCulture)); + + Add("channel", args.Channel); + Add("kind", args.Kind); + + // --errors-only is a convenience shorthand for the single-value Failed status + // filter. The server's status filter accepts one value, so --errors-only and an + // explicit --status are mutually exclusive in effect; --errors-only wins. + Add("status", args.ErrorsOnly ? "Failed" : args.Status); + + Add("sourceSiteId", args.Site); + Add("instance", args.Instance); + Add("target", args.Target); + Add("actor", args.Actor); + Add("correlationId", args.CorrelationId); + Add("pageSize", args.PageSize.ToString(CultureInfo.InvariantCulture)); + + if (afterOccurredAtUtc.HasValue) + Add("afterOccurredAtUtc", afterOccurredAtUtc.Value.ToString("o", CultureInfo.InvariantCulture)); + Add("afterEventId", afterEventId); + + return parts.Count == 0 ? string.Empty : "?" + string.Join("&", parts); + } + + /// + /// Executes the query: GETs /api/audit/query, renders each page with + /// , and — when is set — + /// follows nextCursor until the server returns a null cursor. Returns the + /// process exit code (0 success, non-zero on HTTP/transport error). + /// + public static async Task RunQueryAsync( + ManagementHttpClient client, + AuditQueryArgs args, + bool fetchAll, + IAuditFormatter formatter, + TextWriter output, + DateTimeOffset now) + { + DateTimeOffset? afterOccurredAtUtc = null; + string? afterEventId = null; + + while (true) + { + var qs = BuildQueryString(args, now, afterOccurredAtUtc, afterEventId); + var response = await client.SendGetAsync("api/audit/query" + qs, TimeSpan.FromSeconds(30)); + + if (response.JsonData == null) + { + OutputFormatter.WriteError( + response.Error ?? "Audit query failed.", response.ErrorCode ?? "ERROR"); + return 1; + } + + using var doc = JsonDocument.Parse(response.JsonData); + var root = doc.RootElement; + + var events = root.TryGetProperty("events", out var evts) && evts.ValueKind == JsonValueKind.Array + ? evts.EnumerateArray().ToList() + : new List(); + formatter.WritePage(events, output); + output.Flush(); + + if (!fetchAll) + return 0; + + if (!root.TryGetProperty("nextCursor", out var cursor) + || cursor.ValueKind != JsonValueKind.Object) + { + return 0; + } + + afterOccurredAtUtc = cursor.TryGetProperty("afterOccurredAtUtc", out var c1) + && c1.ValueKind == JsonValueKind.String + ? DateTimeOffset.Parse(c1.GetString()!, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal) + : null; + afterEventId = cursor.TryGetProperty("afterEventId", out var c2) + && c2.ValueKind == JsonValueKind.String + ? c2.GetString() + : null; + + // A malformed cursor (object present but missing both keys) would loop + // forever — treat it as the end of results. + if (afterOccurredAtUtc == null && afterEventId == null) + return 0; + } + } +} diff --git a/src/ScadaLink.CLI/Commands/AuditVerifyChainHelpers.cs b/src/ScadaLink.CLI/Commands/AuditVerifyChainHelpers.cs new file mode 100644 index 0000000..45aa964 --- /dev/null +++ b/src/ScadaLink.CLI/Commands/AuditVerifyChainHelpers.cs @@ -0,0 +1,20 @@ +using System.Globalization; + +namespace ScadaLink.CLI.Commands; + +/// +/// Helpers for the audit verify-chain subcommand. v1 is a no-op: hash-chain +/// tamper-evidence is deferred to v1.x (see Component-AuditLog.md). The command still +/// validates its --month argument so the surface is stable for v1.x. +/// +public static class AuditVerifyChainHelpers +{ + /// + /// Returns true if is a well-formed YYYY-MM value + /// with a real month (01-12). A malformed month (e.g. 2026-13) is rejected. + /// + public static bool IsValidMonth(string? month) + => !string.IsNullOrWhiteSpace(month) + && DateTime.TryParseExact(month, "yyyy-MM", CultureInfo.InvariantCulture, + DateTimeStyles.None, out _); +} diff --git a/tests/ScadaLink.CLI.Tests/Commands/AuditCommandTestHarness.cs b/tests/ScadaLink.CLI.Tests/Commands/AuditCommandTestHarness.cs new file mode 100644 index 0000000..56ad835 --- /dev/null +++ b/tests/ScadaLink.CLI.Tests/Commands/AuditCommandTestHarness.cs @@ -0,0 +1,39 @@ +using System.CommandLine; +using ScadaLink.CLI.Commands; + +namespace ScadaLink.CLI.Tests.Commands; + +/// +/// Shared helpers for invoking the audit command tree in tests and capturing +/// stdout/stderr/exit code. +/// +internal static class AuditCommandTestHarness +{ + public static RootCommand BuildRoot() + { + var url = new Option("--url") { Recursive = true }; + var username = new Option("--username") { Recursive = true }; + var password = new Option("--password") { Recursive = true }; + var format = CliOptions.CreateFormatOption(); + + var root = new RootCommand(); + root.Add(url); + root.Add(username); + root.Add(password); + root.Add(format); + root.Add(AuditCommands.Build(url, format, username, password)); + return root; + } + + public static (int Exit, string Out, string Err) Invoke(RootCommand root, params string[] args) + { + var output = new StringWriter(); + var error = new StringWriter(); + var exit = root.Parse(args).Invoke(new InvocationConfiguration + { + Output = output, + Error = error, + }); + return (exit, output.ToString(), error.ToString()); + } +} diff --git a/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs b/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs new file mode 100644 index 0000000..7773a90 --- /dev/null +++ b/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs @@ -0,0 +1,245 @@ +using System.Collections.Specialized; +using System.Net; +using System.Text; +using System.Web; +using ScadaLink.CLI; +using ScadaLink.CLI.Commands; + +namespace ScadaLink.CLI.Tests.Commands; + +/// +/// Tests for the scadalink audit query subcommand (Audit Log #23 M8-T2): +/// time-spec resolution, query-string construction, formatter selection, error +/// handling, and keyset-cursor paging via --all. +/// +public class AuditQueryCommandTests +{ + // ---- Time-spec parsing ------------------------------------------------- + + [Fact] + public void ResolveTimeSpec_RelativeHours_ResolvesToNowMinusOffset() + { + var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z"); + var resolved = AuditQueryHelpers.ResolveTimeSpec("1h", now); + Assert.Equal(DateTimeOffset.Parse("2026-05-20T11:00:00Z"), resolved); + } + + [Fact] + public void ResolveTimeSpec_RelativeDays_ResolvesToNowMinusOffset() + { + var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z"); + var resolved = AuditQueryHelpers.ResolveTimeSpec("7d", now); + Assert.Equal(DateTimeOffset.Parse("2026-05-13T12:00:00Z"), resolved); + } + + [Fact] + public void ResolveTimeSpec_AbsoluteIso8601_ParsedVerbatim() + { + var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z"); + var resolved = AuditQueryHelpers.ResolveTimeSpec("2026-01-02T03:04:05Z", now); + Assert.Equal(DateTimeOffset.Parse("2026-01-02T03:04:05Z"), resolved); + } + + [Fact] + public void ResolveTimeSpec_Garbage_Throws() + { + var now = DateTimeOffset.UtcNow; + Assert.Throws(() => AuditQueryHelpers.ResolveTimeSpec("not-a-time", now)); + } + + // ---- Query string construction ---------------------------------------- + + [Fact] + public void BuildQueryString_FullFlagSet_ProducesExpectedParameters() + { + var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z"); + var args = new AuditQueryArgs + { + Since = "1h", + Until = "2026-05-20T12:00:00Z", + Channel = "OutboundApi", + Kind = "CachedCall", + Status = "Delivered", + Site = "site-1", + Instance = "pump-7", + Target = "weather-api", + Actor = "multi-role", + CorrelationId = "abc-123", + ErrorsOnly = false, + PageSize = 250, + }; + + var qs = AuditQueryHelpers.BuildQueryString(args, now, afterOccurredAtUtc: null, afterEventId: null); + var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?')); + + Assert.Equal("OutboundApi", parsed["channel"]); + Assert.Equal("CachedCall", parsed["kind"]); + Assert.Equal("Delivered", parsed["status"]); + Assert.Equal("site-1", parsed["sourceSiteId"]); + Assert.Equal("pump-7", parsed["instance"]); + Assert.Equal("weather-api", parsed["target"]); + Assert.Equal("multi-role", parsed["actor"]); + Assert.Equal("abc-123", parsed["correlationId"]); + Assert.Equal("250", parsed["pageSize"]); + Assert.Equal("2026-05-20T11:00:00.0000000+00:00", parsed["fromUtc"]); + Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]); + } + + [Fact] + public void BuildQueryString_ErrorsOnly_MapsToFailedStatus() + { + var now = DateTimeOffset.UtcNow; + var args = new AuditQueryArgs { ErrorsOnly = true }; + var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null); + var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?')); + Assert.Equal("Failed", parsed["status"]); + } + + [Fact] + public void BuildQueryString_Cursor_AppendsAfterParameters() + { + var now = DateTimeOffset.UtcNow; + var args = new AuditQueryArgs(); + var after = DateTimeOffset.Parse("2026-05-20T10:00:00Z"); + var qs = AuditQueryHelpers.BuildQueryString(args, now, after, "evt-99"); + var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?')); + Assert.Equal("evt-99", parsed["afterEventId"]); + Assert.Equal("2026-05-20T10:00:00.0000000+00:00", parsed["afterOccurredAtUtc"]); + } + + [Fact] + public void BuildQueryString_OmitsUnsetFilters() + { + var now = DateTimeOffset.UtcNow; + var args = new AuditQueryArgs { PageSize = 100 }; + var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null); + var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?')); + Assert.Null(parsed["channel"]); + Assert.Null(parsed["status"]); + Assert.Null(parsed["fromUtc"]); + Assert.Equal("100", parsed["pageSize"]); + } + + // ---- HTTP execution / paging ------------------------------------------ + + private sealed class RecordingHandler : HttpMessageHandler + { + private readonly Queue _bodies; + public List RequestUris { get; } = new(); + + public RecordingHandler(params string[] bodies) + { + _bodies = new Queue(bodies); + } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestUris.Add(request.RequestUri!.PathAndQuery); + var body = _bodies.Count > 0 ? _bodies.Dequeue() : "{\"events\":[],\"nextCursor\":null}"; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }); + } + } + + [Fact] + public async Task RunQuery_SinglePage_WritesEventsAsJsonLines() + { + var handler = new RecordingHandler( + "{\"events\":[{\"eventId\":\"e1\"},{\"eventId\":\"e2\"}],\"nextCursor\":null}"); + var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); + var output = new StringWriter(); + + var exit = await AuditQueryHelpers.RunQueryAsync( + client, new AuditQueryArgs { PageSize = 100 }, fetchAll: false, + new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow); + + Assert.Equal(0, exit); + var lines = output.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(2, lines.Length); + Assert.Contains("e1", lines[0]); + Assert.Contains("e2", lines[1]); + Assert.Single(handler.RequestUris); + } + + [Fact] + public async Task RunQuery_WithAll_FollowsNextCursorAcrossPages() + { + var handler = new RecordingHandler( + "{\"events\":[{\"eventId\":\"e1\"}],\"nextCursor\":{\"afterOccurredAtUtc\":\"2026-05-20T10:00:00Z\",\"afterEventId\":\"e1\"}}", + "{\"events\":[{\"eventId\":\"e2\"}],\"nextCursor\":null}"); + var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); + var output = new StringWriter(); + + var exit = await AuditQueryHelpers.RunQueryAsync( + client, new AuditQueryArgs { PageSize = 100 }, fetchAll: true, + new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow); + + Assert.Equal(0, exit); + Assert.Equal(2, handler.RequestUris.Count); + Assert.Contains("afterEventId=e1", handler.RequestUris[1]); + var lines = output.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(2, lines.Length); + } + + [Fact] + public async Task RunQuery_WithoutAll_StopsAfterFirstPageEvenWhenCursorPresent() + { + var handler = new RecordingHandler( + "{\"events\":[{\"eventId\":\"e1\"}],\"nextCursor\":{\"afterOccurredAtUtc\":\"2026-05-20T10:00:00Z\",\"afterEventId\":\"e1\"}}"); + var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); + var output = new StringWriter(); + + var exit = await AuditQueryHelpers.RunQueryAsync( + client, new AuditQueryArgs { PageSize = 100 }, fetchAll: false, + new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow); + + Assert.Equal(0, exit); + Assert.Single(handler.RequestUris); + } + + [Fact] + public async Task RunQuery_ServerError_ReturnsNonZeroExit() + { + var handler = new ErrorHandler(); + var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); + var output = new StringWriter(); + + var exit = await AuditQueryHelpers.RunQueryAsync( + client, new AuditQueryArgs(), fetchAll: false, + new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow); + + Assert.NotEqual(0, exit); + } + + private sealed class ErrorHandler : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("{\"error\":\"boom\",\"code\":\"INTERNAL\"}"), + }); + } + + // ---- CLI parsing ------------------------------------------------------- + + [Fact] + public void Query_UnknownFlag_ProducesParseErrorAndNonZeroExit() + { + var root = AuditCommandTestHarness.BuildRoot(); + var (exit, _, err) = AuditCommandTestHarness.Invoke(root, "audit", "query", "--bogus", "x"); + Assert.NotEqual(0, exit); + Assert.NotEqual("", err); + } + + [Fact] + public void Query_FormatTable_IsAccepted() + { + var root = AuditCommandTestHarness.BuildRoot(); + var parse = root.Parse(new[] { "audit", "query", "--format", "table" }); + Assert.Empty(parse.Errors); + } +} From 91682cd862146cc8572f4ed3d0e582c2702bd290 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 21:56:20 -0400 Subject: [PATCH 05/10] feat(cli): scadalink audit export subcommand (#23 M8) --- .../Commands/AuditExportCommandTests.cs | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 tests/ScadaLink.CLI.Tests/Commands/AuditExportCommandTests.cs diff --git a/tests/ScadaLink.CLI.Tests/Commands/AuditExportCommandTests.cs b/tests/ScadaLink.CLI.Tests/Commands/AuditExportCommandTests.cs new file mode 100644 index 0000000..e802eb6 --- /dev/null +++ b/tests/ScadaLink.CLI.Tests/Commands/AuditExportCommandTests.cs @@ -0,0 +1,221 @@ +using System.Net; +using System.Text; +using System.Web; +using ScadaLink.CLI; +using ScadaLink.CLI.Commands; + +namespace ScadaLink.CLI.Tests.Commands; + +/// +/// Tests for the scadalink audit export 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. +/// +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 _content; + public string? RequestPathAndQuery { get; private set; } + + public BodyHandler(HttpStatusCode status, Func content) + { + _status = status; + _content = content; + } + + protected override Task 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); + } + } + + /// + /// A read-only stream that yields 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. + /// + 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(); + } +} From 4b3a692170127a7348a15f6553b8be519d62bbdc Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 21:57:16 -0400 Subject: [PATCH 06/10] feat(cli): scadalink audit verify-chain subcommand v1 no-op (#23 M8) --- .../Commands/AuditCommandTestHarness.cs | 31 ++++++++-- .../Commands/AuditExportCommandTests.cs | 1 + .../Commands/AuditQueryCommandTests.cs | 1 + .../Commands/AuditVerifyChainCommandTests.cs | 58 +++++++++++++++++++ 4 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 tests/ScadaLink.CLI.Tests/Commands/AuditVerifyChainCommandTests.cs diff --git a/tests/ScadaLink.CLI.Tests/Commands/AuditCommandTestHarness.cs b/tests/ScadaLink.CLI.Tests/Commands/AuditCommandTestHarness.cs index 56ad835..4050ea7 100644 --- a/tests/ScadaLink.CLI.Tests/Commands/AuditCommandTestHarness.cs +++ b/tests/ScadaLink.CLI.Tests/Commands/AuditCommandTestHarness.cs @@ -25,15 +25,38 @@ internal static class AuditCommandTestHarness return root; } + /// + /// Parses and invokes the command tree, capturing output from both channels the CLI + /// uses: System.CommandLine's parser diagnostics flow through the + /// writers, while command actions write through + /// (consistent with the rest of the CLI). Both are merged into + /// the returned Out/Err strings. Callers must be in the Console + /// xUnit collection so the global redirect is not racy. + /// public static (int Exit, string Out, string Err) Invoke(RootCommand root, params string[] args) { var output = new StringWriter(); var error = new StringWriter(); - var exit = root.Parse(args).Invoke(new InvocationConfiguration + + var originalOut = Console.Out; + var originalErr = Console.Error; + Console.SetOut(output); + Console.SetError(error); + int exit; + try { - Output = output, - Error = error, - }); + exit = root.Parse(args).Invoke(new InvocationConfiguration + { + Output = output, + Error = error, + }); + } + finally + { + Console.SetOut(originalOut); + Console.SetError(originalErr); + } + return (exit, output.ToString(), error.ToString()); } } diff --git a/tests/ScadaLink.CLI.Tests/Commands/AuditExportCommandTests.cs b/tests/ScadaLink.CLI.Tests/Commands/AuditExportCommandTests.cs index e802eb6..cd3349a 100644 --- a/tests/ScadaLink.CLI.Tests/Commands/AuditExportCommandTests.cs +++ b/tests/ScadaLink.CLI.Tests/Commands/AuditExportCommandTests.cs @@ -11,6 +11,7 @@ namespace ScadaLink.CLI.Tests.Commands; /// required-flag enforcement, query-string construction, streaming the response body /// to the output file, and the parquet-not-implemented (501) path. /// +[Collection("Console")] public class AuditExportCommandTests { // ---- CLI parsing: required flags -------------------------------------- diff --git a/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs b/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs index 7773a90..130c8c7 100644 --- a/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs +++ b/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs @@ -12,6 +12,7 @@ namespace ScadaLink.CLI.Tests.Commands; /// time-spec resolution, query-string construction, formatter selection, error /// handling, and keyset-cursor paging via --all. /// +[Collection("Console")] public class AuditQueryCommandTests { // ---- Time-spec parsing ------------------------------------------------- diff --git a/tests/ScadaLink.CLI.Tests/Commands/AuditVerifyChainCommandTests.cs b/tests/ScadaLink.CLI.Tests/Commands/AuditVerifyChainCommandTests.cs new file mode 100644 index 0000000..c399b92 --- /dev/null +++ b/tests/ScadaLink.CLI.Tests/Commands/AuditVerifyChainCommandTests.cs @@ -0,0 +1,58 @@ +using ScadaLink.CLI.Commands; + +namespace ScadaLink.CLI.Tests.Commands; + +/// +/// Tests for the scadalink audit verify-chain subcommand (Audit Log #23 M8-T4). +/// v1 is a no-op stub: a valid --month prints the documented "not enabled" +/// message and exits 0; a malformed month or a missing --month exits non-zero. +/// +[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)); + } +} From d40ee85e14ceaad7de0cf7e6f19ced5bebacc290 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 22:00:57 -0400 Subject: [PATCH 07/10] feat(cli): table output formatter for audit events (#23 M8) --- src/ScadaLink.CLI/Commands/AuditFormatter.cs | 11 +- .../Commands/TableAuditFormatter.cs | 96 ++++++++++++++ .../Commands/AuditTableFormatterTests.cs | 117 ++++++++++++++++++ 3 files changed, 217 insertions(+), 7 deletions(-) create mode 100644 src/ScadaLink.CLI/Commands/TableAuditFormatter.cs create mode 100644 tests/ScadaLink.CLI.Tests/Commands/AuditTableFormatterTests.cs diff --git a/src/ScadaLink.CLI/Commands/AuditFormatter.cs b/src/ScadaLink.CLI/Commands/AuditFormatter.cs index ba0d959..ebfb600 100644 --- a/src/ScadaLink.CLI/Commands/AuditFormatter.cs +++ b/src/ScadaLink.CLI/Commands/AuditFormatter.cs @@ -29,19 +29,16 @@ public sealed class JsonLinesAuditFormatter : IAuditFormatter } /// -/// Resolves an for a given --format value. The -/// table formatter is filled in by Bundle C; until then --format table falls back -/// to JSONL with a one-time notice so the flag is wired but not silently broken. +/// Resolves an for a given --format value: +/// table renders a column-aligned text table (), +/// any other value (including json) renders JSONL. /// public static class AuditFormatterFactory { public static IAuditFormatter Create(string format, TextWriter notices) { if (string.Equals(format, "table", StringComparison.OrdinalIgnoreCase)) - { - notices.WriteLine("note: 'table' output is not yet available; using json. (Bundle C)"); - return new JsonLinesAuditFormatter(); - } + return new TableAuditFormatter(); return new JsonLinesAuditFormatter(); } diff --git a/src/ScadaLink.CLI/Commands/TableAuditFormatter.cs b/src/ScadaLink.CLI/Commands/TableAuditFormatter.cs new file mode 100644 index 0000000..98c9b6c --- /dev/null +++ b/src/ScadaLink.CLI/Commands/TableAuditFormatter.cs @@ -0,0 +1,96 @@ +using System.Text.Json; + +namespace ScadaLink.CLI.Commands; + +/// +/// Human-readable table formatter for audit query --format table (Audit Log +/// #23 M8-T6). Renders each fetched page as a column-aligned text table with a fixed +/// column set (). Long free-text fields (Target, Actor) are +/// truncated with an ellipsis so columns stay aligned regardless of payload size. +/// +/// +/// A header row is emitted once per page (matching the streamable, page-at-a-time +/// contract of ). An empty page emits the header only, +/// so the column shape is visible even with zero results. +/// +public sealed class TableAuditFormatter : IAuditFormatter +{ + /// JSON property name (camelCase, as the server serializes it) → column header. + 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 events, TextWriter output) + { + // Build every cell first so column widths account for the actual data. + var rows = new List(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); + } + + /// + /// Extracts a cell value for from an audit event. + /// A missing property or a JSON null renders as an empty string (never + /// the literal text "null"). + /// + 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(); + } + + /// + /// Truncates to characters, + /// replacing the tail with a single-character ellipsis so the column stays aligned. + /// + 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 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(); + } +} diff --git a/tests/ScadaLink.CLI.Tests/Commands/AuditTableFormatterTests.cs b/tests/ScadaLink.CLI.Tests/Commands/AuditTableFormatterTests.cs new file mode 100644 index 0000000..c22da8b --- /dev/null +++ b/tests/ScadaLink.CLI.Tests/Commands/AuditTableFormatterTests.cs @@ -0,0 +1,117 @@ +using System.Text.Json; +using ScadaLink.CLI.Commands; + +namespace ScadaLink.CLI.Tests.Commands; + +/// +/// Tests for the table output formatter of the scadalink audit query +/// subcommand (Audit Log #23 M8-T6): header rendering, long-field truncation, the +/// empty-result-set case, and null-actor handling. +/// +public class AuditTableFormatterTests +{ + private static IReadOnlyList 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]); + } +} From ba8ddcc032c204fbdffe22ff4b31264b47c3e4f1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 22:02:19 -0400 Subject: [PATCH 08/10] refactor(cli): rename audit-log to audit-config with deprecation alias (#23 M8) --- .../Commands/AuditLogCommands.cs | 41 ++++++- src/ScadaLink.CLI/Program.cs | 5 + .../Commands/AuditConfigDeprecationTests.cs | 100 ++++++++++++++++++ 3 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 tests/ScadaLink.CLI.Tests/Commands/AuditConfigDeprecationTests.cs diff --git a/src/ScadaLink.CLI/Commands/AuditLogCommands.cs b/src/ScadaLink.CLI/Commands/AuditLogCommands.cs index 00fc084..eee6143 100644 --- a/src/ScadaLink.CLI/Commands/AuditLogCommands.cs +++ b/src/ScadaLink.CLI/Commands/AuditLogCommands.cs @@ -4,11 +4,50 @@ using ScadaLink.Commons.Messages.Management; namespace ScadaLink.CLI.Commands; +/// +/// The scadalink audit-config command group: views the configuration-change +/// audit log (the IAuditService trail of admin edits — distinct from the +/// centralized append-only Audit Log served by ). +/// +/// +/// Renamed from audit-log in #23 M8-T7 to avoid confusion with the new +/// scadalink audit group. The old audit-log name is retained as a +/// deprecated alias; still resolves the full subcommand +/// tree, and Program.cs prints a deprecation warning when it is used. +/// public static class AuditLogCommands { + /// The deprecated alias kept for backward compatibility with the old command name. + public const string DeprecatedAlias = "audit-log"; + + /// The deprecation warning emitted when the old audit-log name is used. + public const string DeprecationWarning = + "Warning: 'audit-log' is deprecated and will be removed in a future release. " + + "Use 'audit-config' instead."; + + /// + /// Writes the to when the + /// CLI was invoked via the deprecated audit-log command name (i.e. the first + /// argument is ). The command itself still works — it is + /// an alias of audit-config — so this only adds the migration warning. + /// Factored out of Program.cs so it is unit-testable without spawning a process. + /// + 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 urlOption, Option formatOption, Option usernameOption, Option 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)); diff --git a/src/ScadaLink.CLI/Program.cs b/src/ScadaLink.CLI/Program.cs index a79f678..240b410 100644 --- a/src/ScadaLink.CLI/Program.cs +++ b/src/ScadaLink.CLI/Program.cs @@ -39,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(); diff --git a/tests/ScadaLink.CLI.Tests/Commands/AuditConfigDeprecationTests.cs b/tests/ScadaLink.CLI.Tests/Commands/AuditConfigDeprecationTests.cs new file mode 100644 index 0000000..3cc4d20 --- /dev/null +++ b/tests/ScadaLink.CLI.Tests/Commands/AuditConfigDeprecationTests.cs @@ -0,0 +1,100 @@ +using System.CommandLine; +using ScadaLink.CLI.Commands; + +namespace ScadaLink.CLI.Tests.Commands; + +/// +/// Tests for the audit-logaudit-config rename (Audit Log #23 M8-T7): +/// the new name parses, the deprecated audit-log alias still resolves the full +/// subcommand tree and emits a stderr deprecation warning, and the renamed group does +/// not collide with the distinct audit group from Bundle A. +/// +public class AuditConfigDeprecationTests +{ + private static readonly Option Url = new("--url") { Recursive = true }; + private static readonly Option Username = new("--username") { Recursive = true }; + private static readonly Option Password = new("--password") { Recursive = true }; + private static readonly Option 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); + } +} From 36d58e8988730ac0f4723afb335afba31e9c4641 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 22:03:32 -0400 Subject: [PATCH 09/10] docs(cli): document scadalink audit group + audit-config rename (#23 M8) --- src/ScadaLink.CLI/README.md | 113 ++++++++++++++++++++++++++++++++++-- 1 file changed, 109 insertions(+), 4 deletions(-) diff --git a/src/ScadaLink.CLI/README.md b/src/ScadaLink.CLI/README.md index 134af60..8c76d4c 100644 --- a/src/ScadaLink.CLI/README.md +++ b/src/ScadaLink.CLI/README.md @@ -1049,14 +1049,119 @@ 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 audit-log query [options] +scadalink --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 (`OutboundApi`, `OutboundDb`, `Notification`, `InboundApi`) | +| `--kind` | no | — | Filter by event kind | +| `--status` | no | — | Filter by status (single value) | +| `--site` | no | — | Filter by source site ID | +| `--instance` | no | — | Filter by instance | +| `--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 audit export --since