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