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); + } +}