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 }