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; /// /// Bundle A (M5-T2) tests for truncation. /// The filter caps RequestSummary / ResponseSummary / ErrorDetail / Extra at /// (8 KiB) on success rows and /// (64 KiB) on error rows. "Error /// row" = NOT IN (Delivered, /// Submitted, Forwarded). Truncation must respect UTF-8 character /// boundaries (never split a multi-byte sequence mid-character) and must set /// true when any field is shortened. /// public class TruncationTests { private static IOptionsMonitor Monitor(AuditLogOptions? opts = null) { var snapshot = opts ?? new AuditLogOptions(); return new StaticMonitor(snapshot); } private static DefaultAuditPayloadFilter Filter(AuditLogOptions? opts = null) => new(Monitor(opts), NullLogger.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('�', 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); } /// /// 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). /// private sealed class StaticMonitor : IOptionsMonitor { private readonly AuditLogOptions _value; public StaticMonitor(AuditLogOptions value) => _value = value; public AuditLogOptions CurrentValue => _value; public AuditLogOptions Get(string? name) => _value; public IDisposable? OnChange(Action listener) => null; } }