diff --git a/tests/ScadaLink.AuditLog.Tests/Integration/InboundApiAuditTests.cs b/tests/ScadaLink.AuditLog.Tests/Integration/InboundApiAuditTests.cs new file mode 100644 index 0000000..a881e56 --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Integration/InboundApiAuditTests.cs @@ -0,0 +1,298 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using ScadaLink.AuditLog.Central; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Interfaces.Services; +using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; +using ScadaLink.ConfigurationDatabase; +using ScadaLink.ConfigurationDatabase.Repositories; +using ScadaLink.ConfigurationDatabase.Tests.Migrations; +using ScadaLink.InboundAPI.Middleware; +using System.Security.Claims; +using System.Text; +using System.Text.Encodings.Web; + +namespace ScadaLink.AuditLog.Tests.Integration; + +/// +/// Audit Log #23 — M4 Bundle E (Task E3) end-to-end audit trail for the +/// inbound API surface. Wires the production +/// into a Microsoft.AspNetCore.TestHost +/// pipeline that mirrors the production +/// UseAuthentication → UseAuditWriteMiddleware → POST /api/{methodName} +/// order, with the real backed by the per-class +/// MSSQL AuditLog table. +/// +/// +/// +/// Three response shapes are covered: a happy-path 200 (with the actor +/// resolved from ), a 401 unauthenticated +/// (Actor stays null, kind flips to +/// ), and a 500 internal-error +/// response. Each test uses a unique method name so concurrent tests sharing +/// the fixture don't interfere. +/// +/// +/// The middleware-level unit tests already cover the recording-writer shape +/// (AuditWriteMiddlewareTests) and the pipeline ordering +/// (MiddlewareOrderTests); these tests verify the END-TO-END +/// materialisation in the central AuditLog table — the production +/// glue from request → writer → repository → MSSQL row. +/// +/// +public class InboundApiAuditTests : IClassFixture +{ + private readonly MsSqlMigrationFixture _fixture; + + public InboundApiAuditTests(MsSqlMigrationFixture fixture) + { + _fixture = fixture; + } + + /// + /// Per-test unique method name suffix — the audit row's Target + /// captures it so each test can query by + /// without disturbing other tests using the same MSSQL fixture. + /// + private static string NewMethodName(string prefix) => + prefix + "-" + Guid.NewGuid().ToString("N").Substring(0, 8); + + private ScadaLinkDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(_fixture.ConnectionString) + .ConfigureWarnings(w => w.Ignore( + Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)) + .Options; + return new ScadaLinkDbContext(options); + } + + /// + /// Spins up a minimal in-memory ASP.NET host whose pipeline mirrors the + /// production arrangement. The endpoint handler delegate is supplied by + /// each test so it can shape the response (200 with an actor, 401 + /// auth-fail, 500 server error) the way the production handler would. + /// + private async Task BuildHostAsync(RequestDelegate endpointHandler) + { + var hostBuilder = new HostBuilder() + .ConfigureWebHost(webBuilder => + { + webBuilder + .UseTestServer() + .ConfigureServices(services => + { + // Real EF DbContext + AuditLogRepository wired against + // the per-class MSSQL fixture. CentralAuditWriter is a + // singleton — same pattern the production Host uses — + // opening a fresh scope per call to resolve the scoped + // repository. + services.AddDbContext(opts => + opts.UseSqlServer(_fixture.ConnectionString) + .ConfigureWarnings(w => w.Ignore( + Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning))); + services.AddScoped(sp => + new AuditLogRepository(sp.GetRequiredService())); + services.AddSingleton(sp => + new CentralAuditWriter(sp, NullLogger.Instance)); + + services.AddRouting(); + services.AddAuthorization(); + services.AddAuthentication("TestScheme") + .AddScheme( + "TestScheme", _ => { }); + }) + .Configure(app => + { + // Mirror production order: routing → auth → audit + // middleware → endpoint. The auth scheme always + // succeeds; per-request auth-failure semantics are + // produced INSIDE the endpoint handler (mirroring + // ApiKeyValidator's in-handler short-circuit). + app.UseRouting(); + app.UseAuthentication(); + app.UseAuthorization(); + app.UseAuditWriteMiddleware(); + app.UseEndpoints(endpoints => + { + endpoints.MapPost("/api/{methodName}", endpointHandler); + }); + }); + }); + + return await hostBuilder.StartAsync(); + } + + /// + /// Minimal authentication handler that always succeeds — keeps + /// populated so the middleware's + /// Items-then-User fallback path has a real principal to ignore. The + /// middleware's primary actor resolution path uses + /// so this handler's + /// claim never appears on the emitted Actor unless the endpoint stashes + /// it explicitly. + /// + private sealed class AlwaysAuthenticatedHandler : AuthenticationHandler + { + public AlwaysAuthenticatedHandler( + Microsoft.Extensions.Options.IOptionsMonitor options, + Microsoft.Extensions.Logging.ILoggerFactory logger, + UrlEncoder encoder) + : base(options, logger, encoder) { } + + protected override Task HandleAuthenticateAsync() + { + var identity = new ClaimsIdentity( + new[] { new Claim(ClaimTypes.Name, "framework-user") }, "TestScheme"); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, "TestScheme"); + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + } + + /// + /// Queries the central AuditLog table for the row produced by the + /// test's unique method name. Wrapped in calls so the + /// query can be used inside a polling helper. + /// + private async Task> QueryByTargetAsync(string methodName) + { + await using var ctx = CreateContext(); + var repo = new AuditLogRepository(ctx); + return await repo.QueryAsync( + new AuditLogQueryFilter(Target: methodName), + new AuditLogPaging(PageSize: 10)); + } + + /// + /// Awaits the central AuditLog row materialising for + /// . The writer is fire-and-forget so we + /// poll briefly after the HTTP response returns to absorb scheduling + /// jitter between the middleware's finally block and the row hitting + /// MSSQL. + /// + private async Task AwaitOneAsync(string methodName) + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); + while (DateTime.UtcNow < deadline) + { + var rows = await QueryByTargetAsync(methodName); + if (rows.Count > 0) + { + return Assert.Single(rows); + } + await Task.Delay(100); + } + + // Fall through to a final query so the failure message carries the + // actual row count from the last attempt. + var finalRows = await QueryByTargetAsync(methodName); + return Assert.Single(finalRows); + } + + [SkippableFact] + public async Task PostToApi_WithValidActor_Emits_InboundRequest_StatusDelivered_HttpStatus200_ActorPopulated() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + var methodName = NewMethodName("echo"); + + using var host = await BuildHostAsync(async ctx => + { + // Simulate the production endpoint stashing the resolved API key + // name on HttpContext.Items AFTER successful auth — the middleware + // reads it in its finally block to populate Actor. + ctx.Items[AuditWriteMiddleware.AuditActorItemKey] = "integration-svc"; + ctx.Response.StatusCode = 200; + await ctx.Response.WriteAsync("ok"); + }); + + var client = host.GetTestClient(); + var resp = await client.PostAsync( + $"/api/{methodName}", + new StringContent("{\"x\":1}", Encoding.UTF8, "application/json")); + Assert.Equal(System.Net.HttpStatusCode.OK, resp.StatusCode); + + var evt = await AwaitOneAsync(methodName); + Assert.Equal(AuditChannel.ApiInbound, evt.Channel); + Assert.Equal(AuditKind.InboundRequest, evt.Kind); + Assert.Equal(AuditStatus.Delivered, evt.Status); + Assert.Equal(200, evt.HttpStatus); + Assert.Equal("integration-svc", evt.Actor); + // Central direct-write — no site-local forward state (alog.md §6). + Assert.Null(evt.ForwardState); + // IngestedAtUtc stamped by the central writer. + Assert.NotNull(evt.IngestedAtUtc); + } + + [SkippableFact] + public async Task PostToApi_Without_Auth_Emits_InboundAuthFailure_StatusFailed_HttpStatus401_ActorNull() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + var methodName = NewMethodName("auth-fail"); + + using var host = await BuildHostAsync(async ctx => + { + // The production ApiKeyValidator returns 401 from inside the + // handler when the X-API-Key header is missing or invalid; the + // handler must NOT stash an actor name in that case so the + // middleware emits Actor=null on the resulting audit row. + ctx.Response.StatusCode = 401; + await ctx.Response.WriteAsync("unauthorized"); + }); + + var client = host.GetTestClient(); + var resp = await client.PostAsync( + $"/api/{methodName}", + new StringContent("{}", Encoding.UTF8, "application/json")); + Assert.Equal(System.Net.HttpStatusCode.Unauthorized, resp.StatusCode); + + var evt = await AwaitOneAsync(methodName); + Assert.Equal(AuditChannel.ApiInbound, evt.Channel); + Assert.Equal(AuditKind.InboundAuthFailure, evt.Kind); + Assert.Equal(AuditStatus.Failed, evt.Status); + Assert.Equal(401, evt.HttpStatus); + // Never echo back an unauthenticated principal — middleware suppresses + // the framework user resolution on 401/403 paths. + Assert.Null(evt.Actor); + } + + [SkippableFact] + public async Task PostToApi_Returning500_Emits_InboundRequest_StatusFailed_HttpStatus500() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + var methodName = NewMethodName("server-error"); + + using var host = await BuildHostAsync(async ctx => + { + // A handler-returned 500 (not a throw) — auth succeeded so Actor + // resolution is still allowed; the audit row's Kind stays + // InboundRequest (not InboundAuthFailure) and Status flips to + // Failed because the response is not a 2xx. + ctx.Items[AuditWriteMiddleware.AuditActorItemKey] = "integration-svc"; + ctx.Response.StatusCode = 500; + await ctx.Response.WriteAsync("kaboom"); + }); + + var client = host.GetTestClient(); + var resp = await client.PostAsync( + $"/api/{methodName}", + new StringContent("{}", Encoding.UTF8, "application/json")); + Assert.Equal(System.Net.HttpStatusCode.InternalServerError, resp.StatusCode); + + var evt = await AwaitOneAsync(methodName); + Assert.Equal(AuditChannel.ApiInbound, evt.Channel); + Assert.Equal(AuditKind.InboundRequest, evt.Kind); + Assert.Equal(AuditStatus.Failed, evt.Status); + Assert.Equal(500, evt.HttpStatus); + Assert.Equal("integration-svc", evt.Actor); + } +}