Files
git.stella-ops.org/src/Scanner/__Tests/StellaOps.Scanner.Reachability.Tests/PrReachabilityGateTests.cs

414 lines
13 KiB
C#

// -----------------------------------------------------------------------------
// 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<PrReachabilityGateOptions>(_options);
_gate = new PrReachabilityGate(optionsMonitor, NullLogger<PrReachabilityGate>.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<StateFlip>
{
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<StateFlip>
{
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<StateFlip>
{
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<StateFlip>
{
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<StateFlip>
{
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<StateFlip>
{
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<StateFlip>
{
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<StateFlip>
{
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<StateFlip>
{
new StateFlip
{
EntryMethodKey = "Controller.Get",
SinkMethodKey = "Vulnerable.Execute",
IsReachable = true,
Confidence = 0.95
}
},
NewlyUnreachable = new List<StateFlip>
{
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<StateFlip>
{
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<T> : IOptionsMonitor<T>
{
private readonly T _currentValue;
public TestOptionsMonitor(T value)
{
_currentValue = value;
}
public T CurrentValue => _currentValue;
public T Get(string? name) => _currentValue;
public IDisposable? OnChange(Action<T, string?> listener) => null;
}
}