// ----------------------------------------------------------------------------- // CallGraphDigestsTests.cs // Sprint: SPRINT_20260104_001_CLI // Description: Unit tests for call graph digest computation and determinism. // ----------------------------------------------------------------------------- using System.Collections.Immutable; using StellaOps.Scanner.CallGraph; using StellaOps.Scanner.Reachability; using StellaOps.TestKit; using Xunit; namespace StellaOps.Scanner.CallGraph.Tests; [Trait("Category", TestCategories.Unit)] public class CallGraphDigestsTests { [Fact] public void ComputeGraphDigest_ReturnsValidSha256Format() { // Arrange var snapshot = CreateMinimalSnapshot(); // Act var digest = CallGraphDigests.ComputeGraphDigest(snapshot); // Assert Assert.NotNull(digest); Assert.StartsWith("sha256:", digest, StringComparison.Ordinal); Assert.Equal(71, digest.Length); // "sha256:" (7) + 64 hex chars Assert.True(IsValidHex(digest[7..]), "Digest should be valid hex string"); } [Fact] public void ComputeGraphDigest_IsDeterministic() { // Arrange var snapshot = CreateMinimalSnapshot(); // Act var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot); var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot); var digest3 = CallGraphDigests.ComputeGraphDigest(snapshot); // Assert Assert.Equal(digest1, digest2); Assert.Equal(digest2, digest3); } [Fact] public void ComputeGraphDigest_EquivalentSnapshotsProduceSameDigest() { // Arrange - two separately created but equivalent snapshots var snapshot1 = new CallGraphSnapshot( ScanId: "test-scan-1", GraphDigest: "", Language: "native", ExtractedAt: DateTimeOffset.UtcNow, Nodes: ImmutableArray.Create( new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, true, EntrypointType.CliCommand, false, null), new CallGraphNode("node-b", "func_b", "test.c", 20, "pkg", Visibility.Internal, false, null, false, null) ), Edges: ImmutableArray.Create( new CallGraphEdge("node-a", "node-b", CallKind.Direct) ), EntrypointIds: ImmutableArray.Create("node-a"), SinkIds: ImmutableArray.Empty ); var snapshot2 = new CallGraphSnapshot( ScanId: "test-scan-1", GraphDigest: "", Language: "native", ExtractedAt: DateTimeOffset.UtcNow.AddMinutes(5), // Different timestamp Nodes: ImmutableArray.Create( new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, true, EntrypointType.CliCommand, false, null), new CallGraphNode("node-b", "func_b", "test.c", 20, "pkg", Visibility.Internal, false, null, false, null) ), Edges: ImmutableArray.Create( new CallGraphEdge("node-a", "node-b", CallKind.Direct) ), EntrypointIds: ImmutableArray.Create("node-a"), SinkIds: ImmutableArray.Empty ); // Act var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot1); var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot2); // Assert - digests should match because ExtractedAt is not part of the digest payload Assert.Equal(digest1, digest2); } [Fact] public void ComputeGraphDigest_DifferentNodesProduceDifferentDigests() { // Arrange var snapshot1 = new CallGraphSnapshot( ScanId: "test-scan", GraphDigest: "", Language: "native", ExtractedAt: DateTimeOffset.UtcNow, Nodes: ImmutableArray.Create( new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, false, null, false, null) ), Edges: ImmutableArray.Empty, EntrypointIds: ImmutableArray.Empty, SinkIds: ImmutableArray.Empty ); var snapshot2 = new CallGraphSnapshot( ScanId: "test-scan", GraphDigest: "", Language: "native", ExtractedAt: DateTimeOffset.UtcNow, Nodes: ImmutableArray.Create( new CallGraphNode("node-b", "func_b", "test.c", 20, "pkg", Visibility.Public, false, null, false, null) ), Edges: ImmutableArray.Empty, EntrypointIds: ImmutableArray.Empty, SinkIds: ImmutableArray.Empty ); // Act var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot1); var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot2); // Assert Assert.NotEqual(digest1, digest2); } [Fact] public void ComputeGraphDigest_NodeOrderDoesNotAffectDigest() { // Arrange - nodes in different order var snapshot1 = new CallGraphSnapshot( ScanId: "test-scan", GraphDigest: "", Language: "native", ExtractedAt: DateTimeOffset.UtcNow, Nodes: ImmutableArray.Create( new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, false, null, false, null), new CallGraphNode("node-b", "func_b", "test.c", 20, "pkg", Visibility.Public, false, null, false, null) ), Edges: ImmutableArray.Empty, EntrypointIds: ImmutableArray.Empty, SinkIds: ImmutableArray.Empty ); var snapshot2 = new CallGraphSnapshot( ScanId: "test-scan", GraphDigest: "", Language: "native", ExtractedAt: DateTimeOffset.UtcNow, Nodes: ImmutableArray.Create( new CallGraphNode("node-b", "func_b", "test.c", 20, "pkg", Visibility.Public, false, null, false, null), new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, false, null, false, null) ), Edges: ImmutableArray.Empty, EntrypointIds: ImmutableArray.Empty, SinkIds: ImmutableArray.Empty ); // Act var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot1); var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot2); // Assert - digests should match because Trimmed() sorts nodes Assert.Equal(digest1, digest2); } [Fact] public void ComputeGraphDigest_EdgeOrderDoesNotAffectDigest() { // Arrange - edges in different order var nodes = ImmutableArray.Create( new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, true, EntrypointType.CliCommand, false, null), new CallGraphNode("node-b", "func_b", "test.c", 20, "pkg", Visibility.Internal, false, null, false, null), new CallGraphNode("node-c", "func_c", "test.c", 30, "pkg", Visibility.Internal, false, null, false, null) ); var snapshot1 = new CallGraphSnapshot( ScanId: "test-scan", GraphDigest: "", Language: "native", ExtractedAt: DateTimeOffset.UtcNow, Nodes: nodes, Edges: ImmutableArray.Create( new CallGraphEdge("node-a", "node-b", CallKind.Direct), new CallGraphEdge("node-a", "node-c", CallKind.Direct) ), EntrypointIds: ImmutableArray.Create("node-a"), SinkIds: ImmutableArray.Empty ); var snapshot2 = new CallGraphSnapshot( ScanId: "test-scan", GraphDigest: "", Language: "native", ExtractedAt: DateTimeOffset.UtcNow, Nodes: nodes, Edges: ImmutableArray.Create( new CallGraphEdge("node-a", "node-c", CallKind.Direct), new CallGraphEdge("node-a", "node-b", CallKind.Direct) ), EntrypointIds: ImmutableArray.Create("node-a"), SinkIds: ImmutableArray.Empty ); // Act var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot1); var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot2); // Assert - digests should match because Trimmed() sorts edges Assert.Equal(digest1, digest2); } [Fact] public void ComputeGraphDigest_WhitespaceIsTrimmed() { // Arrange var snapshot1 = new CallGraphSnapshot( ScanId: "test-scan", GraphDigest: "", Language: "native", ExtractedAt: DateTimeOffset.UtcNow, Nodes: ImmutableArray.Create( new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, false, null, false, null) ), Edges: ImmutableArray.Empty, EntrypointIds: ImmutableArray.Empty, SinkIds: ImmutableArray.Empty ); var snapshot2 = new CallGraphSnapshot( ScanId: " test-scan ", GraphDigest: "", Language: " native ", ExtractedAt: DateTimeOffset.UtcNow, Nodes: ImmutableArray.Create( new CallGraphNode(" node-a ", " func_a ", " test.c ", 10, " pkg ", Visibility.Public, false, null, false, null) ), Edges: ImmutableArray.Empty, EntrypointIds: ImmutableArray.Empty, SinkIds: ImmutableArray.Empty ); // Act var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot1); var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot2); // Assert - digests should match because Trimmed() trims whitespace Assert.Equal(digest1, digest2); } [Fact] public void ComputeGraphDigest_ThrowsOnNull() { // Act & Assert Assert.Throws(() => CallGraphDigests.ComputeGraphDigest(null!)); } [Fact] public void ComputeGraphDigest_HandlesEmptySnapshot() { // Arrange var snapshot = new CallGraphSnapshot( ScanId: "", GraphDigest: "", Language: "native", ExtractedAt: DateTimeOffset.UtcNow, Nodes: ImmutableArray.Empty, Edges: ImmutableArray.Empty, EntrypointIds: ImmutableArray.Empty, SinkIds: ImmutableArray.Empty ); // Act var digest = CallGraphDigests.ComputeGraphDigest(snapshot); // Assert Assert.NotNull(digest); Assert.StartsWith("sha256:", digest, StringComparison.Ordinal); } [Fact] public void ComputeGraphDigest_LanguageAffectsDigest() { // Arrange var snapshot1 = new CallGraphSnapshot( ScanId: "test-scan", GraphDigest: "", Language: "native", ExtractedAt: DateTimeOffset.UtcNow, Nodes: ImmutableArray.Empty, Edges: ImmutableArray.Empty, EntrypointIds: ImmutableArray.Empty, SinkIds: ImmutableArray.Empty ); var snapshot2 = new CallGraphSnapshot( ScanId: "test-scan", GraphDigest: "", Language: "dotnet", ExtractedAt: DateTimeOffset.UtcNow, Nodes: ImmutableArray.Empty, Edges: ImmutableArray.Empty, EntrypointIds: ImmutableArray.Empty, SinkIds: ImmutableArray.Empty ); // Act var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot1); var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot2); // Assert Assert.NotEqual(digest1, digest2); } [Fact] public void ComputeGraphDigest_EdgeExplanationAffectsDigest() { // Arrange var nodes = ImmutableArray.Create( new CallGraphNode("node-a", "func_a", "test.c", 10, "pkg", Visibility.Public, false, null, false, null), new CallGraphNode("node-b", "func_b", "test.c", 20, "pkg", Visibility.Public, false, null, false, null) ); var snapshot1 = new CallGraphSnapshot( ScanId: "test-scan", GraphDigest: "", Language: "native", ExtractedAt: DateTimeOffset.UtcNow, Nodes: nodes, Edges: ImmutableArray.Create( new CallGraphEdge("node-a", "node-b", CallKind.Direct, null, null) ), EntrypointIds: ImmutableArray.Empty, SinkIds: ImmutableArray.Empty ); var snapshot2 = new CallGraphSnapshot( ScanId: "test-scan", GraphDigest: "", Language: "native", ExtractedAt: DateTimeOffset.UtcNow, Nodes: nodes, Edges: ImmutableArray.Create( new CallGraphEdge("node-a", "node-b", CallKind.Direct, null, CallEdgeExplanation.DirectCall()) ), EntrypointIds: ImmutableArray.Empty, SinkIds: ImmutableArray.Empty ); // Act var digest1 = CallGraphDigests.ComputeGraphDigest(snapshot1); var digest2 = CallGraphDigests.ComputeGraphDigest(snapshot2); // Assert Assert.NotEqual(digest1, digest2); } [Fact] public void CallGraphNodeIds_Compute_ReturnsValidSha256Format() { // Arrange var stableId = "native:main"; // Act var nodeId = CallGraphNodeIds.Compute(stableId); // Assert Assert.NotNull(nodeId); Assert.StartsWith("sha256:", nodeId, StringComparison.Ordinal); Assert.Equal(71, nodeId.Length); } [Fact] public void CallGraphNodeIds_Compute_IsDeterministic() { // Arrange var stableId = "native:SSL_read"; // Act var id1 = CallGraphNodeIds.Compute(stableId); var id2 = CallGraphNodeIds.Compute(stableId); var id3 = CallGraphNodeIds.Compute(stableId); // Assert Assert.Equal(id1, id2); Assert.Equal(id2, id3); } [Fact] public void CallGraphNodeIds_Compute_DifferentSymbolsProduceDifferentIds() { // Arrange var stableId1 = "native:func_a"; var stableId2 = "native:func_b"; // Act var id1 = CallGraphNodeIds.Compute(stableId1); var id2 = CallGraphNodeIds.Compute(stableId2); // Assert Assert.NotEqual(id1, id2); } [Fact] public void CallGraphNodeIds_StableSymbolId_CreatesConsistentFormat() { // Arrange & Act var stableId = CallGraphNodeIds.StableSymbolId("Native", "SSL_read"); // Assert Assert.Equal("native:SSL_read", stableId); } [Fact] public void CallGraphNodeIds_StableSymbolId_TrimsWhitespace() { // Arrange & Act var stableId = CallGraphNodeIds.StableSymbolId(" Native ", " SSL_read "); // Assert Assert.Equal("native:SSL_read", stableId); } private static CallGraphSnapshot CreateMinimalSnapshot() { return new CallGraphSnapshot( ScanId: "test-scan-001", GraphDigest: "", Language: "native", ExtractedAt: DateTimeOffset.UtcNow, Nodes: ImmutableArray.Create( new CallGraphNode( NodeId: "sha256:abc123", Symbol: "main", File: "main.c", Line: 1, Package: "test-binary", Visibility: Visibility.Public, IsEntrypoint: true, EntrypointType: EntrypointType.CliCommand, IsSink: false, SinkCategory: null ) ), Edges: ImmutableArray.Empty, EntrypointIds: ImmutableArray.Create("sha256:abc123"), SinkIds: ImmutableArray.Empty ); } private static bool IsValidHex(string hex) { if (string.IsNullOrEmpty(hex)) return false; foreach (char c in hex) { if (!char.IsAsciiHexDigit(c)) return false; } return true; } }