diff --git a/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs b/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs
new file mode 100644
index 0000000..66ed746
--- /dev/null
+++ b/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs
@@ -0,0 +1,170 @@
+using System.Globalization;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+using ScadaLink.CentralUI.Services;
+using ScadaLink.Commons.Types.Audit;
+using ScadaLink.Commons.Types.Enums;
+using ScadaLink.Security;
+
+namespace ScadaLink.CentralUI.Audit;
+
+///
+/// Minimal-API endpoint hosting the Audit Log CSV export (#23 M7-T14 / Bundle F).
+///
+///
+/// CentralUI ships no MVC controllers (see
+/// and ),
+/// so the brief's "controller" is implemented as a minimal-API endpoint instead.
+/// The endpoint streams to Response.Body directly so the export does NOT
+/// buffer the full result set in memory — see
+/// .
+///
+///
+///
+/// The route is admin-gated to mirror the NavMenu (RequireAdmin wraps
+/// the Audit section). The query-string parser silently drops unrecognised
+/// values to match the page-level parser in
+/// AuditLogPage.ApplyQueryStringFilters — an unknown enum value yields
+/// the same "no constraint" outcome rather than a 400.
+///
+///
+public static class AuditExportEndpoints
+{
+ ///
+ /// Default row cap for a single export. Large enough to satisfy realistic
+ /// operator workflows; mirrors the brief's recommended ceiling. Operators
+ /// who need more should fall back to the CLI (footnote rendered in the
+ /// cap-footer line).
+ ///
+ public const int DefaultMaxRows = 100_000;
+
+ public static IEndpointRouteBuilder MapAuditExportEndpoints(this IEndpointRouteBuilder endpoints)
+ {
+ endpoints.MapGet("/api/centralui/audit/export", HandleExportAsync)
+ .RequireAuthorization(AuthorizationPolicies.RequireAdmin);
+
+ return endpoints;
+ }
+
+ ///
+ /// Handles GET /api/centralui/audit/export. Internal so endpoint
+ /// tests can call it directly when desirable; the live wire-up goes
+ /// through the minimal-API map above.
+ ///
+ internal static async Task HandleExportAsync(HttpContext context, IAuditLogExportService exportService)
+ {
+ var filter = ParseFilter(context.Request.Query);
+ var maxRows = ParseMaxRows(context.Request.Query);
+
+ // Stamp the response headers BEFORE the first body write so the client
+ // sees text/csv + an attachment download right away.
+ var fileName = $"audit-log-{DateTime.UtcNow:yyyyMMdd-HHmmss}.csv";
+ context.Response.ContentType = "text/csv; charset=utf-8";
+ context.Response.Headers["Content-Disposition"] = $"attachment; filename=\"{fileName}\"";
+ // Defeat any intermediate buffering proxy so the operator sees rows
+ // streaming through as the server flushes each repository page.
+ context.Response.Headers["Cache-Control"] = "no-store";
+
+ await exportService.ExportAsync(filter, maxRows, context.Response.Body, context.RequestAborted);
+ }
+
+ ///
+ /// Parses the query-string into an .
+ /// Unknown enum names / un-parseable Guids / dates are silently dropped
+ /// (same contract as AuditLogPage.ApplyQueryStringFilters).
+ ///
+ internal static AuditLogQueryFilter ParseFilter(IQueryCollection query)
+ {
+ AuditChannel? channel = null;
+ if (query.TryGetValue("channel", out var channelValues)
+ && Enum.TryParse(channelValues.ToString(), ignoreCase: true, out var parsedChannel))
+ {
+ channel = parsedChannel;
+ }
+
+ AuditKind? kind = null;
+ if (query.TryGetValue("kind", out var kindValues)
+ && Enum.TryParse(kindValues.ToString(), ignoreCase: true, out var parsedKind))
+ {
+ kind = parsedKind;
+ }
+
+ AuditStatus? status = null;
+ if (query.TryGetValue("status", out var statusValues)
+ && Enum.TryParse(statusValues.ToString(), ignoreCase: true, out var parsedStatus))
+ {
+ status = parsedStatus;
+ }
+
+ string? site = TrimToNullable(query, "site");
+ string? target = TrimToNullable(query, "target");
+ string? actor = TrimToNullable(query, "actor");
+
+ Guid? correlationId = null;
+ if (query.TryGetValue("correlationId", out var corrValues)
+ && Guid.TryParse(corrValues.ToString(), out var parsedCorr))
+ {
+ correlationId = parsedCorr;
+ }
+
+ DateTime? fromUtc = ParseUtcDate(query, "from");
+ DateTime? toUtc = ParseUtcDate(query, "to");
+
+ return new AuditLogQueryFilter(
+ Channel: channel,
+ Kind: kind,
+ Status: status,
+ SourceSiteId: site,
+ Target: target,
+ Actor: actor,
+ CorrelationId: correlationId,
+ FromUtc: fromUtc,
+ ToUtc: toUtc);
+ }
+
+ ///
+ /// Optional maxRows= query-string override. Falls back to
+ /// on a missing / non-positive / unparseable
+ /// value rather than erroring — same lax contract as the rest of the
+ /// query parser.
+ ///
+ private static int ParseMaxRows(IQueryCollection query)
+ {
+ if (query.TryGetValue("maxRows", out var raw)
+ && int.TryParse(raw.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
+ && parsed > 0)
+ {
+ return parsed;
+ }
+ return DefaultMaxRows;
+ }
+
+ private static string? TrimToNullable(IQueryCollection query, string key)
+ {
+ if (!query.TryGetValue(key, out var values))
+ {
+ return null;
+ }
+ var v = values.ToString();
+ return string.IsNullOrWhiteSpace(v) ? null : v.Trim();
+ }
+
+ private static DateTime? ParseUtcDate(IQueryCollection query, string key)
+ {
+ if (!query.TryGetValue(key, out var values))
+ {
+ return null;
+ }
+ if (DateTime.TryParse(
+ values.ToString(),
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
+ out var parsed))
+ {
+ return DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
+ }
+ return null;
+ }
+}
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor
index 1aadb03..2b48fa0 100644
--- a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor
+++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor
@@ -19,6 +19,22 @@
InitialInstanceSearch="@_initialInstanceSearch" />
+ @* Export button (Bundle F / M7-T14). A plain link triggers the
+ streaming CSV endpoint at /api/centralui/audit/export — chosen over a
+ SignalR-driven download because the request can stream 100k rows directly
+ to the response body without buffering through the Blazor circuit. The
+ href reflects the most recently applied filter; before Apply is clicked,
+ an unconstrained export is exposed. *@
+
+
@* Results grid (Bundle B / M7-T3). Row clicks emit OnRowSelected for Bundle C's
drilldown drawer; the grid stays in "no events" mode until the user applies a
filter so the page does not auto-load the full audit table on first render. *@
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs
index bc26789..b3c05ff 100644
--- a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs
+++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.WebUtilities;
using ScadaLink.Commons.Entities.Audit;
@@ -158,4 +159,70 @@ public partial class AuditLogPage
// grid) shows the same row instantly without a re-render flicker.
_drawerOpen = false;
}
+
+ ///
+ /// Bundle F (M7-T14): URL the Export-CSV link points at. Renders the most
+ /// recently applied filter as query-string params so the server-side
+ /// streaming endpoint reproduces the user's current view. With no filter
+ /// applied yet, returns the bare endpoint — i.e. an unconstrained export.
+ ///
+ ///
+ /// Built here rather than in markup so the per-row test coverage can
+ /// exercise the URL composition without booting the full Blazor renderer.
+ ///
+ internal string ExportUrl => BuildExportUrl(_currentFilter);
+
+ internal static string BuildExportUrl(AuditLogQueryFilter? filter)
+ {
+ const string basePath = "/api/centralui/audit/export";
+ if (filter is null)
+ {
+ return basePath;
+ }
+
+ var parts = new List>(9);
+ if (filter.Channel is { } ch)
+ {
+ parts.Add(new("channel", ch.ToString()));
+ }
+ if (filter.Kind is { } kind)
+ {
+ parts.Add(new("kind", kind.ToString()));
+ }
+ if (filter.Status is { } status)
+ {
+ parts.Add(new("status", status.ToString()));
+ }
+ if (!string.IsNullOrWhiteSpace(filter.SourceSiteId))
+ {
+ parts.Add(new("site", filter.SourceSiteId));
+ }
+ if (!string.IsNullOrWhiteSpace(filter.Target))
+ {
+ parts.Add(new("target", filter.Target));
+ }
+ if (!string.IsNullOrWhiteSpace(filter.Actor))
+ {
+ parts.Add(new("actor", filter.Actor));
+ }
+ if (filter.CorrelationId is { } corr)
+ {
+ parts.Add(new("correlationId", corr.ToString()));
+ }
+ if (filter.FromUtc is { } from)
+ {
+ parts.Add(new("from", from.ToString("O", CultureInfo.InvariantCulture)));
+ }
+ if (filter.ToUtc is { } to)
+ {
+ parts.Add(new("to", to.ToString("O", CultureInfo.InvariantCulture)));
+ }
+
+ if (parts.Count == 0)
+ {
+ return basePath;
+ }
+
+ return QueryHelpers.AddQueryString(basePath, parts);
+ }
}
diff --git a/src/ScadaLink.CentralUI/EndpointExtensions.cs b/src/ScadaLink.CentralUI/EndpointExtensions.cs
index ff4062f..608e62a 100644
--- a/src/ScadaLink.CentralUI/EndpointExtensions.cs
+++ b/src/ScadaLink.CentralUI/EndpointExtensions.cs
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
+using ScadaLink.CentralUI.Audit;
using ScadaLink.CentralUI.Auth;
using ScadaLink.CentralUI.Components.Layout;
using ScadaLink.CentralUI.ScriptAnalysis;
@@ -17,6 +18,7 @@ public static class EndpointExtensions
{
endpoints.MapAuthEndpoints();
endpoints.MapScriptAnalysisEndpoints();
+ endpoints.MapAuditExportEndpoints();
endpoints.MapRazorComponents()
.AddInteractiveServerRenderMode()
diff --git a/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs b/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs
index be2310b..14d59ba 100644
--- a/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs
+++ b/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs
@@ -32,6 +32,10 @@ public static class ServiceCollectionExtensions
// results grid can be tested with a stubbed query source.
services.AddScoped();
+ // Audit Log (#23 M7-T14 / Bundle F): server-side streaming CSV export.
+ // Backs the Audit Log page's Export button via GET /api/centralui/audit/export.
+ services.AddScoped();
+
// Roslyn-backed C# analysis for the Monaco script editor.
// Scoped because SharedScriptCatalog wraps a scoped service.
services.AddMemoryCache(o => o.SizeLimit = 200);
diff --git a/src/ScadaLink.CentralUI/Services/AuditLogExportService.cs b/src/ScadaLink.CentralUI/Services/AuditLogExportService.cs
new file mode 100644
index 0000000..dbbbd25
--- /dev/null
+++ b/src/ScadaLink.CentralUI/Services/AuditLogExportService.cs
@@ -0,0 +1,238 @@
+using System.Globalization;
+using System.Text;
+using ScadaLink.Commons.Entities.Audit;
+using ScadaLink.Commons.Interfaces.Repositories;
+using ScadaLink.Commons.Types.Audit;
+
+namespace ScadaLink.CentralUI.Services;
+
+///
+/// Streaming CSV exporter for the Audit Log page (#23 M7-T14 / Bundle F).
+///
+///
+/// The exporter iterates page by page
+/// using its keyset cursor and writes each row to a destination
+/// as RFC 4180-compliant CSV. The output is flushed after
+/// each page so a large export starts streaming to the client immediately
+/// instead of buffering the whole result set in memory.
+///
+///
+///
+/// Output is capped at a caller-supplied maxRows ceiling; when the cap
+/// is hit the service appends a # Capped at … rows. Use the CLI for larger
+/// exports. footer line so an operator can tell a truncated download from
+/// a complete one. The header row contains the 21 columns of
+/// in declaration order.
+///
+///
+public interface IAuditLogExportService
+{
+ ///
+ /// Streams a CSV export of the rows matching to
+ /// , capping at .
+ ///
+ /// Repository filter to apply.
+ ///
+ /// Maximum number of data rows (excluding header / footer) to emit. The
+ /// service stops paging once this is reached and appends a cap footer.
+ ///
+ /// Destination stream — typically the HTTP response body.
+ /// Cancellation token (e.g. HttpContext.RequestAborted).
+ ///
+ /// Optional override for the repository page size. Defaults to 1000 — large
+ /// enough to amortise the per-query overhead, small enough that one page in
+ /// memory is bounded.
+ ///
+ Task ExportAsync(
+ AuditLogQueryFilter filter,
+ int maxRows,
+ Stream output,
+ CancellationToken ct,
+ int pageSize = AuditLogExportService.DefaultPageSize);
+}
+
+///
+public sealed class AuditLogExportService : IAuditLogExportService
+{
+ /// Default rows pulled per repository round-trip.
+ public const int DefaultPageSize = 1000;
+
+ private readonly IAuditLogRepository _repository;
+
+ public AuditLogExportService(IAuditLogRepository repository)
+ {
+ _repository = repository ?? throw new ArgumentNullException(nameof(repository));
+ }
+
+ ///
+ public async Task ExportAsync(
+ AuditLogQueryFilter filter,
+ int maxRows,
+ Stream output,
+ CancellationToken ct,
+ int pageSize = DefaultPageSize)
+ {
+ ArgumentNullException.ThrowIfNull(filter);
+ ArgumentNullException.ThrowIfNull(output);
+ if (maxRows <= 0) throw new ArgumentOutOfRangeException(nameof(maxRows), "maxRows must be positive.");
+ if (pageSize <= 0) throw new ArgumentOutOfRangeException(nameof(pageSize), "pageSize must be positive.");
+
+ // UTF-8 no-BOM: Excel will still recognise the CSV but the file stays
+ // a clean ASCII-superset for downstream pipes / grep. The StreamWriter
+ // leaves the underlying stream open so the controller can decide when
+ // to dispose / complete it.
+ await using var writer = new StreamWriter(
+ output,
+ new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
+ bufferSize: 4096,
+ leaveOpen: true);
+ writer.NewLine = "\r\n"; // RFC 4180
+
+ // Header — 21 columns in AuditEvent declaration order.
+ await writer.WriteLineAsync(Header);
+
+ int written = 0;
+ AuditLogPaging cursor = new(PageSize: Math.Min(pageSize, maxRows));
+
+ while (written < maxRows)
+ {
+ // Honour cancellation BEFORE we kick off another round-trip — this
+ // is the deterministic cancellation point that the test pins on.
+ ct.ThrowIfCancellationRequested();
+
+ // Tighten the last page's size so we never pull more than the cap.
+ var remaining = maxRows - written;
+ var effectivePageSize = Math.Min(cursor.PageSize, remaining);
+ var pageCursor = cursor with { PageSize = effectivePageSize };
+
+ var page = await _repository.QueryAsync(filter, pageCursor, ct);
+ if (page.Count == 0)
+ {
+ break;
+ }
+
+ foreach (var evt in page)
+ {
+ if (written >= maxRows)
+ {
+ break;
+ }
+ await writer.WriteLineAsync(FormatCsvRow(evt));
+ written++;
+ }
+
+ // Push bytes through the StreamWriter buffer into the underlying
+ // stream so the client sees progress per-page instead of waiting
+ // for the full export to buffer up.
+ await writer.FlushAsync(ct);
+ await output.FlushAsync(ct);
+
+ // Last page (short read) — no more data to fetch.
+ if (page.Count < effectivePageSize)
+ {
+ break;
+ }
+
+ var last = page[^1];
+ cursor = new AuditLogPaging(
+ PageSize: pageSize,
+ AfterOccurredAtUtc: last.OccurredAtUtc,
+ AfterEventId: last.EventId);
+ }
+
+ if (written >= maxRows)
+ {
+ // Cap footer — visible to operators so a truncated download is
+ // distinguishable from a complete one. The "#" prefix keeps it
+ // out of the data columns; spreadsheet tools will surface it as
+ // a single-cell row.
+ await writer.WriteLineAsync(
+ $"# Capped at {maxRows.ToString(CultureInfo.InvariantCulture)} rows. Use the CLI for larger exports.");
+ await writer.FlushAsync(ct);
+ await output.FlushAsync(ct);
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // CSV helpers
+ // ─────────────────────────────────────────────────────────────────────
+
+ /// The 21 column names in declaration order.
+ internal const string Header =
+ "EventId,OccurredAtUtc,IngestedAtUtc,Channel,Kind,CorrelationId," +
+ "SourceSiteId,SourceInstanceId,SourceScript,Actor,Target,Status," +
+ "HttpStatus,DurationMs,ErrorMessage,ErrorDetail,RequestSummary," +
+ "ResponseSummary,PayloadTruncated,Extra,ForwardState";
+
+ ///
+ /// Serialises one as a CSV row (no trailing newline).
+ /// Each nullable column renders as the empty string when null; non-null
+ /// scalars use invariant culture so an export taken on one locale parses
+ /// cleanly on another.
+ ///
+ internal static string FormatCsvRow(AuditEvent evt)
+ {
+ var sb = new StringBuilder(256);
+ AppendField(sb, evt.EventId.ToString(), first: true);
+ AppendField(sb, FormatDate(evt.OccurredAtUtc));
+ AppendField(sb, FormatDate(evt.IngestedAtUtc));
+ AppendField(sb, evt.Channel.ToString());
+ AppendField(sb, evt.Kind.ToString());
+ AppendField(sb, evt.CorrelationId?.ToString());
+ AppendField(sb, evt.SourceSiteId);
+ AppendField(sb, evt.SourceInstanceId);
+ AppendField(sb, evt.SourceScript);
+ AppendField(sb, evt.Actor);
+ AppendField(sb, evt.Target);
+ AppendField(sb, evt.Status.ToString());
+ AppendField(sb, evt.HttpStatus?.ToString(CultureInfo.InvariantCulture));
+ AppendField(sb, evt.DurationMs?.ToString(CultureInfo.InvariantCulture));
+ AppendField(sb, evt.ErrorMessage);
+ AppendField(sb, evt.ErrorDetail);
+ AppendField(sb, evt.RequestSummary);
+ AppendField(sb, evt.ResponseSummary);
+ AppendField(sb, evt.PayloadTruncated.ToString());
+ AppendField(sb, evt.Extra);
+ AppendField(sb, evt.ForwardState?.ToString());
+ return sb.ToString();
+ }
+
+ private static string? FormatDate(DateTime? value) =>
+ value?.ToString("O", CultureInfo.InvariantCulture);
+
+ private static string FormatDate(DateTime value) =>
+ value.ToString("O", CultureInfo.InvariantCulture);
+
+ private static void AppendField(StringBuilder sb, string? value, bool first = false)
+ {
+ if (!first) sb.Append(',');
+ if (string.IsNullOrEmpty(value))
+ {
+ return;
+ }
+
+ // RFC 4180: quote on comma / quote / CR / LF; double-up embedded quotes.
+ bool needsQuoting = value.IndexOfAny(s_quoteTriggers) >= 0;
+ if (!needsQuoting)
+ {
+ sb.Append(value);
+ return;
+ }
+
+ sb.Append('"');
+ foreach (var ch in value)
+ {
+ if (ch == '"')
+ {
+ sb.Append('"').Append('"');
+ }
+ else
+ {
+ sb.Append(ch);
+ }
+ }
+ sb.Append('"');
+ }
+
+ private static readonly char[] s_quoteTriggers = { ',', '"', '\r', '\n' };
+}
diff --git a/tests/ScadaLink.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs b/tests/ScadaLink.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs
new file mode 100644
index 0000000..fcb01f4
--- /dev/null
+++ b/tests/ScadaLink.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs
@@ -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;
+
+///
+/// Endpoint-level tests for the Audit Log CSV export (#23 M7-T14 / Bundle F).
+///
+///
+/// CentralUI uses minimal-API endpoints (see AuthEndpoints /
+/// ScriptAnalysisEndpoints) rather than MVC controllers, so this brief's
+/// "controller" is implemented as . The tests
+/// pin two things: (a) the GET /api/centralui/audit/export route sets
+/// the correct content-type + attachment disposition + body, and (b) the
+/// query-string is parsed into an and handed
+/// to .
+///
+///
+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,
+ };
+
+ ///
+ /// Builds a tiny in-process test host that wires the export endpoint to a
+ /// stubbed . Returns a ready-to-call
+ /// and the repo substitute so the test can assert
+ /// on what the endpoint did.
+ ///
+ private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildHostAsync()
+ {
+ var repo = Substitute.For();
+ repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(
+ Task.FromResult>(new[] { SampleEvent() }),
+ Task.FromResult>(Array.Empty()));
+
+ 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(
+ FakeAuthHandler.SchemeName, _ => { });
+ services.AddAuthorization(options =>
+ {
+ options.AddPolicy(AuthorizationPolicies.RequireAdmin, policy =>
+ policy.RequireClaim(JwtTokenService.RoleClaimType, "Admin"));
+ });
+ services.AddSingleton(repo);
+ services.AddScoped();
+ });
+ 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(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(),
+ Arg.Any());
+ }
+ }
+
+ [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(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(),
+ Arg.Any());
+ }
+ }
+
+ [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(f => f.Channel == null),
+ Arg.Any(),
+ Arg.Any());
+ }
+ }
+
+ ///
+ /// Test-only authentication handler that signs every request in as an Admin.
+ /// Lets the endpoint's RequireAdmin policy pass without spinning up
+ /// the real cookie + LDAP pipeline.
+ ///
+ private sealed class FakeAuthHandler : AuthenticationHandler
+ {
+ public const string SchemeName = "FakeAuth";
+
+ public FakeAuthHandler(
+ IOptionsMonitor options,
+ ILoggerFactory logger,
+ UrlEncoder encoder)
+ : base(options, logger, encoder) { }
+
+ protected override Task 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());
+ builder.Services.AddScoped();
+ // 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()
+ .ToList();
+
+ var export = endpoints.FirstOrDefault(e =>
+ e.RoutePattern.RawText == "/api/centralui/audit/export" &&
+ (e.Metadata.GetMetadata()?.HttpMethods.Contains("GET") ?? false));
+
+ Assert.NotNull(export);
+ }
+}
diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageExportUrlTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageExportUrlTests.cs
new file mode 100644
index 0000000..136a929
--- /dev/null
+++ b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageExportUrlTests.cs
@@ -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;
+
+///
+/// Unit tests for (#23 M7-T14 /
+/// Bundle F). Builds the ?... querystring the Export-CSV link points
+/// at; the same conversion is round-tripped on the server side by
+/// .
+/// These tests pin the no-filter base path + the round-trip back through
+/// so the link contract stays stable.
+///
+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"]);
+ }
+}
diff --git a/tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj b/tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj
index cbab8f3..4b1faa7 100644
--- a/tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj
+++ b/tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj
@@ -11,6 +11,7 @@
+
diff --git a/tests/ScadaLink.CentralUI.Tests/Services/AuditLogExportServiceTests.cs b/tests/ScadaLink.CentralUI.Tests/Services/AuditLogExportServiceTests.cs
new file mode 100644
index 0000000..3d58b5e
--- /dev/null
+++ b/tests/ScadaLink.CentralUI.Tests/Services/AuditLogExportServiceTests.cs
@@ -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;
+
+///
+/// Tests for (#23 M7-T14 / Bundle F). The
+/// service streams the filtered Audit Log query to a destination stream as
+/// RFC 4180 CSV. These tests pin:
+///
+/// - Header + body row count for a simple page.
+/// - RFC 4180 quoting for fields containing commas / quotes / CR-LF.
+/// - Null fields render as empty (no literal "null").
+/// - Row cap honoured + cap footer appended.
+/// - Cancellation tokens propagate mid-stream.
+///
+///
+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
+ {
+ 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();
+ // First call returns the 5 rows; subsequent calls return empty so the
+ // service terminates the keyset loop.
+ repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(
+ Task.FromResult>(rows),
+ Task.FromResult>(Array.Empty()));
+
+ 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();
+ repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult>(Array.Empty()));
+
+ 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();
+ repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(
+ Task.FromResult>(new[] { row }),
+ Task.FromResult>(Array.Empty()));
+
+ 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();
+ repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(
+ Task.FromResult>(new[] { row }),
+ Task.FromResult>(Array.Empty()));
+
+ 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();
+ // Repo returns the 5 rows in a single page; the service must stop after 3.
+ repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult>(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
+ {
+ SimpleEvent("11111111-1111-1111-1111-111111111111"),
+ SimpleEvent("22222222-2222-2222-2222-222222222222"),
+ };
+
+ var repo = Substitute.For();
+ repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(callInfo =>
+ {
+ // Cancel after delivering the first page so the next loop iteration
+ // sees a canceled token.
+ cts.Cancel();
+ return Task.FromResult>(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(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
+ {
+ 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
+ {
+ 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();
+ var repo = Substitute.For();
+ repo.QueryAsync(Arg.Any(), Arg.Do(p => pagings.Add(p)), Arg.Any())
+ .Returns(
+ Task.FromResult>(p1),
+ Task.FromResult>(p2),
+ Task.FromResult>(Array.Empty()));
+
+ 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);
+ }
+}