using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Scanner.CallGraph;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.ReachabilityDrift;
using StellaOps.Scanner.ReachabilityDrift.Services;
using Xunit;
namespace StellaOps.ScannerSignals.IntegrationTests;
///
/// Integration tests for the Reachability Drift Detection pipeline.
/// Tests the end-to-end flow from call graph extraction through drift detection.
///
///
/// Task: RDRIFT-MASTER-0002
/// Sprint: SPRINT_3600_0001_0001_reachability_drift_master
///
public sealed class ReachabilityDriftIntegrationTests
{
private readonly TimeProvider _fixedTime = new FakeTimeProvider(
new DateTimeOffset(2025, 12, 19, 12, 0, 0, TimeSpan.Zero));
#region Drift Detection Tests
[Fact]
public void DetectDrift_WhenPathBecomesReachable_ReportsNewlyReachableSink()
{
// Arrange: unreachable -> reachable (guard removed)
var baseGraph = CreateUnreachableGraph("scan-v1");
var headGraph = CreateReachableGraph("scan-v2");
var extractor = new CodeChangeFactExtractor(_fixedTime);
var codeChanges = extractor.Extract(baseGraph, headGraph);
var detector = new ReachabilityDriftDetector(_fixedTime);
// Act
var drift = detector.Detect(baseGraph, headGraph, codeChanges, includeFullPath: true);
// Assert
drift.Should().NotBeNull();
drift.BaseScanId.Should().Be("scan-v1");
drift.HeadScanId.Should().Be("scan-v2");
drift.Language.Should().Be("java");
drift.HasMaterialDrift.Should().BeTrue();
drift.NewlyReachable.Should().HaveCount(1);
drift.NewlyUnreachable.Should().BeEmpty();
var sink = drift.NewlyReachable[0];
sink.Direction.Should().Be(DriftDirection.BecameReachable);
sink.SinkNodeId.Should().Be("jndi-lookup-sink");
sink.SinkCategory.Should().Be(SinkCategory.CmdExec);
sink.Cause.Kind.Should().Be(DriftCauseKind.GuardRemoved);
sink.Path.Entrypoint.NodeId.Should().Be("http-handler-entry");
sink.Path.Sink.NodeId.Should().Be("jndi-lookup-sink");
}
[Fact]
public void DetectDrift_WhenPathBecomesUnreachable_ReportsNewlyUnreachableSink()
{
// Arrange: reachable -> unreachable (guard added)
var baseGraph = CreateReachableGraph("scan-v1");
var headGraph = CreateUnreachableGraph("scan-v2");
var extractor = new CodeChangeFactExtractor(_fixedTime);
var codeChanges = extractor.Extract(baseGraph, headGraph);
var detector = new ReachabilityDriftDetector(_fixedTime);
// Act
var drift = detector.Detect(baseGraph, headGraph, codeChanges, includeFullPath: true);
// Assert
drift.Should().NotBeNull();
drift.BaseScanId.Should().Be("scan-v1");
drift.HeadScanId.Should().Be("scan-v2");
drift.HasMaterialDrift.Should().BeFalse();
drift.NewlyReachable.Should().BeEmpty();
drift.NewlyUnreachable.Should().HaveCount(1);
var sink = drift.NewlyUnreachable[0];
sink.Direction.Should().Be(DriftDirection.BecameUnreachable);
sink.SinkNodeId.Should().Be("jndi-lookup-sink");
sink.Cause.Kind.Should().Be(DriftCauseKind.GuardAdded);
}
[Fact]
public void DetectDrift_WhenNoChange_ReportsNoDrift()
{
// Arrange: same graph, no changes
var baseGraph = CreateReachableGraph("scan-v1");
var headGraph = CreateReachableGraph("scan-v2");
var extractor = new CodeChangeFactExtractor(_fixedTime);
var codeChanges = extractor.Extract(baseGraph, headGraph);
var detector = new ReachabilityDriftDetector(_fixedTime);
// Act
var drift = detector.Detect(baseGraph, headGraph, codeChanges, includeFullPath: false);
// Assert
drift.Should().NotBeNull();
drift.HasMaterialDrift.Should().BeFalse();
drift.NewlyReachable.Should().BeEmpty();
drift.NewlyUnreachable.Should().BeEmpty();
drift.TotalDriftCount.Should().Be(0);
}
#endregion
#region Determinism Tests
[Fact]
public void DetectDrift_IsDeterministic_SameInputsProduceSameOutputs()
{
// Arrange
var baseGraph = CreateUnreachableGraph("scan-v1");
var headGraph = CreateReachableGraph("scan-v2");
var extractor = new CodeChangeFactExtractor(_fixedTime);
var codeChanges = extractor.Extract(baseGraph, headGraph);
var detector = new ReachabilityDriftDetector(_fixedTime);
// Act
var drift1 = detector.Detect(baseGraph, headGraph, codeChanges, includeFullPath: true);
var drift2 = detector.Detect(baseGraph, headGraph, codeChanges, includeFullPath: true);
// Assert
drift1.Id.Should().Be(drift2.Id);
drift1.ResultDigest.Should().Be(drift2.ResultDigest);
drift1.DetectedAt.Should().Be(drift2.DetectedAt);
drift1.NewlyReachable.Length.Should().Be(drift2.NewlyReachable.Length);
for (var i = 0; i < drift1.NewlyReachable.Length; i++)
{
drift1.NewlyReachable[i].Id.Should().Be(drift2.NewlyReachable[i].Id);
drift1.NewlyReachable[i].SinkNodeId.Should().Be(drift2.NewlyReachable[i].SinkNodeId);
}
}
[Fact]
public void DetectDrift_ResultDigest_IsStableAcrossRuns()
{
// Arrange
var baseGraph = CreateUnreachableGraph("scan-v1");
var headGraph = CreateReachableGraph("scan-v2");
var extractor = new CodeChangeFactExtractor(_fixedTime);
var codeChanges = extractor.Extract(baseGraph, headGraph);
// Act: Create multiple detectors and run independently
var detector1 = new ReachabilityDriftDetector(_fixedTime);
var detector2 = new ReachabilityDriftDetector(_fixedTime);
var drift1 = detector1.Detect(baseGraph, headGraph, codeChanges, includeFullPath: false);
var drift2 = detector2.Detect(baseGraph, headGraph, codeChanges, includeFullPath: false);
// Assert
drift1.ResultDigest.Should().NotBeNullOrWhiteSpace();
drift1.ResultDigest.Should().Be(drift2.ResultDigest);
}
#endregion
#region CodeChangeFact Extraction Tests
[Fact]
public void CodeChangeFactExtractor_DetectsAddedEdge()
{
// Arrange
var baseGraph = CreateUnreachableGraph("scan-v1");
var headGraph = CreateReachableGraph("scan-v2");
var extractor = new CodeChangeFactExtractor(_fixedTime);
// Act
var codeChanges = extractor.Extract(baseGraph, headGraph);
// Assert - The extractor reports edge changes as GuardChanged with details
codeChanges.Should().NotBeEmpty();
codeChanges.Should().Contain(c =>
c.Kind == CodeChangeKind.GuardChanged &&
c.Details.HasValue &&
c.Details.Value.GetRawText().Contains("edge_added", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void CodeChangeFactExtractor_DetectsRemovedEdge()
{
// Arrange
var baseGraph = CreateReachableGraph("scan-v1");
var headGraph = CreateUnreachableGraph("scan-v2");
var extractor = new CodeChangeFactExtractor(_fixedTime);
// Act
var codeChanges = extractor.Extract(baseGraph, headGraph);
// Assert - The extractor reports edge changes as GuardChanged with details
codeChanges.Should().NotBeEmpty();
codeChanges.Should().Contain(c =>
c.Kind == CodeChangeKind.GuardChanged &&
c.Details.HasValue &&
c.Details.Value.GetRawText().Contains("edge_removed", StringComparison.OrdinalIgnoreCase));
}
#endregion
#region Multi-Sink Tests
[Fact]
public void DetectDrift_WithMultipleSinks_ReportsAllDriftedSinks()
{
// Arrange: Multiple sinks become reachable
var baseGraph = CreateMultiSinkUnreachableGraph("scan-v1");
var headGraph = CreateMultiSinkReachableGraph("scan-v2");
var extractor = new CodeChangeFactExtractor(_fixedTime);
var codeChanges = extractor.Extract(baseGraph, headGraph);
var detector = new ReachabilityDriftDetector(_fixedTime);
// Act
var drift = detector.Detect(baseGraph, headGraph, codeChanges, includeFullPath: true);
// Assert
drift.Should().NotBeNull();
drift.HasMaterialDrift.Should().BeTrue();
drift.NewlyReachable.Should().HaveCount(2);
drift.NewlyUnreachable.Should().BeEmpty();
var sinkIds = drift.NewlyReachable.Select(s => s.SinkNodeId).OrderBy(s => s).ToList();
sinkIds.Should().Contain("jndi-lookup-sink");
sinkIds.Should().Contain("file-write-sink");
}
[Fact]
public void DetectDrift_OrderingSinks_IsStableAndDeterministic()
{
// Arrange
var baseGraph = CreateMultiSinkUnreachableGraph("scan-v1");
var headGraph = CreateMultiSinkReachableGraph("scan-v2");
var extractor = new CodeChangeFactExtractor(_fixedTime);
var codeChanges = extractor.Extract(baseGraph, headGraph);
var detector = new ReachabilityDriftDetector(_fixedTime);
// Act: Run multiple times
var results = Enumerable.Range(0, 5)
.Select(_ => detector.Detect(baseGraph, headGraph, codeChanges, includeFullPath: false))
.ToList();
// Assert: All results should have same ordering
var expectedOrder = results[0].NewlyReachable.Select(s => s.SinkNodeId).ToList();
foreach (var result in results.Skip(1))
{
var actualOrder = result.NewlyReachable.Select(s => s.SinkNodeId).ToList();
actualOrder.Should().Equal(expectedOrder);
}
}
#endregion
#region Path Compression Tests
[Fact]
public void DetectDrift_WithFullPath_IncludesIntermediateNodes()
{
// Arrange
var baseGraph = CreateUnreachableGraph("scan-v1");
var headGraph = CreateReachableGraphWithIntermediates("scan-v2");
var extractor = new CodeChangeFactExtractor(_fixedTime);
var codeChanges = extractor.Extract(baseGraph, headGraph);
var detector = new ReachabilityDriftDetector(_fixedTime);
// Act
var drift = detector.Detect(baseGraph, headGraph, codeChanges, includeFullPath: true);
// Assert
drift.NewlyReachable.Should().HaveCount(1);
var sink = drift.NewlyReachable[0];
sink.Path.Should().NotBeNull();
sink.Path.Entrypoint.NodeId.Should().Be("http-handler-entry");
sink.Path.Sink.NodeId.Should().Be("jndi-lookup-sink");
sink.Path.FullPath.Should().NotBeNullOrEmpty();
sink.Path.FullPath!.Value.Length.Should().BeGreaterThan(2);
}
[Fact]
public void DetectDrift_WithoutFullPath_OmitsIntermediateNodes()
{
// Arrange
var baseGraph = CreateUnreachableGraph("scan-v1");
var headGraph = CreateReachableGraphWithIntermediates("scan-v2");
var extractor = new CodeChangeFactExtractor(_fixedTime);
var codeChanges = extractor.Extract(baseGraph, headGraph);
var detector = new ReachabilityDriftDetector(_fixedTime);
// Act
var drift = detector.Detect(baseGraph, headGraph, codeChanges, includeFullPath: false);
// Assert
drift.NewlyReachable.Should().HaveCount(1);
var sink = drift.NewlyReachable[0];
sink.Path.Should().NotBeNull();
sink.Path.Entrypoint.NodeId.Should().Be("http-handler-entry");
sink.Path.Sink.NodeId.Should().Be("jndi-lookup-sink");
sink.Path.FullPath.Should().BeNullOrEmpty();
}
#endregion
#region Error Handling Tests
[Fact]
public void DetectDrift_WithLanguageMismatch_ThrowsArgumentException()
{
// Arrange
var baseGraph = CreateGraph("scan-v1", "java", ImmutableArray.Empty);
var headGraph = CreateGraph("scan-v2", "dotnet", ImmutableArray.Empty);
var detector = new ReachabilityDriftDetector(_fixedTime);
// Act & Assert
var act = () => detector.Detect(baseGraph, headGraph, ImmutableArray.Empty.ToList(), includeFullPath: false);
act.Should().Throw().WithMessage("*Language mismatch*");
}
[Fact]
public void DetectDrift_WithNullBaseGraph_ThrowsArgumentNullException()
{
// Arrange
var headGraph = CreateReachableGraph("scan-v2");
var detector = new ReachabilityDriftDetector(_fixedTime);
// Act & Assert
var act = () => detector.Detect(null!, headGraph, Array.Empty(), includeFullPath: false);
act.Should().Throw();
}
[Fact]
public void DetectDrift_WithNullHeadGraph_ThrowsArgumentNullException()
{
// Arrange
var baseGraph = CreateUnreachableGraph("scan-v1");
var detector = new ReachabilityDriftDetector(_fixedTime);
// Act & Assert
var act = () => detector.Detect(baseGraph, null!, Array.Empty(), includeFullPath: false);
act.Should().Throw();
}
#endregion
#region Helper Methods
private static CallGraphSnapshot CreateUnreachableGraph(string scanId)
{
// Graph with no edges - sink is unreachable
return CreateGraph(scanId, "java", ImmutableArray.Empty);
}
private static CallGraphSnapshot CreateReachableGraph(string scanId)
{
// Graph with edge from entry to sink - sink is reachable
var edges = ImmutableArray.Create(
new CallGraphEdge("http-handler-entry", "jndi-lookup-sink", CallKind.Direct, "Logger.java:42"));
return CreateGraph(scanId, "java", edges);
}
private static CallGraphSnapshot CreateReachableGraphWithIntermediates(string scanId)
{
// Graph with intermediate nodes: entry -> logger -> substitutor -> sink
var edges = ImmutableArray.Create(
new CallGraphEdge("http-handler-entry", "logger-method", CallKind.Direct, "App.java:10"),
new CallGraphEdge("logger-method", "pattern-converter", CallKind.Direct, "Logger.java:15"),
new CallGraphEdge("pattern-converter", "str-substitutor", CallKind.Direct, "PatternConverter.java:20"),
new CallGraphEdge("str-substitutor", "jndi-lookup-sink", CallKind.Direct, "StrSubstitutor.java:25"));
var nodes = ImmutableArray.Create(
new CallGraphNode(
NodeId: "http-handler-entry",
Symbol: "com.example.App.handleRequest",
File: "App.java",
Line: 10,
Package: "pkg:maven/com.example/app@1.0.0",
Visibility: Visibility.Public,
IsEntrypoint: true,
EntrypointType: EntrypointType.HttpHandler,
IsSink: false,
SinkCategory: null),
new CallGraphNode(
NodeId: "logger-method",
Symbol: "org.apache.logging.log4j.Logger.info",
File: "Logger.java",
Line: 15,
Package: "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0",
Visibility: Visibility.Public,
IsEntrypoint: false,
EntrypointType: null,
IsSink: false,
SinkCategory: null),
new CallGraphNode(
NodeId: "pattern-converter",
Symbol: "org.apache.logging.log4j.core.pattern.MessagePatternConverter.format",
File: "PatternConverter.java",
Line: 20,
Package: "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0",
Visibility: Visibility.Internal,
IsEntrypoint: false,
EntrypointType: null,
IsSink: false,
SinkCategory: null),
new CallGraphNode(
NodeId: "str-substitutor",
Symbol: "org.apache.logging.log4j.core.lookup.StrSubstitutor.replace",
File: "StrSubstitutor.java",
Line: 25,
Package: "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0",
Visibility: Visibility.Internal,
IsEntrypoint: false,
EntrypointType: null,
IsSink: false,
SinkCategory: null),
new CallGraphNode(
NodeId: "jndi-lookup-sink",
Symbol: "org.apache.logging.log4j.core.lookup.JndiLookup.lookup",
File: "JndiLookup.java",
Line: 30,
Package: "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0",
Visibility: Visibility.Public,
IsEntrypoint: false,
EntrypointType: null,
IsSink: true,
SinkCategory: SinkCategory.CmdExec));
var provisional = new CallGraphSnapshot(
ScanId: scanId,
GraphDigest: string.Empty,
Language: "java",
ExtractedAt: DateTimeOffset.UnixEpoch,
Nodes: nodes,
Edges: edges,
EntrypointIds: ImmutableArray.Create("http-handler-entry"),
SinkIds: ImmutableArray.Create("jndi-lookup-sink"));
return provisional with { GraphDigest = CallGraphDigests.ComputeGraphDigest(provisional) };
}
private static CallGraphSnapshot CreateMultiSinkUnreachableGraph(string scanId)
{
// Graph with multiple sinks, none reachable
var nodes = ImmutableArray.Create(
new CallGraphNode(
NodeId: "http-handler-entry",
Symbol: "com.example.App.handleRequest",
File: "App.java",
Line: 10,
Package: "pkg:maven/com.example/app@1.0.0",
Visibility: Visibility.Public,
IsEntrypoint: true,
EntrypointType: EntrypointType.HttpHandler,
IsSink: false,
SinkCategory: null),
new CallGraphNode(
NodeId: "jndi-lookup-sink",
Symbol: "org.apache.logging.log4j.core.lookup.JndiLookup.lookup",
File: "JndiLookup.java",
Line: 30,
Package: "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0",
Visibility: Visibility.Public,
IsEntrypoint: false,
EntrypointType: null,
IsSink: true,
SinkCategory: SinkCategory.CmdExec),
new CallGraphNode(
NodeId: "file-write-sink",
Symbol: "java.io.FileOutputStream.write",
File: "FileOutputStream.java",
Line: 100,
Package: "pkg:maven/java/jdk@17",
Visibility: Visibility.Public,
IsEntrypoint: false,
EntrypointType: null,
IsSink: true,
SinkCategory: SinkCategory.FileWrite));
var provisional = new CallGraphSnapshot(
ScanId: scanId,
GraphDigest: string.Empty,
Language: "java",
ExtractedAt: DateTimeOffset.UnixEpoch,
Nodes: nodes,
Edges: ImmutableArray.Empty,
EntrypointIds: ImmutableArray.Create("http-handler-entry"),
SinkIds: ImmutableArray.Create("jndi-lookup-sink", "file-write-sink"));
return provisional with { GraphDigest = CallGraphDigests.ComputeGraphDigest(provisional) };
}
private static CallGraphSnapshot CreateMultiSinkReachableGraph(string scanId)
{
// Graph with multiple sinks, all reachable
var nodes = ImmutableArray.Create(
new CallGraphNode(
NodeId: "http-handler-entry",
Symbol: "com.example.App.handleRequest",
File: "App.java",
Line: 10,
Package: "pkg:maven/com.example/app@1.0.0",
Visibility: Visibility.Public,
IsEntrypoint: true,
EntrypointType: EntrypointType.HttpHandler,
IsSink: false,
SinkCategory: null),
new CallGraphNode(
NodeId: "jndi-lookup-sink",
Symbol: "org.apache.logging.log4j.core.lookup.JndiLookup.lookup",
File: "JndiLookup.java",
Line: 30,
Package: "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0",
Visibility: Visibility.Public,
IsEntrypoint: false,
EntrypointType: null,
IsSink: true,
SinkCategory: SinkCategory.CmdExec),
new CallGraphNode(
NodeId: "file-write-sink",
Symbol: "java.io.FileOutputStream.write",
File: "FileOutputStream.java",
Line: 100,
Package: "pkg:maven/java/jdk@17",
Visibility: Visibility.Public,
IsEntrypoint: false,
EntrypointType: null,
IsSink: true,
SinkCategory: SinkCategory.FileWrite));
var edges = ImmutableArray.Create(
new CallGraphEdge("http-handler-entry", "jndi-lookup-sink", CallKind.Direct, "App.java:15"),
new CallGraphEdge("http-handler-entry", "file-write-sink", CallKind.Direct, "App.java:20"));
var provisional = new CallGraphSnapshot(
ScanId: scanId,
GraphDigest: string.Empty,
Language: "java",
ExtractedAt: DateTimeOffset.UnixEpoch,
Nodes: nodes,
Edges: edges,
EntrypointIds: ImmutableArray.Create("http-handler-entry"),
SinkIds: ImmutableArray.Create("jndi-lookup-sink", "file-write-sink"));
return provisional with { GraphDigest = CallGraphDigests.ComputeGraphDigest(provisional) };
}
private static CallGraphSnapshot CreateGraph(string scanId, string language, ImmutableArray edges)
{
var nodes = ImmutableArray.Create(
new CallGraphNode(
NodeId: "http-handler-entry",
Symbol: "com.example.App.handleRequest",
File: "App.java",
Line: 10,
Package: "pkg:maven/com.example/app@1.0.0",
Visibility: Visibility.Public,
IsEntrypoint: true,
EntrypointType: EntrypointType.HttpHandler,
IsSink: false,
SinkCategory: null),
new CallGraphNode(
NodeId: "jndi-lookup-sink",
Symbol: "org.apache.logging.log4j.core.lookup.JndiLookup.lookup",
File: "JndiLookup.java",
Line: 30,
Package: "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0",
Visibility: Visibility.Public,
IsEntrypoint: false,
EntrypointType: null,
IsSink: true,
SinkCategory: SinkCategory.CmdExec));
var provisional = new CallGraphSnapshot(
ScanId: scanId,
GraphDigest: string.Empty,
Language: language,
ExtractedAt: DateTimeOffset.UnixEpoch,
Nodes: nodes,
Edges: edges,
EntrypointIds: ImmutableArray.Create("http-handler-entry"),
SinkIds: ImmutableArray.Create("jndi-lookup-sink"));
return provisional with { GraphDigest = CallGraphDigests.ComputeGraphDigest(provisional) };
}
#endregion
#region FakeTimeProvider
private sealed class FakeTimeProvider : TimeProvider
{
private readonly DateTimeOffset _fixedTime;
public FakeTimeProvider(DateTimeOffset fixedTime) => _fixedTime = fixedTime;
public override DateTimeOffset GetUtcNow() => _fixedTime;
}
#endregion
}