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);
}
// ---------------------------------------------------------------------
// ParentExecutionId — Audit Log #23 (ParentExecutionId feature, T3): the
// inbound request's ExecutionId is minted ONCE, early, and stashed on
// HttpContext.Items so the endpoint handler can carry it onto the routed
// RouteToCallRequest as ParentExecutionId. The inbound row that the
// middleware itself emits stays top-level — its own ParentExecutionId is
// NEVER set.
// ---------------------------------------------------------------------
[Fact]
public async Task InboundExecutionId_IsStashedOnHttpItems_BeforeEndpointRuns()
{
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
object? observedDuringHandler = null;
var mw = CreateMiddleware(hc =>
{
// The endpoint handler must be able to read the early-minted id —
// it is stashed before _next so a downstream reader sees it.
hc.Items.TryGetValue(AuditWriteMiddleware.InboundExecutionIdItemKey, out observedDuringHandler);
hc.Response.StatusCode = 200;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(ctx);
var stashed = Assert.IsType(observedDuringHandler);
Assert.NotEqual(Guid.Empty, stashed);
}
[Fact]
public async Task InboundRow_ExecutionId_Equals_TheEarlyMintedStashedId()
{
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
Guid stashedDuringHandler = Guid.Empty;
var mw = CreateMiddleware(hc =>
{
stashedDuringHandler =
(Guid)hc.Items[AuditWriteMiddleware.InboundExecutionIdItemKey]!;
hc.Response.StatusCode = 200;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(ctx);
// The inbound audit row's ExecutionId must be the SAME id minted early
// and shared with the endpoint handler — not a second, late mint.
var evt = Assert.Single(writer.Events);
Assert.Equal(stashedDuringHandler, evt.ExecutionId);
}
[Fact]
public async Task InboundRow_OwnParentExecutionId_StaysNull()
{
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
var mw = CreateMiddleware(_ =>
{
ctx.Response.StatusCode = 200;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(ctx);
// The inbound request is itself top-level — only the spawn id flows
// OUT on RouteToCallRequest. The inbound row's own ParentExecutionId
// is never set.
var evt = Assert.Single(writer.Events);
Assert.Null(evt.ParentExecutionId);
}
[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);
}
}