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