299 lines
13 KiB
C#
299 lines
13 KiB
C#
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);
|
|
}
|
|
}
|