File: AnalyzeCiFailureTextTests.cs
Web Access
Project: src\tests\AnalyzeCiFailure.Tests\AnalyzeCiFailure.Tests.csproj (AnalyzeCiFailure.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Nodes;
using Xunit;
 
namespace Infrastructure.Tests;
 
/// <summary>Checks diagnostic redaction, Unicode limits and inert rendering of untrusted text.</summary>
public sealed class AnalyzeCiFailureTextTests
{
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public void LongNonSecretPrefixesAreSanitizedWithinTheProcessingBudget(bool metadata)
    {
        var text = string.Concat(Enumerable.Repeat("a-", 10000));
        _ = AnalyzeCiFailure.DiagnosticTextSanitizer.Sanitize("warmup");
        var started = Stopwatch.GetTimestamp();
 
        var sanitized = metadata
            ? AnalyzeCiFailure.DiagnosticTextSanitizer.SanitizeRunMetadata(text, limit: 4000)
            : AnalyzeCiFailure.DiagnosticTextSanitizer.Sanitize(text, limit: 4000);
        var elapsed = Stopwatch.GetElapsedTime(started);
 
        Assert.Equal(string.Concat(Enumerable.Repeat("a-", 2000)), sanitized);
        Assert.True(elapsed < TimeSpan.FromSeconds(5), $"Sanitizing 20,000 characters took {elapsed}.");
    }
 
    [Theory]
    [InlineData("{prefix}token=secret", "{prefix}token=[REDACTED]")]
    [InlineData("{prefix}token='secret'", "{prefix}token='[REDACTED]'")]
    [InlineData("{prefix}token=\"secret\"", "{prefix}token=\"[REDACTED]\"")]
    [InlineData("\"{prefix}token\": \"secret\"", "\"{prefix}token\": \"[REDACTED]\"")]
    [InlineData("'{prefix}token': 'secret'", "'{prefix}token': '[REDACTED]'")]
    [InlineData("{prefix}ConnectionString=Server=db;Password=secret", "{prefix}ConnectionString=[REDACTED]")]
    [InlineData("{prefix}ConnectionString='Server=db;Password=secret'", "{prefix}ConnectionString='[REDACTED]'")]
    [InlineData("{prefix}ConnectionStrings__db=\"Server=db;Password=secret\"", "{prefix}ConnectionStrings__db=\"[REDACTED]\"")]
    [InlineData("{prefix}ConnectionStrings__db=Server=db;Password=secret", "{prefix}ConnectionStrings__db=[REDACTED]")]
    public void LongSettingPrefixesDoNotWeakenSecretRedaction(string input, string expected)
    {
        var prefix = new string('a', 500) + "_";
        input = input.Replace("{prefix}", prefix, StringComparison.Ordinal);
        expected = expected.Replace("{prefix}", prefix, StringComparison.Ordinal);
 
        Assert.Equal(expected, AnalyzeCiFailure.DiagnosticTextSanitizer.Sanitize(input));
        Assert.Equal(expected, AnalyzeCiFailure.DiagnosticTextSanitizer.SanitizeRunMetadata(input, int.MaxValue));
    }
 
    [Fact]
    public void FocusedLogsPrioritizeActualAssertionsOverPassingTestsAndGenericNoise()
    {
        var noise = string.Join('\n', Enumerable.Range(1, count: 250).Select(index =>
            $"2026-10-29T01:00:00Z   \u2714 passing test {index} handles failed operations\nordinary failed operation {index}"));
        var focused = AnalyzeCiFailure.WorkflowRunEvidenceCollector.FocusLogs(
            $"AssertionError: early failure\n{noise}\nAssertionError [ERR_ASSERTION]: Lock holder failed: signal=SIGTERM\nnull !== 0\n##[error]Process completed with exit code 1.\n");
 
        Assert.Contains("AssertionError: early failure", focused, StringComparison.Ordinal);
        Assert.Contains("AssertionError [ERR_ASSERTION]: Lock holder failed: signal=SIGTERM", focused, StringComparison.Ordinal);
        Assert.Contains("null !== 0", focused, StringComparison.Ordinal);
        Assert.True(focused.Split('\n', StringSplitOptions.RemoveEmptyEntries).Length <= 150);
    }
 
    [Theory]
    [InlineData("AssertionError [ERR_ASSERTION]: web entered FailedToStart")]
    [InlineData("AssertionError: web entered FailedToStart")]
    public void FocusedLogsRetainNodeAssertionBeforeTheFinalExitAnnotation(string assertion)
    {
        var log = $"""
            setup
            before assertion
            2026-10-20T04:02:02Z {assertion}
                at file:///workspace/NuxtApp/verify.mjs:19:20
            generatedMessage: false
            context six
            context seven
            context eight
            omitted gap
            final context ten
            final context eleven
            final context twelve
            ##[error]Process completed with exit code 1.
            cleanup
            """.ReplaceLineEndings("\n");
 
        var focused = AnalyzeCiFailure.WorkflowRunEvidenceCollector.FocusLogs(log);
 
        Assert.Equal(
            $"""
            1-setup
            2-before assertion
            3:2026-10-20T04:02:02Z {assertion}
            4-    at file:///workspace/NuxtApp/verify.mjs:19:20
            5-generatedMessage: false
            6-context six
            7-context seven
            8-context eight
            --
            10-final context ten
            11-final context eleven
            12-final context twelve
            13:##[error]Process completed with exit code 1.
            14-cleanup
            """.ReplaceLineEndings("\n") + "\n",
            focused);
    }
 
    [Theory]
    [InlineData("Fix ConnectionString: preserve named configuration", "Fix ConnectionString: [REDACTED]", "Fix ConnectionString: preserve named configuration")]
    [InlineData("ConnectionString=Server=db;Password=secret", "ConnectionString=[REDACTED]", "ConnectionString=[REDACTED]")]
    [InlineData("ConnectionStrings__db='Server=db;Password=secret'", "ConnectionStrings__db='[REDACTED]'", "ConnectionStrings__db='[REDACTED]'")]
    [InlineData("\"ConnectionStrings\": \"Server=db\"", "\"ConnectionStrings\": \"[REDACTED]\"", "\"ConnectionStrings\": \"[REDACTED]\"")]
    public void DiagnosticTextAndRunMetadataPreserveTheirConnectionStringPolicies(string input, string diagnostic, string metadata)
    {
        Assert.Equal(diagnostic, AnalyzeCiFailure.DiagnosticTextSanitizer.Sanitize(input));
        Assert.Equal(metadata, AnalyzeCiFailure.DiagnosticTextSanitizer.SanitizeRunMetadata(input, int.MaxValue));
    }
 
    [Theory]
    [InlineData(null, "null")]
    [InlineData("null", "null")]
    [InlineData("\"\"", "")]
    [InlineData("\"null\"", "null")]
    [InlineData("\"line\\n\\\"quoted\\\"\\\\tail\"", "line\n\"quoted\"\\tail")]
    [InlineData("\"\\u003cT\\u003e\"", "<T>")]
    [InlineData("\"\\uD83D\\uDE00\"", "\U0001f600")]
    [InlineData("10.0", "10.0")]
    [InlineData("1e1", "1e1")]
    [InlineData("9007199254740993", "9007199254740993")]
    [InlineData("123.000000000000000001", "123.000000000000000001")]
    [InlineData("1e-400", "1e-400")]
    [InlineData("true", "true")]
    [InlineData("false", "false")]
    [InlineData("""[1,"text",false,null]""", """[1,"text",false,null]""")]
    [InlineData("""{ "x": [1, true, null], "s": "text" }""", """{"x":[1,true,null],"s":"text"}""")]
    public void ValueTextDecodesStringsAndPreservesOtherJsonRepresentations(string? json, string expected)
    {
        var node = json is null ? null : AnalyzeCiFailure.CiFailureJson.Parse(json);
 
        Assert.Equal(expected, AnalyzeCiFailure.CiFailureJson.GetValueText(node));
    }
 
    [Fact]
    public void ValueTextPreservesTheJsonRepresentationOfNonStringBackedValues()
    {
        var node = JsonValue.Create('x');
 
        Assert.Equal("\"x\"", AnalyzeCiFailure.CiFailureJson.GetValueText(node));
    }
 
    [Theory]
    [InlineData(null, "")]
    [InlineData("null", "")]
    [InlineData("false", "")]
    [InlineData("\"false\"", "false")]
    [InlineData("\"null\"", "null")]
    [InlineData("\"\"", "")]
    [InlineData("\"value\\u0000\\n\"", "value\0\n")]
    [InlineData("true", "true")]
    [InlineData("0", "0")]
    [InlineData("1e1", "1e1")]
    [InlineData("[]", "[]")]
    [InlineData("""{"value":false}""", """{"value":false}""")]
    public void RequiredReasonTextRejectsMissingNullAndNonStringValues(string? json, string expected)
    {
        var value = json is null ? null : JsonNode.Parse(json);
        var document = new JsonObject { ["job_id"] = "123", ["classification"] = "code-issue" };
        if (json is not null)
        {
            document["reason"] = value?.DeepClone();
        }
        if (value?.GetValueKind() == JsonValueKind.String)
        {
            Assert.Equal(expected, AnalyzeCiFailure.TransientJson.Read<AnalyzeCiFailure.ProposedJobDecision>(document.ToJsonString()).Reason);
        }
        else
        {
            Assert.Throws<AnalyzeCiFailure.ValidationException>(() =>
                AnalyzeCiFailure.TransientJson.Read<AnalyzeCiFailure.ProposedJobDecision>(document.ToJsonString()));
        }
    }
 
    [Theory]
    [InlineData("main\0\n\n", "main")]
    [InlineData("ma\0in", "main")]
    [InlineData("\0flaky-test\n", "flaky-test")]
    [InlineData("n\0ull\n", "null")]
    [InlineData(" main \n", " main ")]
    [InlineData("main\r\n", "main\r")]
    [InlineData("main\t", "main\t")]
    [InlineData("main\nmiddle\n", "main\nmiddle")]
    [InlineData("", "")]
    public void IdentityFieldsDoNotStripControlsOrCoerceNullText(string text, string formerlyNormalized)
    {
        var node = JsonValue.Create(text);
 
        Assert.Equal(text, AnalyzeCiFailure.CiFailureJson.GetValueText(node));
        if (formerlyNormalized == "null")
        {
            Assert.Throws<AnalyzeCiFailure.ValidationException>(() => AnalyzeCiFailure.TransientJson.Read<AnalyzeCiFailure.CollectedSubjectPr>(
                new JsonObject { ["number"] = "42", ["metadata"] = text }.ToJsonString()));
        }
        else
        {
            Assert.False(formerlyNormalized == "flaky-test"
                ? AnalyzeCiFailure.CiFailureJson.TryReadEnum<AnalyzeCiFailure.AnalysisVerdict>(text, out _)
                : AnalyzeCiFailure.CiFailureJson.TryReadEnum<AnalyzeCiFailure.RunScope>(text, out _));
        }
    }
 
    [Fact]
    public void DurableValueTextTranslatesMalformedUnicode()
    {
        var node = JsonNode.Parse("\"bad\\ud800\"");
 
        var exception = Assert.Throws<JsonException>(() => AnalyzeCiFailure.CiFailureJson.GetValueText(node));
 
        Assert.Equal("JSON contains malformed Unicode text", exception.Message);
    }
 
    [Theory]
    [InlineData(500, 500, true)]
    [InlineData(501, 500, false)]
    [InlineData(238, 238, true)]
    [InlineData(239, 238, false)]
    public void TextLimitsCountUnicodeCodePoints(int count, int limit, bool expected)
    {
        var text = string.Concat(Enumerable.Repeat("\U0001f600", count));
 
        Assert.Equal(expected, AnalyzeCiFailure.CiFailureJson.Safe(JsonValue.Create(text), limit));
    }
 
    [Theory]
    [InlineData("\u0001")]
    [InlineData("\u200b")]
    [InlineData("\u2028")]
    [InlineData("\u2029")]
    [InlineData("\ufe00")]
    [InlineData("\U000e0041")]
    [InlineData("\U000e0100")]
    [InlineData("\U000e01ef")]
    public void RawTextRuleRejectsInvisibleCodePoints(string character)
    {
        // Entry sanitization strips these characters. Exercise the independent rule before
        // sanitization to catch supplementary-plane format/variation-selector regressions.
        var text = JsonValue.Create("before" + character + "after");
 
        Assert.False(AnalyzeCiFailure.CiFailureJson.Safe(text, max: 500));
        Assert.False(AnalyzeCiFailure.CiFailureJson.Safe(text, max: 500, multiline: true));
    }
 
    [Theory]
    [InlineData("\t")]
    [InlineData("\n")]
    public void OnlyMultilineFieldsAllowTabAndNewline(string character)
    {
        var text = JsonValue.Create("before" + character + "after");
 
        Assert.False(AnalyzeCiFailure.CiFailureJson.Safe(text, max: 500));
        Assert.True(AnalyzeCiFailure.CiFailureJson.Safe(text, max: 500, multiline: true));
    }
}