test(auditlog): Inbound API request audit end-to-end (#23 M4)
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 — M4 Bundle E (Task E3) end-to-end audit trail for the
|
||||
/// inbound API surface. Wires the production
|
||||
/// <see cref="AuditWriteMiddleware"/> into a Microsoft.AspNetCore.TestHost
|
||||
/// pipeline that mirrors the production
|
||||
/// <c>UseAuthentication → UseAuditWriteMiddleware → POST /api/{methodName}</c>
|
||||
/// order, with the real <see cref="CentralAuditWriter"/> backed by the per-class
|
||||
/// <see cref="MsSqlMigrationFixture"/> MSSQL <c>AuditLog</c> table.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Three response shapes are covered: a happy-path 200 (with the actor
|
||||
/// resolved from <see cref="HttpContext.Items"/>), a 401 unauthenticated
|
||||
/// (Actor stays null, kind flips to
|
||||
/// <see cref="AuditKind.InboundAuthFailure"/>), and a 500 internal-error
|
||||
/// response. Each test uses a unique method name so concurrent tests sharing
|
||||
/// the fixture don't interfere.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The middleware-level unit tests already cover the recording-writer shape
|
||||
/// (<c>AuditWriteMiddlewareTests</c>) and the pipeline ordering
|
||||
/// (<c>MiddlewareOrderTests</c>); these tests verify the END-TO-END
|
||||
/// materialisation in the central <c>AuditLog</c> table — the production
|
||||
/// glue from request → writer → repository → MSSQL row.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class InboundApiAuditTests : IClassFixture<MsSqlMigrationFixture>
|
||||
{
|
||||
private readonly MsSqlMigrationFixture _fixture;
|
||||
|
||||
public InboundApiAuditTests(MsSqlMigrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-test unique method name suffix — the audit row's <c>Target</c>
|
||||
/// captures it so each test can query by <see cref="AuditLogQueryFilter.Target"/>
|
||||
/// without disturbing other tests using the same MSSQL fixture.
|
||||
/// </summary>
|
||||
private static string NewMethodName(string prefix) =>
|
||||
prefix + "-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||
|
||||
private ScadaLinkDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
||||
.UseSqlServer(_fixture.ConnectionString)
|
||||
.ConfigureWarnings(w => w.Ignore(
|
||||
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning))
|
||||
.Options;
|
||||
return new ScadaLinkDbContext(options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private async Task<IHost> 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<ScadaLinkDbContext>(opts =>
|
||||
opts.UseSqlServer(_fixture.ConnectionString)
|
||||
.ConfigureWarnings(w => w.Ignore(
|
||||
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
|
||||
services.AddScoped<IAuditLogRepository>(sp =>
|
||||
new AuditLogRepository(sp.GetRequiredService<ScadaLinkDbContext>()));
|
||||
services.AddSingleton<ICentralAuditWriter>(sp =>
|
||||
new CentralAuditWriter(sp, NullLogger<CentralAuditWriter>.Instance));
|
||||
|
||||
services.AddRouting();
|
||||
services.AddAuthorization();
|
||||
services.AddAuthentication("TestScheme")
|
||||
.AddScheme<AuthenticationSchemeOptions, AlwaysAuthenticatedHandler>(
|
||||
"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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal authentication handler that always succeeds — keeps
|
||||
/// <see cref="HttpContext.User"/> populated so the middleware's
|
||||
/// Items-then-User fallback path has a real principal to ignore. The
|
||||
/// middleware's primary actor resolution path uses
|
||||
/// <see cref="AuditWriteMiddleware.AuditActorItemKey"/> so this handler's
|
||||
/// claim never appears on the emitted Actor unless the endpoint stashes
|
||||
/// it explicitly.
|
||||
/// </summary>
|
||||
private sealed class AlwaysAuthenticatedHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public AlwaysAuthenticatedHandler(
|
||||
Microsoft.Extensions.Options.IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
Microsoft.Extensions.Logging.ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: base(options, logger, encoder) { }
|
||||
|
||||
protected override Task<AuthenticateResult> 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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queries the central <c>AuditLog</c> table for the row produced by the
|
||||
/// test's unique method name. Wrapped in <see cref="Assert"/> calls so the
|
||||
/// query can be used inside a polling helper.
|
||||
/// </summary>
|
||||
private async Task<IReadOnlyList<AuditEvent>> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Awaits the central <c>AuditLog</c> row materialising for
|
||||
/// <paramref name="methodName"/>. The writer is fire-and-forget so we
|
||||
/// poll briefly after the HTTP response returns to absorb scheduling
|
||||
/// jitter between the middleware's <c>finally</c> block and the row hitting
|
||||
/// MSSQL.
|
||||
/// </summary>
|
||||
private async Task<AuditEvent> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user