using System.Text; using System.Text.Json; 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 B (M5-T3) tests for HTTP header /// redaction. Redaction parses / /// as JSON of shape /// {"headers": {"name": "value", ...}, "body": "..."}, replaces values /// whose header NAME (case-insensitive) is in /// with "<redacted>", /// and re-serialises. Non-JSON inputs pass through unchanged (no-op for /// emitters that have not yet adopted the convention). The stage runs BEFORE /// truncation so the redaction marker survives the cap. /// public class HeaderRedactionTests { private static IOptionsMonitor Monitor(AuditLogOptions? opts = null) => new StaticMonitor(opts ?? new AuditLogOptions()); 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) => new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTime.UtcNow, Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = status, RequestSummary = request, ResponseSummary = response, }; private static string BuildSummary(IDictionary headers, string body) { // Serialize via System.Text.Json so we get a representative shape. return JsonSerializer.Serialize(new { headers = headers, body = body, }); } private static IDictionary ParseSummary(string? summary) { Assert.NotNull(summary); using var doc = JsonDocument.Parse(summary!); var dict = new Dictionary(); foreach (var property in doc.RootElement.EnumerateObject()) { dict[property.Name] = property.Value.Clone(); } return dict; } [Fact] public void HeaderRedaction_AuthorizationBearer_Redacted() { var headers = new Dictionary { ["Authorization"] = "Bearer secret-token-xyz", ["Content-Type"] = "application/json", }; var input = BuildSummary(headers, "hello"); var evt = NewEvent(request: input); var result = Filter().Apply(evt); var parsed = ParseSummary(result.RequestSummary); var resultHeaders = parsed["headers"]; Assert.Equal("", resultHeaders.GetProperty("Authorization").GetString()); } [Fact] public void HeaderRedaction_CaseInsensitive_LowercaseAuthorization_Redacted() { var headers = new Dictionary { ["authorization"] = "Bearer secret-token-xyz", }; var input = BuildSummary(headers, "hello"); var evt = NewEvent(request: input); var result = Filter().Apply(evt); var parsed = ParseSummary(result.RequestSummary); var resultHeaders = parsed["headers"]; Assert.Equal("", resultHeaders.GetProperty("authorization").GetString()); } [Fact] public void HeaderRedaction_CustomRedactList_RedactsCustomHeaderName() { var opts = new AuditLogOptions { HeaderRedactList = new List { "X-Custom-Secret" }, }; var headers = new Dictionary { ["X-Custom-Secret"] = "topsecret", ["Authorization"] = "Bearer keep-me", // not in list anymore }; var input = BuildSummary(headers, "hi"); var evt = NewEvent(request: input); var result = Filter(opts).Apply(evt); var parsed = ParseSummary(result.RequestSummary); var resultHeaders = parsed["headers"]; Assert.Equal("", resultHeaders.GetProperty("X-Custom-Secret").GetString()); // Authorization no longer listed -> preserved verbatim. Assert.Equal("Bearer keep-me", resultHeaders.GetProperty("Authorization").GetString()); } [Fact] public void HeaderRedaction_NonJson_RequestSummary_Unchanged() { const string input = "this is not JSON at all"; var evt = NewEvent(request: input); var result = Filter().Apply(evt); Assert.Equal(input, result.RequestSummary); } [Fact] public void HeaderRedaction_NoHeadersField_Unchanged() { var input = JsonSerializer.Serialize(new { body = "only a body, no headers" }); var evt = NewEvent(request: input); var result = Filter().Apply(evt); // The stage may re-serialise but the content must be semantically identical. var parsed = ParseSummary(result.RequestSummary); Assert.Equal("only a body, no headers", parsed["body"].GetString()); Assert.False(parsed.ContainsKey("headers")); } [Fact] public void HeaderRedaction_Other_Headers_Preserved() { var headers = new Dictionary { ["Authorization"] = "Bearer secret", ["Content-Type"] = "application/json", ["X-Request-Id"] = "abc-123", ["Accept"] = "application/json", }; var input = BuildSummary(headers, "payload"); var evt = NewEvent(request: input); var result = Filter().Apply(evt); var parsed = ParseSummary(result.RequestSummary); var resultHeaders = parsed["headers"]; Assert.Equal("", resultHeaders.GetProperty("Authorization").GetString()); Assert.Equal("application/json", resultHeaders.GetProperty("Content-Type").GetString()); Assert.Equal("abc-123", resultHeaders.GetProperty("X-Request-Id").GetString()); Assert.Equal("application/json", resultHeaders.GetProperty("Accept").GetString()); } [Fact] public void HeaderRedaction_AppliedBeforeTruncation() { // Build a summary whose Authorization header value is enormous AND whose // body padding pushes the total beyond the 8 KB cap. After redaction the // Authorization value becomes "" — then truncation caps the // re-serialised string. Result must: // * carry "" (header redaction ran first), // * NOT carry the original secret bytes (proves redaction won, not order swap), // * be capped at the configured DefaultCapBytes, // * have PayloadTruncated == true. const string secret = "SUPER-SECRET-TOKEN-DO-NOT-LEAK"; var headers = new Dictionary { ["Authorization"] = "Bearer " + secret, }; var body = new string('x', 9 * 1024); var input = BuildSummary(headers, body); Assert.True(Encoding.UTF8.GetByteCount(input) > 8192); var evt = NewEvent(AuditStatus.Delivered, request: input); var result = Filter().Apply(evt); Assert.NotNull(result.RequestSummary); Assert.True(Encoding.UTF8.GetByteCount(result.RequestSummary!) <= 8192); Assert.Contains("", result.RequestSummary); Assert.DoesNotContain(secret, result.RequestSummary); Assert.True(result.PayloadTruncated); } /// /// IOptionsMonitor test double — returns the same snapshot on every read, /// no change-token plumbing required for these tests. /// 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; } }