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-T5) tests for SQL parameter redaction in /// . M4 Bundle A's /// AuditingDbCommand emits RequestSummary as /// {"sql":"...","parameters":{"@name":"value", ...}}; the SQL-parameter /// redactor parses this shape on /// rows, replaces values whose key /// matches the configured case-insensitive regex with <redacted>, /// and re-serialises. Default behaviour with no opt-in: parameter values are /// captured verbatim. Connection lookup uses the connection-name prefix of /// (everything before the first .) so /// the same per-connection regex applies regardless of the SQL-snippet suffix /// that AuditingDbCommand appends to disambiguate rows. /// public class SqlParamRedactionTests { 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 NewDbEvent(string target, string requestSummary) => new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTime.UtcNow, Channel = AuditChannel.DbOutbound, Kind = AuditKind.DbWrite, Status = AuditStatus.Delivered, Target = target, RequestSummary = requestSummary, }; /// /// Build a RequestSummary in the exact shape M4's AuditingDbCommand /// emits — hand-rolled JSON with "sql" + "parameters" keys. /// Tests depend on this format; if AuditingDbCommand ever changes, this /// helper updates in lockstep. /// private static string DbRequestSummary(string sql, params (string name, string value)[] parameters) { var sb = new System.Text.StringBuilder(); sb.Append("{\"sql\":\"").Append(sql).Append('"'); if (parameters.Length > 0) { sb.Append(",\"parameters\":{"); for (var i = 0; i < parameters.Length; i++) { if (i > 0) sb.Append(','); sb.Append('"').Append(parameters[i].name).Append("\":\"") .Append(parameters[i].value).Append('"'); } sb.Append('}'); } sb.Append('}'); return sb.ToString(); } [Fact] public void NoOptIn_ParamsVerbatim_Unchanged() { var input = DbRequestSummary( "INSERT INTO Users (Name, Token) VALUES (@name, @token)", ("@name", "Alice"), ("@token", "secret-xyz")); var evt = NewDbEvent("PrimaryDb.INSERT INTO Users", input); var result = Filter().Apply(evt); Assert.Equal(input, result.RequestSummary); } [Fact] public void OptInRegex_AtToken_OrAtApikey_RedactsThoseValues_KeepsOthers() { var opts = new AuditLogOptions { PerTargetOverrides = new Dictionary { ["PrimaryDb"] = new PerTargetRedactionOverride { RedactSqlParamsMatching = "^@(token|apikey)$", }, }, }; var input = DbRequestSummary( "INSERT INTO Users (Name, Token, ApiKey) VALUES (@name, @token, @apikey)", ("@name", "Alice"), ("@token", "secret-xyz"), ("@apikey", "k-987")); var evt = NewDbEvent("PrimaryDb.INSERT INTO Users", input); var result = Filter(opts).Apply(evt); Assert.NotNull(result.RequestSummary); Assert.Contains("\"@name\":\"Alice\"", result.RequestSummary); Assert.Contains("\"@token\":\"\"", result.RequestSummary); Assert.Contains("\"@apikey\":\"\"", result.RequestSummary); Assert.DoesNotContain("secret-xyz", result.RequestSummary); Assert.DoesNotContain("k-987", result.RequestSummary); } [Fact] public void RegexCaseInsensitive_MatchesParamNames() { var opts = new AuditLogOptions { PerTargetOverrides = new Dictionary { ["PrimaryDb"] = new PerTargetRedactionOverride { RedactSqlParamsMatching = "token", }, }, }; var input = DbRequestSummary( "UPDATE x SET Token = @TOKEN", ("@TOKEN", "uppercased-secret")); var evt = NewDbEvent("PrimaryDb.UPDATE x SET Token", input); var result = Filter(opts).Apply(evt); Assert.NotNull(result.RequestSummary); Assert.Contains("\"@TOKEN\":\"\"", result.RequestSummary); Assert.DoesNotContain("uppercased-secret", result.RequestSummary); } [Fact] public void NonDbOutboundChannel_NotAffected() { // ApiOutbound row whose RequestSummary happens to look like the // DbOutbound JSON shape (worst-case false positive). The SQL // redactor must NOT touch it — channel guards the stage. var opts = new AuditLogOptions { PerTargetOverrides = new Dictionary { ["PrimaryDb"] = new PerTargetRedactionOverride { RedactSqlParamsMatching = "^@token$", }, }, }; var input = DbRequestSummary( "SELECT @token", ("@token", "should-survive")); var evt = new AuditEvent { EventId = Guid.NewGuid(), OccurredAtUtc = DateTime.UtcNow, Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered, Target = "PrimaryDb.SELECT", // doesn't matter — channel guards RequestSummary = input, }; var result = Filter(opts).Apply(evt); Assert.Equal(input, result.RequestSummary); } [Fact] public void PerTargetSetting_MatchesByTarget() { // Two connections — A is configured to redact tokens, B is not. Same // payload through each must yield different results. var opts = new AuditLogOptions { PerTargetOverrides = new Dictionary { ["ConnA"] = new PerTargetRedactionOverride { RedactSqlParamsMatching = "^@token$", }, }, }; var input = DbRequestSummary( "SELECT @token", ("@token", "the-secret")); var aEvt = NewDbEvent("ConnA.SELECT @token", input); var bEvt = NewDbEvent("ConnB.SELECT @token", input); var aResult = Filter(opts).Apply(aEvt); var bResult = Filter(opts).Apply(bEvt); Assert.Contains("", aResult.RequestSummary!); Assert.DoesNotContain("the-secret", aResult.RequestSummary!); Assert.Equal(input, bResult.RequestSummary); } /// IOptionsMonitor test double. 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; } }