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 AuditExport-gated (#23 M7-T15 Bundle G); // the tests run as pre-authenticated principals built by // FakeAuthHandler (everyone has the Admin role), which is // one of AuditExportRoles, so the policy succeeds. services.AddAuthentication(FakeAuthHandler.SchemeName) .AddScheme( FakeAuthHandler.SchemeName, _ => { }); // Use the real production policy wiring so the endpoint's // updated AuditExport gate (#23 M7-T15 Bundle G) is what // the tests exercise. The fake principal carries the // "Admin" role, which AuditExportRoles permits. services.AddScadaLinkAuthorization(); 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 executionId = Guid.NewGuid().ToString(); var parentExecutionId = 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}&" + $"executionId={executionId}&" + $"parentExecutionId={parentExecutionId}&" + "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.Channels != null && f.Channels.Count == 1 && f.Channels[0] == AuditChannel.ApiOutbound && f.Kinds != null && f.Kinds.Count == 1 && f.Kinds[0] == AuditKind.ApiCall && f.Statuses != null && f.Statuses.Count == 1 && f.Statuses[0] == AuditStatus.Failed && f.SourceSiteIds != null && f.SourceSiteIds.Count == 1 && f.SourceSiteIds[0] == "plant-a" && f.Target == "PaymentApi" && f.Actor == "apikey-1" && f.CorrelationId == Guid.Parse(correlationId) && f.ExecutionId == Guid.Parse(executionId) && f.ParentExecutionId == Guid.Parse(parentExecutionId) && 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.Channels == null && f.Kinds == null && f.Statuses == null && f.SourceSiteIds == null && f.Target == null && f.Actor == null && f.CorrelationId == null && f.ExecutionId == null && f.ParentExecutionId == 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.Channels == null), Arg.Any(), Arg.Any()); } } [Fact] public async Task ExportEndpoint_UnparseableExecutionId_SilentlyDropped() { // Lax-parse contract: an unparseable executionId is dropped (no 400) — // mirrors the correlationId parse. var (client, repo, host) = await BuildHostAsync(); using (host) { var response = await client.GetAsync("/api/centralui/audit/export?executionId=not-a-guid"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); _ = await response.Content.ReadAsStringAsync(); await repo.Received().QueryAsync( Arg.Is(f => f.ExecutionId == null), Arg.Any(), Arg.Any()); } } [Fact] public async Task ExportEndpoint_UnparseableParentExecutionId_SilentlyDropped() { // Lax-parse contract: an unparseable parentExecutionId is dropped (no 400) // — mirrors the executionId / correlationId parse. var (client, repo, host) = await BuildHostAsync(); using (host) { var response = await client.GetAsync("/api/centralui/audit/export?parentExecutionId=not-a-guid"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); _ = await response.Content.ReadAsStringAsync(); await repo.Received().QueryAsync( Arg.Is(f => f.ParentExecutionId == null), Arg.Any(), Arg.Any()); } } /// /// Test-only authentication handler that signs every request in as an Admin. /// Admin is in AuditExportRoles, so the endpoint's AuditExport policy /// passes 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); } }