9a3f006721
P6-7 (CWE-1236): AuditEndpoints CSV export quoted cells on comma/quote/CR/LF but never neutralized a leading =, +, -, @, TAB, or CR — inbound-API-caller text landing in Target/ErrorMessage/RequestSummary/ResponseSummary/Extra could execute as a formula/DDE trigger when opened in Excel. AppendField now prefixes a literal single quote on free-text columns only (never the GUID/enum/numeric/date/bool columns the exporter formats itself), applied before RFC 4180 quoting. The CSV was also missing ExecutionId/ ParentExecutionId/SourceNode that the JSONL export already carried; both are now 24 columns in the same AuditExportRow order. A second, independently maintained CSV writer (CentralUI's AuditLogExportService, feeding the Audit Log page's bulk-export button) had the identical gap and got the identical fix + column alignment. P6-8: `audit export` was the one CLI HTTP path with no deadline at all — a residual gap from the ManagementHttpClient timeout-honesty fix, which made HttpClient.Timeout infinite and the per-call token the single deadline everywhere except this streamed GET. RunExportAsync now takes a headers- received deadline (CancelAfter, reusing ManagementHttpClient. DefaultConnectTimeout) around the initial GetAsync, an inactivity deadline around the streaming copy (rearmed on every successful read, so large exports are bounded by silence, not total size), and an external CancellationToken wired to Console.CancelKeyPress in AuditCommands so Ctrl-C cancels promptly. A stalled/faulted stream now produces a friendly error noting the partial output file instead of hanging or dumping a stack trace.
633 lines
26 KiB
C#
633 lines
26 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 #23 M8-T3):
|
|
/// 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 --------------------------------------
|
|
|
|
[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<HttpContent> _content;
|
|
public string? RequestPathAndQuery { get; private set; }
|
|
|
|
public BodyHandler(HttpStatusCode status, Func<HttpContent> content)
|
|
{
|
|
_status = status;
|
|
_content = content;
|
|
}
|
|
|
|
protected override Task<HttpResponseMessage> 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);
|
|
}
|
|
}
|
|
|
|
/// <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;
|
|
|
|
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.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
/// <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
|
|
{
|
|
public int CallCount { get; private set; }
|
|
|
|
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;
|
|
|
|
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<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
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/// <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;
|
|
|
|
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<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)");
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/// <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();
|
|
}
|
|
|
|
[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));
|
|
}
|
|
}
|