294 lines
11 KiB
C#
294 lines
11 KiB
C#
// -----------------------------------------------------------------------------
|
|
// 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<StateFlip>
|
|
{
|
|
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<StateFlip>
|
|
{
|
|
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<StateFlip>
|
|
{
|
|
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<StateFlip>
|
|
{
|
|
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<StateFlip>
|
|
{
|
|
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<StateFlip> 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
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fake reachability query service for testing.
|
|
/// </summary>
|
|
private sealed class FakeReachabilityQueryService : IReachabilityQueryService
|
|
{
|
|
public Task<IReadOnlyList<ComponentReachability>> GetComponentsAsync(
|
|
ScanId scanId,
|
|
string? purlFilter,
|
|
string? statusFilter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return Task.FromResult<IReadOnlyList<ComponentReachability>>(Array.Empty<ComponentReachability>());
|
|
}
|
|
|
|
public Task<IReadOnlyList<ReachabilityFinding>> GetFindingsAsync(
|
|
ScanId scanId,
|
|
string? cveFilter,
|
|
string? statusFilter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return Task.FromResult<IReadOnlyList<ReachabilityFinding>>(Array.Empty<ReachabilityFinding>());
|
|
}
|
|
|
|
public Task<IReadOnlyDictionary<string, ReachabilityState>> GetReachabilityStatesAsync(
|
|
string graphId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return Task.FromResult<IReadOnlyDictionary<string, ReachabilityState>>(
|
|
new Dictionary<string, ReachabilityState>());
|
|
}
|
|
}
|
|
}
|