using System.Net; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging.Abstractions; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Types.Enums; using ScadaLink.InboundAPI.Middleware; namespace ScadaLink.InboundAPI.Tests.Middleware; /// /// M4 Bundle D (D1) — verifies emits exactly one /// row per request via /// covering all outcome shapes: /// success (InboundRequest/Delivered), client/server error (InboundRequest/Failed), /// and unauthenticated (InboundAuthFailure/Failed). Audit-write failures must NEVER /// alter the HTTP response (alog.md §13). /// public class AuditWriteMiddlewareTests { /// /// Test-only recording . Captures every /// the middleware emits so each test can assert on /// the shape of the row produced for one request. /// private sealed class RecordingAuditWriter : ICentralAuditWriter { public List Events { get; } = new(); public Func? OnWrite { get; set; } public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { lock (Events) { Events.Add(evt); } return OnWrite?.Invoke(evt) ?? Task.CompletedTask; } } /// /// Builds an primed for the inbound API route shape: /// POST /api/{methodName}, optional JSON body, RemoteIpAddress + User-Agent. /// The route value resolver mirrors the production endpoint mapping so the /// middleware can pull the method name without owning routing itself. /// private static DefaultHttpContext BuildContext( string methodName = "echo", string? body = null, string? userAgent = "test-agent/1.0", IPAddress? remoteIp = null) { var ctx = new DefaultHttpContext(); ctx.Request.Method = "POST"; ctx.Request.Path = $"/api/{methodName}"; ctx.Request.RouteValues["methodName"] = methodName; if (body is not null) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Request.Body = new MemoryStream(bytes); ctx.Request.ContentLength = bytes.Length; ctx.Request.ContentType = "application/json"; } if (userAgent is not null) { ctx.Request.Headers["User-Agent"] = userAgent; } ctx.Connection.RemoteIpAddress = remoteIp ?? IPAddress.Parse("10.0.0.5"); return ctx; } private static AuditWriteMiddleware CreateMiddleware( RequestDelegate next, ICentralAuditWriter writer) => new(next, writer, NullLogger.Instance); // --------------------------------------------------------------------- // 1. Happy path — InboundRequest/Delivered/HttpStatus 200 // --------------------------------------------------------------------- [Fact] public async Task Pipeline_Success_EmitsOneEvent_KindInboundRequest_StatusDelivered_HttpStatus200() { var writer = new RecordingAuditWriter(); var ctx = BuildContext(); var mw = CreateMiddleware(_ => { ctx.Response.StatusCode = 200; return Task.CompletedTask; }, writer); await mw.InvokeAsync(ctx); var evt = Assert.Single(writer.Events); Assert.Equal(AuditChannel.ApiInbound, evt.Channel); Assert.Equal(AuditKind.InboundRequest, evt.Kind); Assert.Equal(AuditStatus.Delivered, evt.Status); Assert.Equal(200, evt.HttpStatus); // Central direct-write — no ForwardState (alog.md §6). Assert.Null(evt.ForwardState); Assert.NotEqual(Guid.Empty, evt.EventId); Assert.Equal("echo", evt.Target); } // --------------------------------------------------------------------- // 2. 400 — script/validation failure path // --------------------------------------------------------------------- [Fact] public async Task Pipeline_400_EmitsEvent_Status_Failed_HttpStatus400() { var writer = new RecordingAuditWriter(); var ctx = BuildContext(); var mw = CreateMiddleware(_ => { ctx.Response.StatusCode = 400; return Task.CompletedTask; }, writer); await mw.InvokeAsync(ctx); var evt = Assert.Single(writer.Events); // A 400 is a request the auth succeeded for — still InboundRequest, not // InboundAuthFailure. Only 401/403 maps to the auth-failure kind. Assert.Equal(AuditKind.InboundRequest, evt.Kind); Assert.Equal(AuditStatus.Failed, evt.Status); Assert.Equal(400, evt.HttpStatus); } // --------------------------------------------------------------------- // 3. 401 — auth failure path // --------------------------------------------------------------------- [Fact] public async Task Pipeline_401_EmitsEvent_KindInboundAuthFailure_StatusFailed() { var writer = new RecordingAuditWriter(); var ctx = BuildContext(); var mw = CreateMiddleware(_ => { ctx.Response.StatusCode = 401; return Task.CompletedTask; }, writer); await mw.InvokeAsync(ctx); var evt = Assert.Single(writer.Events); Assert.Equal(AuditKind.InboundAuthFailure, evt.Kind); Assert.Equal(AuditStatus.Failed, evt.Status); Assert.Equal(401, evt.HttpStatus); // The candidate API key never resolved to a name, so Actor stays null — // never echo back an unauthenticated principal. Assert.Null(evt.Actor); } [Fact] public async Task Pipeline_403_EmitsEvent_KindInboundAuthFailure_StatusFailed() { var writer = new RecordingAuditWriter(); var ctx = BuildContext(); var mw = CreateMiddleware(_ => { ctx.Response.StatusCode = 403; return Task.CompletedTask; }, writer); await mw.InvokeAsync(ctx); var evt = Assert.Single(writer.Events); Assert.Equal(AuditKind.InboundAuthFailure, evt.Kind); Assert.Equal(AuditStatus.Failed, evt.Status); Assert.Equal(403, evt.HttpStatus); } // --------------------------------------------------------------------- // 4. 500 — handler threw OR returned 500 // --------------------------------------------------------------------- [Fact] public async Task Pipeline_500_EmitsEvent_Status_Failed() { var writer = new RecordingAuditWriter(); var ctx = BuildContext(); var mw = CreateMiddleware(_ => { ctx.Response.StatusCode = 500; return Task.CompletedTask; }, writer); await mw.InvokeAsync(ctx); var evt = Assert.Single(writer.Events); Assert.Equal(AuditKind.InboundRequest, evt.Kind); Assert.Equal(AuditStatus.Failed, evt.Status); Assert.Equal(500, evt.HttpStatus); } [Fact] public async Task Pipeline_Throws_EmitsEvent_Status_Failed_And_Rethrows() { var writer = new RecordingAuditWriter(); var ctx = BuildContext(); var boom = new InvalidOperationException("kaboom"); var mw = CreateMiddleware(_ => throw boom, writer); // The middleware MUST re-throw so the request's own error path is // authoritative — audit emission is best-effort only. var thrown = await Assert.ThrowsAsync( () => mw.InvokeAsync(ctx)); Assert.Same(boom, thrown); var evt = Assert.Single(writer.Events); Assert.Equal(AuditStatus.Failed, evt.Status); Assert.Equal("kaboom", evt.ErrorMessage); } // --------------------------------------------------------------------- // 5. Actor resolution — the endpoint handler stashes the API key name // AFTER successful auth so the middleware can pick it up from // HttpContext.Items. // --------------------------------------------------------------------- [Fact] public async Task ApiKeyName_Resolved_From_HttpContext_AsActor() { var writer = new RecordingAuditWriter(); var ctx = BuildContext(); var mw = CreateMiddleware(_ => { // The endpoint handler is expected to stash the resolved API key // name here once ApiKeyValidator.ValidateAsync has succeeded. ctx.Items[AuditWriteMiddleware.AuditActorItemKey] = "integration-svc"; ctx.Response.StatusCode = 200; return Task.CompletedTask; }, writer); await mw.InvokeAsync(ctx); var evt = Assert.Single(writer.Events); Assert.Equal("integration-svc", evt.Actor); } // --------------------------------------------------------------------- // 6. Writer failure must NEVER alter the HTTP response // --------------------------------------------------------------------- [Fact] public async Task AuditWriter_Throws_HttpResponse_Unchanged_Success_Stays_Success() { var writer = new RecordingAuditWriter { OnWrite = _ => throw new InvalidOperationException("writer offline"), }; var ctx = BuildContext(); var mw = CreateMiddleware(_ => { ctx.Response.StatusCode = 200; return Task.CompletedTask; }, writer); // Audit emission is best-effort; even a thrown writer must NOT bubble // up and contaminate the user-facing response status. await mw.InvokeAsync(ctx); Assert.Equal(200, ctx.Response.StatusCode); } [Fact] public async Task AuditWriter_Throws_OnFailedRequest_HttpResponse_Unchanged() { var writer = new RecordingAuditWriter { OnWrite = _ => throw new InvalidOperationException("writer offline"), }; var ctx = BuildContext(); var mw = CreateMiddleware(_ => { ctx.Response.StatusCode = 500; return Task.CompletedTask; }, writer); await mw.InvokeAsync(ctx); Assert.Equal(500, ctx.Response.StatusCode); } // --------------------------------------------------------------------- // 7. Provenance — RemoteIp + User-Agent surface in Extra JSON // --------------------------------------------------------------------- [Fact] public async Task RemoteIp_And_UserAgent_AppearInExtra() { var writer = new RecordingAuditWriter(); var ctx = BuildContext( userAgent: "curl/8.4.0", remoteIp: IPAddress.Parse("192.168.50.50")); var mw = CreateMiddleware(_ => { ctx.Response.StatusCode = 200; return Task.CompletedTask; }, writer); await mw.InvokeAsync(ctx); var evt = Assert.Single(writer.Events); Assert.NotNull(evt.Extra); using var doc = JsonDocument.Parse(evt.Extra!); var root = doc.RootElement; Assert.Equal("192.168.50.50", root.GetProperty("remoteIp").GetString()); Assert.Equal("curl/8.4.0", root.GetProperty("userAgent").GetString()); } // --------------------------------------------------------------------- // Body capture — the small JSON body is buffered and stashed on // RequestSummary so subsequent reads (the endpoint handler's // JsonDocument.Parse) still see the full payload. // --------------------------------------------------------------------- [Fact] public async Task RequestBody_IsBuffered_AndStashed_OnRequestSummary() { var writer = new RecordingAuditWriter(); var requestJson = "{\"x\":1}"; var ctx = BuildContext(body: requestJson); string? observedAfterMiddleware = null; var mw = CreateMiddleware(async hc => { // Downstream code must still be able to read the body — the // middleware enables buffering and rewinds so the handler sees the // unconsumed stream. using var reader = new StreamReader(hc.Request.Body); observedAfterMiddleware = await reader.ReadToEndAsync(); hc.Response.StatusCode = 200; }, writer); await mw.InvokeAsync(ctx); Assert.Equal(requestJson, observedAfterMiddleware); var evt = Assert.Single(writer.Events); Assert.Equal(requestJson, evt.RequestSummary); } // --------------------------------------------------------------------- // Execution id — Audit Log #23: each inbound row carries a fresh // per-request execution id so inbound rows are correlatable. The inbound // row's CorrelationId stays null — CorrelationId is purely the // per-operation-lifecycle id and an inbound request is a one-shot. // --------------------------------------------------------------------- [Fact] public async Task InboundRow_CarriesNonNull_ExecutionId_And_NullCorrelationId() { var writer = new RecordingAuditWriter(); var ctx = BuildContext(); var mw = CreateMiddleware(_ => { ctx.Response.StatusCode = 200; return Task.CompletedTask; }, writer); await mw.InvokeAsync(ctx); var evt = Assert.Single(writer.Events); Assert.NotNull(evt.ExecutionId); Assert.NotEqual(Guid.Empty, evt.ExecutionId!.Value); // CorrelationId is the per-operation-lifecycle id; an inbound request // is a one-shot with no multi-row operation to correlate. Assert.Null(evt.CorrelationId); } [Fact] public async Task SeparateRequests_GetDistinct_ExecutionIds() { var writer = new RecordingAuditWriter(); var mw = CreateMiddleware(hc => { hc.Response.StatusCode = 200; return Task.CompletedTask; }, writer); await mw.InvokeAsync(BuildContext()); await mw.InvokeAsync(BuildContext()); Assert.Equal(2, writer.Events.Count); Assert.NotEqual(writer.Events[0].ExecutionId, writer.Events[1].ExecutionId); } [Fact] public async Task DurationMs_IsRecorded() { var writer = new RecordingAuditWriter(); var ctx = BuildContext(); var mw = CreateMiddleware(async _ => { // The middleware records elapsed milliseconds — a small delay // ensures DurationMs is non-negative and roughly tracks reality // without being flake-sensitive in CI. await Task.Delay(5); ctx.Response.StatusCode = 200; }, writer); await mw.InvokeAsync(ctx); var evt = Assert.Single(writer.Events); Assert.NotNull(evt.DurationMs); Assert.True(evt.DurationMs >= 0); } }