feat(ui): server-side streaming CSV export of Audit Log (#23 M7)

This commit is contained in:
Joseph Doherty
2026-05-20 20:57:01 -04:00
parent 943c2ced39
commit 8744630adb
10 changed files with 1163 additions and 0 deletions

View File

@@ -0,0 +1,278 @@
using System.Net;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
using ScadaLink.CentralUI.Audit;
using ScadaLink.CentralUI.Services;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.Security;
namespace ScadaLink.CentralUI.Tests.Audit;
/// <summary>
/// Endpoint-level tests for the Audit Log CSV export (#23 M7-T14 / Bundle F).
///
/// <para>
/// CentralUI uses minimal-API endpoints (see <c>AuthEndpoints</c> /
/// <c>ScriptAnalysisEndpoints</c>) rather than MVC controllers, so this brief's
/// "controller" is implemented as <see cref="AuditExportEndpoints"/>. The tests
/// pin two things: (a) the <c>GET /api/centralui/audit/export</c> route sets
/// the correct content-type + attachment disposition + body, and (b) the
/// query-string is parsed into an <see cref="AuditLogQueryFilter"/> and handed
/// to <see cref="IAuditLogExportService"/>.
/// </para>
/// </summary>
public class AuditExportEndpointsTests
{
private static AuditEvent SampleEvent() => new()
{
EventId = Guid.Parse("11111111-1111-1111-1111-111111111111"),
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
IngestedAtUtc = null,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
SourceSiteId = "plant-a",
Status = AuditStatus.Delivered,
HttpStatus = 200,
};
/// <summary>
/// Builds a tiny in-process test host that wires the export endpoint to a
/// stubbed <see cref="IAuditLogRepository"/>. Returns a ready-to-call
/// <see cref="HttpClient"/> and the repo substitute so the test can assert
/// on what the endpoint did.
/// </summary>
private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildHostAsync()
{
var repo = Substitute.For<IAuditLogRepository>();
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(
Task.FromResult<IReadOnlyList<AuditEvent>>(new[] { SampleEvent() }),
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
var hostBuilder = new HostBuilder()
.ConfigureWebHost(web =>
{
web.UseTestServer();
web.ConfigureServices(services =>
{
services.AddRouting();
// The endpoint is admin-gated; the tests run as
// pre-authenticated principals built by FakeAuthHandler
// (everyone has the Admin role) so the RequireAdmin policy
// succeeds.
services.AddAuthentication(FakeAuthHandler.SchemeName)
.AddScheme<Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions, FakeAuthHandler>(
FakeAuthHandler.SchemeName, _ => { });
services.AddAuthorization(options =>
{
options.AddPolicy(AuthorizationPolicies.RequireAdmin, policy =>
policy.RequireClaim(JwtTokenService.RoleClaimType, "Admin"));
});
services.AddSingleton(repo);
services.AddScoped<IAuditLogExportService, AuditLogExportService>();
});
web.Configure(app =>
{
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapAuditExportEndpoints();
});
});
});
var host = await hostBuilder.StartAsync();
var client = host.GetTestClient();
return (client, repo, host);
}
[Fact]
public async Task ExportEndpoint_Get_ReturnsCsvContentType_AndAttachmentDisposition()
{
var (client, _, host) = await BuildHostAsync();
using (host)
{
var response = await client.GetAsync("/api/centralui/audit/export");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
// Content-Type: text/csv (charset may or may not be present).
Assert.NotNull(response.Content.Headers.ContentType);
Assert.Equal("text/csv", response.Content.Headers.ContentType!.MediaType);
// Content-Disposition: attachment with a *.csv filename.
ContentDispositionHeaderValue? disposition = response.Content.Headers.ContentDisposition;
Assert.NotNull(disposition);
Assert.Equal("attachment", disposition!.DispositionType);
Assert.NotNull(disposition.FileName);
Assert.EndsWith(".csv", disposition.FileName, StringComparison.OrdinalIgnoreCase);
// Body starts with the header row and contains the sample row.
var body = await response.Content.ReadAsStringAsync();
Assert.StartsWith("EventId,OccurredAtUtc,IngestedAtUtc,", body);
Assert.Contains("11111111-1111-1111-1111-111111111111", body);
}
}
[Fact]
public async Task ExportEndpoint_PassesFilterFromQueryString_ToService()
{
var (client, repo, host) = await BuildHostAsync();
using (host)
{
var correlationId = Guid.NewGuid().ToString();
var url =
"/api/centralui/audit/export?" +
"channel=ApiOutbound&" +
"kind=ApiCall&" +
"status=Failed&" +
"site=plant-a&" +
"target=PaymentApi&" +
"actor=apikey-1&" +
$"correlationId={correlationId}&" +
"from=2026-05-20T00:00:00Z&" +
"to=2026-05-20T23:59:59Z";
var response = await client.GetAsync(url);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
// Read the body to ensure the streaming response is fully drained
// before we assert on the repo substitute (the test server flushes
// the endpoint pipeline on response read).
_ = await response.Content.ReadAsStringAsync();
await repo.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f =>
f.Channel == AuditChannel.ApiOutbound &&
f.Kind == AuditKind.ApiCall &&
f.Status == AuditStatus.Failed &&
f.SourceSiteId == "plant-a" &&
f.Target == "PaymentApi" &&
f.Actor == "apikey-1" &&
f.CorrelationId == Guid.Parse(correlationId) &&
f.FromUtc == new DateTime(2026, 5, 20, 0, 0, 0, DateTimeKind.Utc) &&
f.ToUtc == new DateTime(2026, 5, 20, 23, 59, 59, DateTimeKind.Utc)),
Arg.Any<AuditLogPaging>(),
Arg.Any<CancellationToken>());
}
}
[Fact]
public async Task ExportEndpoint_NoQueryString_PassesEmptyFilter()
{
// Sanity: a bare GET (no params) yields a filter with every column null
// — i.e. an unconstrained export.
var (client, repo, host) = await BuildHostAsync();
using (host)
{
var response = await client.GetAsync("/api/centralui/audit/export");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
_ = await response.Content.ReadAsStringAsync();
await repo.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f =>
f.Channel == null &&
f.Kind == null &&
f.Status == null &&
f.SourceSiteId == null &&
f.Target == null &&
f.Actor == null &&
f.CorrelationId == null &&
f.FromUtc == null &&
f.ToUtc == null),
Arg.Any<AuditLogPaging>(),
Arg.Any<CancellationToken>());
}
}
[Fact]
public async Task ExportEndpoint_UnknownEnumValue_SilentlyIgnored()
{
// Defensive parsing: a junk channel value MUST NOT 500 the export —
// mirrors the page-level query-string parser (#23 M7 Bundle D) which
// silently drops unrecognised values.
var (client, repo, host) = await BuildHostAsync();
using (host)
{
var response = await client.GetAsync("/api/centralui/audit/export?channel=DefinitelyNotAChannel");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
_ = await response.Content.ReadAsStringAsync();
await repo.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f => f.Channel == null),
Arg.Any<AuditLogPaging>(),
Arg.Any<CancellationToken>());
}
}
/// <summary>
/// Test-only authentication handler that signs every request in as an Admin.
/// Lets the endpoint's <c>RequireAdmin</c> policy pass without spinning up
/// the real cookie + LDAP pipeline.
/// </summary>
private sealed class FakeAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "FakeAuth";
public FakeAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder) { }
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new[]
{
new Claim(ClaimTypes.Name, "test-admin"),
new Claim(JwtTokenService.RoleClaimType, "Admin"),
};
var identity = new ClaimsIdentity(claims, SchemeName);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
[Fact]
public void ExportEndpoint_RouteIsRegistered()
{
var builder = WebApplication.CreateBuilder();
builder.Services.AddRouting();
builder.Services.AddAuthorization();
builder.Services.AddSingleton(Substitute.For<IAuditLogRepository>());
builder.Services.AddScoped<IAuditLogExportService, AuditLogExportService>();
// Dispose the host: an undisposed WebApplication leaks its config
// PhysicalFileProvider watcher and the ConsoleLoggerProcessor thread.
using var app = builder.Build();
app.MapAuditExportEndpoints();
var endpoints = ((IEndpointRouteBuilder)app).DataSources
.SelectMany(ds => ds.Endpoints)
.OfType<RouteEndpoint>()
.ToList();
var export = endpoints.FirstOrDefault(e =>
e.RoutePattern.RawText == "/api/centralui/audit/export" &&
(e.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods.Contains("GET") ?? false));
Assert.NotNull(export);
}
}

View File

@@ -0,0 +1,77 @@
using Microsoft.AspNetCore.WebUtilities;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.Commons.Types.Enums;
using AuditLogPage = ScadaLink.CentralUI.Components.Pages.Audit.AuditLogPage;
namespace ScadaLink.CentralUI.Tests.Pages;
/// <summary>
/// Unit tests for <see cref="AuditLogPage.BuildExportUrl"/> (#23 M7-T14 /
/// Bundle F). Builds the <c>?...</c> querystring the Export-CSV link points
/// at; the same conversion is round-tripped on the server side by
/// <see cref="ScadaLink.CentralUI.Audit.AuditExportEndpoints.ParseFilter"/>.
/// These tests pin the no-filter base path + the round-trip back through
/// <see cref="QueryHelpers.ParseQuery"/> so the link contract stays stable.
/// </summary>
public class AuditLogPageExportUrlTests
{
[Fact]
public void BuildExportUrl_NullFilter_ReturnsBasePath()
{
var url = AuditLogPage.BuildExportUrl(null);
Assert.Equal("/api/centralui/audit/export", url);
}
[Fact]
public void BuildExportUrl_EmptyFilter_ReturnsBasePath()
{
// Defensive: a filter where every column is null should still render
// as the bare path — no trailing "?" so the URL stays clean.
var url = AuditLogPage.BuildExportUrl(new AuditLogQueryFilter());
Assert.Equal("/api/centralui/audit/export", url);
}
[Fact]
public void BuildExportUrl_AllFiltersSet_RoundTrips()
{
var corr = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
var filter = new AuditLogQueryFilter(
Channel: AuditChannel.ApiOutbound,
Kind: AuditKind.ApiCall,
Status: AuditStatus.Failed,
SourceSiteId: "plant-a",
Target: "PaymentApi",
Actor: "apikey-1",
CorrelationId: corr,
FromUtc: new DateTime(2026, 5, 20, 0, 0, 0, DateTimeKind.Utc),
ToUtc: new DateTime(2026, 5, 20, 23, 59, 59, DateTimeKind.Utc));
var url = AuditLogPage.BuildExportUrl(filter);
Assert.StartsWith("/api/centralui/audit/export?", url);
var query = QueryHelpers.ParseQuery(new Uri("http://x" + url).Query);
Assert.Equal("ApiOutbound", query["channel"]);
Assert.Equal("ApiCall", query["kind"]);
Assert.Equal("Failed", query["status"]);
Assert.Equal("plant-a", query["site"]);
Assert.Equal("PaymentApi", query["target"]);
Assert.Equal("apikey-1", query["actor"]);
Assert.Equal(corr.ToString(), query["correlationId"]);
Assert.Equal("2026-05-20T00:00:00.0000000Z", query["from"]);
Assert.Equal("2026-05-20T23:59:59.0000000Z", query["to"]);
}
[Fact]
public void BuildExportUrl_OnlyChannelSet_OmitsOtherParams()
{
var filter = new AuditLogQueryFilter(Channel: AuditChannel.Notification);
var url = AuditLogPage.BuildExportUrl(filter);
Assert.StartsWith("/api/centralui/audit/export?", url);
var query = QueryHelpers.ParseQuery(new Uri("http://x" + url).Query);
Assert.Single(query);
Assert.Equal("Notification", query["channel"]);
}
}

View File

@@ -11,6 +11,7 @@
<ItemGroup>
<PackageReference Include="bunit" />
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="xunit" />

View File

@@ -0,0 +1,310 @@
using System.Text;
using NSubstitute;
using ScadaLink.CentralUI.Services;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.CentralUI.Tests.Services;
/// <summary>
/// Tests for <see cref="AuditLogExportService"/> (#23 M7-T14 / Bundle F). The
/// service streams the filtered Audit Log query to a destination stream as
/// RFC 4180 CSV. These tests pin:
/// <list type="bullet">
/// <item>Header + body row count for a simple page.</item>
/// <item>RFC 4180 quoting for fields containing commas / quotes / CR-LF.</item>
/// <item>Null fields render as empty (no literal "null").</item>
/// <item>Row cap honoured + cap footer appended.</item>
/// <item>Cancellation tokens propagate mid-stream.</item>
/// </list>
/// </summary>
public class AuditLogExportServiceTests
{
private static AuditEvent SimpleEvent(string id, string? target = null, string? error = null)
=> new()
{
EventId = Guid.Parse(id),
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
IngestedAtUtc = new DateTime(2026, 5, 20, 12, 0, 1, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
CorrelationId = null,
SourceSiteId = "plant-a",
SourceInstanceId = null,
SourceScript = null,
Actor = null,
Target = target,
Status = AuditStatus.Delivered,
HttpStatus = 200,
DurationMs = 42,
ErrorMessage = error,
ErrorDetail = null,
RequestSummary = null,
ResponseSummary = null,
PayloadTruncated = false,
Extra = null,
ForwardState = null,
};
[Fact]
public async Task ExportAsync_FiveRows_WritesHeaderPlusFiveRows()
{
var rows = new List<AuditEvent>
{
SimpleEvent("11111111-1111-1111-1111-111111111111"),
SimpleEvent("22222222-2222-2222-2222-222222222222"),
SimpleEvent("33333333-3333-3333-3333-333333333333"),
SimpleEvent("44444444-4444-4444-4444-444444444444"),
SimpleEvent("55555555-5555-5555-5555-555555555555"),
};
var repo = Substitute.For<IAuditLogRepository>();
// First call returns the 5 rows; subsequent calls return empty so the
// service terminates the keyset loop.
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(
Task.FromResult<IReadOnlyList<AuditEvent>>(rows),
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
var sut = new AuditLogExportService(repo);
using var ms = new MemoryStream();
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 100, ms, CancellationToken.None);
var csv = Encoding.UTF8.GetString(ms.ToArray());
var lines = csv.Split("\r\n", StringSplitOptions.None);
// 1 header + 5 rows + trailing empty token from final \r\n = 7 entries.
Assert.Equal(7, lines.Length);
Assert.StartsWith("EventId,OccurredAtUtc,IngestedAtUtc,Channel,Kind,CorrelationId,SourceSiteId,", lines[0]);
Assert.StartsWith("11111111-1111-1111-1111-111111111111,", lines[1]);
Assert.StartsWith("55555555-5555-5555-5555-555555555555,", lines[5]);
Assert.Equal(string.Empty, lines[6]);
}
[Fact]
public async Task ExportAsync_HeaderHasAll21Columns_InSpecOrder()
{
var repo = Substitute.For<IAuditLogRepository>();
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
var sut = new AuditLogExportService(repo);
using var ms = new MemoryStream();
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None);
var csv = Encoding.UTF8.GetString(ms.ToArray()).TrimEnd('\r', '\n');
var header = csv.Split("\r\n")[0];
var columns = header.Split(',');
Assert.Equal(21, columns.Length);
Assert.Equal(new[]
{
"EventId", "OccurredAtUtc", "IngestedAtUtc", "Channel", "Kind",
"CorrelationId", "SourceSiteId", "SourceInstanceId", "SourceScript",
"Actor", "Target", "Status", "HttpStatus", "DurationMs",
"ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary",
"PayloadTruncated", "Extra", "ForwardState",
}, columns);
}
[Fact]
public async Task ExportAsync_FieldWithComma_QuotedAndEscaped()
{
// Target contains a comma → field must be wrapped in double quotes.
// Target with embedded quote → quote must be doubled ("") and field quoted.
// ResponseSummary contains CR-LF → field must be quoted.
var row = new AuditEvent
{
EventId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
IngestedAtUtc = null,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
CorrelationId = null,
SourceSiteId = "plant-a, secondary", // comma
SourceInstanceId = null,
SourceScript = "say \"hi\"", // embedded quote
Actor = null,
Target = "x",
Status = AuditStatus.Delivered,
HttpStatus = null,
DurationMs = null,
ErrorMessage = "boom\r\nthen again", // CR-LF
ErrorDetail = null,
RequestSummary = null,
ResponseSummary = null,
PayloadTruncated = false,
Extra = null,
ForwardState = null,
};
var repo = Substitute.For<IAuditLogRepository>();
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(
Task.FromResult<IReadOnlyList<AuditEvent>>(new[] { row }),
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
var sut = new AuditLogExportService(repo);
using var ms = new MemoryStream();
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None);
var csv = Encoding.UTF8.GetString(ms.ToArray());
// Comma-bearing field is quoted.
Assert.Contains("\"plant-a, secondary\"", csv);
// Embedded quote is doubled inside a quoted field.
Assert.Contains("\"say \"\"hi\"\"\"", csv);
// Newline-bearing field is quoted; the inner \r\n stays as-is.
Assert.Contains("\"boom\r\nthen again\"", csv);
}
[Fact]
public async Task ExportAsync_NullField_WrittenAsEmpty()
{
// Build a row with deliberate nulls for every nullable column.
var row = new AuditEvent
{
EventId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
IngestedAtUtc = null,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
CorrelationId = null,
SourceSiteId = null,
SourceInstanceId = null,
SourceScript = null,
Actor = null,
Target = null,
Status = AuditStatus.Submitted,
HttpStatus = null,
DurationMs = null,
ErrorMessage = null,
ErrorDetail = null,
RequestSummary = null,
ResponseSummary = null,
PayloadTruncated = false,
Extra = null,
ForwardState = null,
};
var repo = Substitute.For<IAuditLogRepository>();
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(
Task.FromResult<IReadOnlyList<AuditEvent>>(new[] { row }),
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
var sut = new AuditLogExportService(repo);
using var ms = new MemoryStream();
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None);
var csv = Encoding.UTF8.GetString(ms.ToArray());
var dataLine = csv.Split("\r\n")[1];
var fields = dataLine.Split(',');
// EventId(0), OccurredAtUtc(1), IngestedAtUtc(2), Channel(3), Kind(4),
// CorrelationId(5), SourceSiteId(6), SourceInstanceId(7), SourceScript(8),
// Actor(9), Target(10), Status(11), HttpStatus(12), DurationMs(13),
// ErrorMessage(14), ErrorDetail(15), RequestSummary(16), ResponseSummary(17),
// PayloadTruncated(18), Extra(19), ForwardState(20)
Assert.Equal(21, fields.Length);
Assert.Equal("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", fields[0]);
Assert.Equal(string.Empty, fields[2]); // IngestedAtUtc null
Assert.Equal(string.Empty, fields[5]); // CorrelationId null
Assert.Equal(string.Empty, fields[6]); // SourceSiteId null
Assert.Equal(string.Empty, fields[12]); // HttpStatus null
Assert.Equal(string.Empty, fields[14]); // ErrorMessage null
Assert.Equal("False", fields[18]); // PayloadTruncated
Assert.Equal(string.Empty, fields[20]); // ForwardState null
}
[Fact]
public async Task ExportAsync_RowCountAboveCap_Truncates_AppendsCapFooter()
{
// The service is asked for 3 rows but the repo would happily yield 5.
// Output must contain exactly 3 data rows + a footer "# Capped at 3 rows..."
var rows = Enumerable.Range(0, 5)
.Select(i => SimpleEvent(Guid.NewGuid().ToString()))
.ToList();
var repo = Substitute.For<IAuditLogRepository>();
// Repo returns the 5 rows in a single page; the service must stop after 3.
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(rows));
var sut = new AuditLogExportService(repo);
using var ms = new MemoryStream();
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 3, ms, CancellationToken.None);
var csv = Encoding.UTF8.GetString(ms.ToArray());
var lines = csv.Split("\r\n", StringSplitOptions.None);
// 1 header + 3 rows + 1 footer + trailing empty = 6 entries.
Assert.Equal(6, lines.Length);
Assert.Equal("# Capped at 3 rows. Use the CLI for larger exports.", lines[4]);
}
[Fact]
public async Task ExportAsync_CancellationToken_StopsMidStream()
{
// Repo yields a single page, then on the next page call we observe the
// canceled token and throw — service should propagate OperationCanceled.
var cts = new CancellationTokenSource();
var firstPage = new List<AuditEvent>
{
SimpleEvent("11111111-1111-1111-1111-111111111111"),
SimpleEvent("22222222-2222-2222-2222-222222222222"),
};
var repo = Substitute.For<IAuditLogRepository>();
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
// Cancel after delivering the first page so the next loop iteration
// sees a canceled token.
cts.Cancel();
return Task.FromResult<IReadOnlyList<AuditEvent>>(firstPage);
});
var sut = new AuditLogExportService(repo);
using var ms = new MemoryStream();
// The service writes the first page then checks the token before pulling
// the next — we expect OperationCanceledException to surface.
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 1000, ms, cts.Token));
}
[Fact]
public async Task ExportAsync_PaginatesUsingLastRowAsCursor()
{
// Two pages of 2 rows each, then empty. The service must pass the last
// row of page 1 as the cursor on the page-2 call.
var p1 = new List<AuditEvent>
{
new() { EventId = Guid.Parse("11111111-1111-1111-1111-111111111111"), OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered },
new() { EventId = Guid.Parse("22222222-2222-2222-2222-222222222222"), OccurredAtUtc = new DateTime(2026, 5, 20, 11, 59, 0, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered },
};
var p2 = new List<AuditEvent>
{
new() { EventId = Guid.Parse("33333333-3333-3333-3333-333333333333"), OccurredAtUtc = new DateTime(2026, 5, 20, 11, 58, 0, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered },
};
var pagings = new List<AuditLogPaging>();
var repo = Substitute.For<IAuditLogRepository>();
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Do<AuditLogPaging>(p => pagings.Add(p)), Arg.Any<CancellationToken>())
.Returns(
Task.FromResult<IReadOnlyList<AuditEvent>>(p1),
Task.FromResult<IReadOnlyList<AuditEvent>>(p2),
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
var sut = new AuditLogExportService(repo);
using var ms = new MemoryStream();
// PageSize is 2 so the first page returns full and the loop continues.
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None, pageSize: 2);
Assert.True(pagings.Count >= 2, $"Expected at least 2 paged calls, got {pagings.Count}");
Assert.Null(pagings[0].AfterEventId);
Assert.Null(pagings[0].AfterOccurredAtUtc);
Assert.Equal(p1[^1].EventId, pagings[1].AfterEventId);
Assert.Equal(p1[^1].OccurredAtUtc, pagings[1].AfterOccurredAtUtc);
}
}