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