// ----------------------------------------------------------------------------- // PathExplanationServiceTests.cs // Sprint: SPRINT_3620_0002_0001_path_explanation // Description: Unit tests for PathExplanationService. // ----------------------------------------------------------------------------- using Microsoft.Extensions.Logging.Abstractions; using StellaOps.Scanner.Reachability.Explanation; using StellaOps.Scanner.Reachability.Gates; using Xunit; namespace StellaOps.Scanner.Reachability.Tests; public class PathExplanationServiceTests { private readonly PathExplanationService _service; private readonly PathRenderer _renderer; public PathExplanationServiceTests() { _service = new PathExplanationService( NullLogger.Instance); _renderer = new PathRenderer(); } [Fact] public async Task ExplainAsync_WithSimplePath_ReturnsExplainedPath() { // Arrange var graph = CreateSimpleGraph(); var query = new PathExplanationQuery(); // Act var result = await _service.ExplainAsync(graph, query); // Assert Assert.NotNull(result); Assert.True(result.TotalCount >= 0); } [Fact] public async Task ExplainAsync_WithSinkFilter_FiltersResults() { // Arrange var graph = CreateGraphWithMultipleSinks(); var query = new PathExplanationQuery { SinkId = "sink-1" }; // Act var result = await _service.ExplainAsync(graph, query); // Assert Assert.NotNull(result); foreach (var path in result.Paths) { Assert.Equal("sink-1", path.SinkId); } } [Fact] public async Task ExplainAsync_WithGatesFilter_FiltersPathsWithGates() { // Arrange var graph = CreateGraphWithGates(); var query = new PathExplanationQuery { HasGates = true }; // Act var result = await _service.ExplainAsync(graph, query); // Assert Assert.NotNull(result); foreach (var path in result.Paths) { Assert.True(path.Gates.Count > 0); } } [Fact] public async Task ExplainAsync_WithMaxPathLength_LimitsPathLength() { // Arrange var graph = CreateDeepGraph(10); var query = new PathExplanationQuery { MaxPathLength = 5 }; // Act var result = await _service.ExplainAsync(graph, query); // Assert Assert.NotNull(result); foreach (var path in result.Paths) { Assert.True(path.PathLength <= 5); } } [Fact] public async Task ExplainAsync_WithMaxPaths_LimitsResults() { // Arrange var graph = CreateGraphWithMultiplePaths(20); var query = new PathExplanationQuery { MaxPaths = 5 }; // Act var result = await _service.ExplainAsync(graph, query); // Assert Assert.NotNull(result); Assert.True(result.Paths.Count <= 5); if (result.TotalCount > 5) { Assert.True(result.HasMore); } } [Fact] public void Renderer_Text_ProducesExpectedFormat() { // Arrange var path = CreateTestPath(); // Act var text = _renderer.Render(path, PathOutputFormat.Text); // Assert Assert.Contains(path.EntrypointSymbol, text); Assert.Contains("SINK:", text); } [Fact] public void Renderer_Markdown_ProducesExpectedFormat() { // Arrange var path = CreateTestPath(); // Act var markdown = _renderer.Render(path, PathOutputFormat.Markdown); // Assert Assert.Contains("###", markdown); Assert.Contains("```", markdown); Assert.Contains(path.EntrypointSymbol, markdown); } [Fact] public void Renderer_Json_ProducesValidJson() { // Arrange var path = CreateTestPath(); // Act var json = _renderer.Render(path, PathOutputFormat.Json); // Assert Assert.StartsWith("{", json.Trim()); Assert.EndsWith("}", json.Trim()); Assert.Contains("sink_id", json); Assert.Contains("entrypoint_id", json); } [Fact] public void Renderer_WithGates_IncludesGateInfo() { // Arrange var path = CreateTestPathWithGates(); // Act var text = _renderer.Render(path, PathOutputFormat.Text); // Assert Assert.Contains("Gates:", text); Assert.Contains("multiplier", text.ToLowerInvariant()); } [Fact] public async Task ExplainPathAsync_WithValidId_ReturnsPath() { // Arrange var graph = CreateSimpleGraph(); // This test verifies the API works, actual path lookup depends on graph structure // Act var result = await _service.ExplainPathAsync(graph, "entry-1:sink-1:0"); // The result may be null if path doesn't exist, that's OK Assert.True(result is null || result.PathId is not null); } [Fact] public void GateMultiplier_Calculation_IsCorrect() { // Arrange - path with auth gate var pathWithAuth = CreateTestPathWithGates(); // Assert - auth gate should reduce multiplier Assert.True(pathWithAuth.GateMultiplierBps < 10000); } [Fact] public void PathWithoutGates_HasFullMultiplier() { // Arrange var path = CreateTestPath(); // Assert - no gates = 100% multiplier Assert.Equal(10000, path.GateMultiplierBps); } private static RichGraph CreateSimpleGraph() { return new RichGraph( Nodes: new[] { new RichGraphNode( Id: "entry-1", SymbolId: "Handler.handle", CodeId: null, Purl: null, Lang: "java", Kind: "http_handler", Display: "GET /users", BuildId: null, Evidence: null, Attributes: null, SymbolDigest: null), new RichGraphNode( Id: "sink-1", SymbolId: "DB.query", CodeId: null, Purl: null, Lang: "java", Kind: "sql_sink", Display: "executeQuery", BuildId: null, Evidence: null, Attributes: new Dictionary { ["is_sink"] = "true" }, SymbolDigest: null) }, Edges: new[] { new RichGraphEdge("entry-1", "sink-1", "call", null, null, null, 1.0, null) }, Roots: new[] { new RichGraphRoot("entry-1", "runtime", null) }, Analyzer: new RichGraphAnalyzer("test", "1.0", null), Schema: "stellaops.richgraph.v1" ); } private static RichGraph CreateGraphWithMultipleSinks() { return new RichGraph( Nodes: new[] { new RichGraphNode("entry-1", "Handler", null, null, "java", "handler", null, null, null, null, null), new RichGraphNode("sink-1", "Sink1", null, null, "java", "sink", null, null, null, new Dictionary { ["is_sink"] = "true" }, null), new RichGraphNode("sink-2", "Sink2", null, null, "java", "sink", null, null, null, new Dictionary { ["is_sink"] = "true" }, null) }, Edges: new[] { new RichGraphEdge("entry-1", "sink-1", "call", null, null, null, 1.0, null), new RichGraphEdge("entry-1", "sink-2", "call", null, null, null, 1.0, null) }, Roots: new[] { new RichGraphRoot("entry-1", "runtime", null) }, Analyzer: new RichGraphAnalyzer("test", "1.0", null), Schema: "stellaops.richgraph.v1" ); } private static RichGraph CreateGraphWithGates() { var gates = new[] { new DetectedGate { Type = GateType.AuthRequired, Detail = "@Authenticated", GuardSymbol = "AuthFilter", Confidence = 0.9, DetectionMethod = "annotation" } }; return new RichGraph( Nodes: new[] { new RichGraphNode("entry-1", "Handler", null, null, "java", "handler", null, null, null, null, null), new RichGraphNode("sink-1", "Sink", null, null, "java", "sink", null, null, null, new Dictionary { ["is_sink"] = "true" }, null) }, Edges: new[] { new RichGraphEdge("entry-1", "sink-1", "call", null, null, null, 1.0, null, gates) }, Roots: new[] { new RichGraphRoot("entry-1", "runtime", null) }, Analyzer: new RichGraphAnalyzer("test", "1.0", null), Schema: "stellaops.richgraph.v1" ); } private static RichGraph CreateDeepGraph(int depth) { var nodes = new List(); var edges = new List(); for (var i = 0; i < depth; i++) { var attrs = i == depth - 1 ? new Dictionary { ["is_sink"] = "true" } : null; nodes.Add(new RichGraphNode($"node-{i}", $"Method{i}", null, null, "java", i == depth - 1 ? "sink" : "method", null, null, null, attrs, null)); if (i > 0) { edges.Add(new RichGraphEdge($"node-{i - 1}", $"node-{i}", "call", null, null, null, 1.0, null)); } } return new RichGraph( Nodes: nodes, Edges: edges, Roots: new[] { new RichGraphRoot("node-0", "runtime", null) }, Analyzer: new RichGraphAnalyzer("test", "1.0", null), Schema: "stellaops.richgraph.v1" ); } private static RichGraph CreateGraphWithMultiplePaths(int pathCount) { var nodes = new List { new("entry-1", "Handler", null, null, "java", "handler", null, null, null, null, null) }; var edges = new List(); for (var i = 0; i < pathCount; i++) { nodes.Add(new RichGraphNode($"sink-{i}", $"Sink{i}", null, null, "java", "sink", null, null, null, new Dictionary { ["is_sink"] = "true" }, null)); edges.Add(new RichGraphEdge("entry-1", $"sink-{i}", "call", null, null, null, 1.0, null)); } return new RichGraph( Nodes: nodes, Edges: edges, Roots: new[] { new RichGraphRoot("entry-1", "runtime", null) }, Analyzer: new RichGraphAnalyzer("test", "1.0", null), Schema: "stellaops.richgraph.v1" ); } private static ExplainedPath CreateTestPath() { return new ExplainedPath { PathId = "entry:sink:0", SinkId = "sink-1", SinkSymbol = "DB.query", SinkCategory = Explanation.SinkCategory.SqlRaw, EntrypointId = "entry-1", EntrypointSymbol = "Handler.handle", EntrypointType = EntrypointType.HttpEndpoint, PathLength = 2, Hops = new[] { new ExplainedPathHop { NodeId = "entry-1", Symbol = "Handler.handle", Package = "app", Depth = 0, IsEntrypoint = true, IsSink = false }, new ExplainedPathHop { NodeId = "sink-1", Symbol = "DB.query", Package = "database", Depth = 1, IsEntrypoint = false, IsSink = true } }, Gates = Array.Empty(), GateMultiplierBps = 10000 }; } private static ExplainedPath CreateTestPathWithGates() { return new ExplainedPath { PathId = "entry:sink:0", SinkId = "sink-1", SinkSymbol = "DB.query", SinkCategory = Explanation.SinkCategory.SqlRaw, EntrypointId = "entry-1", EntrypointSymbol = "Handler.handle", EntrypointType = EntrypointType.HttpEndpoint, PathLength = 2, Hops = new[] { new ExplainedPathHop { NodeId = "entry-1", Symbol = "Handler.handle", Package = "app", Depth = 0, IsEntrypoint = true, IsSink = false }, new ExplainedPathHop { NodeId = "sink-1", Symbol = "DB.query", Package = "database", Depth = 1, IsEntrypoint = false, IsSink = true } }, Gates = new[] { new DetectedGate { Type = GateType.AuthRequired, Detail = "@Authenticated", GuardSymbol = "AuthFilter", Confidence = 0.9, DetectionMethod = "annotation" } }, GateMultiplierBps = 3000 }; } }