a07ff28f10
1. Request headers in Extra JSON (AuditWriteMiddleware): adds a `requestHeaders` object to the existing Extra JSON alongside remoteIp/userAgent; headers whose names appear in AuditLogOptions.HeaderRedactList (Authorization, X-Api-Key, Cookie, Set-Cookie by default) are replaced with "<redacted>" using OrdinalIgnoreCase matching — same policy as ScadaBridgeAuditRedactor. 2. AuditInboundCeilingHits counter: new IAuditInboundCeilingHitsCounter interface + NoOpAuditInboundCeilingHitsCounter default; AuditCentralHealthSnapshot implements the interface (Interlocked field, thread-safe) and exposes AuditInboundCeilingHits on IAuditCentralHealthSnapshot; AddAuditLog registers the NoOp default, AddAuditLogCentralMaintenance forwards to the snapshot; AuditWriteMiddleware accepts the counter as an optional ctor arg and increments it once per request where either the request or response body hit the cap. 3. Per-method SkipBodyCapture opt-out: adds SkipBodyCapture bool to PerTargetRedactionOverride; AuditWriteMiddleware consults the per-target override map at the start of InvokeAsync (before EnableBuffering) and, when set, skips body read + capture entirely — the audit row still emits with headers/metadata but null RequestSummary/ResponseSummary; truncation flags are also cleared so the ceiling-hits counter is not bumped for opted-out methods.
1451 lines
54 KiB
C#
1451 lines
54 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 Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.Audit;
|
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.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
|
|
{
|
|
// C3 (Task 2.5): store the decomposed row view so assertions keep
|
|
// reading the ScadaBridge domain fields as typed properties.
|
|
public List<AuditRowProjection.AuditRowValues> Events { get; } = new();
|
|
public Func<AuditEvent, Task>? OnWrite { get; set; }
|
|
|
|
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
|
{
|
|
lock (Events)
|
|
{
|
|
Events.Add(evt.AsRow());
|
|
}
|
|
|
|
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,
|
|
AuditLogOptions? options = null,
|
|
IAuditActorAccessor? actorAccessor = null) =>
|
|
new(
|
|
next,
|
|
writer,
|
|
NullLogger<AuditWriteMiddleware>.Instance,
|
|
new StaticAuditLogOptionsMonitor(options ?? new AuditLogOptions()),
|
|
actorAccessor);
|
|
|
|
/// <summary>
|
|
/// File-local <see cref="IAuditActorAccessor"/> test double returning a fixed
|
|
/// actor string — stands in for the HTTP-backed accessor that reads the
|
|
/// authenticated principal off the ambient request (Phase 3).
|
|
/// </summary>
|
|
private sealed class StubAuditActorAccessor : IAuditActorAccessor
|
|
{
|
|
public StubAuditActorAccessor(string? currentActor) => CurrentActor = currentActor;
|
|
|
|
public string? CurrentActor { get; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// File-local <see cref="IOptionsMonitor{TOptions}"/> test double — returns the
|
|
/// same snapshot on every read, no change-token plumbing required. Mirrors the
|
|
/// <c>StaticMonitor</c> pattern in
|
|
/// <c>tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Payload/InboundChannelCapTests.cs</c>.
|
|
/// </summary>
|
|
private sealed class StaticAuditLogOptionsMonitor : IOptionsMonitor<AuditLogOptions>
|
|
{
|
|
private readonly AuditLogOptions _value;
|
|
|
|
public StaticAuditLogOptionsMonitor(AuditLogOptions value) => _value = value;
|
|
|
|
public AuditLogOptions CurrentValue => _value;
|
|
|
|
public AuditLogOptions Get(string? name) => _value;
|
|
|
|
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// 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);
|
|
// C3: ForwardState is no longer a canonical field (site-storage-only);
|
|
// central direct-write rows never carry it.
|
|
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 the shared ZB.MOM.WW.Auth.ApiKeys verifier has
|
|
// authenticated the Bearer token.
|
|
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);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// 5b. Phase 3 — Actor from the authenticated principal. When no API-key
|
|
// name was stashed, the actor is sourced from IAuditActorAccessor
|
|
// (the authenticated interactive cookie/LDAP user). The API-key stash
|
|
// still takes precedence, and auth-failure / no-principal paths stay
|
|
// null — never echo an unauthenticated principal back.
|
|
// ---------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task AuthenticatedUser_FromAccessor_RecordedAsActor_WhenNoApiKeyStash()
|
|
{
|
|
var writer = new RecordingAuditWriter();
|
|
var ctx = BuildContext();
|
|
var mw = CreateMiddleware(
|
|
_ =>
|
|
{
|
|
// No API-key name stashed — this is an interactive cookie/LDAP
|
|
// authenticated inbound user, surfaced via the accessor.
|
|
ctx.Response.StatusCode = 200;
|
|
return Task.CompletedTask;
|
|
},
|
|
writer,
|
|
actorAccessor: new StubAuditActorAccessor("alice"));
|
|
|
|
await mw.InvokeAsync(ctx);
|
|
|
|
var evt = Assert.Single(writer.Events);
|
|
Assert.Equal("alice", evt.Actor);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApiKeyStash_TakesPrecedence_OverAuthenticatedPrincipal()
|
|
{
|
|
// A key-authenticated caller: the endpoint handler stashed the API key
|
|
// name. Even if an accessor would resolve a principal, the API-key
|
|
// identity is the canonical actor for the key-authenticated path.
|
|
var writer = new RecordingAuditWriter();
|
|
var ctx = BuildContext();
|
|
var mw = CreateMiddleware(
|
|
_ =>
|
|
{
|
|
ctx.Items[AuditWriteMiddleware.AuditActorItemKey] = "integration-svc";
|
|
ctx.Response.StatusCode = 200;
|
|
return Task.CompletedTask;
|
|
},
|
|
writer,
|
|
actorAccessor: new StubAuditActorAccessor("should-not-win"));
|
|
|
|
await mw.InvokeAsync(ctx);
|
|
|
|
var evt = Assert.Single(writer.Events);
|
|
Assert.Equal("integration-svc", evt.Actor);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AuthFailure_KeepsActorNull_EvenWhenAccessorResolvesPrincipal()
|
|
{
|
|
// 401/403 force the actor null BEFORE resolution — an auth failure must
|
|
// never echo a principal back, even one the accessor could produce.
|
|
var writer = new RecordingAuditWriter();
|
|
var ctx = BuildContext();
|
|
var mw = CreateMiddleware(
|
|
_ =>
|
|
{
|
|
ctx.Response.StatusCode = 401;
|
|
return Task.CompletedTask;
|
|
},
|
|
writer,
|
|
actorAccessor: new StubAuditActorAccessor("attacker"));
|
|
|
|
await mw.InvokeAsync(ctx);
|
|
|
|
var evt = Assert.Single(writer.Events);
|
|
Assert.Equal(AuditKind.InboundAuthFailure, evt.Kind);
|
|
Assert.Null(evt.Actor);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NoApiKey_NoAuthenticatedPrincipal_LeavesActorNull()
|
|
{
|
|
// Accessor present but returns null (no authenticated interactive user)
|
|
// and no API-key stash — the actor stays null rather than empty/echoed.
|
|
var writer = new RecordingAuditWriter();
|
|
var ctx = BuildContext();
|
|
var mw = CreateMiddleware(
|
|
_ =>
|
|
{
|
|
ctx.Response.StatusCode = 200;
|
|
return Task.CompletedTask;
|
|
},
|
|
writer,
|
|
actorAccessor: new StubAuditActorAccessor(currentActor: null));
|
|
|
|
await mw.InvokeAsync(ctx);
|
|
|
|
var evt = Assert.Single(writer.Events);
|
|
Assert.Null(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<Guid>(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);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Response body capture — Audit Log #23 (inbound full-response feature).
|
|
// Until the M5-deferred work landed, ResponseSummary was always null.
|
|
// These tests pin the new contract: the middleware wraps Response.Body,
|
|
// runs the pipeline, copies the buffered bytes back to the real stream,
|
|
// and stashes a UTF-8 string copy on ResponseSummary.
|
|
// ---------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task ResponseBody_IsCaptured_OnResponseSummary()
|
|
{
|
|
var writer = new RecordingAuditWriter();
|
|
var ctx = BuildContext();
|
|
var responseJson = "{\"result\":42}";
|
|
var mw = CreateMiddleware(async hc =>
|
|
{
|
|
hc.Response.StatusCode = 200;
|
|
hc.Response.ContentType = "application/json";
|
|
await hc.Response.WriteAsync(responseJson);
|
|
}, writer);
|
|
|
|
await mw.InvokeAsync(ctx);
|
|
|
|
var evt = Assert.Single(writer.Events);
|
|
Assert.Equal(responseJson, evt.ResponseSummary);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResponseBody_IsForwardedToOriginalStream_DownstreamReadersSeeIt()
|
|
{
|
|
// Wrapping the response body must be TRANSPARENT — the real client
|
|
// stream still receives every byte the pipeline wrote.
|
|
var writer = new RecordingAuditWriter();
|
|
var ctx = BuildContext();
|
|
var captured = new MemoryStream();
|
|
ctx.Response.Body = captured; // simulate the client/test sink
|
|
|
|
var responseJson = "{\"ok\":true}";
|
|
var mw = CreateMiddleware(async hc =>
|
|
{
|
|
hc.Response.StatusCode = 200;
|
|
await hc.Response.WriteAsync(responseJson);
|
|
}, writer);
|
|
|
|
await mw.InvokeAsync(ctx);
|
|
|
|
Assert.Equal(responseJson, Encoding.UTF8.GetString(captured.ToArray()));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResponseBody_Empty_LeavesResponseSummaryNull()
|
|
{
|
|
// No bytes written => null, not empty-string. Mirrors the request-body
|
|
// contract in ReadBufferedRequestBodyAsync.
|
|
var writer = new RecordingAuditWriter();
|
|
var ctx = BuildContext();
|
|
var mw = CreateMiddleware(hc =>
|
|
{
|
|
hc.Response.StatusCode = 204;
|
|
return Task.CompletedTask;
|
|
}, writer);
|
|
|
|
await mw.InvokeAsync(ctx);
|
|
|
|
var evt = Assert.Single(writer.Events);
|
|
Assert.Null(evt.ResponseSummary);
|
|
Assert.Equal(204, evt.HttpStatus);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResponseBody_OnHandlerThrow_BodyCapturedUpToTheThrow()
|
|
{
|
|
// If the handler writes some bytes then throws, the audit row still
|
|
// surfaces whatever the framework had flushed. The middleware re-throws
|
|
// (audit is best-effort, the request's error path stays authoritative).
|
|
var writer = new RecordingAuditWriter();
|
|
var ctx = BuildContext();
|
|
var boom = new InvalidOperationException("kaboom");
|
|
var mw = CreateMiddleware(async hc =>
|
|
{
|
|
hc.Response.StatusCode = 500;
|
|
await hc.Response.WriteAsync("partial");
|
|
throw boom;
|
|
}, writer);
|
|
|
|
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("partial", evt.ResponseSummary);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Bounded audit capture — memory safety follow-up. The capture site now
|
|
// honours AuditLogOptions.InboundMaxBytes at READ time (not just at
|
|
// filter-time), so a 500 MiB body cannot transiently allocate 500 MiB of
|
|
// string. The cap is local to the AUDIT copy; downstream readers and the
|
|
// real client still see every byte.
|
|
// ---------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task RequestBody_AboveInboundMaxBytes_TruncatedToCap_PayloadTruncatedTrue()
|
|
{
|
|
// 4 KiB cap, 20 KB body — the audit copy must be UTF-8 byte-safe
|
|
// capped at 4 KiB AND PayloadTruncated must flip, while the
|
|
// downstream handler still sees the full 20 KB payload.
|
|
const int cap = 4096;
|
|
var bigBody = new string('a', 20_000);
|
|
var writer = new RecordingAuditWriter();
|
|
var ctx = BuildContext(body: bigBody);
|
|
|
|
string? observedAfterMiddleware = null;
|
|
var mw = CreateMiddleware(
|
|
async hc =>
|
|
{
|
|
using var reader = new StreamReader(hc.Request.Body);
|
|
observedAfterMiddleware = await reader.ReadToEndAsync();
|
|
hc.Response.StatusCode = 200;
|
|
},
|
|
writer,
|
|
options: new AuditLogOptions { InboundMaxBytes = cap });
|
|
|
|
await mw.InvokeAsync(ctx);
|
|
|
|
// (iii) Downstream handler still sees the FULL body — the cap applied
|
|
// only to the audit copy.
|
|
Assert.Equal(bigBody, observedAfterMiddleware);
|
|
|
|
var evt = Assert.Single(writer.Events);
|
|
// (i) Audit copy bounded at cap bytes (UTF-8 byte count).
|
|
Assert.NotNull(evt.RequestSummary);
|
|
Assert.True(
|
|
Encoding.UTF8.GetByteCount(evt.RequestSummary!) <= cap,
|
|
$"RequestSummary byte count {Encoding.UTF8.GetByteCount(evt.RequestSummary!)} exceeded cap {cap}");
|
|
// (ii) Truncation flag set by the middleware (the filter will OR its
|
|
// own determination on top, but the middleware MUST set it itself).
|
|
Assert.True(evt.PayloadTruncated);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResponseBody_AboveInboundMaxBytes_TruncatedToCap_ClientStillReceivesAllBytes_PayloadTruncatedTrue()
|
|
{
|
|
// 4 KiB cap, 20 KB response — the test sink (acts as the real client)
|
|
// MUST receive all 20 KB while the audit copy is bounded at 4 KiB.
|
|
const int cap = 4096;
|
|
var bigResponse = new string('b', 20_000);
|
|
var writer = new RecordingAuditWriter();
|
|
var ctx = BuildContext();
|
|
var captured = new MemoryStream();
|
|
ctx.Response.Body = captured; // stand-in for the client sink
|
|
|
|
var mw = CreateMiddleware(
|
|
async hc =>
|
|
{
|
|
hc.Response.StatusCode = 200;
|
|
await hc.Response.WriteAsync(bigResponse);
|
|
},
|
|
writer,
|
|
options: new AuditLogOptions { InboundMaxBytes = cap });
|
|
|
|
await mw.InvokeAsync(ctx);
|
|
|
|
// Client sink received every byte — the forwarding wrap is transparent.
|
|
Assert.Equal(bigResponse, Encoding.UTF8.GetString(captured.ToArray()));
|
|
|
|
var evt = Assert.Single(writer.Events);
|
|
// Audit copy bounded at cap bytes.
|
|
Assert.NotNull(evt.ResponseSummary);
|
|
Assert.True(
|
|
Encoding.UTF8.GetByteCount(evt.ResponseSummary!) <= cap,
|
|
$"ResponseSummary byte count {Encoding.UTF8.GetByteCount(evt.ResponseSummary!)} exceeded cap {cap}");
|
|
Assert.True(evt.PayloadTruncated);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// InboundAPI-018: asynchronously faulted audit-write tasks must be
|
|
// observed (logged) rather than silently dropped — but must still NOT
|
|
// alter the user-facing HTTP response (alog.md §13).
|
|
// ---------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Test-only writer whose <see cref="WriteAsync"/> returns a Task that
|
|
/// faults AFTER an asynchronous boundary, so the throw happens after
|
|
/// <see cref="AuditWriteMiddleware"/>'s synchronous try/catch can see it —
|
|
/// exactly the fire-and-forget bug InboundAPI-018 closes.
|
|
/// </summary>
|
|
private sealed class AsyncFaultingAuditWriter : ICentralAuditWriter
|
|
{
|
|
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
|
{
|
|
return FaultAsync();
|
|
|
|
static async Task FaultAsync()
|
|
{
|
|
// Yield off-thread so the fault surfaces ASYNCHRONOUSLY (not
|
|
// captured by a synchronous try/catch around the WriteAsync
|
|
// call site).
|
|
await Task.Yield();
|
|
throw new InvalidOperationException("async audit write failed");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Captures log entries written through a <see cref="ILogger{TCategoryName}"/>
|
|
/// so the test can assert on the Warning that
|
|
/// <see cref="AuditWriteMiddleware.ObserveAuditWriteFault"/> emits.
|
|
/// </summary>
|
|
private sealed class RecordingLogger : Microsoft.Extensions.Logging.ILogger<AuditWriteMiddleware>
|
|
{
|
|
public List<(Microsoft.Extensions.Logging.LogLevel Level, string Message, Exception? Exception)> Entries { get; } = new();
|
|
|
|
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
|
|
|
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
|
|
|
|
public void Log<TState>(
|
|
Microsoft.Extensions.Logging.LogLevel logLevel,
|
|
Microsoft.Extensions.Logging.EventId eventId,
|
|
TState state,
|
|
Exception? exception,
|
|
Func<TState, Exception?, string> formatter)
|
|
{
|
|
lock (Entries)
|
|
{
|
|
Entries.Add((logLevel, formatter(state, exception), exception));
|
|
}
|
|
}
|
|
|
|
private sealed class NullScope : IDisposable
|
|
{
|
|
public static readonly NullScope Instance = new();
|
|
public void Dispose() { }
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AuditWriter_AsyncFault_IsObserved_AsWarning_AndDoesNotAlterResponse()
|
|
{
|
|
var writer = new AsyncFaultingAuditWriter();
|
|
var logger = new RecordingLogger();
|
|
var ctx = BuildContext();
|
|
|
|
var mw = new AuditWriteMiddleware(
|
|
next: _ =>
|
|
{
|
|
ctx.Response.StatusCode = 200;
|
|
return Task.CompletedTask;
|
|
},
|
|
auditWriter: writer,
|
|
logger: logger,
|
|
options: new StaticAuditLogOptionsMonitor(new AuditLogOptions()));
|
|
|
|
await mw.InvokeAsync(ctx);
|
|
|
|
// The user-facing response is untouched — audit emission is best-effort.
|
|
Assert.Equal(200, ctx.Response.StatusCode);
|
|
|
|
// Give the off-thread continuation a moment to fire and log. Spin
|
|
// briefly rather than sleep-then-assert so the test is resilient to
|
|
// scheduler jitter without inflating runtime on success.
|
|
var deadline = DateTime.UtcNow.AddSeconds(2);
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
lock (logger.Entries)
|
|
{
|
|
if (logger.Entries.Any(e =>
|
|
e.Level == Microsoft.Extensions.Logging.LogLevel.Warning
|
|
&& e.Exception is not null
|
|
&& e.Message.Contains("async audit write faulted")))
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
await Task.Delay(20);
|
|
}
|
|
|
|
// If we reach this point, the continuation did not fire — pre-fix the
|
|
// fault would have been swallowed entirely and no log line emitted.
|
|
var snapshot = logger.Entries.Select(e => $"{e.Level}: {e.Message}").ToList();
|
|
Assert.Fail(
|
|
"Expected a Warning log entry observing the async audit-write fault — none found. " +
|
|
$"Entries: [{string.Join(", ", snapshot)}]");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// InboundAPI-019 — bodyless requests skip EnableBuffering so the
|
|
// FileBufferingReadStream allocation is avoided on GET/HEAD/DELETE
|
|
// and any request whose Content-Length is 0. The audit row still emits
|
|
// with a null RequestSummary, mirroring the bodyless-POST contract.
|
|
// ---------------------------------------------------------------------
|
|
|
|
[Theory]
|
|
[InlineData("GET")]
|
|
[InlineData("HEAD")]
|
|
[InlineData("DELETE")]
|
|
public async Task BodylessMethod_SkipsEnableBuffering_RequestStreamIsNotReplaced(string method)
|
|
{
|
|
// The middleware previously called EnableBuffering on every request,
|
|
// installing a FileBufferingReadStream wrapper even when the request
|
|
// had no body. The bodyless-method short-circuit must leave
|
|
// Request.Body untouched (still the original empty stream the test
|
|
// assigns below), proving the buffering wrapper allocation is avoided.
|
|
var writer = new RecordingAuditWriter();
|
|
var ctx = new DefaultHttpContext();
|
|
ctx.Request.Method = method;
|
|
ctx.Request.Path = "/api/echo";
|
|
ctx.Request.RouteValues["methodName"] = "echo";
|
|
ctx.Connection.RemoteIpAddress = IPAddress.Parse("10.0.0.5");
|
|
|
|
// Distinct sentinel stream — the production code path that called
|
|
// EnableBuffering would replace this with FileBufferingReadStream.
|
|
// After the fix the original stream survives untouched.
|
|
var sentinel = new MemoryStream();
|
|
ctx.Request.Body = sentinel;
|
|
|
|
Stream? observedDuringHandler = null;
|
|
var mw = CreateMiddleware(hc =>
|
|
{
|
|
observedDuringHandler = hc.Request.Body;
|
|
hc.Response.StatusCode = 200;
|
|
return Task.CompletedTask;
|
|
}, writer);
|
|
|
|
await mw.InvokeAsync(ctx);
|
|
|
|
Assert.Same(sentinel, observedDuringHandler);
|
|
var evt = Assert.Single(writer.Events);
|
|
// No body → RequestSummary stays null, matching the bodyless-POST contract.
|
|
Assert.Null(evt.RequestSummary);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BodylessPost_ContentLengthZero_SkipsEnableBuffering()
|
|
{
|
|
// A POST with an explicit Content-Length of 0 is also bodyless — even
|
|
// though POST is conventionally a body-carrying method, the explicit
|
|
// zero short-circuits buffering. This pins the ContentLength branch of
|
|
// the RequestHasBody guard.
|
|
var writer = new RecordingAuditWriter();
|
|
var ctx = new DefaultHttpContext();
|
|
ctx.Request.Method = "POST";
|
|
ctx.Request.Path = "/api/echo";
|
|
ctx.Request.RouteValues["methodName"] = "echo";
|
|
ctx.Request.ContentLength = 0;
|
|
ctx.Connection.RemoteIpAddress = IPAddress.Parse("10.0.0.5");
|
|
|
|
var sentinel = new MemoryStream();
|
|
ctx.Request.Body = sentinel;
|
|
|
|
Stream? observedDuringHandler = null;
|
|
var mw = CreateMiddleware(hc =>
|
|
{
|
|
observedDuringHandler = hc.Request.Body;
|
|
hc.Response.StatusCode = 200;
|
|
return Task.CompletedTask;
|
|
}, writer);
|
|
|
|
await mw.InvokeAsync(ctx);
|
|
|
|
Assert.Same(sentinel, observedDuringHandler);
|
|
var evt = Assert.Single(writer.Events);
|
|
Assert.Null(evt.RequestSummary);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PostWithBody_StillEnablesBuffering_AndCapturesRequestSummary()
|
|
{
|
|
// Regression: the bodyless short-circuit must NOT regress the existing
|
|
// body-capture contract for normal POSTs — we still need to buffer +
|
|
// capture the request body for the audit row.
|
|
var writer = new RecordingAuditWriter();
|
|
var requestJson = "{\"a\":42}";
|
|
var ctx = BuildContext(body: requestJson);
|
|
|
|
string? observedAfterMiddleware = null;
|
|
var mw = CreateMiddleware(async hc =>
|
|
{
|
|
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);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// M5.3 (T7) Increment 1: Request headers in Extra JSON
|
|
// Request headers are captured into the Extra JSON object alongside the
|
|
// existing remoteIp / userAgent fields. Sensitive headers (e.g.
|
|
// Authorization, X-Api-Key) are redacted to "<redacted>" using the same
|
|
// HeaderRedactList as ScadaBridgeAuditRedactor.
|
|
// ---------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task RequestHeaders_AppearInExtra_UnderRequestHeadersKey()
|
|
{
|
|
var writer = new RecordingAuditWriter();
|
|
var ctx = BuildContext();
|
|
ctx.Request.Headers["X-Custom-Header"] = "custom-value";
|
|
|
|
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;
|
|
// Extra must carry a requestHeaders object.
|
|
Assert.True(root.TryGetProperty("requestHeaders", out var headers),
|
|
"Extra JSON must contain a 'requestHeaders' property");
|
|
Assert.Equal(JsonValueKind.Object, headers.ValueKind);
|
|
// The non-sensitive custom header must appear unredacted.
|
|
Assert.True(headers.TryGetProperty("X-Custom-Header", out var customVal),
|
|
"requestHeaders must contain 'X-Custom-Header'");
|
|
Assert.Equal("custom-value", customVal.GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RequestHeaders_AuthorizationHeader_IsRedacted()
|
|
{
|
|
// Authorization is in the default HeaderRedactList and must appear as
|
|
// "<redacted>" rather than the real token value.
|
|
var writer = new RecordingAuditWriter();
|
|
var ctx = BuildContext();
|
|
ctx.Request.Headers["Authorization"] = "Bearer secret-token-abc";
|
|
|
|
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;
|
|
var headers = root.GetProperty("requestHeaders");
|
|
Assert.True(headers.TryGetProperty("Authorization", out var authVal),
|
|
"requestHeaders must contain 'Authorization'");
|
|
Assert.Equal("<redacted>", authVal.GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RequestHeaders_XApiKeyHeader_IsRedacted()
|
|
{
|
|
// X-Api-Key is in the default HeaderRedactList and must be redacted.
|
|
var writer = new RecordingAuditWriter();
|
|
var ctx = BuildContext();
|
|
ctx.Request.Headers["X-Api-Key"] = "sbk_12345_secretkey";
|
|
|
|
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;
|
|
var headers = root.GetProperty("requestHeaders");
|
|
Assert.True(headers.TryGetProperty("X-Api-Key", out var keyVal));
|
|
Assert.Equal("<redacted>", keyVal.GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RequestHeaders_CustomRedactListEntry_IsRedacted()
|
|
{
|
|
// A non-default entry added to HeaderRedactList must also be redacted.
|
|
var opts = new AuditLogOptions
|
|
{
|
|
HeaderRedactList = new List<string>
|
|
{
|
|
"Authorization", "X-Api-Key", "Cookie", "Set-Cookie",
|
|
"X-Internal-Secret", // custom addition
|
|
},
|
|
};
|
|
var writer = new RecordingAuditWriter();
|
|
var ctx = BuildContext();
|
|
ctx.Request.Headers["X-Internal-Secret"] = "my-secret-value";
|
|
ctx.Request.Headers["X-Safe-Header"] = "safe-value";
|
|
|
|
var mw = CreateMiddleware(
|
|
_ =>
|
|
{
|
|
ctx.Response.StatusCode = 200;
|
|
return Task.CompletedTask;
|
|
},
|
|
writer,
|
|
options: opts);
|
|
|
|
await mw.InvokeAsync(ctx);
|
|
|
|
var evt = Assert.Single(writer.Events);
|
|
using var doc = JsonDocument.Parse(evt.Extra!);
|
|
var headers = doc.RootElement.GetProperty("requestHeaders");
|
|
Assert.Equal("<redacted>", headers.GetProperty("X-Internal-Secret").GetString());
|
|
Assert.Equal("safe-value", headers.GetProperty("X-Safe-Header").GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RequestHeaders_Redaction_IsCaseInsensitive()
|
|
{
|
|
// HeaderRedactList match must be case-insensitive (mirrors the
|
|
// ScadaBridgeAuditRedactor behaviour — the redact set uses
|
|
// OrdinalIgnoreCase).
|
|
var writer = new RecordingAuditWriter();
|
|
var ctx = BuildContext();
|
|
// Vary the casing from the list entry ("Authorization").
|
|
ctx.Request.Headers["authorization"] = "Bearer lower-case-token";
|
|
|
|
var mw = CreateMiddleware(_ =>
|
|
{
|
|
ctx.Response.StatusCode = 200;
|
|
return Task.CompletedTask;
|
|
}, writer);
|
|
|
|
await mw.InvokeAsync(ctx);
|
|
|
|
var evt = Assert.Single(writer.Events);
|
|
using var doc = JsonDocument.Parse(evt.Extra!);
|
|
var headers = doc.RootElement.GetProperty("requestHeaders");
|
|
// ASP.NET Core normalises the header name to "authorization" in the dict;
|
|
// the redact set (OrdinalIgnoreCase) must still match it.
|
|
Assert.Equal("<redacted>", headers.GetProperty("authorization").GetString());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// M5.3 (T7) Increment 2: AuditInboundCeilingHits counter
|
|
// When request OR response exceeds InboundMaxBytes, the middleware
|
|
// increments IAuditInboundCeilingHitsCounter once per request.
|
|
// ---------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// In-memory <see cref="IAuditInboundCeilingHitsCounter"/> that records
|
|
/// every <see cref="Increment"/> call.
|
|
/// </summary>
|
|
private sealed class RecordingCeilingHitsCounter : ZB.MOM.WW.ScadaBridge.AuditLog.Central.IAuditInboundCeilingHitsCounter
|
|
{
|
|
private int _count;
|
|
public int Count => Volatile.Read(ref _count);
|
|
public void Increment() => Interlocked.Increment(ref _count);
|
|
}
|
|
|
|
private static AuditWriteMiddleware CreateMiddlewareWithCounter(
|
|
RequestDelegate next,
|
|
ICentralAuditWriter writer,
|
|
AuditLogOptions? options,
|
|
ZB.MOM.WW.ScadaBridge.AuditLog.Central.IAuditInboundCeilingHitsCounter counter) =>
|
|
new(
|
|
next,
|
|
writer,
|
|
NullLogger<AuditWriteMiddleware>.Instance,
|
|
new StaticAuditLogOptionsMonitor(options ?? new AuditLogOptions()),
|
|
actorAccessor: null,
|
|
ceilingHitsCounter: counter);
|
|
|
|
[Fact]
|
|
public async Task RequestBody_AboveInboundMaxBytes_IncrementsCeilingHitsCounter()
|
|
{
|
|
const int cap = 1024;
|
|
var bigBody = new string('x', cap + 100);
|
|
var writer = new RecordingAuditWriter();
|
|
var counter = new RecordingCeilingHitsCounter();
|
|
var ctx = BuildContext(body: bigBody);
|
|
var mw = CreateMiddlewareWithCounter(
|
|
hc =>
|
|
{
|
|
hc.Response.StatusCode = 200;
|
|
return Task.CompletedTask;
|
|
},
|
|
writer,
|
|
options: new AuditLogOptions { InboundMaxBytes = cap },
|
|
counter: counter);
|
|
|
|
await mw.InvokeAsync(ctx);
|
|
|
|
Assert.Equal(1, counter.Count);
|
|
// Verify the truncation did happen to confirm ceiling was hit.
|
|
var evt = Assert.Single(writer.Events);
|
|
Assert.True(evt.PayloadTruncated);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResponseBody_AboveInboundMaxBytes_IncrementsCeilingHitsCounter()
|
|
{
|
|
const int cap = 1024;
|
|
var bigResponse = new string('y', cap + 100);
|
|
var writer = new RecordingAuditWriter();
|
|
var counter = new RecordingCeilingHitsCounter();
|
|
var ctx = BuildContext();
|
|
ctx.Response.Body = new MemoryStream();
|
|
|
|
var mw = CreateMiddlewareWithCounter(
|
|
async hc =>
|
|
{
|
|
hc.Response.StatusCode = 200;
|
|
await hc.Response.WriteAsync(bigResponse);
|
|
},
|
|
writer,
|
|
options: new AuditLogOptions { InboundMaxBytes = cap },
|
|
counter: counter);
|
|
|
|
await mw.InvokeAsync(ctx);
|
|
|
|
Assert.Equal(1, counter.Count);
|
|
var evt = Assert.Single(writer.Events);
|
|
Assert.True(evt.PayloadTruncated);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NormalRequest_WithinCap_DoesNotIncrementCeilingHitsCounter()
|
|
{
|
|
var writer = new RecordingAuditWriter();
|
|
var counter = new RecordingCeilingHitsCounter();
|
|
var smallBody = "{\"ok\":true}";
|
|
var ctx = BuildContext(body: smallBody);
|
|
// Cap is well above the body size.
|
|
var mw = CreateMiddlewareWithCounter(
|
|
hc =>
|
|
{
|
|
hc.Response.StatusCode = 200;
|
|
return Task.CompletedTask;
|
|
},
|
|
writer,
|
|
options: new AuditLogOptions { InboundMaxBytes = 8192 },
|
|
counter: counter);
|
|
|
|
await mw.InvokeAsync(ctx);
|
|
|
|
Assert.Equal(0, counter.Count);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// M5.3 (T7) Increment 3: SkipBodyCapture per-method opt-out
|
|
// A target with SkipBodyCapture=true produces an audit row with
|
|
// headers/metadata but empty/omitted body. A normal target still captures.
|
|
// ---------------------------------------------------------------------
|
|
|
|
private static DefaultHttpContext BuildContextWithRoute(
|
|
string methodName,
|
|
string? body = null)
|
|
{
|
|
var ctx = new DefaultHttpContext();
|
|
ctx.Request.Method = "POST";
|
|
ctx.Request.Path = $"/api/{methodName}";
|
|
ctx.Request.RouteValues["methodName"] = methodName;
|
|
ctx.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("10.0.0.1");
|
|
|
|
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";
|
|
}
|
|
|
|
return ctx;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SkipBodyCapture_True_AuditRowEmitted_ButBodyIsNull()
|
|
{
|
|
// A target with SkipBodyCapture=true must produce an audit row (the
|
|
// row must not be suppressed entirely) but RequestSummary and
|
|
// ResponseSummary must both be null — only the body is omitted.
|
|
var writer = new RecordingAuditWriter();
|
|
var opts = new AuditLogOptions
|
|
{
|
|
PerTargetOverrides = new Dictionary<string, ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride>
|
|
{
|
|
["secret-method"] = new ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride
|
|
{
|
|
SkipBodyCapture = true,
|
|
},
|
|
},
|
|
};
|
|
var ctx = BuildContextWithRoute("secret-method", body: "{\"sensitive\":\"data\"}");
|
|
|
|
var mw = CreateMiddleware(
|
|
async hc =>
|
|
{
|
|
hc.Response.StatusCode = 200;
|
|
await hc.Response.WriteAsync("{\"result\":\"secret\"}");
|
|
},
|
|
writer,
|
|
options: opts);
|
|
|
|
await mw.InvokeAsync(ctx);
|
|
|
|
var evt = Assert.Single(writer.Events);
|
|
// Row IS emitted — only the body content is suppressed.
|
|
Assert.Equal("secret-method", evt.Target);
|
|
Assert.Equal(AuditStatus.Delivered, evt.Status);
|
|
// Bodies are null — SkipBodyCapture stripped them.
|
|
Assert.Null(evt.RequestSummary);
|
|
Assert.Null(evt.ResponseSummary);
|
|
// Headers / metadata are still present.
|
|
Assert.NotNull(evt.Extra);
|
|
using var doc = JsonDocument.Parse(evt.Extra!);
|
|
Assert.True(doc.RootElement.TryGetProperty("requestHeaders", out _),
|
|
"Headers must be present even when body capture is skipped");
|
|
Assert.Equal(200, evt.HttpStatus);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SkipBodyCapture_True_CeilingHitsCounter_NotIncremented()
|
|
{
|
|
// When SkipBodyCapture=true the body is never measured against the cap;
|
|
// the counter must NOT be bumped even if the body would have exceeded it.
|
|
var writer = new RecordingAuditWriter();
|
|
var counter = new RecordingCeilingHitsCounter();
|
|
const int cap = 64;
|
|
var bigBody = new string('z', cap + 1000);
|
|
var opts = new AuditLogOptions
|
|
{
|
|
InboundMaxBytes = cap,
|
|
PerTargetOverrides = new Dictionary<string, ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride>
|
|
{
|
|
["large-method"] = new ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride
|
|
{
|
|
SkipBodyCapture = true,
|
|
},
|
|
},
|
|
};
|
|
var ctx = BuildContextWithRoute("large-method", body: bigBody);
|
|
|
|
var mw = CreateMiddlewareWithCounter(
|
|
hc =>
|
|
{
|
|
hc.Response.StatusCode = 200;
|
|
return Task.CompletedTask;
|
|
},
|
|
writer,
|
|
options: opts,
|
|
counter: counter);
|
|
|
|
await mw.InvokeAsync(ctx);
|
|
|
|
Assert.Equal(0, counter.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SkipBodyCapture_False_NormalTarget_StillCapturesBody()
|
|
{
|
|
// Regression: a target WITHOUT SkipBodyCapture (or with SkipBodyCapture=false)
|
|
// must still capture the body normally.
|
|
var writer = new RecordingAuditWriter();
|
|
var opts = new AuditLogOptions
|
|
{
|
|
PerTargetOverrides = new Dictionary<string, ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride>
|
|
{
|
|
["normal-method"] = new ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride
|
|
{
|
|
SkipBodyCapture = false,
|
|
},
|
|
},
|
|
};
|
|
var requestJson = "{\"a\":1}";
|
|
var ctx = BuildContextWithRoute("normal-method", body: requestJson);
|
|
|
|
var mw = CreateMiddleware(
|
|
async hc =>
|
|
{
|
|
hc.Response.StatusCode = 200;
|
|
await hc.Response.WriteAsync("{\"result\":1}");
|
|
},
|
|
writer,
|
|
options: opts);
|
|
|
|
await mw.InvokeAsync(ctx);
|
|
|
|
var evt = Assert.Single(writer.Events);
|
|
Assert.Equal(requestJson, evt.RequestSummary);
|
|
Assert.Equal("{\"result\":1}", evt.ResponseSummary);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SkipBodyCapture_NoOverride_DefaultTarget_StillCapturesBody()
|
|
{
|
|
// A target with no per-target override at all must still capture the body —
|
|
// SkipBodyCapture defaults to false and must not suppress capture.
|
|
var writer = new RecordingAuditWriter();
|
|
var requestJson = "{\"x\":99}";
|
|
var ctx = BuildContext(body: requestJson);
|
|
|
|
var mw = CreateMiddleware(
|
|
async hc =>
|
|
{
|
|
hc.Response.StatusCode = 200;
|
|
await hc.Response.WriteAsync("{\"y\":99}");
|
|
},
|
|
writer);
|
|
|
|
await mw.InvokeAsync(ctx);
|
|
|
|
var evt = Assert.Single(writer.Events);
|
|
Assert.Equal(requestJson, evt.RequestSummary);
|
|
Assert.Equal("{\"y\":99}", evt.ResponseSummary);
|
|
}
|
|
}
|