Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditExportCommandTests.cs
T
Joseph Doherty 513fa1aa16 docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)
Comment/XML-doc changes only:
- add summaries to ~10k test methods, fixtures, fakes and helpers
  (+ <param> on theories, <returns> on async Task methods,
  <inheritdoc/> on interface implementations the checker can resolve)
- strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N,
  #NN, Component-0NN) from comments, keeping the explanatory text;
  reword false positives in comments only
- real docs on fakes implementing BCL/library interfaces
  (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime)
- reword the last 5 src/ false positives (CWE-1236, failures-1,
  year-0001, M365)

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
2026-10-07 15:05:49 -04:00

708 lines
30 KiB
C#

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;
/// <summary>
/// Tests for the <c>scadabridge audit export</c> subcommand (Audit Log):
/// required-flag enforcement, query-string construction, streaming the response body
/// to the output file, and the parquet-not-implemented (501) path.
/// </summary>
[Collection("Console")]
public class AuditExportCommandTests
{
// ---- CLI parsing: required flags --------------------------------------
/// <summary>Verifies that omitting a required flag produces a parse error and a non-zero exit code.</summary>
[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);
}
/// <summary>Verifies that the command parses without error when all required flags are present.</summary>
[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);
}
/// <summary>Verifies that an invalid format value is not rejected at the parse layer.</summary>
[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);
}
/// <summary>Verifies that the format value is accepted in any letter case.</summary>
/// <param name="format">The format value to parse, in varying case.</param>
[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 -----------------------------------------------------
/// <summary>Verifies that the query string includes the time window, the format and the filters.</summary>
[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"]);
}
/// <summary>Verifies that multi-value filters emit one query key per value.</summary>
[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"));
}
/// <summary>Verifies that multi-value filters that were not set are omitted from the query string.</summary>
[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"]);
}
/// <summary>Verifies that multiple channel values supplied in a single token are accepted.</summary>
[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);
}
/// <summary>Verifies that multiple channel values supplied through a repeated flag are accepted.</summary>
[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);
}
/// <summary>Verifies that a multi-value channel option still parses when one name is unrecognised.</summary>
[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<HttpContent> _content;
/// <summary>Gets the path and query string of the last request the handler received.</summary>
public string? RequestPathAndQuery { get; private set; }
/// <summary>Initializes a handler that answers every request with the given status and content.</summary>
/// <param name="status">The HTTP status code to return.</param>
/// <param name="content">A factory that creates the response content.</param>
public BodyHandler(HttpStatusCode status, Func<HttpContent> content)
{
_status = status;
_content = content;
}
/// <inheritdoc />
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
RequestPathAndQuery = request.RequestUri!.PathAndQuery;
return Task.FromResult(new HttpResponseMessage(_status) { Content = _content() });
}
}
/// <summary>Verifies that a successful export streams the response body to the output file.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
}
/// <summary>Verifies that an HTTP 403 response returns exit code 2.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RunExport_Http403_ReturnsExitCode2()
{
// An HTTP 403 on /api/audit/export must produce exit code 2 per the
// documented CLI contract — the legacy plain exit code 1 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);
}
}
/// <summary>Verifies that an unauthorized error code on a response whose status is not 403 also returns exit code 2.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
}
/// <summary>Verifies that a parquet 501 response prints the server message and returns a non-zero exit code.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
}
/// <summary>Verifies that a large response body is streamed to the file rather than fully buffered.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
}
/// <summary>
/// A read-only stream that yields <paramref name="length"/> 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.
/// </summary>
private sealed class RepeatingStream : Stream
{
private readonly byte _value;
private long _remaining;
/// <summary>Initializes a stream that yields the given byte repeatedly.</summary>
/// <param name="value">The byte value to repeat.</param>
/// <param name="length">The total number of bytes the stream yields.</param>
public RepeatingStream(byte value, long length)
{
_value = value;
_remaining = length;
}
/// <inheritdoc />
public override bool CanRead => true;
/// <inheritdoc />
public override bool CanSeek => false;
/// <inheritdoc />
public override bool CanWrite => false;
/// <inheritdoc />
public override long Length => throw new NotSupportedException();
/// <inheritdoc />
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
/// <inheritdoc />
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;
}
/// <inheritdoc />
public override void Flush() { }
/// <inheritdoc />
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
/// <inheritdoc />
public override void SetLength(long value) => throw new NotSupportedException();
/// <inheritdoc />
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}
// ─────────────────────────────────────────────────────────────────────
// Deadline + Ctrl-C cancellation (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.
// ─────────────────────────────────────────────────────────────────────
/// <summary>A handler that never completes the request until its <see cref="CancellationToken"/> fires — simulates a server that accepted the connection but never answers.</summary>
private sealed class HangingHandler : HttpMessageHandler
{
/// <summary>Gets the number of requests the handler has received.</summary>
public int CallCount { get; private set; }
/// <inheritdoc />
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
CallCount++;
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
throw new InvalidOperationException("unreachable — Task.Delay(Infinite) only returns via cancellation");
}
}
/// <summary>
/// A stream that serves one small chunk, then goes silent — any subsequent
/// <see cref="ReadAsync(Memory{byte}, CancellationToken)"/> blocks until the
/// caller's token cancels. Simulates a connection that stalls mid-transfer.
/// </summary>
private sealed class StallingStream : Stream
{
private readonly byte[] _firstChunk;
private bool _servedFirstChunk;
/// <summary>Initializes a stream that serves the given first chunk and then stalls.</summary>
/// <param name="firstChunk">The bytes served before the stream goes silent.</param>
public StallingStream(byte[] firstChunk) => _firstChunk = firstChunk;
/// <inheritdoc />
public override bool CanRead => true;
/// <inheritdoc />
public override bool CanSeek => false;
/// <inheritdoc />
public override bool CanWrite => false;
/// <inheritdoc />
public override long Length => throw new NotSupportedException();
/// <inheritdoc />
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
/// <inheritdoc />
public override int Read(byte[] buffer, int offset, int count) =>
ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult();
/// <inheritdoc />
public override async ValueTask<int> ReadAsync(Memory<byte> 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
}
/// <inheritdoc />
public override void Flush() { }
/// <inheritdoc />
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
/// <inheritdoc />
public override void SetLength(long value) => throw new NotSupportedException();
/// <inheritdoc />
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}
/// <summary>A stream that serves one small chunk, then throws <see cref="IOException"/> — simulates a connection reset mid-transfer.</summary>
private sealed class FaultingStream : Stream
{
private readonly byte[] _firstChunk;
private bool _servedFirstChunk;
/// <summary>Initializes a stream that serves the given first chunk and then throws.</summary>
/// <param name="firstChunk">The bytes served before the stream faults.</param>
public FaultingStream(byte[] firstChunk) => _firstChunk = firstChunk;
/// <inheritdoc />
public override bool CanRead => true;
/// <inheritdoc />
public override bool CanSeek => false;
/// <inheritdoc />
public override bool CanWrite => false;
/// <inheritdoc />
public override long Length => throw new NotSupportedException();
/// <inheritdoc />
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
/// <inheritdoc />
public override int Read(byte[] buffer, int offset, int count) =>
ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult();
/// <inheritdoc />
public override ValueTask<int> ReadAsync(Memory<byte> 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<int>(n);
}
throw new IOException("connection reset by peer (simulated)");
}
/// <inheritdoc />
public override void Flush() { }
/// <inheritdoc />
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
/// <inheritdoc />
public override void SetLength(long value) => throw new NotSupportedException();
/// <inheritdoc />
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}
/// <summary>Redirects <see cref="Console.Error"/> for the duration of the callback and returns what was written.</summary>
private static async Task<string> CaptureStderrAsync(Func<Task> action)
{
var original = Console.Error;
var capture = new StringWriter();
Console.SetError(capture);
try
{
await action();
}
finally
{
Console.SetError(original);
}
return capture.ToString();
}
/// <summary>Verifies that a server that never sends response headers causes a timeout with a friendly error message.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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));
}
/// <summary>Verifies that the inactivity timeout fires when the stream stalls mid-transfer.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
}
/// <summary>Verifies that an I/O exception during streaming produces a clean message rather than a stack trace.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
}
/// <summary>Verifies that a Ctrl-C cancellation token cancels the export promptly with a clean message.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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));
}
}