Files
git.stella-ops.org/src/Scanner/__Tests/StellaOps.Scanner.CallGraph.Tests/CallGraphDigestsTests.cs
StellaOps Bot 3098e84de4 save progress
2026-01-04 14:54:52 +02:00

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;
}
}