470 lines
16 KiB
C#
470 lines
16 KiB
C#
// -----------------------------------------------------------------------------
|
|
// 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<string>.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<string>.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<CallGraphEdge>.Empty,
|
|
EntrypointIds: ImmutableArray<string>.Empty,
|
|
SinkIds: ImmutableArray<string>.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<CallGraphEdge>.Empty,
|
|
EntrypointIds: ImmutableArray<string>.Empty,
|
|
SinkIds: ImmutableArray<string>.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<CallGraphEdge>.Empty,
|
|
EntrypointIds: ImmutableArray<string>.Empty,
|
|
SinkIds: ImmutableArray<string>.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<CallGraphEdge>.Empty,
|
|
EntrypointIds: ImmutableArray<string>.Empty,
|
|
SinkIds: ImmutableArray<string>.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<string>.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<string>.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<CallGraphEdge>.Empty,
|
|
EntrypointIds: ImmutableArray<string>.Empty,
|
|
SinkIds: ImmutableArray<string>.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<CallGraphEdge>.Empty,
|
|
EntrypointIds: ImmutableArray<string>.Empty,
|
|
SinkIds: ImmutableArray<string>.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<ArgumentNullException>(() => CallGraphDigests.ComputeGraphDigest(null!));
|
|
}
|
|
|
|
[Fact]
|
|
public void ComputeGraphDigest_HandlesEmptySnapshot()
|
|
{
|
|
// Arrange
|
|
var snapshot = new CallGraphSnapshot(
|
|
ScanId: "",
|
|
GraphDigest: "",
|
|
Language: "native",
|
|
ExtractedAt: DateTimeOffset.UtcNow,
|
|
Nodes: ImmutableArray<CallGraphNode>.Empty,
|
|
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
|
EntrypointIds: ImmutableArray<string>.Empty,
|
|
SinkIds: ImmutableArray<string>.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<CallGraphNode>.Empty,
|
|
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
|
EntrypointIds: ImmutableArray<string>.Empty,
|
|
SinkIds: ImmutableArray<string>.Empty
|
|
);
|
|
|
|
var snapshot2 = new CallGraphSnapshot(
|
|
ScanId: "test-scan",
|
|
GraphDigest: "",
|
|
Language: "dotnet",
|
|
ExtractedAt: DateTimeOffset.UtcNow,
|
|
Nodes: ImmutableArray<CallGraphNode>.Empty,
|
|
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
|
EntrypointIds: ImmutableArray<string>.Empty,
|
|
SinkIds: ImmutableArray<string>.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<string>.Empty,
|
|
SinkIds: ImmutableArray<string>.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<string>.Empty,
|
|
SinkIds: ImmutableArray<string>.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<CallGraphEdge>.Empty,
|
|
EntrypointIds: ImmutableArray.Create("sha256:abc123"),
|
|
SinkIds: ImmutableArray<string>.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;
|
|
}
|
|
}
|