From 91682cd862146cc8572f4ed3d0e582c2702bd290 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 21:56:20 -0400 Subject: [PATCH] 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(); + } +}