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:
master
2025-12-19 15:35:00 +02:00
parent 43882078a4
commit 951a38d561
192 changed files with 27550 additions and 2611 deletions

View File

@@ -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]);
}
}