using FluentAssertions; using SIGCM2.Infrastructure.Audit; using Xunit; namespace SIGCM2.Application.Tests.Infrastructure.Audit; /// UDT-010 Batch 3 — JsonSanitizer unit tests (Strict TDD). /// Validates #REQ-AUD-5: metadata MUST be stripped of blacklisted keys before persisting. public sealed class JsonSanitizerTests { private static readonly string[] DefaultBlacklist = [ "password", "passwordHash", "token", "refreshToken", "accessToken", "cvv", "card", "cardNumber", "secret", "apiKey", "privateKey", ]; [Fact] public void Sanitize_NullInput_ReturnsNull() { var result = JsonSanitizer.Sanitize(null, DefaultBlacklist); result.Should().BeNull(); } [Fact] public void Sanitize_EmptyBlacklist_PreservesAllKeys() { var result = JsonSanitizer.Sanitize(new { password = "x", username = "y" }, []); result.Should().NotBeNull(); result.Should().Contain("\"password\"").And.Contain("\"username\""); } [Fact] public void Sanitize_FlatObject_RemovesBlacklistedKeysAndKeepsOthers() { var result = JsonSanitizer.Sanitize( new { password = "secret1", username = "juan", email = "j@x.com" }, DefaultBlacklist); result.Should().NotBeNull(); result.Should().NotContain("\"password\""); result.Should().NotContain("secret1"); result.Should().Contain("\"username\":\"juan\""); result.Should().Contain("\"email\":\"j@x.com\""); } [Fact] public void Sanitize_NestedObject_RemovesBlacklistedKeysAtEveryLevel() { var input = new { user = new { password = "x", email = "y@z.com" }, payload = new { nested = new { token = "abc", safe = "keep" } }, }; var result = JsonSanitizer.Sanitize(input, DefaultBlacklist); result.Should().NotBeNull(); result.Should().NotContain("\"password\"").And.NotContain("\"token\"") .And.NotContain("\"x\"").And.NotContain("abc"); result.Should().Contain("\"email\":\"y@z.com\""); result.Should().Contain("\"safe\":\"keep\""); } [Fact] public void Sanitize_ArrayOfObjects_StripsBlacklistedFromEachElement() { var input = new { items = new object[] { new { password = "a", name = "first" }, new { token = "b", name = "second" }, new { name = "third" }, }, }; var result = JsonSanitizer.Sanitize(input, DefaultBlacklist); result.Should().NotBeNull(); result.Should().NotContain("\"password\"").And.NotContain("\"token\""); result.Should().Contain("\"name\":\"first\""); result.Should().Contain("\"name\":\"second\""); result.Should().Contain("\"name\":\"third\""); } [Fact] public void Sanitize_CaseInsensitiveMatching_StripsAllVariants() { var input = new Dictionary { ["Password"] = "a", ["PASSWORD"] = "b", ["pAsSwOrD"] = "c", ["username"] = "keep", }; var result = JsonSanitizer.Sanitize(input, new[] { "password" }); result.Should().NotBeNull(); result.Should().NotContain("\"a\"").And.NotContain("\"b\"").And.NotContain("\"c\""); result.Should().Contain("\"username\":\"keep\""); } [Fact] public void Sanitize_PreservesPrimitives_Numbers_Bools_Null() { var input = new { count = 42, flag = true, missing = (string?)null, password = "secret" }; var result = JsonSanitizer.Sanitize(input, DefaultBlacklist); result.Should().NotBeNull(); result.Should().Contain("\"count\":42"); result.Should().Contain("\"flag\":true"); result.Should().Contain("\"missing\":null"); result.Should().NotContain("\"password\""); } [Fact] public void Sanitize_ProducesValidJson() { var input = new { password = "x", user = new { token = "y", name = "z" } }; var result = JsonSanitizer.Sanitize(input, DefaultBlacklist); result.Should().NotBeNull(); // Round-trip parse validates shape var parsed = System.Text.Json.JsonDocument.Parse(result!); parsed.RootElement.TryGetProperty("password", out _).Should().BeFalse(); parsed.RootElement.GetProperty("user").TryGetProperty("token", out _).Should().BeFalse(); parsed.RootElement.GetProperty("user").GetProperty("name").GetString().Should().Be("z"); } [Fact] public void Sanitize_AlreadySerializedJsonString_IsTreatedAsStringNotObject() { // Callers that want to sanitize an already-serialized JSON string must parse+re-sanitize // themselves — the sanitizer treats a string argument as a JSON string primitive. var result = JsonSanitizer.Sanitize("""{"password":"x"}""", DefaultBlacklist); result.Should().NotBeNull(); // Round-trip parse proves the result is a JSON string primitive (not an object). var parsed = System.Text.Json.JsonDocument.Parse(result!); parsed.RootElement.ValueKind.Should().Be(System.Text.Json.JsonValueKind.String); parsed.RootElement.GetString().Should().Be("""{"password":"x"}"""); } [Fact] public void Sanitize_AuditOptionsDefaultKeys_AreAllEffective() { // Defensive: AuditOptions.SanitizedKeys must match what tests expect. var opts = new SIGCM2.Application.Audit.AuditOptions(); var input = new { password = "1", passwordHash = "2", token = "3", refreshToken = "4", accessToken = "5", cvv = "6", card = "7", cardNumber = "8", secret = "9", apiKey = "10", privateKey = "11", keep = "yes", }; var result = JsonSanitizer.Sanitize(input, opts.SanitizedKeys); result.Should().NotBeNull(); foreach (var bad in new[] { "password", "passwordHash", "token", "refreshToken", "accessToken", "cvv", "card", "cardNumber", "secret", "apiKey", "privateKey" }) { result.Should().NotContain($"\"{bad}\"", because: $"'{bad}' is in default blacklist"); } result.Should().Contain("\"keep\":\"yes\""); } }