feat(ui): server-side streaming CSV export of Audit Log (#23 M7)
This commit is contained in:
170
src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs
Normal file
170
src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal-API endpoint hosting the Audit Log CSV export (#23 M7-T14 / Bundle F).
|
||||
///
|
||||
/// <para>
|
||||
/// CentralUI ships no MVC controllers (see <see cref="ScadaLink.CentralUI.Auth.AuthEndpoints"/>
|
||||
/// and <see cref="ScadaLink.CentralUI.ScriptAnalysis.ScriptAnalysisEndpoints"/>),
|
||||
/// so the brief's "controller" is implemented as a minimal-API endpoint instead.
|
||||
/// The endpoint streams to <c>Response.Body</c> directly so the export does NOT
|
||||
/// buffer the full result set in memory — see
|
||||
/// <see cref="IAuditLogExportService.ExportAsync"/>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The route is admin-gated to mirror the NavMenu (<c>RequireAdmin</c> wraps
|
||||
/// the Audit section). The query-string parser silently drops unrecognised
|
||||
/// values to match the page-level parser in
|
||||
/// <c>AuditLogPage.ApplyQueryStringFilters</c> — an unknown enum value yields
|
||||
/// the same "no constraint" outcome rather than a 400.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class AuditExportEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles <c>GET /api/centralui/audit/export</c>. Internal so endpoint
|
||||
/// tests can call it directly when desirable; the live wire-up goes
|
||||
/// through the minimal-API map above.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the query-string into an <see cref="AuditLogQueryFilter"/>.
|
||||
/// Unknown enum names / un-parseable Guids / dates are silently dropped
|
||||
/// (same contract as <c>AuditLogPage.ApplyQueryStringFilters</c>).
|
||||
/// </summary>
|
||||
internal static AuditLogQueryFilter ParseFilter(IQueryCollection query)
|
||||
{
|
||||
AuditChannel? channel = null;
|
||||
if (query.TryGetValue("channel", out var channelValues)
|
||||
&& Enum.TryParse<AuditChannel>(channelValues.ToString(), ignoreCase: true, out var parsedChannel))
|
||||
{
|
||||
channel = parsedChannel;
|
||||
}
|
||||
|
||||
AuditKind? kind = null;
|
||||
if (query.TryGetValue("kind", out var kindValues)
|
||||
&& Enum.TryParse<AuditKind>(kindValues.ToString(), ignoreCase: true, out var parsedKind))
|
||||
{
|
||||
kind = parsedKind;
|
||||
}
|
||||
|
||||
AuditStatus? status = null;
|
||||
if (query.TryGetValue("status", out var statusValues)
|
||||
&& Enum.TryParse<AuditStatus>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional <c>maxRows=</c> query-string override. Falls back to
|
||||
/// <see cref="DefaultMaxRows"/> on a missing / non-positive / unparseable
|
||||
/// value rather than erroring — same lax contract as the rest of the
|
||||
/// query parser.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,22 @@
|
||||
InitialInstanceSearch="@_initialInstanceSearch" />
|
||||
</div>
|
||||
|
||||
@* Export button (Bundle F / M7-T14). A plain <a download> 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. *@
|
||||
<div class="mb-3 d-flex justify-content-end">
|
||||
<a class="btn btn-outline-secondary btn-sm"
|
||||
href="@ExportUrl"
|
||||
download
|
||||
role="button"
|
||||
aria-label="Export current view to CSV">
|
||||
Export CSV
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@* 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. *@
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Built here rather than in markup so the per-row test coverage can
|
||||
/// exercise the URL composition without booting the full Blazor renderer.
|
||||
/// </remarks>
|
||||
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<KeyValuePair<string, string?>>(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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TApp>()
|
||||
.AddInteractiveServerRenderMode()
|
||||
|
||||
@@ -32,6 +32,10 @@ public static class ServiceCollectionExtensions
|
||||
// results grid can be tested with a stubbed query source.
|
||||
services.AddScoped<IAuditLogQueryService, AuditLogQueryService>();
|
||||
|
||||
// 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<IAuditLogExportService, AuditLogExportService>();
|
||||
|
||||
// Roslyn-backed C# analysis for the Monaco script editor.
|
||||
// Scoped because SharedScriptCatalog wraps a scoped service.
|
||||
services.AddMemoryCache(o => o.SizeLimit = 200);
|
||||
|
||||
238
src/ScadaLink.CentralUI/Services/AuditLogExportService.cs
Normal file
238
src/ScadaLink.CentralUI/Services/AuditLogExportService.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Streaming CSV exporter for the Audit Log page (#23 M7-T14 / Bundle F).
|
||||
///
|
||||
/// <para>
|
||||
/// The exporter iterates <see cref="IAuditLogRepository.QueryAsync"/> page by page
|
||||
/// using its keyset cursor and writes each row to a destination
|
||||
/// <see cref="Stream"/> 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.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Output is capped at a caller-supplied <c>maxRows</c> ceiling; when the cap
|
||||
/// is hit the service appends a <c># Capped at … rows. Use the CLI for larger
|
||||
/// exports.</c> footer line so an operator can tell a truncated download from
|
||||
/// a complete one. The header row contains the 21 columns of
|
||||
/// <see cref="AuditEvent"/> in declaration order.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IAuditLogExportService
|
||||
{
|
||||
/// <summary>
|
||||
/// Streams a CSV export of the rows matching <paramref name="filter"/> to
|
||||
/// <paramref name="output"/>, capping at <paramref name="maxRows"/>.
|
||||
/// </summary>
|
||||
/// <param name="filter">Repository filter to apply.</param>
|
||||
/// <param name="maxRows">
|
||||
/// Maximum number of data rows (excluding header / footer) to emit. The
|
||||
/// service stops paging once this is reached and appends a cap footer.
|
||||
/// </param>
|
||||
/// <param name="output">Destination stream — typically the HTTP response body.</param>
|
||||
/// <param name="ct">Cancellation token (e.g. <c>HttpContext.RequestAborted</c>).</param>
|
||||
/// <param name="pageSize">
|
||||
/// 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.
|
||||
/// </param>
|
||||
Task ExportAsync(
|
||||
AuditLogQueryFilter filter,
|
||||
int maxRows,
|
||||
Stream output,
|
||||
CancellationToken ct,
|
||||
int pageSize = AuditLogExportService.DefaultPageSize);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IAuditLogExportService"/>
|
||||
public sealed class AuditLogExportService : IAuditLogExportService
|
||||
{
|
||||
/// <summary>Default rows pulled per repository round-trip.</summary>
|
||||
public const int DefaultPageSize = 1000;
|
||||
|
||||
private readonly IAuditLogRepository _repository;
|
||||
|
||||
public AuditLogExportService(IAuditLogRepository repository)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
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
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>The 21 column names in <see cref="AuditEvent"/> declaration order.</summary>
|
||||
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";
|
||||
|
||||
/// <summary>
|
||||
/// Serialises one <see cref="AuditEvent"/> 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.
|
||||
/// </summary>
|
||||
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' };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user