// ----------------------------------------------------------------------------- // PrAnnotationServiceTests.cs // Sprint: SPRINT_20260112_007_SCANNER_pr_mr_annotations (SCANNER-PR-004) // Description: Tests for PR annotation service with ASCII-only output and evidence anchors. // ----------------------------------------------------------------------------- using Microsoft.Extensions.Time.Testing; using StellaOps.Scanner.Reachability; using StellaOps.Scanner.WebService.Services; using StellaOps.Scanner.WebService.Domain; namespace StellaOps.Scanner.WebService.Tests; public sealed class PrAnnotationServiceTests { private readonly FakeTimeProvider _timeProvider; private readonly PrAnnotationService _service; public PrAnnotationServiceTests() { _timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 15, 10, 0, 0, TimeSpan.Zero)); _service = new PrAnnotationService( new FakeReachabilityQueryService(), _timeProvider); } [Fact] public void FormatAsComment_NoFlips_ReturnsAsciiOnlyOutput() { // Arrange var summary = CreateSummary(newRiskCount: 0, mitigatedCount: 0, flips: []); // Act var comment = _service.FormatAsComment(summary); // Assert Assert.DoesNotContain("\u2705", comment); // No checkmark emoji Assert.DoesNotContain("\u26d4", comment); // No stop sign emoji Assert.DoesNotContain("\u26a0", comment); // No warning sign emoji Assert.DoesNotContain("\u2192", comment); // No arrow Assert.Contains("[OK]", comment); Assert.Contains("NO CHANGE", comment); } [Fact] public void FormatAsComment_WithNewRisks_ReturnsBlockingStatus() { // Arrange var flips = new List { new StateFlip { FlipType = StateFlipType.BecameReachable, CveId = "CVE-2026-0001", Purl = "pkg:npm/lodash@4.17.21", NewTier = "confirmed", WitnessId = "witness-123" } }; var summary = CreateSummary(newRiskCount: 1, mitigatedCount: 0, flips: flips, shouldBlock: true); // Act var comment = _service.FormatAsComment(summary); // Assert Assert.Contains("[BLOCKING]", comment); Assert.Contains("[+] Became Reachable", comment); Assert.DoesNotContain("\ud83d\udd34", comment); // No red circle emoji } [Fact] public void FormatAsComment_WithMitigatedRisks_ReturnsImprovedStatus() { // Arrange var flips = new List { new StateFlip { FlipType = StateFlipType.BecameUnreachable, CveId = "CVE-2026-0002", Purl = "pkg:npm/express@4.18.0", PreviousTier = "likely", NewTier = "unreachable" } }; var summary = CreateSummary(newRiskCount: 0, mitigatedCount: 1, flips: flips); // Act var comment = _service.FormatAsComment(summary); // Assert Assert.Contains("[OK]", comment); Assert.Contains("IMPROVED", comment); Assert.Contains("[-] Became Unreachable", comment); Assert.DoesNotContain("\ud83d\udfe2", comment); // No green circle emoji } [Fact] public void FormatAsComment_WithEvidenceAnchors_IncludesEvidenceSection() { // Arrange var summary = CreateSummary( newRiskCount: 0, mitigatedCount: 0, flips: [], attestationDigest: "sha256:abc123def456", policyVerdict: "PASS", policyReasonCode: "NO_BLOCKERS", verifyCommand: "stella scan verify --digest sha256:abc123def456"); // Act var comment = _service.FormatAsComment(summary); // Assert Assert.Contains("### Evidence", comment); Assert.Contains("sha256:abc123def456", comment); Assert.Contains("PASS", comment); Assert.Contains("NO_BLOCKERS", comment); Assert.Contains("stella scan verify", comment); } [Fact] public void FormatAsComment_DeterministicOrdering_SortsByFlipTypeThenCveId() { // Arrange var flips = new List { new StateFlip { FlipType = StateFlipType.BecameUnreachable, CveId = "CVE-2026-0001", Purl = "pkg:a", NewTier = "unreachable" }, new StateFlip { FlipType = StateFlipType.BecameReachable, CveId = "CVE-2026-0003", Purl = "pkg:b", NewTier = "confirmed" }, new StateFlip { FlipType = StateFlipType.BecameReachable, CveId = "CVE-2026-0002", Purl = "pkg:c", NewTier = "likely" }, }; var summary = CreateSummary(newRiskCount: 2, mitigatedCount: 1, flips: flips); // Act var comment = _service.FormatAsComment(summary); // Assert - BecameReachable should come first, then sorted by CVE ID var cve0002Pos = comment.IndexOf("CVE-2026-0002"); var cve0003Pos = comment.IndexOf("CVE-2026-0003"); var cve0001Pos = comment.IndexOf("CVE-2026-0001"); // BecameReachable CVEs first (0002, 0003), then BecameUnreachable (0001) Assert.True(cve0002Pos < cve0001Pos, "CVE-2026-0002 (reachable) should appear before CVE-2026-0001 (unreachable)"); Assert.True(cve0003Pos < cve0001Pos, "CVE-2026-0003 (reachable) should appear before CVE-2026-0001 (unreachable)"); // Within reachable, sorted by CVE ID Assert.True(cve0002Pos < cve0003Pos, "CVE-2026-0002 should appear before CVE-2026-0003 (alphabetical)"); } [Fact] public void FormatAsComment_TierChanges_UsesAsciiIndicators() { // Arrange var flips = new List { new StateFlip { FlipType = StateFlipType.TierIncreased, CveId = "CVE-2026-0001", Purl = "pkg:a", PreviousTier = "present", NewTier = "likely" }, new StateFlip { FlipType = StateFlipType.TierDecreased, CveId = "CVE-2026-0002", Purl = "pkg:b", PreviousTier = "likely", NewTier = "present" }, }; var summary = CreateSummary(newRiskCount: 0, mitigatedCount: 0, flips: flips); // Act var comment = _service.FormatAsComment(summary); // Assert Assert.Contains("[^] Tier Increased", comment); Assert.Contains("[v] Tier Decreased", comment); Assert.DoesNotContain("\u2191", comment); // No up arrow Assert.DoesNotContain("\u2193", comment); // No down arrow } [Fact] public void FormatAsComment_LimitedTo20Flips_ShowsMoreIndicator() { // Arrange var flips = Enumerable.Range(1, 25) .Select(i => new StateFlip { FlipType = StateFlipType.BecameReachable, CveId = $"CVE-2026-{i:D4}", Purl = $"pkg:test/package-{i}", NewTier = "likely" }) .ToList(); var summary = CreateSummary(newRiskCount: 25, mitigatedCount: 0, flips: flips); // Act var comment = _service.FormatAsComment(summary); // Assert Assert.Contains("... and 5 more flips", comment); } [Fact] public void FormatAsComment_TimestampIsIso8601() { // Arrange var summary = CreateSummary(newRiskCount: 0, mitigatedCount: 0, flips: []); // Act var comment = _service.FormatAsComment(summary); // Assert Assert.Contains("2026-01-15T10:00:00", comment); } [Fact] public void FormatAsComment_NoNonAsciiCharacters() { // Arrange var flips = new List { new StateFlip { FlipType = StateFlipType.BecameReachable, CveId = "CVE-2026-0001", Purl = "pkg:test", NewTier = "confirmed" }, new StateFlip { FlipType = StateFlipType.BecameUnreachable, CveId = "CVE-2026-0002", Purl = "pkg:test2", NewTier = "unreachable" }, new StateFlip { FlipType = StateFlipType.TierIncreased, CveId = "CVE-2026-0003", Purl = "pkg:test3", NewTier = "likely" }, new StateFlip { FlipType = StateFlipType.TierDecreased, CveId = "CVE-2026-0004", Purl = "pkg:test4", NewTier = "present" }, }; var summary = CreateSummary( newRiskCount: 1, mitigatedCount: 1, flips: flips, shouldBlock: true, attestationDigest: "sha256:test", policyVerdict: "FAIL"); // Act var comment = _service.FormatAsComment(summary); // Assert - Check all characters are ASCII (0-127) foreach (var ch in comment) { Assert.True(ch <= 127, $"Non-ASCII character found: U+{(int)ch:X4} '{ch}'"); } } private static StateFlipSummary CreateSummary( int newRiskCount, int mitigatedCount, IReadOnlyList flips, bool shouldBlock = false, string? attestationDigest = null, string? policyVerdict = null, string? policyReasonCode = null, string? verifyCommand = null) { return new StateFlipSummary { BaseScanId = "base-scan-123", HeadScanId = "head-scan-456", HasFlips = flips.Count > 0, NewRiskCount = newRiskCount, MitigatedCount = mitigatedCount, NetChange = newRiskCount - mitigatedCount, ShouldBlockPr = shouldBlock, Summary = $"Test summary: {newRiskCount} new, {mitigatedCount} mitigated", Flips = flips, AttestationDigest = attestationDigest, PolicyVerdict = policyVerdict, PolicyReasonCode = policyReasonCode, VerifyCommand = verifyCommand }; } /// /// Fake reachability query service for testing. /// private sealed class FakeReachabilityQueryService : IReachabilityQueryService { public Task> GetComponentsAsync( ScanId scanId, string? purlFilter, string? statusFilter, CancellationToken cancellationToken = default) { return Task.FromResult>(Array.Empty()); } public Task> GetFindingsAsync( ScanId scanId, string? cveFilter, string? statusFilter, CancellationToken cancellationToken = default) { return Task.FromResult>(Array.Empty()); } public Task> GetReachabilityStatesAsync( string graphId, CancellationToken cancellationToken = default) { return Task.FromResult>( new Dictionary()); } } }