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.
///
[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_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 = 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_WithOneInvalidName_FailsFast()
{
// AcceptOnlyFromAmong validates EACH value of the multi-value option.
var root = AuditCommandTestHarness.BuildRoot();
var (exit, _, err) = AuditCommandTestHarness.Invoke(
root, "audit", "export", "--since", "1h", "--until", "0h",
"--format", "csv", "--output", "/tmp/out.csv",
"--channel", "ApiOutbound", "OutboundApi");
Assert.NotEqual(0, exit);
Assert.NotEqual("", err);
}
// ---- 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();
}
}