using System.Collections.Immutable; using StellaOps.Scanner.CallGraph; using StellaOps.Scanner.ReachabilityDrift; using StellaOps.Scanner.ReachabilityDrift.Services; using Xunit; namespace StellaOps.Scanner.ReachabilityDrift.Tests; public sealed class DriftCauseExplainerTests { private static readonly DateTimeOffset FixedNow = DateTimeOffset.Parse("2025-12-17T00:00:00Z"); [Fact] public void ExplainNewlyReachable_NewEntrypoint_ReturnsNewPublicRoute() { var entry = Node("E", "HomeController.Get", Visibility.Public); var sink = Sink("S", "System.Diagnostics.Process.Start"); var baseGraph = Graph( scanId: "base", entrypointIds: ImmutableArray.Empty, nodes: new[] { entry, sink }, edges: Array.Empty()); var headGraph = Graph( scanId: "head", entrypointIds: ImmutableArray.Create("E"), nodes: new[] { entry, sink }, edges: new[] { new CallGraphEdge("E", "S", CallKind.Direct) }); var explainer = new DriftCauseExplainer(); var cause = explainer.ExplainNewlyReachable(baseGraph, headGraph, "S", ImmutableArray.Create("E", "S"), Array.Empty()); Assert.Equal(DriftCauseKind.NewPublicRoute, cause.Kind); Assert.Contains("HomeController.Get", cause.Description, StringComparison.Ordinal); } [Fact] public void ExplainNewlyReachable_VisibilityEscalation_UsesCodeChangeId() { var changed = Node("N1", "ApiController.GetSecret", Visibility.Public); var baseNode = changed with { Visibility = Visibility.Internal }; var baseGraph = Graph( scanId: "base", entrypointIds: ImmutableArray.Create("N1"), nodes: new[] { baseNode }, edges: Array.Empty()); var headGraph = Graph( scanId: "head", entrypointIds: ImmutableArray.Create("N1"), nodes: new[] { changed }, edges: Array.Empty()); var changeId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); var changes = new[] { new CodeChangeFact { Id = changeId, ScanId = "head", BaseScanId = "base", Language = "dotnet", NodeId = "N1", File = "api.cs", Symbol = "ApiController.GetSecret", Kind = CodeChangeKind.VisibilityChanged, Details = null, DetectedAt = FixedNow } }; var explainer = new DriftCauseExplainer(); var cause = explainer.ExplainNewlyReachable(baseGraph, headGraph, "N1", ImmutableArray.Create("N1"), changes); Assert.Equal(DriftCauseKind.VisibilityEscalated, cause.Kind); Assert.Equal(changeId, cause.CodeChangeId); } [Fact] public void ExplainNewlyUnreachable_SinkRemoved_ReturnsSymbolRemoved() { var entry = Node("E", "Entry", Visibility.Public); var sink = Sink("S", "System.Diagnostics.Process.Start"); var baseGraph = Graph( scanId: "base", entrypointIds: ImmutableArray.Create("E"), nodes: new[] { entry, sink }, edges: new[] { new CallGraphEdge("E", "S", CallKind.Direct) }); var headGraph = Graph( scanId: "head", entrypointIds: ImmutableArray.Create("E"), nodes: new[] { entry }, edges: Array.Empty()); var explainer = new DriftCauseExplainer(); var cause = explainer.ExplainNewlyUnreachable(baseGraph, headGraph, "S", ImmutableArray.Create("E", "S"), Array.Empty()); Assert.Equal(DriftCauseKind.SymbolRemoved, cause.Kind); Assert.Contains("System.Diagnostics.Process.Start", cause.Description, StringComparison.Ordinal); } [Fact] public void ExplainNewlyUnreachable_EdgeRemoved_ReturnsGuardAdded() { var entry = Node("E", "Entry", Visibility.Public); var sink = Sink("S", "System.Diagnostics.Process.Start"); var baseGraph = Graph( scanId: "base", entrypointIds: ImmutableArray.Create("E"), nodes: new[] { entry, sink }, edges: new[] { new CallGraphEdge("E", "S", CallKind.Direct) }); var headGraph = Graph( scanId: "head", entrypointIds: ImmutableArray.Create("E"), nodes: new[] { entry, sink }, edges: Array.Empty()); var explainer = new DriftCauseExplainer(); var cause = explainer.ExplainNewlyUnreachable(baseGraph, headGraph, "S", ImmutableArray.Create("E", "S"), Array.Empty()); Assert.Equal(DriftCauseKind.GuardAdded, cause.Kind); Assert.Contains("Entry", cause.Description, StringComparison.Ordinal); } private static CallGraphSnapshot Graph( string scanId, ImmutableArray entrypointIds, IEnumerable nodes, IEnumerable edges) { var nodesArray = nodes.OrderBy(n => n.NodeId, StringComparer.Ordinal).ToImmutableArray(); var edgesArray = edges.ToImmutableArray(); var sinkIds = nodesArray.Where(n => n.IsSink).Select(n => n.NodeId).ToImmutableArray(); var provisional = new CallGraphSnapshot( ScanId: scanId, GraphDigest: string.Empty, Language: "dotnet", ExtractedAt: FixedNow, Nodes: nodesArray, Edges: edgesArray, EntrypointIds: entrypointIds, SinkIds: sinkIds); return provisional with { GraphDigest = CallGraphDigests.ComputeGraphDigest(provisional) }; } private static CallGraphNode Node(string nodeId, string symbol, Visibility visibility) => new( NodeId: nodeId, Symbol: symbol, File: $"{nodeId}.cs", Line: 1, Package: "app", Visibility: visibility, IsEntrypoint: true, EntrypointType: EntrypointType.HttpHandler, IsSink: false, SinkCategory: null); private static CallGraphNode Sink(string nodeId, string symbol) => new( NodeId: nodeId, Symbol: symbol, File: $"{nodeId}.cs", Line: 1, Package: "app", Visibility: Visibility.Public, IsEntrypoint: false, EntrypointType: null, IsSink: true, SinkCategory: Reachability.SinkCategory.CmdExec); }