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