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 ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.Auth.Abstractions.Ldap; using ZB.MOM.WW.ScadaBridge.ManagementService; using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.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"; // C3 (Task 2.5): canonical ZB.MOM.WW.Audit.AuditEvent via the shared factory; // the endpoint serializes the canonical record (eventId + occurredAtUtc top-level, // domain fields out of DetailsJson) so these tests assert on the JSON/CSV output. private static AuditEvent SampleEvent(Guid? id = null, DateTime? occurredAt = null) => ScadaBridgeAuditEventFactory.Create( channel: AuditChannel.ApiOutbound, kind: AuditKind.ApiCall, status: AuditStatus.Delivered, eventId: id ?? Guid.Parse("11111111-1111-1111-1111-111111111111"), occurredAtUtc: occurredAt ?? new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), sourceSiteId: "plant-a", 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 — the shared ILdapAuthService is the seam now (Task 1.2). var ldap = Substitute.For(); ldap.AuthenticateAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(ldapSucceeds ? LdapAuthResult.Success("auditor", "Auditor", new[] { "audit" }) : LdapAuthResult.Fail(LdapAuthFailure.BadCredentials)); // 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[] { "Administrator" }, 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[] { "Administrator" }, 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 Designer holds neither OperationalAudit nor // AuditExport — the query endpoint must 403. var (client, _, host) = await BuildHostAsync(roles: new[] { "Designer" }); 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[] { "Administrator" }); using (host) { var response = await client.SendAsync(Get("/api/audit/query", credential: "")); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } } [Fact] public async Task Query_ViewerRole_IsAllowed() { // Viewer (post Task 1.7 home of the former AuditReadOnly role) satisfies // OperationalAudit (read) — query must succeed. var (client, _, host) = await BuildHostAsync(roles: new[] { "Viewer" }); 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[] { "Administrator" }, 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[] { "Administrator" }, 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[] { "Administrator" }, 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[] { "Administrator" }); 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() { // Viewer (former AuditReadOnly) grants read (OperationalAudit) but NOT // bulk export (AuditExport) — the export endpoint must 403. This is the // preserved half-SoD after the Task 1.7 AuditReadOnly→Viewer collapse. var (client, _, host) = await BuildHostAsync(roles: new[] { "Viewer" }); 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[] { "Administrator" }); 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[] { "Administrator" }); 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); } // ───────────────────────────────────────────────────────────────────── // ApplySiteScope (Management-019) // ───────────────────────────────────────────────────────────────────── [Fact] public void ApplySiteScope_SystemWideUser_ReturnsFilterUnchanged() { // Empty PermittedSiteIds is the system-wide signal (Administrator, // system-wide Deployer, audit roles with no scope rules attached). The // filter should pass through with no restriction added. var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser( "alice", "Alice", new[] { "Administrator" }, Array.Empty()); var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-a" }); var result = AuditEndpoints.ApplySiteScope(filter, user); Assert.NotNull(result); Assert.Same(filter, result); } [Fact] public void ApplySiteScope_ScopedUser_EmptyCallerFilter_RestrictedToPermittedSet() { // No explicit sourceSiteId from the caller — the helper must restrict // the query to the user's permitted set, otherwise a site-scoped audit // user could read every site's rows. var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser( "alice", "Alice", new[] { "Viewer" }, new[] { "plant-a", "plant-b" }); var filter = new AuditLogQueryFilter(); var result = AuditEndpoints.ApplySiteScope(filter, user); Assert.NotNull(result); Assert.NotNull(result!.SourceSiteIds); Assert.Equal(new[] { "plant-a", "plant-b" }, result.SourceSiteIds!.OrderBy(s => s).ToArray()); } [Fact] public void ApplySiteScope_ScopedUser_ExplicitInScopeFilter_KeptVerbatim() { var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser( "alice", "Alice", new[] { "Viewer" }, new[] { "plant-a", "plant-b" }); var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-a" }); var result = AuditEndpoints.ApplySiteScope(filter, user); Assert.NotNull(result); Assert.Equal(new[] { "plant-a" }, result!.SourceSiteIds); } [Fact] public void ApplySiteScope_ScopedUser_ExplicitOutOfScopeFilter_ReturnsNull() { // Caller explicitly asked for a site they cannot see — the helper signals // "403" by returning null rather than silently producing an empty page. var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser( "alice", "Alice", new[] { "Viewer" }, new[] { "plant-a" }); var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-b" }); var result = AuditEndpoints.ApplySiteScope(filter, user); Assert.Null(result); } [Fact] public void ApplySiteScope_ScopedUser_MixedInAndOutOfScopeFilter_IntersectedToInScopeOnly() { var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser( "alice", "Alice", new[] { "Viewer" }, new[] { "plant-a" }); var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-a", "plant-b" }); var result = AuditEndpoints.ApplySiteScope(filter, user); Assert.NotNull(result); Assert.Equal(new[] { "plant-a" }, result!.SourceSiteIds); } // ───────────────────────────────────────────────────────────────────── // /api/audit/tree // ───────────────────────────────────────────────────────────────────── /// /// Builds a TestServer with the audit-log endpoints wired up and the repository /// stub returning the supplied for /// GetExecutionTreeAsync. /// private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildHostWithTreeAsync( string[] roles, IReadOnlyList? treeNodes = null) { var repo = Substitute.For(); // Default QueryAsync stub so the shared host initialisation does not fail. repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(Array.Empty())); var returnNodes = treeNodes ?? Array.Empty(); repo.GetExecutionTreeAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(returnNodes)); var ldap = Substitute.For(); ldap.AuthenticateAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(LdapAuthResult.Success("auditor", "Auditor", new[] { "audit" })); 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 ExecutionTreeNode MakeNode(Guid id, Guid? parentId = null, int rowCount = 2) => new ExecutionTreeNode( ExecutionId: id, ParentExecutionId: parentId, RowCount: rowCount, Channels: new[] { "ApiOutbound" }, Statuses: new[] { "Delivered" }, SourceSiteId: "plant-a", SourceInstanceId: "inst-1", FirstOccurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc), LastOccurredAtUtc: new DateTime(2026, 5, 20, 10, 1, 0, DateTimeKind.Utc)); [Fact] public async Task Tree_ValidExecutionId_ReturnsJsonArray() { var root = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001"); var child = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000002"); var nodes = new[] { MakeNode(root), MakeNode(child, parentId: root), }; var (client, repo, host) = await BuildHostWithTreeAsync( roles: new[] { "Administrator" }, treeNodes: nodes); using (host) { var response = await client.SendAsync(Get($"/api/audit/tree?executionId={root:D}")); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("application/json", response.Content.Headers.ContentType!.MediaType); using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); Assert.Equal(2, doc.RootElement.GetArrayLength()); await repo.Received(1).GetExecutionTreeAsync(root, Arg.Any()); } } [Fact] public async Task Tree_RepoReturnsEmpty_ReturnsEmptyArray() { var id = Guid.NewGuid(); var (client, _, host) = await BuildHostWithTreeAsync( roles: new[] { "Administrator" }, treeNodes: Array.Empty()); using (host) { var response = await client.SendAsync(Get($"/api/audit/tree?executionId={id:D}")); Assert.Equal(HttpStatusCode.OK, response.StatusCode); using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); Assert.Equal(0, doc.RootElement.GetArrayLength()); } } [Fact] public async Task Tree_MissingExecutionId_Returns400() { var (client, _, host) = await BuildHostWithTreeAsync(roles: new[] { "Administrator" }); using (host) { var response = await client.SendAsync(Get("/api/audit/tree")); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } } [Fact] public async Task Tree_InvalidExecutionId_Returns400() { var (client, _, host) = await BuildHostWithTreeAsync(roles: new[] { "Administrator" }); using (host) { var response = await client.SendAsync(Get("/api/audit/tree?executionId=not-a-guid")); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var body = await response.Content.ReadAsStringAsync(); Assert.Contains("BAD_REQUEST", body); } } [Fact] public async Task Tree_WithoutOperationalAudit_Returns403() { var (client, _, host) = await BuildHostWithTreeAsync(roles: new[] { "Designer" }); using (host) { var response = await client.SendAsync(Get($"/api/audit/tree?executionId={Guid.NewGuid():D}")); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } } [Fact] public async Task Tree_WithoutCredentials_Returns401() { var (client, _, host) = await BuildHostWithTreeAsync(roles: new[] { "Administrator" }); using (host) { var response = await client.SendAsync(Get($"/api/audit/tree?executionId={Guid.NewGuid():D}", credential: "")); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } } [Fact] public async Task Tree_ViewerRole_IsAllowed() { var (client, _, host) = await BuildHostWithTreeAsync(roles: new[] { "Viewer" }); using (host) { var response = await client.SendAsync(Get($"/api/audit/tree?executionId={Guid.NewGuid():D}")); Assert.Equal(HttpStatusCode.OK, response.StatusCode); } } // ───────────────────────────────────────────────────────────────────── // POST /api/audit/backfill-source-node (M5.6 T5) // ───────────────────────────────────────────────────────────────────── private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildHostWithBackfillAsync( string[] roles, long backfillResult = 42L, bool ldapSucceeds = true) { var repo = Substitute.For(); repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(Array.Empty())); repo.BackfillSourceNodeAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(backfillResult)); repo.GetExecutionTreeAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult>( Array.Empty())); var ldap = Substitute.For(); ldap.AuthenticateAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(ldapSucceeds ? LdapAuthResult.Success("auditor", "Auditor", new[] { "audit" }) : LdapAuthResult.Fail(LdapAuthFailure.BadCredentials)); 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 Post(string url, string body, string credential = BasicCredential) { var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = new StringContent(body, Encoding.UTF8, "application/json"), }; if (credential.Length > 0) { request.Headers.Authorization = new AuthenticationHeaderValue( "Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes(credential))); } return request; } [Fact] public async Task BackfillSourceNode_AdminRole_Returns200WithRowCount() { var (client, _, host) = await BuildHostWithBackfillAsync( roles: new[] { "Administrator" }, backfillResult: 12345L); using (host) { var response = await client.SendAsync(Post( "/api/audit/backfill-source-node", "{\"sentinel\":\"unknown\",\"before\":\"2026-01-01T00:00:00Z\"}")); Assert.Equal(HttpStatusCode.OK, response.StatusCode); using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); var root = doc.RootElement; Assert.Equal(12345L, root.GetProperty("rowsUpdated").GetInt64()); Assert.Equal("unknown", root.GetProperty("sentinel").GetString()); } } [Fact] public async Task BackfillSourceNode_ViewerRole_Returns403() { // Viewer has OperationalAudit but NOT the Admin-only backfill permission. var (client, _, host) = await BuildHostWithBackfillAsync(roles: new[] { "Viewer" }); using (host) { var response = await client.SendAsync(Post( "/api/audit/backfill-source-node", "{\"sentinel\":\"unknown\",\"before\":\"2026-01-01T00:00:00Z\"}")); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } } [Fact] public async Task BackfillSourceNode_NoCredentials_Returns401() { var (client, _, host) = await BuildHostWithBackfillAsync(roles: new[] { "Administrator" }); using (host) { var response = await client.SendAsync(Post( "/api/audit/backfill-source-node", "{\"sentinel\":\"unknown\",\"before\":\"2026-01-01T00:00:00Z\"}", credential: "")); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } } [Fact] public async Task BackfillSourceNode_MissingBefore_Returns400() { var (client, _, host) = await BuildHostWithBackfillAsync(roles: new[] { "Administrator" }); using (host) { // No "before" field — required. var response = await client.SendAsync(Post( "/api/audit/backfill-source-node", "{\"sentinel\":\"unknown\"}")); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } } [Fact] public async Task BackfillSourceNode_InvalidBeforeDate_Returns400() { var (client, _, host) = await BuildHostWithBackfillAsync(roles: new[] { "Administrator" }); using (host) { var response = await client.SendAsync(Post( "/api/audit/backfill-source-node", "{\"sentinel\":\"unknown\",\"before\":\"not-a-date\"}")); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } } [Fact] public async Task BackfillSourceNode_CustomSentinelAndBatch_PassedToRepo() { var (client, repo, host) = await BuildHostWithBackfillAsync( roles: new[] { "Administrator" }, backfillResult: 7L); using (host) { var response = await client.SendAsync(Post( "/api/audit/backfill-source-node", "{\"sentinel\":\"pre-feature\",\"before\":\"2026-01-01T00:00:00Z\",\"batchSize\":2000}")); Assert.Equal(HttpStatusCode.OK, response.StatusCode); await repo.Received(1).BackfillSourceNodeAsync( "pre-feature", Arg.Is(d => d.Year == 2026 && d.Month == 1 && d.Day == 1), 2000, Arg.Any()); } } [Fact] public async Task BackfillSourceNode_DefaultSentinel_IsUnknown_WhenOmitted() { var (client, repo, host) = await BuildHostWithBackfillAsync( roles: new[] { "Administrator" }, backfillResult: 0L); using (host) { // Omit "sentinel" — endpoint defaults to "unknown". var response = await client.SendAsync(Post( "/api/audit/backfill-source-node", "{\"before\":\"2026-01-01T00:00:00Z\"}")); Assert.Equal(HttpStatusCode.OK, response.StatusCode); await repo.Received(1).BackfillSourceNodeAsync( "unknown", Arg.Any(), Arg.Any(), Arg.Any()); } } }