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