134 lines
5.0 KiB
C#
134 lines
5.0 KiB
C#
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;
|
|
}
|
|
}
|