- Implemented TelemetryClient to handle event queuing and flushing to the telemetry endpoint. - Created TtfsTelemetryService for emitting specific telemetry events related to TTFS. - Added tests for TelemetryClient to ensure event queuing and flushing functionality. - Introduced models for reachability drift detection, including DriftResult and DriftedSink. - Developed DriftApiService for interacting with the drift detection API. - Updated FirstSignalCardComponent to emit telemetry events on signal appearance. - Enhanced localization support for first signal component with i18n strings.
446 lines
14 KiB
C#
446 lines
14 KiB
C#
// -----------------------------------------------------------------------------
|
|
// 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<PathExplanationService>.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
|
|
{
|
|
Schema = "stellaops.richgraph.v1",
|
|
Meta = new RichGraphMeta { Hash = "test-hash" },
|
|
Roots = new[]
|
|
{
|
|
new RichGraphRoot("entry-1", "runtime", null)
|
|
},
|
|
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<string, string> { ["is_sink"] = "true" },
|
|
SymbolDigest: null)
|
|
},
|
|
Edges = new[]
|
|
{
|
|
new RichGraphEdge("entry-1", "sink-1", "call", null)
|
|
}
|
|
};
|
|
}
|
|
|
|
private static RichGraph CreateGraphWithMultipleSinks()
|
|
{
|
|
return new RichGraph
|
|
{
|
|
Schema = "stellaops.richgraph.v1",
|
|
Meta = new RichGraphMeta { Hash = "test-hash" },
|
|
Roots = new[] { new RichGraphRoot("entry-1", "runtime", null) },
|
|
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<string, string> { ["is_sink"] = "true" }, null),
|
|
new RichGraphNode("sink-2", "Sink2", null, null, "java", "sink", null, null, null,
|
|
new Dictionary<string, string> { ["is_sink"] = "true" }, null)
|
|
},
|
|
Edges = new[]
|
|
{
|
|
new RichGraphEdge("entry-1", "sink-1", "call", null),
|
|
new RichGraphEdge("entry-1", "sink-2", "call", null)
|
|
}
|
|
};
|
|
}
|
|
|
|
private static RichGraph CreateGraphWithGates()
|
|
{
|
|
var gates = new[]
|
|
{
|
|
new DetectedGate
|
|
{
|
|
Type = GateType.AuthRequired,
|
|
Detail = "@Authenticated",
|
|
GuardSymbol = "AuthFilter",
|
|
Confidence = 0.9,
|
|
DetectionMethod = "annotation"
|
|
}
|
|
};
|
|
|
|
return new RichGraph
|
|
{
|
|
Schema = "stellaops.richgraph.v1",
|
|
Meta = new RichGraphMeta { Hash = "test-hash" },
|
|
Roots = new[] { new RichGraphRoot("entry-1", "runtime", null) },
|
|
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<string, string> { ["is_sink"] = "true" }, null)
|
|
},
|
|
Edges = new[]
|
|
{
|
|
new RichGraphEdge("entry-1", "sink-1", "call", gates)
|
|
}
|
|
};
|
|
}
|
|
|
|
private static RichGraph CreateDeepGraph(int depth)
|
|
{
|
|
var nodes = new List<RichGraphNode>();
|
|
var edges = new List<RichGraphEdge>();
|
|
|
|
for (var i = 0; i < depth; i++)
|
|
{
|
|
var attrs = i == depth - 1
|
|
? new Dictionary<string, string> { ["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));
|
|
}
|
|
}
|
|
|
|
return new RichGraph
|
|
{
|
|
Schema = "stellaops.richgraph.v1",
|
|
Meta = new RichGraphMeta { Hash = "test-hash" },
|
|
Roots = new[] { new RichGraphRoot("node-0", "runtime", null) },
|
|
Nodes = nodes,
|
|
Edges = edges
|
|
};
|
|
}
|
|
|
|
private static RichGraph CreateGraphWithMultiplePaths(int pathCount)
|
|
{
|
|
var nodes = new List<RichGraphNode>
|
|
{
|
|
new("entry-1", "Handler", null, null, "java", "handler", null, null, null, null, null)
|
|
};
|
|
|
|
var edges = new List<RichGraphEdge>();
|
|
|
|
for (var i = 0; i < pathCount; i++)
|
|
{
|
|
nodes.Add(new RichGraphNode($"sink-{i}", $"Sink{i}", null, null, "java", "sink", null, null, null,
|
|
new Dictionary<string, string> { ["is_sink"] = "true" }, null));
|
|
edges.Add(new RichGraphEdge("entry-1", $"sink-{i}", "call", null));
|
|
}
|
|
|
|
return new RichGraph
|
|
{
|
|
Schema = "stellaops.richgraph.v1",
|
|
Meta = new RichGraphMeta { Hash = "test-hash" },
|
|
Roots = new[] { new RichGraphRoot("entry-1", "runtime", null) },
|
|
Nodes = nodes,
|
|
Edges = edges
|
|
};
|
|
}
|
|
|
|
private static ExplainedPath CreateTestPath()
|
|
{
|
|
return new ExplainedPath
|
|
{
|
|
PathId = "entry:sink:0",
|
|
SinkId = "sink-1",
|
|
SinkSymbol = "DB.query",
|
|
SinkCategory = 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<DetectedGate>(),
|
|
GateMultiplierBps = 10000
|
|
};
|
|
}
|
|
|
|
private static ExplainedPath CreateTestPathWithGates()
|
|
{
|
|
return new ExplainedPath
|
|
{
|
|
PathId = "entry:sink:0",
|
|
SinkId = "sink-1",
|
|
SinkSymbol = "DB.query",
|
|
SinkCategory = 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
|
|
};
|
|
}
|
|
}
|