// ----------------------------------------------------------------------------- // PrReachabilityGateTests.cs // Sprint: SPRINT_3700_0006_0001_incremental_cache (CACHE-014) // Description: Unit tests for PR reachability gate. // ----------------------------------------------------------------------------- using System; using System.Collections.Generic; using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using StellaOps.Scanner.Reachability.Cache; using Xunit; using StellaOps.TestKit; namespace StellaOps.Scanner.Reachability.Tests; public sealed class PrReachabilityGateTests { private readonly PrReachabilityGate _gate; private readonly PrReachabilityGateOptions _options; public PrReachabilityGateTests() { _options = new PrReachabilityGateOptions(); var optionsMonitor = new TestOptionsMonitor(_options); _gate = new PrReachabilityGate(optionsMonitor, NullLogger.Instance); } [Trait("Category", TestCategories.Unit)] [Fact] public void EvaluateFlips_NoFlips_ReturnsPass() { // Arrange var stateFlips = StateFlipResult.Empty; // Act var result = _gate.EvaluateFlips(stateFlips, wasIncremental: true, savingsRatio: 0.8, TimeSpan.FromMilliseconds(100)); // Assert result.Passed.Should().BeTrue(); result.Reason.Should().Be("No reachability changes"); result.Decision.NewReachableCount.Should().Be(0); result.Decision.MitigatedCount.Should().Be(0); } [Trait("Category", TestCategories.Unit)] [Fact] public void EvaluateFlips_NewReachable_ReturnsBlock() { // Arrange var stateFlips = new StateFlipResult { NewlyReachable = new List { new StateFlip { EntryMethodKey = "Controller.Get", SinkMethodKey = "Vulnerable.Execute", IsReachable = true, Confidence = 0.9 } }, NewlyUnreachable = [] }; // Act var result = _gate.EvaluateFlips(stateFlips, wasIncremental: true, savingsRatio: 0.7, TimeSpan.FromMilliseconds(150)); // Assert result.Passed.Should().BeFalse(); result.Reason.Should().Contain("1 vulnerabilities became reachable"); result.Decision.NewReachableCount.Should().Be(1); result.Decision.BlockingFlips.Should().HaveCount(1); } [Trait("Category", TestCategories.Unit)] [Fact] public void EvaluateFlips_OnlyMitigated_ReturnsPass() { // Arrange var stateFlips = new StateFlipResult { NewlyReachable = [], NewlyUnreachable = new List { new StateFlip { EntryMethodKey = "Controller.Get", SinkMethodKey = "Vulnerable.Execute", IsReachable = false, WasReachable = true } } }; // Act var result = _gate.EvaluateFlips(stateFlips, wasIncremental: true, savingsRatio: 0.9, TimeSpan.FromMilliseconds(50)); // Assert result.Passed.Should().BeTrue(); result.Reason.Should().Contain("mitigated"); result.Decision.MitigatedCount.Should().Be(1); } [Trait("Category", TestCategories.Unit)] [Fact] public void EvaluateFlips_GateDisabled_AlwaysPasses() { // Arrange _options.Enabled = false; var stateFlips = new StateFlipResult { NewlyReachable = new List { new StateFlip { EntryMethodKey = "Controller.Get", SinkMethodKey = "Vulnerable.Execute", IsReachable = true } } }; // Act var result = _gate.EvaluateFlips(stateFlips, wasIncremental: true, savingsRatio: 0.5, TimeSpan.FromMilliseconds(100)); // Assert result.Passed.Should().BeTrue(); result.Reason.Should().Be("PR gate is disabled"); } [Trait("Category", TestCategories.Unit)] [Fact] public void EvaluateFlips_LowConfidence_Excluded() { // Arrange _options.RequireMinimumConfidence = true; _options.MinimumConfidenceThreshold = 0.8; var stateFlips = new StateFlipResult { NewlyReachable = new List { new StateFlip { EntryMethodKey = "Controller.Get", SinkMethodKey = "Vulnerable.Execute", IsReachable = true, Confidence = 0.5 // Below threshold } } }; // Act var result = _gate.EvaluateFlips(stateFlips, wasIncremental: true, savingsRatio: 0.8, TimeSpan.FromMilliseconds(100)); // Assert result.Passed.Should().BeTrue(); // Should pass because low confidence path is excluded result.Decision.BlockingFlips.Should().BeEmpty(); } [Trait("Category", TestCategories.Unit)] [Fact] public void EvaluateFlips_MaxNewReachableThreshold_AllowsUnderThreshold() { // Arrange _options.MaxNewReachablePaths = 2; var stateFlips = new StateFlipResult { NewlyReachable = new List { new StateFlip { EntryMethodKey = "A.Method", SinkMethodKey = "Vuln1", IsReachable = true, Confidence = 1.0 }, new StateFlip { EntryMethodKey = "B.Method", SinkMethodKey = "Vuln2", IsReachable = true, Confidence = 1.0 } } }; // Act var result = _gate.EvaluateFlips(stateFlips, wasIncremental: true, savingsRatio: 0.7, TimeSpan.FromMilliseconds(200)); // Assert result.Passed.Should().BeTrue(); // 2 == threshold, so should pass } [Trait("Category", TestCategories.Unit)] [Fact] public void EvaluateFlips_MaxNewReachableThreshold_BlocksOverThreshold() { // Arrange _options.MaxNewReachablePaths = 1; var stateFlips = new StateFlipResult { NewlyReachable = new List { new StateFlip { EntryMethodKey = "A.Method", SinkMethodKey = "Vuln1", IsReachable = true, Confidence = 1.0 }, new StateFlip { EntryMethodKey = "B.Method", SinkMethodKey = "Vuln2", IsReachable = true, Confidence = 1.0 } } }; // Act var result = _gate.EvaluateFlips(stateFlips, wasIncremental: true, savingsRatio: 0.6, TimeSpan.FromMilliseconds(200)); // Assert result.Passed.Should().BeFalse(); // 2 > 1, so should block } [Trait("Category", TestCategories.Unit)] [Fact] public void EvaluateFlips_Annotations_GeneratedForBlockingFlips() { // Arrange _options.AddAnnotations = true; _options.MaxAnnotations = 5; var stateFlips = new StateFlipResult { NewlyReachable = new List { new StateFlip { EntryMethodKey = "Controller.Get", SinkMethodKey = "Vulnerable.Execute", IsReachable = true, Confidence = 1.0, SourceFile = "Controllers/MyController.cs", StartLine = 42, EndLine = 45 } } }; // Act var result = _gate.EvaluateFlips(stateFlips, wasIncremental: true, savingsRatio: 0.8, TimeSpan.FromMilliseconds(100)); // Assert result.Annotations.Should().HaveCount(1); result.Annotations[0].Level.Should().Be(PrAnnotationLevel.Error); result.Annotations[0].FilePath.Should().Be("Controllers/MyController.cs"); result.Annotations[0].StartLine.Should().Be(42); } [Trait("Category", TestCategories.Unit)] [Fact] public void EvaluateFlips_AnnotationsDisabled_NoAnnotations() { // Arrange _options.AddAnnotations = false; var stateFlips = new StateFlipResult { NewlyReachable = new List { new StateFlip { EntryMethodKey = "Controller.Get", SinkMethodKey = "Vulnerable.Execute", IsReachable = true } } }; // Act var result = _gate.EvaluateFlips(stateFlips, wasIncremental: true, savingsRatio: 0.8, TimeSpan.FromMilliseconds(100)); // Assert result.Annotations.Should().BeEmpty(); } [Trait("Category", TestCategories.Unit)] [Fact] public void EvaluateFlips_SummaryMarkdown_Generated() { // Arrange var stateFlips = new StateFlipResult { NewlyReachable = new List { new StateFlip { EntryMethodKey = "Controller.Get", SinkMethodKey = "Vulnerable.Execute", IsReachable = true, Confidence = 0.95 } }, NewlyUnreachable = new List { new StateFlip { EntryMethodKey = "Old.Entry", SinkMethodKey = "Fixed.Sink", IsReachable = false, WasReachable = true } } }; // Act var result = _gate.EvaluateFlips(stateFlips, wasIncremental: true, savingsRatio: 0.75, TimeSpan.FromMilliseconds(150)); // Assert result.SummaryMarkdown.Should().NotBeNullOrEmpty(); result.SummaryMarkdown.Should().Contain("Reachability Gate"); result.SummaryMarkdown.Should().Contain("New reachable paths"); result.SummaryMarkdown.Should().Contain("Mitigated paths"); } [Trait("Category", TestCategories.Unit)] [Fact] public void Evaluate_NullStateFlips_ReturnsPass() { // Arrange var result = new IncrementalReachabilityResult { ServiceId = "test-service", Results = [], StateFlips = null, FromCache = false, WasIncremental = true, SavingsRatio = 1.0, Duration = TimeSpan.FromMilliseconds(50) }; // Act var gateResult = _gate.Evaluate(result); // Assert gateResult.Passed.Should().BeTrue(); gateResult.Reason.Should().Be("No state flip detection performed"); } [Trait("Category", TestCategories.Unit)] [Fact] public void Evaluate_WithStateFlips_DelegatesCorrectly() { // Arrange var stateFlips = new StateFlipResult { NewlyReachable = new List { new StateFlip { EntryMethodKey = "A", SinkMethodKey = "B", IsReachable = true, Confidence = 1.0 } } }; var analysisResult = new IncrementalReachabilityResult { ServiceId = "test-service", Results = [], StateFlips = stateFlips, FromCache = false, WasIncremental = true, SavingsRatio = 0.9, Duration = TimeSpan.FromMilliseconds(100) }; // Act var gateResult = _gate.Evaluate(analysisResult); // Assert gateResult.Passed.Should().BeFalse(); gateResult.Decision.WasIncremental.Should().BeTrue(); gateResult.Decision.SavingsRatio.Should().Be(0.9); } private sealed class TestOptionsMonitor : IOptionsMonitor { private readonly T _currentValue; public TestOptionsMonitor(T value) { _currentValue = value; } public T CurrentValue => _currentValue; public T Get(string? name) => _currentValue; public IDisposable? OnChange(Action listener) => null; } }