feat(auditlog): DefaultAuditPayloadFilter truncation with UTF-8 boundary safety (#23 M5)
This commit is contained in:
226
tests/ScadaLink.AuditLog.Tests/Payload/TruncationTests.cs
Normal file
226
tests/ScadaLink.AuditLog.Tests/Payload/TruncationTests.cs
Normal file
@@ -0,0 +1,226 @@
|
||||
using System.Linq;
|
||||
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>
|
||||
/// Bundle A (M5-T2) tests for <see cref="DefaultAuditPayloadFilter"/> truncation.
|
||||
/// The filter caps RequestSummary / ResponseSummary / ErrorDetail / Extra at
|
||||
/// <see cref="AuditLogOptions.DefaultCapBytes"/> (8 KiB) on success rows and
|
||||
/// <see cref="AuditLogOptions.ErrorCapBytes"/> (64 KiB) on error rows. "Error
|
||||
/// row" = <see cref="AuditEvent.Status"/> NOT IN (<c>Delivered</c>,
|
||||
/// <c>Submitted</c>, <c>Forwarded</c>). Truncation must respect UTF-8 character
|
||||
/// boundaries (never split a multi-byte sequence mid-character) and must set
|
||||
/// <see cref="AuditEvent.PayloadTruncated"/> true when any field is shortened.
|
||||
/// </summary>
|
||||
public class TruncationTests
|
||||
{
|
||||
private static IOptionsMonitor<AuditLogOptions> Monitor(AuditLogOptions? opts = null)
|
||||
{
|
||||
var snapshot = opts ?? new AuditLogOptions();
|
||||
return new StaticMonitor(snapshot);
|
||||
}
|
||||
|
||||
private static DefaultAuditPayloadFilter Filter(AuditLogOptions? opts = null) =>
|
||||
new(Monitor(opts), NullLogger<DefaultAuditPayloadFilter>.Instance);
|
||||
|
||||
private static AuditEvent NewEvent(
|
||||
AuditStatus status = AuditStatus.Delivered,
|
||||
string? request = null,
|
||||
string? response = null,
|
||||
string? errorDetail = null,
|
||||
string? extra = null,
|
||||
bool payloadTruncated = false) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = status,
|
||||
RequestSummary = request,
|
||||
ResponseSummary = response,
|
||||
ErrorDetail = errorDetail,
|
||||
Extra = extra,
|
||||
PayloadTruncated = payloadTruncated,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void SuccessRow_10KB_RequestSummary_TruncatedTo8KB_PayloadTruncatedTrue()
|
||||
{
|
||||
var input = new string('a', 10 * 1024);
|
||||
var evt = NewEvent(AuditStatus.Delivered, request: input);
|
||||
|
||||
var result = Filter().Apply(evt);
|
||||
|
||||
Assert.NotNull(result.RequestSummary);
|
||||
Assert.Equal(8192, Encoding.UTF8.GetByteCount(result.RequestSummary!));
|
||||
Assert.True(result.PayloadTruncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ErrorRow_10KB_RequestSummary_NotTruncated_UnderErrorCap()
|
||||
{
|
||||
var input = new string('b', 10 * 1024);
|
||||
var evt = NewEvent(AuditStatus.Failed, request: input);
|
||||
|
||||
var result = Filter().Apply(evt);
|
||||
|
||||
Assert.Equal(input, result.RequestSummary);
|
||||
Assert.False(result.PayloadTruncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ErrorRow_70KB_RequestSummary_TruncatedTo64KB_PayloadTruncatedTrue()
|
||||
{
|
||||
var input = new string('c', 70 * 1024);
|
||||
var evt = NewEvent(AuditStatus.Failed, request: input);
|
||||
|
||||
var result = Filter().Apply(evt);
|
||||
|
||||
Assert.NotNull(result.RequestSummary);
|
||||
Assert.Equal(65536, Encoding.UTF8.GetByteCount(result.RequestSummary!));
|
||||
Assert.True(result.PayloadTruncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multibyte_UTF8_TruncatedAtCharacterBoundary_NotMidByte()
|
||||
{
|
||||
// U+1F600 (grinning face) encodes to 4 UTF-8 bytes; 2000 of them = 8000 bytes,
|
||||
// safely under the 8192 default cap so the boundary scan kicks in mid-character
|
||||
// when we push past it. Pad with a few extra emoji so the *input* is > 8192 bytes
|
||||
// and forces truncation.
|
||||
var emoji = "😀"; // surrogate pair => one code point => 4 UTF-8 bytes
|
||||
var sb = new StringBuilder();
|
||||
for (int i = 0; i < 2100; i++)
|
||||
{
|
||||
sb.Append(emoji);
|
||||
}
|
||||
var input = sb.ToString();
|
||||
Assert.True(Encoding.UTF8.GetByteCount(input) > 8192);
|
||||
|
||||
var evt = NewEvent(AuditStatus.Delivered, request: input);
|
||||
|
||||
var result = Filter().Apply(evt);
|
||||
|
||||
Assert.NotNull(result.RequestSummary);
|
||||
var resultBytes = Encoding.UTF8.GetByteCount(result.RequestSummary!);
|
||||
Assert.True(resultBytes <= 8192, $"expected <= 8192 bytes, got {resultBytes}");
|
||||
// 4-byte emoji boundary: the kept byte length must be a multiple of 4.
|
||||
Assert.Equal(0, resultBytes % 4);
|
||||
// And round-tripping the result must not introduce a U+FFFD replacement char.
|
||||
Assert.DoesNotContain('<27>', result.RequestSummary);
|
||||
Assert.True(result.PayloadTruncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullSummary_PassesThrough_AsNull()
|
||||
{
|
||||
var evt = NewEvent(AuditStatus.Delivered, request: null, response: null, errorDetail: null, extra: null);
|
||||
|
||||
var result = Filter().Apply(evt);
|
||||
|
||||
Assert.Null(result.RequestSummary);
|
||||
Assert.Null(result.ResponseSummary);
|
||||
Assert.Null(result.ErrorDetail);
|
||||
Assert.Null(result.Extra);
|
||||
Assert.False(result.PayloadTruncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RawEventAlreadyTruncated_PayloadTruncatedRemainsTrue()
|
||||
{
|
||||
// Small payload that requires no truncation, but the caller already
|
||||
// flagged PayloadTruncated upstream — the filter must not clear it.
|
||||
var evt = NewEvent(AuditStatus.Delivered, request: "small", payloadTruncated: true);
|
||||
|
||||
var result = Filter().Apply(evt);
|
||||
|
||||
Assert.Equal("small", result.RequestSummary);
|
||||
Assert.True(result.PayloadTruncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StatusAttempted_TreatedAsError_UsesErrorCap()
|
||||
{
|
||||
// 10 KB is under the 64 KB error cap; if Attempted were a success status
|
||||
// the value would be truncated to 8 KB. We assert it is NOT truncated.
|
||||
var input = new string('d', 10 * 1024);
|
||||
var evt = NewEvent(AuditStatus.Attempted, request: input);
|
||||
|
||||
var result = Filter().Apply(evt);
|
||||
|
||||
Assert.Equal(input, result.RequestSummary);
|
||||
Assert.False(result.PayloadTruncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StatusParked_TreatedAsError_UsesErrorCap()
|
||||
{
|
||||
var input = new string('e', 10 * 1024);
|
||||
var evt = NewEvent(AuditStatus.Parked, request: input);
|
||||
|
||||
var result = Filter().Apply(evt);
|
||||
|
||||
Assert.Equal(input, result.RequestSummary);
|
||||
Assert.False(result.PayloadTruncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StatusSkipped_TreatedAsError_UsesErrorCap()
|
||||
{
|
||||
var input = new string('f', 10 * 1024);
|
||||
var evt = NewEvent(AuditStatus.Skipped, request: input);
|
||||
|
||||
var result = Filter().Apply(evt);
|
||||
|
||||
Assert.Equal(input, result.RequestSummary);
|
||||
Assert.False(result.PayloadTruncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ErrorDetail_AndExtra_Truncated_Independently()
|
||||
{
|
||||
// Each field is capped on its own — a 10 KB RequestSummary and a 10 KB
|
||||
// ErrorDetail on the same Delivered row should both be cut to 8 KB and
|
||||
// the row flagged truncated.
|
||||
var input = new string('g', 10 * 1024);
|
||||
var evt = NewEvent(
|
||||
AuditStatus.Delivered,
|
||||
request: input,
|
||||
response: input,
|
||||
errorDetail: input,
|
||||
extra: input);
|
||||
|
||||
var result = Filter().Apply(evt);
|
||||
|
||||
Assert.Equal(8192, Encoding.UTF8.GetByteCount(result.RequestSummary!));
|
||||
Assert.Equal(8192, Encoding.UTF8.GetByteCount(result.ResponseSummary!));
|
||||
Assert.Equal(8192, Encoding.UTF8.GetByteCount(result.ErrorDetail!));
|
||||
Assert.Equal(8192, Encoding.UTF8.GetByteCount(result.Extra!));
|
||||
Assert.True(result.PayloadTruncated);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IOptionsMonitor test double — returns the same snapshot on every read,
|
||||
/// no change-token plumbing required for these tests (Bundle D wires the
|
||||
/// real hot-reload path).
|
||||
/// </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