feat(auditlog): payload filter uses InboundMaxBytes for ApiInbound rows
This commit is contained in:
@@ -118,7 +118,14 @@ public sealed class DefaultAuditPayloadFilter : IAuditPayloadFilter
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var opts = _options.CurrentValue;
|
var opts = _options.CurrentValue;
|
||||||
var cap = IsErrorStatus(rawEvent.Status) ? opts.ErrorCapBytes : opts.DefaultCapBytes;
|
// Inbound API gets a dedicated, larger ceiling — request/response bodies are
|
||||||
|
// captured verbatim up to InboundMaxBytes (default 1 MiB) so support can
|
||||||
|
// replay exactly what the caller sent and what we returned. Other channels
|
||||||
|
// keep the global 8 KiB / 64 KiB policy.
|
||||||
|
// See docs/plans/2026-05-23-inbound-api-full-response-audit-design.md.
|
||||||
|
var cap = rawEvent.Channel == AuditChannel.ApiInbound
|
||||||
|
? opts.InboundMaxBytes
|
||||||
|
: (IsErrorStatus(rawEvent.Status) ? opts.ErrorCapBytes : opts.DefaultCapBytes);
|
||||||
|
|
||||||
// --- Header-redaction stage (runs BEFORE truncation) ----------
|
// --- Header-redaction stage (runs BEFORE truncation) ----------
|
||||||
var request = RedactHeaders(rawEvent.RequestSummary, opts.HeaderRedactList);
|
var request = RedactHeaders(rawEvent.RequestSummary, opts.HeaderRedactList);
|
||||||
|
|||||||
133
tests/ScadaLink.AuditLog.Tests/Payload/InboundChannelCapTests.cs
Normal file
133
tests/ScadaLink.AuditLog.Tests/Payload/InboundChannelCapTests.cs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ScadaLink.AuditLog.Configuration;
|
||||||
|
using ScadaLink.AuditLog.Payload;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ScadaLink.AuditLog.Tests.Payload;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pins the docs/plans/2026-05-23-inbound-api-full-response-audit-design.md
|
||||||
|
/// inbound carve-out: ApiInbound rows use InboundMaxBytes (default 1 MiB) for
|
||||||
|
/// RequestSummary / ResponseSummary truncation, NOT DefaultCapBytes /
|
||||||
|
/// ErrorCapBytes. Other channels keep the existing caps.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Uses a file-local <see cref="StaticMonitor"/> helper mirroring the
|
||||||
|
/// convention in the sibling Payload tests (TruncationTests,
|
||||||
|
/// FilterIntegrationTests, BodyRegexRedactionTests, etc.) — the
|
||||||
|
/// <c>TestOptionsMonitor<T></c> helper referenced by the plan is a
|
||||||
|
/// private nested class inside <c>AuditLogOptionsBindingTests</c> and thus
|
||||||
|
/// not reachable from this file.
|
||||||
|
/// </remarks>
|
||||||
|
public class InboundChannelCapTests
|
||||||
|
{
|
||||||
|
private static AuditEvent MakeInbound(
|
||||||
|
AuditStatus status,
|
||||||
|
string? request = null,
|
||||||
|
string? response = null) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
EventId = Guid.NewGuid(),
|
||||||
|
OccurredAtUtc = DateTime.UtcNow,
|
||||||
|
Channel = AuditChannel.ApiInbound,
|
||||||
|
Kind = AuditKind.InboundRequest,
|
||||||
|
Status = status,
|
||||||
|
RequestSummary = request,
|
||||||
|
ResponseSummary = response,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApiInbound_Delivered_RequestBody_BelowInboundMaxBytes_NotTruncated()
|
||||||
|
{
|
||||||
|
// Body well above the legacy 8 KiB default cap but under the 1 MiB
|
||||||
|
// inbound ceiling — must NOT truncate.
|
||||||
|
var body = new string('a', 100_000);
|
||||||
|
var opts = new AuditLogOptions(); // defaults
|
||||||
|
var filter = new DefaultAuditPayloadFilter(
|
||||||
|
new StaticMonitor(opts),
|
||||||
|
NullLogger<DefaultAuditPayloadFilter>.Instance);
|
||||||
|
|
||||||
|
var result = filter.Apply(MakeInbound(AuditStatus.Delivered, request: body));
|
||||||
|
|
||||||
|
Assert.False(result.PayloadTruncated);
|
||||||
|
Assert.Equal(100_000, Encoding.UTF8.GetByteCount(result.RequestSummary!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApiInbound_Delivered_ResponseBody_BelowInboundMaxBytes_NotTruncated()
|
||||||
|
{
|
||||||
|
var body = new string('a', 100_000);
|
||||||
|
var opts = new AuditLogOptions();
|
||||||
|
var filter = new DefaultAuditPayloadFilter(
|
||||||
|
new StaticMonitor(opts),
|
||||||
|
NullLogger<DefaultAuditPayloadFilter>.Instance);
|
||||||
|
|
||||||
|
var result = filter.Apply(MakeInbound(AuditStatus.Delivered, response: body));
|
||||||
|
|
||||||
|
Assert.False(result.PayloadTruncated);
|
||||||
|
Assert.Equal(100_000, Encoding.UTF8.GetByteCount(result.ResponseSummary!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApiInbound_Failed_BodyAboveInboundMaxBytes_TruncatedToInboundMaxBytes()
|
||||||
|
{
|
||||||
|
// Even on error rows, the inbound cap is InboundMaxBytes (NOT ErrorCapBytes).
|
||||||
|
var opts = new AuditLogOptions { InboundMaxBytes = 16_384 };
|
||||||
|
var oversized = new string('z', 50_000);
|
||||||
|
var filter = new DefaultAuditPayloadFilter(
|
||||||
|
new StaticMonitor(opts),
|
||||||
|
NullLogger<DefaultAuditPayloadFilter>.Instance);
|
||||||
|
|
||||||
|
var result = filter.Apply(MakeInbound(AuditStatus.Failed, response: oversized));
|
||||||
|
|
||||||
|
Assert.True(result.PayloadTruncated);
|
||||||
|
Assert.True(Encoding.UTF8.GetByteCount(result.ResponseSummary!) <= 16_384);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApiOutbound_StillUsesDefaultCap_NotInboundMaxBytes()
|
||||||
|
{
|
||||||
|
// Regression guard: lifting the inbound cap MUST NOT change other
|
||||||
|
// channels. An ApiOutbound 100 KB body still hits the 8 KiB cap.
|
||||||
|
var opts = new AuditLogOptions();
|
||||||
|
var body = new string('a', 100_000);
|
||||||
|
var filter = new DefaultAuditPayloadFilter(
|
||||||
|
new StaticMonitor(opts),
|
||||||
|
NullLogger<DefaultAuditPayloadFilter>.Instance);
|
||||||
|
|
||||||
|
var evt = new AuditEvent
|
||||||
|
{
|
||||||
|
EventId = Guid.NewGuid(),
|
||||||
|
OccurredAtUtc = DateTime.UtcNow,
|
||||||
|
Channel = AuditChannel.ApiOutbound,
|
||||||
|
Kind = AuditKind.ApiCall,
|
||||||
|
Status = AuditStatus.Delivered,
|
||||||
|
RequestSummary = body,
|
||||||
|
};
|
||||||
|
var result = filter.Apply(evt);
|
||||||
|
|
||||||
|
Assert.True(result.PayloadTruncated);
|
||||||
|
Assert.True(Encoding.UTF8.GetByteCount(result.RequestSummary!) <= opts.DefaultCapBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IOptionsMonitor test double — returns the same snapshot on every read,
|
||||||
|
/// no change-token plumbing required for these tests. Mirrors the helper
|
||||||
|
/// used in <c>TruncationTests</c>, <c>FilterIntegrationTests</c>, etc.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
|
||||||
|
{
|
||||||
|
private readonly AuditLogOptions _value;
|
||||||
|
|
||||||
|
public StaticMonitor(AuditLogOptions value) => _value = value;
|
||||||
|
|
||||||
|
public AuditLogOptions CurrentValue => _value;
|
||||||
|
|
||||||
|
public AuditLogOptions Get(string? name) => _value;
|
||||||
|
|
||||||
|
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user