using System.Net; using System.Net.Http.Headers; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NSubstitute; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Types.Audit; using ScadaLink.Commons.Types.Enums; using ScadaLink.ManagementService; using ScadaLink.Security; namespace ScadaLink.ManagementService.Tests; /// /// HTTP-pipeline tests for the #23 M8 audit endpoints (). /// /// /// ManagementService authenticates each request by hand (HTTP Basic → LDAP → /// roles) rather than via the ASP.NET authorization-policy pipeline, so these /// tests substitute + /// (both expose a virtual test seam) to drive the role outcome without standing /// up a directory. The repository is a stubbed . /// /// public class AuditEndpointsTests { private const string BasicCredential = "auditor:password"; private static AuditEvent SampleEvent(Guid? id = null, DateTime? occurredAt = null) => new() { EventId = id ?? Guid.Parse("11111111-1111-1111-1111-111111111111"), OccurredAtUtc = occurredAt ?? new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, SourceSiteId = "plant-a", Status = AuditStatus.Delivered, HttpStatus = 200, }; /// /// Builds an in-process TestServer hosting the audit endpoints with stubbed /// auth + repository. is the role set the /// substituted returns for the authenticated user; /// pass an empty array to simulate a user with no audit permission. /// private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildHostAsync( string[] roles, IReadOnlyList[]? queryPages = null, bool ldapSucceeds = true) { var repo = Substitute.For(); if (queryPages is { Length: > 0 }) { var returns = queryPages .Select(p => Task.FromResult>(p)) .ToArray(); repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(returns[0], returns.Skip(1).ToArray()); } else { repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(Array.Empty())); } // Substituted LDAP bind — AuthenticateAsync is virtual (test seam). var ldap = Substitute.For( Options.Create(new SecurityOptions()), Substitute.For>()); ldap.AuthenticateAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(ldapSucceeds ? new LdapAuthResult(true, "Auditor", "auditor", new[] { "cn=audit" }, null) : new LdapAuthResult(false, null, null, null, "Bad credentials.")); // Substituted role mapper — MapGroupsToRolesAsync is virtual (test seam). var roleMapper = Substitute.For(Substitute.For()); roleMapper.MapGroupsToRolesAsync(Arg.Any>(), Arg.Any()) .Returns(new RoleMappingResult(roles, Array.Empty(), IsSystemWideDeployment: true)); var hostBuilder = new HostBuilder() .ConfigureWebHost(web => { web.UseTestServer(); web.ConfigureServices(services => { services.AddRouting(); services.AddSingleton(repo); services.AddSingleton(ldap); services.AddSingleton(roleMapper); }); web.Configure(app => { app.UseRouting(); app.UseEndpoints(endpoints => endpoints.MapAuditAPI()); }); }); var host = await hostBuilder.StartAsync(); return (host.GetTestClient(), repo, host); } private static HttpRequestMessage Get(string url, string credential = BasicCredential) { var request = new HttpRequestMessage(HttpMethod.Get, url); if (credential.Length > 0) { request.Headers.Authorization = new AuthenticationHeaderValue( "Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes(credential))); } return request; } // ───────────────────────────────────────────────────────────────────── // /api/audit/query // ───────────────────────────────────────────────────────────────────── [Fact] public async Task Query_ValidParams_ReturnsJsonPage() { var (client, _, host) = await BuildHostAsync( roles: new[] { "Audit" }, queryPages: new[] { (IReadOnlyList)new[] { SampleEvent() } }); using (host) { var response = await client.SendAsync(Get( "/api/audit/query?channel=ApiOutbound&status=Delivered&pageSize=100")); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("application/json", response.Content.Headers.ContentType!.MediaType); using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); var events = doc.RootElement.GetProperty("events"); Assert.Equal(1, events.GetArrayLength()); Assert.Equal("11111111-1111-1111-1111-111111111111", events[0].GetProperty("eventId").GetString()); // A short page (1 row < pageSize 100) means no further pages. Assert.Equal(JsonValueKind.Null, doc.RootElement.GetProperty("nextCursor").ValueKind); } } [Fact] public async Task Query_WithCursor_ReturnsNextPage() { // First page is FULL (pageSize=2 → 2 rows) so the response carries a // non-null nextCursor; the test then replays that cursor as the next // request and asserts the repo saw a keyset-paged AuditLogPaging. var pageOne = (IReadOnlyList)new[] { SampleEvent(Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001"), new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc)), SampleEvent(Guid.Parse("aaaaaaaa-0000-0000-0000-000000000002"), new DateTime(2026, 5, 20, 11, 0, 0, DateTimeKind.Utc)), }; var (client, repo, host) = await BuildHostAsync( roles: new[] { "Audit" }, queryPages: new[] { pageOne }); using (host) { var first = await client.SendAsync(Get("/api/audit/query?pageSize=2")); Assert.Equal(HttpStatusCode.OK, first.StatusCode); using var firstDoc = JsonDocument.Parse(await first.Content.ReadAsStringAsync()); var cursor = firstDoc.RootElement.GetProperty("nextCursor"); Assert.Equal(JsonValueKind.Object, cursor.ValueKind); var afterEventId = cursor.GetProperty("afterEventId").GetString()!; var afterOccurredAt = cursor.GetProperty("afterOccurredAtUtc").GetString()!; Assert.Equal("aaaaaaaa-0000-0000-0000-000000000002", afterEventId); // Replay the cursor — the endpoint must thread it into AuditLogPaging. var second = await client.SendAsync(Get( $"/api/audit/query?pageSize=2&afterEventId={afterEventId}&afterOccurredAtUtc={Uri.EscapeDataString(afterOccurredAt)}")); Assert.Equal(HttpStatusCode.OK, second.StatusCode); await repo.Received().QueryAsync( Arg.Any(), Arg.Is(p => p.PageSize == 2 && p.AfterEventId == Guid.Parse("aaaaaaaa-0000-0000-0000-000000000002") && p.AfterOccurredAtUtc != null), Arg.Any()); } } [Fact] public async Task Query_WithoutOperationalAudit_Returns403() { // A user whose only role is Design holds neither OperationalAudit nor // AuditExport — the query endpoint must 403. var (client, _, host) = await BuildHostAsync(roles: new[] { "Design" }); using (host) { var response = await client.SendAsync(Get("/api/audit/query")); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } } [Fact] public async Task Query_WithoutCredentials_Returns401() { var (client, _, host) = await BuildHostAsync(roles: new[] { "Audit" }); using (host) { var response = await client.SendAsync(Get("/api/audit/query", credential: "")); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } } [Fact] public async Task Query_AuditReadOnlyRole_IsAllowed() { // AuditReadOnly satisfies OperationalAudit (read) — query must succeed. var (client, _, host) = await BuildHostAsync(roles: new[] { "AuditReadOnly" }); using (host) { var response = await client.SendAsync(Get("/api/audit/query")); Assert.Equal(HttpStatusCode.OK, response.StatusCode); } } // ───────────────────────────────────────────────────────────────────── // /api/audit/export // ───────────────────────────────────────────────────────────────────── [Fact] public async Task Export_Csv_StreamsContent_WithCsvContentType() { var (client, _, host) = await BuildHostAsync( roles: new[] { "Audit" }, queryPages: new[] { (IReadOnlyList)new[] { SampleEvent() }, (IReadOnlyList)Array.Empty(), }); using (host) { var response = await client.SendAsync(Get("/api/audit/export?format=csv")); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("text/csv", response.Content.Headers.ContentType!.MediaType); var disposition = response.Content.Headers.ContentDisposition; Assert.NotNull(disposition); Assert.Equal("attachment", disposition!.DispositionType); Assert.EndsWith(".csv", disposition.FileName, StringComparison.OrdinalIgnoreCase); var body = await response.Content.ReadAsStringAsync(); Assert.StartsWith("EventId,OccurredAtUtc,IngestedAtUtc,", body); Assert.Contains("11111111-1111-1111-1111-111111111111", body); } } [Fact] public async Task Export_Csv_DefaultsWhenFormatOmitted() { // No format= param → csv default. var (client, _, host) = await BuildHostAsync( roles: new[] { "Audit" }, queryPages: new[] { (IReadOnlyList)Array.Empty() }); using (host) { var response = await client.SendAsync(Get("/api/audit/export")); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("text/csv", response.Content.Headers.ContentType!.MediaType); } } [Fact] public async Task Export_Jsonl_StreamsOnePerLine() { var (client, _, host) = await BuildHostAsync( roles: new[] { "Audit" }, queryPages: new[] { (IReadOnlyList)new[] { SampleEvent(Guid.Parse("bbbbbbbb-0000-0000-0000-000000000001")), SampleEvent(Guid.Parse("bbbbbbbb-0000-0000-0000-000000000002")), }, (IReadOnlyList)Array.Empty(), }); using (host) { var response = await client.SendAsync(Get("/api/audit/export?format=jsonl")); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("application/x-ndjson", response.Content.Headers.ContentType!.MediaType); var body = await response.Content.ReadAsStringAsync(); var lines = body.Split('\n', StringSplitOptions.RemoveEmptyEntries); Assert.Equal(2, lines.Length); // Each line must be a standalone, parseable JSON object. foreach (var line in lines) { using var doc = JsonDocument.Parse(line); Assert.Equal(JsonValueKind.Object, doc.RootElement.ValueKind); Assert.True(doc.RootElement.TryGetProperty("eventId", out _)); } } } [Fact] public async Task Export_Parquet_Returns501() { // Parquet archival is deferred to v1.x (Component-AuditLog.md) — no // library is referenced, so the endpoint returns 501 with guidance. var (client, _, host) = await BuildHostAsync(roles: new[] { "Audit" }); using (host) { var response = await client.SendAsync(Get("/api/audit/export?format=parquet")); Assert.Equal(HttpStatusCode.NotImplemented, response.StatusCode); var body = await response.Content.ReadAsStringAsync(); Assert.Contains("Parquet export deferred to v1.x", body); } } [Fact] public async Task Export_WithoutAuditExport_Returns403() { // AuditReadOnly grants read (OperationalAudit) but NOT bulk export // (AuditExport) — the export endpoint must 403. var (client, _, host) = await BuildHostAsync(roles: new[] { "AuditReadOnly" }); using (host) { var response = await client.SendAsync(Get("/api/audit/export?format=csv")); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } } [Fact] public async Task Export_UnsupportedFormat_Returns400() { var (client, _, host) = await BuildHostAsync(roles: new[] { "Audit" }); using (host) { var response = await client.SendAsync(Get("/api/audit/export?format=xml")); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } } // ───────────────────────────────────────────────────────────────────── // Query-string parsing units // ───────────────────────────────────────────────────────────────────── [Fact] public void ParsePaging_ClampsPageSizeToMax() { var query = new Microsoft.AspNetCore.Http.QueryCollection( new Dictionary { ["pageSize"] = "999999", }); var paging = AuditEndpoints.ParsePaging(query); Assert.Equal(AuditEndpoints.MaxPageSize, paging.PageSize); } [Fact] public void ParseFilter_RepeatedParams_ParseIntoMultiValueLists() { // Repeated query params (channel=A&channel=B …) must widen to multi-value // filter lists — one element per supplied value. var query = new Microsoft.AspNetCore.Http.QueryCollection( new Dictionary { ["channel"] = new[] { "ApiOutbound", "DbOutbound" }, ["kind"] = new[] { "ApiCall", "DbWrite" }, ["status"] = new[] { "Failed", "Parked" }, ["sourceSiteId"] = new[] { "plant-a", "plant-b" }, }); var filter = AuditEndpoints.ParseFilter(query); Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.DbOutbound }, filter.Channels); Assert.Equal(new[] { AuditKind.ApiCall, AuditKind.DbWrite }, filter.Kinds); Assert.Equal(new[] { AuditStatus.Failed, AuditStatus.Parked }, filter.Statuses); Assert.Equal(new[] { "plant-a", "plant-b" }, filter.SourceSiteIds); } [Fact] public void ParseFilter_SingleParam_ParsesIntoOneElementList() { // The single-valued contract still holds — one param yields a // one-element list, not a scalar. var query = new Microsoft.AspNetCore.Http.QueryCollection( new Dictionary { ["channel"] = "ApiOutbound", ["status"] = "Delivered", }); var filter = AuditEndpoints.ParseFilter(query); Assert.Equal(new[] { AuditChannel.ApiOutbound }, filter.Channels); Assert.Equal(new[] { AuditStatus.Delivered }, filter.Statuses); Assert.Null(filter.Kinds); Assert.Null(filter.SourceSiteIds); } [Fact] public void ParseFilter_UnparseableValuesInRepeatedSet_AreDroppedSilently() { // Lax-parse contract: an unrecognised enum name is dropped, the rest of // the repeated set survives — no 400, no whole-set drop. var query = new Microsoft.AspNetCore.Http.QueryCollection( new Dictionary { ["channel"] = new[] { "ApiOutbound", "Bogus", "Notification" }, ["status"] = new[] { "Nonsense" }, }); var filter = AuditEndpoints.ParseFilter(query); Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.Notification }, filter.Channels); // Every value unparseable → the dimension stays unconstrained (null). Assert.Null(filter.Statuses); } [Fact] public void ParseFilter_ExecutionId_ParsesIntoSingleValueGuid() { // executionId is a single-value Guid? filter — mirrors correlationId. var executionId = Guid.NewGuid(); var query = new Microsoft.AspNetCore.Http.QueryCollection( new Dictionary { ["executionId"] = executionId.ToString(), }); var filter = AuditEndpoints.ParseFilter(query); Assert.Equal(executionId, filter.ExecutionId); } [Fact] public void ParseFilter_UnparseableExecutionId_IsDroppedSilently() { // Lax-parse contract: an unparseable executionId is dropped (no 400) — // mirrors the correlationId parse. var query = new Microsoft.AspNetCore.Http.QueryCollection( new Dictionary { ["executionId"] = "not-a-guid", }); var filter = AuditEndpoints.ParseFilter(query); Assert.Null(filter.ExecutionId); } [Fact] public void ParseFilter_ParentExecutionId_ParsesIntoSingleValueGuid() { // parentExecutionId is a single-value Guid? filter — mirrors executionId. var parentExecutionId = Guid.NewGuid(); var query = new Microsoft.AspNetCore.Http.QueryCollection( new Dictionary { ["parentExecutionId"] = parentExecutionId.ToString(), }); var filter = AuditEndpoints.ParseFilter(query); Assert.Equal(parentExecutionId, filter.ParentExecutionId); } [Fact] public void ParseFilter_UnparseableParentExecutionId_IsDroppedSilently() { // Lax-parse contract: an unparseable parentExecutionId is dropped (no 400) — // mirrors the executionId parse. var query = new Microsoft.AspNetCore.Http.QueryCollection( new Dictionary { ["parentExecutionId"] = "not-a-guid", }); var filter = AuditEndpoints.ParseFilter(query); Assert.Null(filter.ParentExecutionId); } [Fact] public async Task Query_RepeatedChannelParams_ReachRepositoryAsMultiValueFilter() { // End-to-end: a repeated channel= query param must surface at the // repository as a two-element Channels list. var (client, repo, host) = await BuildHostAsync(roles: new[] { "Audit" }); using (host) { var response = await client.SendAsync(Get( "/api/audit/query?channel=ApiOutbound&channel=DbOutbound")); Assert.Equal(HttpStatusCode.OK, response.StatusCode); await repo.Received().QueryAsync( Arg.Is(f => f.Channels != null && f.Channels.Count == 2 && f.Channels.Contains(AuditChannel.ApiOutbound) && f.Channels.Contains(AuditChannel.DbOutbound)), Arg.Any(), Arg.Any()); } } [Fact] public void ParsePaging_HalfSuppliedCursor_IsDropped() { // afterEventId without afterOccurredAtUtc is an invalid keyset cursor — // both must be dropped so the repository gets a first-page request. var query = new Microsoft.AspNetCore.Http.QueryCollection( new Dictionary { ["afterEventId"] = Guid.NewGuid().ToString(), }); var paging = AuditEndpoints.ParsePaging(query); Assert.Null(paging.AfterEventId); Assert.Null(paging.AfterOccurredAtUtc); } }