Add Canonical JSON serialization library with tests and documentation
- Implemented CanonJson class for deterministic JSON serialization and hashing. - Added unit tests for CanonJson functionality, covering various scenarios including key sorting, handling of nested objects, arrays, and special characters. - Created project files for the Canonical JSON library and its tests, including necessary package references. - Added README.md for library usage and API reference. - Introduced RabbitMqIntegrationFactAttribute for conditional RabbitMQ integration tests.
This commit is contained in:
@@ -4,6 +4,10 @@ using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ReachabilityAnalyzer"/>.
|
||||
/// Sprint: SPRINT_3700_0001_0001 (WIT-007A) - determinism contract tests.
|
||||
/// </summary>
|
||||
public class ReachabilityAnalyzerTests
|
||||
{
|
||||
[Fact]
|
||||
@@ -63,4 +67,321 @@ public class ReachabilityAnalyzerTests
|
||||
Assert.Empty(result.Paths);
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.ResultDigest));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WIT-007A: Verify deterministic path ordering (SinkId ASC, EntrypointId ASC, PathLength ASC).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Analyze_PathsAreDeterministicallyOrdered_BySinkIdThenEntrypointIdThenLength()
|
||||
{
|
||||
// Arrange: create graph with multiple entrypoints and sinks
|
||||
var entry1 = "entry:aaa";
|
||||
var entry2 = "entry:bbb";
|
||||
var mid1 = "mid:001";
|
||||
var mid2 = "mid:002";
|
||||
var sink1 = "sink:zzz"; // lexicographically last
|
||||
var sink2 = "sink:aaa"; // lexicographically first
|
||||
|
||||
var snapshot = new CallGraphSnapshot(
|
||||
ScanId: "scan-1",
|
||||
GraphDigest: "sha256:test",
|
||||
Language: "dotnet",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes:
|
||||
[
|
||||
new CallGraphNode(entry1, "Entry1", "f.cs", 1, "app", Visibility.Public, true, EntrypointType.HttpHandler, false, null),
|
||||
new CallGraphNode(entry2, "Entry2", "f.cs", 2, "app", Visibility.Public, true, EntrypointType.HttpHandler, false, null),
|
||||
new CallGraphNode(mid1, "Mid1", "f.cs", 3, "app", Visibility.Public, false, null, false, null),
|
||||
new CallGraphNode(mid2, "Mid2", "f.cs", 4, "app", Visibility.Public, false, null, false, null),
|
||||
new CallGraphNode(sink1, "Sink1", "f.cs", 5, "lib", Visibility.Public, false, null, true, StellaOps.Scanner.Reachability.SinkCategory.CmdExec),
|
||||
new CallGraphNode(sink2, "Sink2", "f.cs", 6, "lib", Visibility.Public, false, null, true, StellaOps.Scanner.Reachability.SinkCategory.SqlRaw),
|
||||
],
|
||||
Edges:
|
||||
[
|
||||
// entry1 -> mid1 -> sink2 (path length 3)
|
||||
new CallGraphEdge(entry1, mid1, CallKind.Direct),
|
||||
new CallGraphEdge(mid1, sink2, CallKind.Direct),
|
||||
// entry2 -> sink1 (path length 2, shorter)
|
||||
new CallGraphEdge(entry2, sink1, CallKind.Direct),
|
||||
],
|
||||
EntrypointIds: [entry2, entry1], // deliberately out of order
|
||||
SinkIds: [sink1, sink2]); // deliberately out of order
|
||||
|
||||
var analyzer = new ReachabilityAnalyzer();
|
||||
|
||||
// Act
|
||||
var result = analyzer.Analyze(snapshot);
|
||||
|
||||
// Assert: paths should be ordered by SinkId ASC
|
||||
Assert.Equal(2, result.Paths.Length);
|
||||
Assert.Equal(sink2, result.Paths[0].SinkId); // "sink:aaa" comes before "sink:zzz"
|
||||
Assert.Equal(sink1, result.Paths[1].SinkId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WIT-007A: Verify that multiple runs produce identical results (determinism).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Analyze_ProducesIdenticalResults_OnMultipleRuns()
|
||||
{
|
||||
var entry = "entry:test";
|
||||
var mid = "mid:test";
|
||||
var sink = "sink:test";
|
||||
|
||||
var snapshot = new CallGraphSnapshot(
|
||||
ScanId: "scan-1",
|
||||
GraphDigest: "sha256:test",
|
||||
Language: "dotnet",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes:
|
||||
[
|
||||
new CallGraphNode(entry, "Entry", "f.cs", 1, "app", Visibility.Public, true, EntrypointType.HttpHandler, false, null),
|
||||
new CallGraphNode(mid, "Mid", "f.cs", 2, "app", Visibility.Public, false, null, false, null),
|
||||
new CallGraphNode(sink, "Sink", "f.cs", 3, "lib", Visibility.Public, false, null, true, StellaOps.Scanner.Reachability.SinkCategory.CmdExec),
|
||||
],
|
||||
Edges:
|
||||
[
|
||||
new CallGraphEdge(entry, mid, CallKind.Direct),
|
||||
new CallGraphEdge(mid, sink, CallKind.Direct),
|
||||
],
|
||||
EntrypointIds: [entry],
|
||||
SinkIds: [sink]);
|
||||
|
||||
var analyzer = new ReachabilityAnalyzer();
|
||||
|
||||
// Act: run analysis multiple times
|
||||
var result1 = analyzer.Analyze(snapshot);
|
||||
var result2 = analyzer.Analyze(snapshot);
|
||||
var result3 = analyzer.Analyze(snapshot);
|
||||
|
||||
// Assert: all results should have identical digests (determinism proof)
|
||||
Assert.Equal(result1.ResultDigest, result2.ResultDigest);
|
||||
Assert.Equal(result2.ResultDigest, result3.ResultDigest);
|
||||
Assert.Equal(result1.Paths.Length, result2.Paths.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WIT-007A: Verify MaxTotalPaths limit is enforced.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Analyze_WithOptions_RespectsMaxTotalPathsLimit()
|
||||
{
|
||||
// Arrange: create graph with 5 sinks reachable from 1 entrypoint
|
||||
var entry = "entry:test";
|
||||
var nodes = new List<CallGraphNode>
|
||||
{
|
||||
new(entry, "Entry", "f.cs", 1, "app", Visibility.Public, true, EntrypointType.HttpHandler, false, null),
|
||||
};
|
||||
var edges = new List<CallGraphEdge>();
|
||||
var sinks = new List<string>();
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var sink = $"sink:{i:D3}";
|
||||
sinks.Add(sink);
|
||||
nodes.Add(new CallGraphNode(sink, $"Sink{i}", "f.cs", i + 10, "lib", Visibility.Public, false, null, true, StellaOps.Scanner.Reachability.SinkCategory.CmdExec));
|
||||
edges.Add(new CallGraphEdge(entry, sink, CallKind.Direct));
|
||||
}
|
||||
|
||||
var snapshot = new CallGraphSnapshot(
|
||||
ScanId: "scan-1",
|
||||
GraphDigest: "sha256:test",
|
||||
Language: "dotnet",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: nodes.ToImmutableArray(),
|
||||
Edges: edges.ToImmutableArray(),
|
||||
EntrypointIds: [entry],
|
||||
SinkIds: sinks.ToImmutableArray());
|
||||
|
||||
var options = new ReachabilityAnalysisOptions { MaxTotalPaths = 3 };
|
||||
var analyzer = new ReachabilityAnalyzer(null, options);
|
||||
|
||||
// Act
|
||||
var result = analyzer.Analyze(snapshot);
|
||||
|
||||
// Assert: should only return MaxTotalPaths paths
|
||||
Assert.Equal(3, result.Paths.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WIT-007A: Verify MaxDepth limit is enforced.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Analyze_WithOptions_RespectsMaxDepthLimit()
|
||||
{
|
||||
// Arrange: create a chain of 10 nodes
|
||||
var nodes = new List<CallGraphNode>();
|
||||
var edges = new List<CallGraphEdge>();
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var nodeId = $"node:{i:D3}";
|
||||
var isEntry = i == 0;
|
||||
var isSink = i == 9;
|
||||
nodes.Add(new CallGraphNode(nodeId, $"Node{i}", "f.cs", i, "app", Visibility.Public, isEntry, isEntry ? EntrypointType.HttpHandler : null, isSink, isSink ? StellaOps.Scanner.Reachability.SinkCategory.CmdExec : null));
|
||||
if (i > 0)
|
||||
{
|
||||
edges.Add(new CallGraphEdge($"node:{(i-1):D3}", nodeId, CallKind.Direct));
|
||||
}
|
||||
}
|
||||
|
||||
var snapshot = new CallGraphSnapshot(
|
||||
ScanId: "scan-1",
|
||||
GraphDigest: "sha256:test",
|
||||
Language: "dotnet",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: nodes.ToImmutableArray(),
|
||||
Edges: edges.ToImmutableArray(),
|
||||
EntrypointIds: ["node:000"],
|
||||
SinkIds: ["node:009"]);
|
||||
|
||||
// With MaxDepth=5, the sink at depth 9 should not be reachable
|
||||
var options = new ReachabilityAnalysisOptions { MaxDepth = 5 };
|
||||
var analyzer = new ReachabilityAnalyzer(null, options);
|
||||
|
||||
// Act
|
||||
var result = analyzer.Analyze(snapshot);
|
||||
|
||||
// Assert: sink should not be reachable due to depth limit
|
||||
Assert.Empty(result.ReachableSinkIds);
|
||||
Assert.Empty(result.Paths);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WIT-007A: Verify node IDs in paths are ordered from entrypoint to sink.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Analyze_PathNodeIds_AreOrderedFromEntrypointToSink()
|
||||
{
|
||||
var entry = "entry:start";
|
||||
var mid1 = "mid:step1";
|
||||
var mid2 = "mid:step2";
|
||||
var sink = "sink:end";
|
||||
|
||||
var snapshot = new CallGraphSnapshot(
|
||||
ScanId: "scan-1",
|
||||
GraphDigest: "sha256:test",
|
||||
Language: "dotnet",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes:
|
||||
[
|
||||
new CallGraphNode(entry, "Entry", "f.cs", 1, "app", Visibility.Public, true, EntrypointType.HttpHandler, false, null),
|
||||
new CallGraphNode(mid1, "Mid1", "f.cs", 2, "app", Visibility.Public, false, null, false, null),
|
||||
new CallGraphNode(mid2, "Mid2", "f.cs", 3, "app", Visibility.Public, false, null, false, null),
|
||||
new CallGraphNode(sink, "Sink", "f.cs", 4, "lib", Visibility.Public, false, null, true, StellaOps.Scanner.Reachability.SinkCategory.CmdExec),
|
||||
],
|
||||
Edges:
|
||||
[
|
||||
new CallGraphEdge(entry, mid1, CallKind.Direct),
|
||||
new CallGraphEdge(mid1, mid2, CallKind.Direct),
|
||||
new CallGraphEdge(mid2, sink, CallKind.Direct),
|
||||
],
|
||||
EntrypointIds: [entry],
|
||||
SinkIds: [sink]);
|
||||
|
||||
var analyzer = new ReachabilityAnalyzer();
|
||||
|
||||
// Act
|
||||
var result = analyzer.Analyze(snapshot);
|
||||
|
||||
// Assert: path should start with entry and end with sink
|
||||
Assert.Single(result.Paths);
|
||||
var path = result.Paths[0];
|
||||
Assert.Equal(4, path.NodeIds.Length);
|
||||
Assert.Equal(entry, path.NodeIds[0]); // First: entrypoint
|
||||
Assert.Equal(mid1, path.NodeIds[1]);
|
||||
Assert.Equal(mid2, path.NodeIds[2]);
|
||||
Assert.Equal(sink, path.NodeIds[3]); // Last: sink
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WIT-007B: Verify ExplicitSinks option allows targeting specific sinks not in snapshot.SinkIds.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Analyze_WithExplicitSinks_FindsPathsToSpecifiedSinksOnly()
|
||||
{
|
||||
// Arrange: graph with 3 reachable nodes, only 1 is in snapshot.SinkIds
|
||||
var entry = "entry:start";
|
||||
var mid = "mid:step";
|
||||
var snapshotSink = "sink:in-snapshot";
|
||||
var explicitSink = "sink:explicit-target"; // Not in snapshot.SinkIds
|
||||
|
||||
var snapshot = new CallGraphSnapshot(
|
||||
ScanId: "scan-1",
|
||||
GraphDigest: "sha256:test",
|
||||
Language: "dotnet",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes:
|
||||
[
|
||||
new CallGraphNode(entry, "Entry", "f.cs", 1, "app", Visibility.Public, true, EntrypointType.HttpHandler, false, null),
|
||||
new CallGraphNode(mid, "Mid", "f.cs", 2, "app", Visibility.Public, false, null, false, null),
|
||||
new CallGraphNode(snapshotSink, "SnapshotSink", "f.cs", 3, "lib", Visibility.Public, false, null, true, StellaOps.Scanner.Reachability.SinkCategory.CmdExec),
|
||||
new CallGraphNode(explicitSink, "ExplicitSink", "f.cs", 4, "lib", Visibility.Public, false, null, false, null), // Not marked as sink
|
||||
],
|
||||
Edges:
|
||||
[
|
||||
new CallGraphEdge(entry, mid, CallKind.Direct),
|
||||
new CallGraphEdge(mid, snapshotSink, CallKind.Direct),
|
||||
new CallGraphEdge(mid, explicitSink, CallKind.Direct),
|
||||
],
|
||||
EntrypointIds: [entry],
|
||||
SinkIds: [snapshotSink]); // Only snapshotSink is in the default sink list
|
||||
|
||||
// Use ExplicitSinks to target the non-sink node as if it were a trigger method
|
||||
var options = new ReachabilityAnalysisOptions
|
||||
{
|
||||
ExplicitSinks = [explicitSink]
|
||||
};
|
||||
var analyzer = new ReachabilityAnalyzer(null, options);
|
||||
|
||||
// Act
|
||||
var result = analyzer.Analyze(snapshot);
|
||||
|
||||
// Assert: should find path to explicit sink only, not the snapshot sink
|
||||
Assert.Single(result.ReachableSinkIds);
|
||||
Assert.Equal(explicitSink, result.ReachableSinkIds[0]);
|
||||
Assert.Single(result.Paths);
|
||||
Assert.Equal(explicitSink, result.Paths[0].SinkId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WIT-007B: Verify ExplicitSinks with empty array falls back to snapshot sinks.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Analyze_WithEmptyExplicitSinks_UsesSnapshotSinks()
|
||||
{
|
||||
var entry = "entry:start";
|
||||
var sink = "sink:default";
|
||||
|
||||
var snapshot = new CallGraphSnapshot(
|
||||
ScanId: "scan-1",
|
||||
GraphDigest: "sha256:test",
|
||||
Language: "dotnet",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes:
|
||||
[
|
||||
new CallGraphNode(entry, "Entry", "f.cs", 1, "app", Visibility.Public, true, EntrypointType.HttpHandler, false, null),
|
||||
new CallGraphNode(sink, "Sink", "f.cs", 2, "lib", Visibility.Public, false, null, true, StellaOps.Scanner.Reachability.SinkCategory.CmdExec),
|
||||
],
|
||||
Edges:
|
||||
[
|
||||
new CallGraphEdge(entry, sink, CallKind.Direct),
|
||||
],
|
||||
EntrypointIds: [entry],
|
||||
SinkIds: [sink]);
|
||||
|
||||
// Empty explicit sinks should fall back to snapshot sinks
|
||||
var options = new ReachabilityAnalysisOptions
|
||||
{
|
||||
ExplicitSinks = ImmutableArray<string>.Empty
|
||||
};
|
||||
var analyzer = new ReachabilityAnalyzer(null, options);
|
||||
|
||||
// Act
|
||||
var result = analyzer.Analyze(snapshot);
|
||||
|
||||
// Assert: should use snapshot sinks
|
||||
Assert.Single(result.ReachableSinkIds);
|
||||
Assert.Equal(sink, result.ReachableSinkIds[0]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user