414 lines
13 KiB
C#
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;
|
|
}
|
|
}
|