using FluentAssertions; using StellaOps.Scanner.Analyzers.Secrets; namespace StellaOps.Scanner.Analyzers.Secrets.Tests; [Trait("Category", "Unit")] public sealed class PayloadMaskerTests { private readonly PayloadMasker _masker = new(); [Fact] public void Mask_EmptySpan_ReturnsEmpty() { _masker.Mask(ReadOnlySpan.Empty).Should().BeEmpty(); } [Fact] public void Mask_ShortValue_ReturnsMaskChars() { // Values shorter than prefix+suffix get masked placeholder var result = _masker.Mask("abc".AsSpan()); result.Should().Contain("*"); } [Fact] public void Mask_StandardValue_PreservesPrefixAndSuffix() { var result = _masker.Mask("1234567890".AsSpan()); // Default: 4 char prefix, 2 char suffix result.Should().StartWith("1234"); result.Should().EndWith("90"); result.Should().Contain("****"); } [Fact] public void Mask_AwsAccessKey_PreservesPrefix() { var awsKey = "AKIAIOSFODNN7EXAMPLE"; var result = _masker.Mask(awsKey.AsSpan()); result.Should().StartWith("AKIA"); result.Should().EndWith("LE"); result.Should().Contain("****"); } [Fact] public void Mask_WithPrefixHint_UsesCustomPrefixLength() { var apiKey = "sk-proj-abcdefghijklmnop"; // MaxExposedChars is 6, so prefix:8 + suffix:2 gets scaled down var result = _masker.Mask(apiKey.AsSpan(), "prefix:4,suffix:2"); result.Should().StartWith("sk-p"); result.Should().Contain("****"); } [Fact] public void Mask_LongValue_MasksMiddle() { var longSecret = "verylongsecretthatexceeds100characters" + "andshouldbemaskkedproperlywithoutexpo" + "singtheentirecontentstoanyoneviewingit"; var result = _masker.Mask(longSecret.AsSpan()); // Should contain mask characters and be shorter than original result.Should().Contain("****"); result.Length.Should().BeLessThan(longSecret.Length); } [Fact] public void Mask_IsDeterministic() { var secret = "AKIAIOSFODNN7EXAMPLE"; var result1 = _masker.Mask(secret.AsSpan()); var result2 = _masker.Mask(secret.AsSpan()); result1.Should().Be(result2); } [Fact] public void Mask_NeverExposesFullSecret() { var secret = "supersecretkey123"; var result = _masker.Mask(secret.AsSpan()); result.Should().NotBe(secret); result.Should().Contain("*"); } [Theory] [InlineData("prefix:6,suffix:0")] [InlineData("prefix:0,suffix:6")] [InlineData("prefix:3,suffix:3")] public void Mask_WithVariousHints_RespectsTotalLimit(string hint) { var secret = "abcdefghijklmnopqrstuvwxyz"; var result = _masker.Mask(secret.AsSpan(), hint); var visibleChars = result.Replace("*", "").Length; visibleChars.Should().BeLessThanOrEqualTo(PayloadMasker.MaxExposedChars); } [Fact] public void Mask_EnforcesMinOutputLength() { var secret = "abcdefghijklmnop"; var result = _masker.Mask(secret.AsSpan()); result.Length.Should().BeGreaterThanOrEqualTo(PayloadMasker.MinOutputLength); } [Fact] public void Mask_ByteOverload_DecodesUtf8() { var text = "secretpassword123"; var bytes = System.Text.Encoding.UTF8.GetBytes(text); var result = _masker.Mask(bytes.AsSpan()); result.Should().Contain("****"); result.Should().StartWith("secr"); } [Fact] public void Mask_EmptyByteSpan_ReturnsEmpty() { _masker.Mask(ReadOnlySpan.Empty).Should().BeEmpty(); } [Fact] public void Mask_InvalidHint_UsesDefaults() { var secret = "abcdefghijklmnop"; var result1 = _masker.Mask(secret.AsSpan(), "invalid:hint:format"); var result2 = _masker.Mask(secret.AsSpan()); result1.Should().Be(result2); } [Fact] public void Mask_UsesCorrectMaskChar() { var secret = "abcdefghijklmnop"; var result = _masker.Mask(secret.AsSpan()); result.Should().Contain(PayloadMasker.MaskChar.ToString()); } [Fact] public void Mask_MaskLengthLimited() { var longSecret = new string('x', 100); var result = _masker.Mask(longSecret.AsSpan()); // Count mask characters var maskCount = result.Count(c => c == PayloadMasker.MaskChar); maskCount.Should().BeLessThanOrEqualTo(PayloadMasker.MaxMaskLength); } }