414 lines
14 KiB
C#
414 lines
14 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// M4 Bundle D (D1) — verifies <see cref="AuditWriteMiddleware"/> emits exactly one
|
|
/// <see cref="AuditChannel.ApiInbound"/> row per request via
|
|
/// <see cref="ICentralAuditWriter"/> 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).
|
|
/// </summary>
|
|
public class AuditWriteMiddlewareTests
|
|
{
|
|
/// <summary>
|
|
/// Test-only recording <see cref="ICentralAuditWriter"/>. Captures every
|
|
/// <see cref="AuditEvent"/> the middleware emits so each test can assert on
|
|
/// the shape of the row produced for one request.
|
|
/// </summary>
|
|
private sealed class RecordingAuditWriter : ICentralAuditWriter
|
|
{
|
|
public List<AuditEvent> Events { get; } = new();
|
|
public Func<AuditEvent, Task>? OnWrite { get; set; }
|
|
|
|
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
|
{
|
|
lock (Events)
|
|
{
|
|
Events.Add(evt);
|
|
}
|
|
|
|
return OnWrite?.Invoke(evt) ?? Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds an <see cref="HttpContext"/> 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.
|
|
/// </summary>
|
|
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<AuditWriteMiddleware>.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<InvalidOperationException>(
|
|
() => 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);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Correlation id — Audit Log #23: each inbound row carries a fresh
|
|
// per-request correlation id so inbound rows are correlatable.
|
|
// ---------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task InboundRow_CarriesNonNull_CorrelationId()
|
|
{
|
|
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.CorrelationId);
|
|
Assert.NotEqual(Guid.Empty, evt.CorrelationId!.Value);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SeparateRequests_GetDistinct_CorrelationIds()
|
|
{
|
|
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].CorrelationId, writer.Events[1].CorrelationId);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|