using System.Diagnostics; using System.Net; using System.Text; using System.Web; using ZB.MOM.WW.ScadaBridge.CLI; using ZB.MOM.WW.ScadaBridge.CLI.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; /// /// Tests for the scadabridge 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. /// [Collection("Console")] public class AuditExportCommandTests { // ---- CLI parsing: required flags -------------------------------------- [Fact] public void Export_MissingRequiredFlag_ProducesParseErrorAndNonZeroExit() { var root = AuditCommandTestHarness.BuildRoot(); // --output is omitted. var (exit, _, err) = AuditCommandTestHarness.Invoke( root, "audit", "export", "--since", "1h", "--until", "0h", "--format", "csv"); Assert.NotEqual(0, exit); Assert.Contains("--output", err); } [Fact] public void Export_AllRequiredFlagsPresent_ParsesWithoutError() { var root = AuditCommandTestHarness.BuildRoot(); var parse = root.Parse(new[] { "audit", "export", "--since", "1h", "--until", "0h", "--format", "csv", "--output", "/tmp/out.csv", }); Assert.Empty(parse.Errors); } [Fact] public void Export_InvalidFormat_IsNotRejectedAtTheParseLayer() { // Register row 99: the client-side AcceptOnlyFromAmong gate is gone. It // matched case-SENSITIVELY, so it rejected `--format CSV` even though the // server lowercases the value before matching. The server returns a 400 // naming csv/jsonl/parquet and is the authoritative check. var root = AuditCommandTestHarness.BuildRoot(); var parse = root.Parse(new[] { "audit", "export", "--since", "1h", "--until", "0h", "--format", "xml", "--output", "/tmp/out.xml", }); Assert.Empty(parse.Errors); } [Theory] [InlineData("CSV")] [InlineData("Csv")] [InlineData("JSONL")] public void Export_FormatInAnyCase_IsAccepted(string format) { var root = AuditCommandTestHarness.BuildRoot(); var parse = root.Parse(new[] { "audit", "export", "--since", "1h", "--until", "0h", "--format", format, "--output", "/tmp/out.csv", }); Assert.Empty(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 = new[] { "Notification" }, Site = new[] { "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"]); } [Fact] public void BuildQueryString_MultiValueFilters_EmitOneKeyPerValue() { var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z"); var args = new AuditExportArgs { Since = "1h", Until = "2026-05-20T12:00:00Z", Format = "csv", Output = "/tmp/x", Channel = new[] { "ApiOutbound", "DbOutbound" }, Kind = new[] { "ApiCall", "DbWrite" }, Status = new[] { "Failed", "Parked" }, Site = new[] { "site-1", "site-2" }, }; var qs = AuditExportHelpers.BuildQueryString(args, now); var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?')); Assert.Equal(new[] { "ApiOutbound", "DbOutbound" }, parsed.GetValues("channel")); Assert.Equal(new[] { "ApiCall", "DbWrite" }, parsed.GetValues("kind")); Assert.Equal(new[] { "Failed", "Parked" }, parsed.GetValues("status")); Assert.Equal(new[] { "site-1", "site-2" }, parsed.GetValues("sourceSiteId")); } [Fact] public void BuildQueryString_OmitsUnsetMultiValueFilters() { var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z"); var args = new AuditExportArgs { Since = "1h", Until = "0h", Format = "csv", Output = "/tmp/x", }; var qs = AuditExportHelpers.BuildQueryString(args, now); var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?')); Assert.Null(parsed["channel"]); Assert.Null(parsed["kind"]); Assert.Null(parsed["status"]); Assert.Null(parsed["sourceSiteId"]); } [Fact] public void Export_MultipleChannelValues_SingleToken_AreAccepted() { // AllowMultipleArgumentsPerToken: --channel A B parses as two values. var root = AuditCommandTestHarness.BuildRoot(); var parse = root.Parse(new[] { "audit", "export", "--since", "1h", "--until", "0h", "--format", "csv", "--output", "/tmp/out.csv", "--channel", "ApiOutbound", "DbOutbound", }); Assert.Empty(parse.Errors); } [Fact] public void Export_MultipleChannelValues_RepeatedFlag_AreAccepted() { var root = AuditCommandTestHarness.BuildRoot(); var parse = root.Parse(new[] { "audit", "export", "--since", "1h", "--until", "0h", "--format", "csv", "--output", "/tmp/out.csv", "--channel", "ApiOutbound", "--channel", "Notification", }); Assert.Empty(parse.Errors); } [Fact] public void Export_MultiValueChannel_WithOneUnrecognisedName_StillParses() { // Register row 99: the vocabulary gate moved server-side; the multi-value // shape itself is unchanged, so both values reach the server to adjudicate. var root = AuditCommandTestHarness.BuildRoot(); var parse = root.Parse(new[] { "audit", "export", "--since", "1h", "--until", "0h", "--format", "csv", "--output", "/tmp/out.csv", "--channel", "ApiOutbound", "OutboundApi", }); Assert.Empty(parse.Errors); } // ---- 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_Http403_ReturnsExitCode2() { // CLI-018: an HTTP 403 on /api/audit/export must produce exit code 2 per the // documented CLI contract — the legacy bare-1 return masked auth failures // as generic command failures. var path = Path.Combine(Path.GetTempPath(), $"audit-export-403-{Guid.NewGuid():N}.csv"); try { var handler = new BodyHandler(HttpStatusCode.Forbidden, () => new StringContent("{\"error\":\"nope\",\"code\":\"UNAUTHORIZED\"}", Encoding.UTF8, "application/json")); 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 = "csv", Output = path }, output, DateTimeOffset.UtcNow); Assert.Equal(2, exit); Assert.False(File.Exists(path)); } finally { if (File.Exists(path)) File.Delete(path); } } [Fact] public async Task RunExport_UnauthorizedCodeOnNon403_ReturnsExitCode2() { var path = Path.Combine(Path.GetTempPath(), $"audit-export-401-{Guid.NewGuid():N}.csv"); try { var handler = new BodyHandler(HttpStatusCode.BadRequest, () => new StringContent("{\"error\":\"nope\",\"code\":\"FORBIDDEN\"}", Encoding.UTF8, "application/json")); 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 = "csv", Output = path }, output, DateTimeOffset.UtcNow); Assert.Equal(2, exit); } 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(); } // ───────────────────────────────────────────────────────────────────── // Deadline + Ctrl-C cancellation (arch-review 2026-08-19 P6-8: audit export // was the one CLI HTTP path with NO deadline — a hung server hung the CLI // forever). Every test below bounds its own wait well under what the old, // deadline-less implementation would have done (indefinite hang) — a // regression back to that state fails these on a timeout, proving the fix. // ───────────────────────────────────────────────────────────────────── /// A handler that never completes the request until its fires — simulates a server that accepted the connection but never answers. private sealed class HangingHandler : HttpMessageHandler { public int CallCount { get; private set; } protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { CallCount++; await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); throw new InvalidOperationException("unreachable — Task.Delay(Infinite) only returns via cancellation"); } } /// /// A stream that serves one small chunk, then goes silent — any subsequent /// blocks until the /// caller's token cancels. Simulates a connection that stalls mid-transfer. /// private sealed class StallingStream : Stream { private readonly byte[] _firstChunk; private bool _servedFirstChunk; public StallingStream(byte[] firstChunk) => _firstChunk = firstChunk; 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) => ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { if (!_servedFirstChunk) { _servedFirstChunk = true; var n = Math.Min(buffer.Length, _firstChunk.Length); _firstChunk.AsSpan(0, n).CopyTo(buffer.Span); return n; } await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); return 0; // unreachable — Task.Delay(Infinite) only returns via cancellation } 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(); } /// A stream that serves one small chunk, then throws — simulates a connection reset mid-transfer. private sealed class FaultingStream : Stream { private readonly byte[] _firstChunk; private bool _servedFirstChunk; public FaultingStream(byte[] firstChunk) => _firstChunk = firstChunk; 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) => ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { if (!_servedFirstChunk) { _servedFirstChunk = true; var n = Math.Min(buffer.Length, _firstChunk.Length); _firstChunk.AsSpan(0, n).CopyTo(buffer.Span); return new ValueTask(n); } throw new IOException("connection reset by peer (simulated)"); } 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(); } /// Redirects for the duration of the callback and returns what was written. private static async Task CaptureStderrAsync(Func action) { var original = Console.Error; var capture = new StringWriter(); Console.SetError(capture); try { await action(); } finally { Console.SetError(original); } return capture.ToString(); } [Fact] public async Task RunExport_ServerNeverSendsHeaders_TimesOutWithFriendlyError() { var path = Path.Combine(Path.GetTempPath(), $"audit-export-hang-{Guid.NewGuid():N}.csv"); var handler = new HangingHandler(); var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); var output = new StringWriter(); var sw = Stopwatch.StartNew(); var exit = 0; var stderr = await CaptureStderrAsync(async () => { exit = await AuditExportHelpers.RunExportAsync( client, new AuditExportArgs { Since = "1h", Until = "0h", Format = "csv", Output = path }, output, DateTimeOffset.UtcNow, headersTimeout: TimeSpan.FromMilliseconds(200)); }); sw.Stop(); Assert.Equal(1, exit); // Bounded well under the old infinite-hang behaviour. Assert.True(sw.Elapsed < TimeSpan.FromSeconds(5), $"took {sw.Elapsed}"); Assert.Contains("TIMEOUT", stderr); Assert.False(File.Exists(path)); } [Fact] public async Task RunExport_StreamStallsMidTransfer_InactivityTimeoutFires() { var path = Path.Combine(Path.GetTempPath(), $"audit-export-stall-{Guid.NewGuid():N}.csv"); try { var firstChunk = Encoding.UTF8.GetBytes("EventId,OccurredAtUtc\n"); var handler = new BodyHandler(HttpStatusCode.OK, () => new StreamContent(new StallingStream(firstChunk))); var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); var output = new StringWriter(); var sw = Stopwatch.StartNew(); var exit = 0; var stderr = await CaptureStderrAsync(async () => { exit = await AuditExportHelpers.RunExportAsync( client, new AuditExportArgs { Since = "1h", Until = "0h", Format = "csv", Output = path }, output, DateTimeOffset.UtcNow, inactivityTimeout: TimeSpan.FromMilliseconds(200)); }); sw.Stop(); Assert.Equal(1, exit); Assert.True(sw.Elapsed < TimeSpan.FromSeconds(5), $"took {sw.Elapsed}"); Assert.Contains("TIMEOUT", stderr); // Clean message, not a raw stack trace, and it notes the partial file. Assert.DoesNotContain(" at ", stderr); Assert.Contains(path, stderr); // The first chunk that DID arrive before the stall must have been // flushed to disk — this is a partial, not empty, export. Assert.True(File.Exists(path)); Assert.Equal(firstChunk, await File.ReadAllBytesAsync(path)); } finally { if (File.Exists(path)) File.Delete(path); } } [Fact] public async Task RunExport_MidStreamIOException_ProducesCleanMessageNotStackTrace() { var path = Path.Combine(Path.GetTempPath(), $"audit-export-fault-{Guid.NewGuid():N}.csv"); try { var firstChunk = Encoding.UTF8.GetBytes("EventId,OccurredAtUtc\n"); var handler = new BodyHandler(HttpStatusCode.OK, () => new StreamContent(new FaultingStream(firstChunk))); var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); var output = new StringWriter(); var exit = 0; var stderr = await CaptureStderrAsync(async () => { exit = await AuditExportHelpers.RunExportAsync( client, new AuditExportArgs { Since = "1h", Until = "0h", Format = "csv", Output = path }, output, DateTimeOffset.UtcNow); }); Assert.Equal(1, exit); Assert.Contains("STREAM_FAILED", stderr); Assert.Contains("connection reset by peer", stderr); Assert.Contains(path, stderr); Assert.DoesNotContain(" at ", stderr); Assert.DoesNotContain(nameof(FaultingStream), stderr); } finally { if (File.Exists(path)) File.Delete(path); } } [Fact] public async Task RunExport_CtrlCToken_CancelsPromptlyWithCleanMessage() { var path = Path.Combine(Path.GetTempPath(), $"audit-export-ctrlc-{Guid.NewGuid():N}.csv"); var handler = new HangingHandler(); var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p"); var output = new StringWriter(); using var cts = new CancellationTokenSource(); var sw = Stopwatch.StartNew(); var exit = 0; var stderr = await CaptureStderrAsync(async () => { var task = AuditExportHelpers.RunExportAsync( client, new AuditExportArgs { Since = "1h", Until = "0h", Format = "csv", Output = path }, output, DateTimeOffset.UtcNow, cancellationToken: cts.Token, // Deliberately much longer than the Ctrl-C delay below, so a pass // here proves cancellation — not the timeout — ended the call. headersTimeout: TimeSpan.FromSeconds(30)); await Task.Delay(50); cts.Cancel(); exit = await task; }); sw.Stop(); Assert.Equal(1, exit); Assert.True(sw.Elapsed < TimeSpan.FromSeconds(5), $"took {sw.Elapsed}"); Assert.Contains("CANCELLED", stderr); Assert.False(File.Exists(path)); } }