367 lines
14 KiB
C#
367 lines
14 KiB
C#
// -----------------------------------------------------------------------------
|
|
// ReachabilityCacheTests.cs
|
|
// Sprint: SPRINT_3700_0006_0001_incremental_cache (CACHE-016, CACHE-017)
|
|
// Description: Unit tests for reachability cache components.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using FluentAssertions;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using StellaOps.Scanner.Reachability.Cache;
|
|
using Xunit;
|
|
|
|
using StellaOps.TestKit;
|
|
namespace StellaOps.Scanner.Reachability.Tests;
|
|
|
|
public sealed class GraphDeltaComputerTests
|
|
{
|
|
private readonly GraphDeltaComputer _computer;
|
|
private static CancellationToken TestCancellationToken => TestContext.Current.CancellationToken;
|
|
|
|
public GraphDeltaComputerTests()
|
|
{
|
|
_computer = new GraphDeltaComputer(NullLogger<GraphDeltaComputer>.Instance);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task ComputeDeltaAsync_SameHash_ReturnsEmpty()
|
|
{
|
|
// Arrange
|
|
var graph1 = new TestGraphSnapshot("hash1", new[] { "A", "B" }, new[] { ("A", "B") });
|
|
var graph2 = new TestGraphSnapshot("hash1", new[] { "A", "B" }, new[] { ("A", "B") });
|
|
|
|
// Act
|
|
var delta = await _computer.ComputeDeltaAsync(graph1, graph2, TestCancellationToken);
|
|
|
|
// Assert
|
|
delta.HasChanges.Should().BeFalse();
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task ComputeDeltaAsync_AddedNode_ReturnsCorrectDelta()
|
|
{
|
|
// Arrange
|
|
var graph1 = new TestGraphSnapshot("hash1", new[] { "A", "B" }, new[] { ("A", "B") });
|
|
var graph2 = new TestGraphSnapshot("hash2", new[] { "A", "B", "C" }, new[] { ("A", "B"), ("B", "C") });
|
|
|
|
// Act
|
|
var delta = await _computer.ComputeDeltaAsync(graph1, graph2, TestCancellationToken);
|
|
|
|
// Assert
|
|
delta.HasChanges.Should().BeTrue();
|
|
delta.AddedNodes.Should().Contain("C");
|
|
delta.RemovedNodes.Should().BeEmpty();
|
|
delta.AddedEdges.Should().ContainSingle(e => e.CallerKey == "B" && e.CalleeKey == "C");
|
|
delta.AffectedMethodKeys.Should().Contain("C");
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task ComputeDeltaAsync_RemovedNode_ReturnsCorrectDelta()
|
|
{
|
|
// Arrange
|
|
var graph1 = new TestGraphSnapshot("hash1", new[] { "A", "B", "C" }, new[] { ("A", "B"), ("B", "C") });
|
|
var graph2 = new TestGraphSnapshot("hash2", new[] { "A", "B" }, new[] { ("A", "B") });
|
|
|
|
// Act
|
|
var delta = await _computer.ComputeDeltaAsync(graph1, graph2, TestCancellationToken);
|
|
|
|
// Assert
|
|
delta.HasChanges.Should().BeTrue();
|
|
delta.RemovedNodes.Should().Contain("C");
|
|
delta.AddedNodes.Should().BeEmpty();
|
|
delta.RemovedEdges.Should().ContainSingle(e => e.CallerKey == "B" && e.CalleeKey == "C");
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task ComputeDeltaAsync_EdgeChange_DetectsAffectedMethods()
|
|
{
|
|
// Arrange
|
|
var graph1 = new TestGraphSnapshot("hash1", new[] { "A", "B", "C" }, new[] { ("A", "B") });
|
|
var graph2 = new TestGraphSnapshot("hash2", new[] { "A", "B", "C" }, new[] { ("A", "C") });
|
|
|
|
// Act
|
|
var delta = await _computer.ComputeDeltaAsync(graph1, graph2, TestCancellationToken);
|
|
|
|
// Assert
|
|
delta.HasChanges.Should().BeTrue();
|
|
delta.AddedEdges.Should().ContainSingle(e => e.CallerKey == "A" && e.CalleeKey == "C");
|
|
delta.RemovedEdges.Should().ContainSingle(e => e.CallerKey == "A" && e.CalleeKey == "B");
|
|
delta.AffectedMethodKeys.Should().Contain(new[] { "A", "B", "C" });
|
|
}
|
|
|
|
private sealed class TestGraphSnapshot : IGraphSnapshot
|
|
{
|
|
public string Hash { get; }
|
|
public IReadOnlySet<string> NodeKeys { get; }
|
|
public IReadOnlyList<Cache.GraphEdge> Edges { get; }
|
|
public IReadOnlySet<string> EntryPoints { get; }
|
|
|
|
public TestGraphSnapshot(string hash, string[] nodes, (string, string)[] edges, string[]? entryPoints = null)
|
|
{
|
|
Hash = hash;
|
|
NodeKeys = nodes.ToHashSet();
|
|
Edges = edges.Select(e => new Cache.GraphEdge(e.Item1, e.Item2)).ToList();
|
|
EntryPoints = (entryPoints ?? nodes.Take(1).ToArray()).ToHashSet();
|
|
}
|
|
}
|
|
}
|
|
|
|
public sealed class ImpactSetCalculatorTests
|
|
{
|
|
private readonly ImpactSetCalculator _calculator;
|
|
private static CancellationToken TestCancellationToken => TestContext.Current.CancellationToken;
|
|
|
|
public ImpactSetCalculatorTests()
|
|
{
|
|
_calculator = new ImpactSetCalculator(NullLogger<ImpactSetCalculator>.Instance);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task CalculateImpactAsync_NoDelta_ReturnsEmpty()
|
|
{
|
|
// Arrange
|
|
var delta = GraphDelta.Empty;
|
|
var graph = new TestGraphSnapshot("hash1", new[] { "Entry", "A", "B" }, new[] { ("Entry", "A"), ("A", "B") });
|
|
|
|
// Act
|
|
var impact = await _calculator.CalculateImpactAsync(delta, graph, TestCancellationToken);
|
|
|
|
// Assert
|
|
impact.RequiresFullRecompute.Should().BeFalse();
|
|
impact.AffectedEntryPoints.Should().BeEmpty();
|
|
impact.SavingsRatio.Should().Be(1.0);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task CalculateImpactAsync_ChangeInPath_IdentifiesAffectedEntry()
|
|
{
|
|
// Arrange
|
|
var delta = new GraphDelta
|
|
{
|
|
AddedNodes = new HashSet<string> { "C" },
|
|
AddedEdges = new List<Cache.GraphEdge> { new("B", "C") },
|
|
AffectedMethodKeys = new HashSet<string> { "B", "C" }
|
|
};
|
|
|
|
var graph = new TestGraphSnapshot(
|
|
"hash2",
|
|
new[] { "Entry", "Entry2", "Entry3", "Entry4", "A", "B", "C" },
|
|
new[] { ("Entry", "A"), ("A", "B"), ("B", "C") },
|
|
new[] { "Entry", "Entry2", "Entry3", "Entry4" });
|
|
|
|
// Act
|
|
var impact = await _calculator.CalculateImpactAsync(delta, graph, TestCancellationToken);
|
|
|
|
// Assert
|
|
impact.RequiresFullRecompute.Should().BeFalse();
|
|
impact.AffectedEntryPoints.Should().Contain("Entry");
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task CalculateImpactAsync_ManyAffected_TriggersFullRecompute()
|
|
{
|
|
// Arrange - More than 30% affected
|
|
var delta = new GraphDelta
|
|
{
|
|
AddedNodes = new HashSet<string> { "Entry1", "Entry2", "Entry3", "Entry4" },
|
|
AffectedMethodKeys = new HashSet<string> { "Entry1", "Entry2", "Entry3", "Entry4" }
|
|
};
|
|
|
|
var graph = new TestGraphSnapshot(
|
|
"hash2",
|
|
new[] { "Entry1", "Entry2", "Entry3", "Entry4", "Sink" },
|
|
new[] { ("Entry1", "Sink"), ("Entry2", "Sink"), ("Entry3", "Sink"), ("Entry4", "Sink") },
|
|
new[] { "Entry1", "Entry2", "Entry3", "Entry4" });
|
|
|
|
// Act
|
|
var impact = await _calculator.CalculateImpactAsync(delta, graph, TestCancellationToken);
|
|
|
|
// Assert - All 4 entries affected = 100% > 30% threshold
|
|
impact.RequiresFullRecompute.Should().BeTrue();
|
|
}
|
|
|
|
private sealed class TestGraphSnapshot : IGraphSnapshot
|
|
{
|
|
public string Hash { get; }
|
|
public IReadOnlySet<string> NodeKeys { get; }
|
|
public IReadOnlyList<Cache.GraphEdge> Edges { get; }
|
|
public IReadOnlySet<string> EntryPoints { get; }
|
|
|
|
public TestGraphSnapshot(string hash, string[] nodes, (string, string)[] edges, string[]? entryPoints = null)
|
|
{
|
|
Hash = hash;
|
|
NodeKeys = nodes.ToHashSet();
|
|
Edges = edges.Select(e => new Cache.GraphEdge(e.Item1, e.Item2)).ToList();
|
|
EntryPoints = (entryPoints ?? nodes.Take(1).ToArray()).ToHashSet();
|
|
}
|
|
}
|
|
}
|
|
|
|
public sealed class StateFlipDetectorTests
|
|
{
|
|
private readonly StateFlipDetector _detector;
|
|
private static CancellationToken TestCancellationToken => TestContext.Current.CancellationToken;
|
|
|
|
public StateFlipDetectorTests()
|
|
{
|
|
_detector = new StateFlipDetector(NullLogger<StateFlipDetector>.Instance);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task DetectFlipsAsync_NoChanges_ReturnsEmpty()
|
|
{
|
|
// Arrange
|
|
var previous = new List<ReachablePairResult>
|
|
{
|
|
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
|
|
};
|
|
|
|
var current = new List<ReachablePairResult>
|
|
{
|
|
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
|
|
};
|
|
|
|
// Act
|
|
var result = await _detector.DetectFlipsAsync(previous, current, TestCancellationToken);
|
|
|
|
// Assert
|
|
result.HasFlips.Should().BeFalse();
|
|
result.NewRiskCount.Should().Be(0);
|
|
result.MitigatedCount.Should().Be(0);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task DetectFlipsAsync_BecameReachable_ReturnsNewRisk()
|
|
{
|
|
// Arrange
|
|
var previous = new List<ReachablePairResult>
|
|
{
|
|
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = false, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
|
|
};
|
|
|
|
var current = new List<ReachablePairResult>
|
|
{
|
|
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
|
|
};
|
|
|
|
// Act
|
|
var result = await _detector.DetectFlipsAsync(previous, current, TestCancellationToken);
|
|
|
|
// Assert
|
|
result.HasFlips.Should().BeTrue();
|
|
result.NewRiskCount.Should().Be(1);
|
|
result.MitigatedCount.Should().Be(0);
|
|
result.NewlyReachable.Should().ContainSingle()
|
|
.Which.FlipType.Should().Be(StateFlipType.BecameReachable);
|
|
result.ShouldBlockPr.Should().BeTrue();
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task DetectFlipsAsync_BecameUnreachable_ReturnsMitigated()
|
|
{
|
|
// Arrange
|
|
var previous = new List<ReachablePairResult>
|
|
{
|
|
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
|
|
};
|
|
|
|
var current = new List<ReachablePairResult>
|
|
{
|
|
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = false, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
|
|
};
|
|
|
|
// Act
|
|
var result = await _detector.DetectFlipsAsync(previous, current, TestCancellationToken);
|
|
|
|
// Assert
|
|
result.HasFlips.Should().BeTrue();
|
|
result.NewRiskCount.Should().Be(0);
|
|
result.MitigatedCount.Should().Be(1);
|
|
result.NewlyUnreachable.Should().ContainSingle()
|
|
.Which.FlipType.Should().Be(StateFlipType.BecameUnreachable);
|
|
result.ShouldBlockPr.Should().BeFalse();
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task DetectFlipsAsync_NewReachablePair_ReturnsNewRisk()
|
|
{
|
|
// Arrange
|
|
var previous = new List<ReachablePairResult>();
|
|
|
|
var current = new List<ReachablePairResult>
|
|
{
|
|
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
|
|
};
|
|
|
|
// Act
|
|
var result = await _detector.DetectFlipsAsync(previous, current, TestCancellationToken);
|
|
|
|
// Assert
|
|
result.HasFlips.Should().BeTrue();
|
|
result.NewRiskCount.Should().Be(1);
|
|
result.ShouldBlockPr.Should().BeTrue();
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task DetectFlipsAsync_RemovedReachablePair_ReturnsMitigated()
|
|
{
|
|
// Arrange
|
|
var previous = new List<ReachablePairResult>
|
|
{
|
|
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
|
|
};
|
|
|
|
var current = new List<ReachablePairResult>();
|
|
|
|
// Act
|
|
var result = await _detector.DetectFlipsAsync(previous, current, TestCancellationToken);
|
|
|
|
// Assert
|
|
result.HasFlips.Should().BeTrue();
|
|
result.MitigatedCount.Should().Be(1);
|
|
result.ShouldBlockPr.Should().BeFalse();
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task DetectFlipsAsync_NetChange_CalculatesCorrectly()
|
|
{
|
|
// Arrange
|
|
var previous = new List<ReachablePairResult>
|
|
{
|
|
new() { EntryMethodKey = "E1", SinkMethodKey = "S1", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow },
|
|
new() { EntryMethodKey = "E2", SinkMethodKey = "S2", IsReachable = false, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
|
|
};
|
|
|
|
var current = new List<ReachablePairResult>
|
|
{
|
|
new() { EntryMethodKey = "E1", SinkMethodKey = "S1", IsReachable = false, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow },
|
|
new() { EntryMethodKey = "E2", SinkMethodKey = "S2", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow },
|
|
new() { EntryMethodKey = "E3", SinkMethodKey = "S3", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
|
|
};
|
|
|
|
// Act
|
|
var result = await _detector.DetectFlipsAsync(previous, current, TestCancellationToken);
|
|
|
|
// Assert
|
|
result.NewRiskCount.Should().Be(2); // E2->S2 became reachable, E3->S3 new
|
|
result.MitigatedCount.Should().Be(1); // E1->S1 became unreachable
|
|
result.NetChange.Should().Be(1); // +2 - 1 = 1
|
|
}
|
|
}
|