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;
}
}