using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; using System.Threading.Tasks; using Xunit; using StellaOps.TestKit; namespace StellaOps.Scanner.Reachability.Tests; public class EdgeBundleTests { private const string TestGraphHash = "blake3:abc123def456"; [Trait("Category", TestCategories.Unit)] [Fact] public void EdgeBundle_Canonical_SortsEdgesDeterministically() { // Arrange - create bundle with unsorted edges var edges = new List { new("func_z", "func_a", "call", EdgeReason.RuntimeHit, false, 0.9, null, null, null), new("func_a", "func_c", "call", EdgeReason.RuntimeHit, false, 0.8, null, null, null), new("func_a", "func_b", "call", EdgeReason.RuntimeHit, false, 0.7, null, null, null), }; var bundle = new EdgeBundle("bundle:test", TestGraphHash, EdgeBundleReason.RuntimeHits, edges, DateTimeOffset.UtcNow); // Act var canonical = bundle.Canonical(); // Assert - edges should be sorted by From, then To, then Kind Assert.Equal(3, canonical.Edges.Count); Assert.Equal("func_a", canonical.Edges[0].From); Assert.Equal("func_b", canonical.Edges[0].To); Assert.Equal("func_a", canonical.Edges[1].From); Assert.Equal("func_c", canonical.Edges[1].To); Assert.Equal("func_z", canonical.Edges[2].From); Assert.Equal("func_a", canonical.Edges[2].To); } [Trait("Category", TestCategories.Unit)] [Fact] public void EdgeBundle_ComputeContentHash_IsDeterministic() { // Arrange var edges = new List { new("func_a", "func_b", "call", EdgeReason.RuntimeHit, false, 0.9, null, null, null), new("func_b", "func_c", "call", EdgeReason.ThirdPartyCall, false, 0.8, null, null, null), }; var bundle1 = new EdgeBundle("bundle:test", TestGraphHash, EdgeBundleReason.RuntimeHits, edges, DateTimeOffset.UtcNow); var bundle2 = new EdgeBundle("bundle:test", TestGraphHash, EdgeBundleReason.RuntimeHits, edges, DateTimeOffset.UtcNow.AddMinutes(5)); // Act var hash1 = bundle1.ComputeContentHash(); var hash2 = bundle2.ComputeContentHash(); // Assert - same content should produce same hash regardless of timestamp Assert.Equal(hash1, hash2); Assert.StartsWith("sha256:", hash1); } [Trait("Category", TestCategories.Unit)] [Fact] public void EdgeBundle_ComputeContentHash_DiffersWithDifferentEdges() { // Arrange var edges1 = new List { new("func_a", "func_b", "call", EdgeReason.RuntimeHit, false, 0.9, null, null, null), }; var edges2 = new List { new("func_a", "func_c", "call", EdgeReason.RuntimeHit, false, 0.9, null, null, null), }; var bundle1 = new EdgeBundle("bundle:test", TestGraphHash, EdgeBundleReason.RuntimeHits, edges1, DateTimeOffset.UtcNow); var bundle2 = new EdgeBundle("bundle:test", TestGraphHash, EdgeBundleReason.RuntimeHits, edges2, DateTimeOffset.UtcNow); // Act var hash1 = bundle1.ComputeContentHash(); var hash2 = bundle2.ComputeContentHash(); // Assert - different edges should produce different hashes Assert.NotEqual(hash1, hash2); } [Trait("Category", TestCategories.Unit)] [Fact] public void EdgeBundleBuilder_EnforcesMaxEdgeLimit() { // Arrange var builder = new EdgeBundleBuilder(TestGraphHash).WithReason(EdgeBundleReason.RuntimeHits); // Act - add max edges for (var i = 0; i < EdgeBundleConstants.MaxEdgesPerBundle; i++) { builder.AddEdge(new BundledEdge($"func_{i}", $"func_{i + 1}", "call", EdgeReason.RuntimeHit, false, 0.9, null, null, null)); } // Assert - should throw when exceeding limit Assert.Throws(() => builder.AddEdge(new BundledEdge("func_overflow", "func_target", "call", EdgeReason.RuntimeHit, false, 0.9, null, null, null))); } [Trait("Category", TestCategories.Unit)] [Fact] public void EdgeBundleBuilder_Build_CreatesDeterministicBundleId() { // Arrange var builder1 = new EdgeBundleBuilder(TestGraphHash).WithReason(EdgeBundleReason.InitArray); var builder2 = new EdgeBundleBuilder(TestGraphHash).WithReason(EdgeBundleReason.InitArray); builder1.AddEdge(new BundledEdge("init_a", "func_b", "call", EdgeReason.InitArray, false, 1.0, null, null, null)); builder2.AddEdge(new BundledEdge("init_a", "func_b", "call", EdgeReason.InitArray, false, 1.0, null, null, null)); // Act var bundle1 = builder1.Build(); var bundle2 = builder2.Build(); // Assert - same inputs should produce same bundle ID Assert.Equal(bundle1.BundleId, bundle2.BundleId); Assert.StartsWith("bundle:", bundle1.BundleId); } [Trait("Category", TestCategories.Unit)] [Fact] public void BundledEdge_Trimmed_NormalizesValues() { // Arrange var edge = new BundledEdge( From: " func_a ", To: " func_b ", Kind: " call ", Reason: EdgeReason.RuntimeHit, Revoked: false, Confidence: 1.5, // Should be clamped to 1.0 Purl: " pkg:npm/test@1.0.0 ", SymbolDigest: " sha256:abc ", Evidence: " cas://evidence/123 "); // Act var trimmed = edge.Trimmed(); // Assert Assert.Equal("func_a", trimmed.From); Assert.Equal("func_b", trimmed.To); Assert.Equal("call", trimmed.Kind); Assert.Equal(1.0, trimmed.Confidence); // Clamped Assert.Equal("pkg:npm/test@1.0.0", trimmed.Purl); Assert.Equal("sha256:abc", trimmed.SymbolDigest); Assert.Equal("cas://evidence/123", trimmed.Evidence); } [Trait("Category", TestCategories.Unit)] [Fact] public void BundledEdge_Trimmed_HandlesNullableFields() { // Arrange var edge = new BundledEdge("func_a", "func_b", "", EdgeReason.RuntimeHit, false, 0.5, null, " ", null); // Act var trimmed = edge.Trimmed(); // Assert Assert.Equal("call", trimmed.Kind); // Default when empty Assert.Null(trimmed.Purl); Assert.Null(trimmed.SymbolDigest); // Whitespace trimmed to null Assert.Null(trimmed.Evidence); } } public class EdgeBundleExtractorTests { private const string TestGraphHash = "blake3:abc123def456"; private static RichGraph CreateTestGraph(params RichGraphEdge[] edges) { var nodes = edges .SelectMany(e => new[] { e.From, e.To }) .Distinct() .Select(id => new RichGraphNode(id, id, null, null, "native", "function", id, null, null, null, null, null, null)) .ToList(); return new RichGraph(nodes, edges.ToList(), new List(), new RichGraphAnalyzer("test", "1.0", null)); } [Trait("Category", TestCategories.Unit)] [Fact] public void ExtractContestedBundle_ReturnsLowConfidenceEdges() { // Arrange var edges = new[] { new RichGraphEdge("func_a", "func_b", "call", null, null, null, 0.9, null), new RichGraphEdge("func_b", "func_c", "call", null, null, null, 0.4, null), // Low confidence new RichGraphEdge("func_c", "func_d", "call", null, null, null, 0.3, null), // Low confidence }; var graph = CreateTestGraph(edges); // Act var bundle = EdgeBundleExtractor.ExtractContestedBundle(graph, TestGraphHash, confidenceThreshold: 0.5); // Assert Assert.NotNull(bundle); Assert.Equal(EdgeBundleReason.Contested, bundle.BundleReason); Assert.Equal(2, bundle.Edges.Count); Assert.All(bundle.Edges, e => Assert.Equal(EdgeReason.LowConfidence, e.Reason)); } [Trait("Category", TestCategories.Unit)] [Fact] public void ExtractContestedBundle_ReturnsNullWhenNoLowConfidenceEdges() { // Arrange var edges = new[] { new RichGraphEdge("func_a", "func_b", "call", null, null, null, 0.9, null), new RichGraphEdge("func_b", "func_c", "call", null, null, null, 0.8, null), }; var graph = CreateTestGraph(edges); // Act var bundle = EdgeBundleExtractor.ExtractContestedBundle(graph, TestGraphHash, confidenceThreshold: 0.5); // Assert Assert.Null(bundle); } [Trait("Category", TestCategories.Unit)] [Fact] public void ExtractThirdPartyBundle_ReturnsEdgesWithPurl() { // Arrange var edges = new[] { new RichGraphEdge("func_a", "func_b", "call", "pkg:npm/lodash@4.17.0", null, null, 0.9, null), new RichGraphEdge("func_b", "func_c", "call", "pkg:unknown", null, null, 0.8, null), // Excluded new RichGraphEdge("func_c", "func_d", "call", null, null, null, 0.7, null), // Excluded new RichGraphEdge("func_d", "func_e", "call", "pkg:npm/express@4.0.0", null, null, 0.9, null), }; var graph = CreateTestGraph(edges); // Act var bundle = EdgeBundleExtractor.ExtractThirdPartyBundle(graph, TestGraphHash); // Assert Assert.NotNull(bundle); Assert.Equal(EdgeBundleReason.ThirdParty, bundle.BundleReason); Assert.Equal(2, bundle.Edges.Count); Assert.All(bundle.Edges, e => Assert.Equal(EdgeReason.ThirdPartyCall, e.Reason)); } [Trait("Category", TestCategories.Unit)] [Fact] public void ExtractRevokedBundle_ReturnsEdgesToRevokedTargets() { // Arrange var edges = new[] { new RichGraphEdge("func_a", "func_b", "call", null, null, null, 0.9, null), new RichGraphEdge("func_b", "func_c", "call", null, null, null, 0.8, null), new RichGraphEdge("func_c", "func_d", "call", null, null, null, 0.7, null), }; var graph = CreateTestGraph(edges); var revokedTargets = new HashSet { "func_c", "func_d" }; // Act var bundle = EdgeBundleExtractor.ExtractRevokedBundle(graph, TestGraphHash, revokedTargets); // Assert Assert.NotNull(bundle); Assert.Equal(EdgeBundleReason.Revoked, bundle.BundleReason); Assert.Equal(2, bundle.Edges.Count); Assert.All(bundle.Edges, e => { Assert.Equal(EdgeReason.Revoked, e.Reason); Assert.True(e.Revoked); }); } [Trait("Category", TestCategories.Unit)] [Fact] public void ExtractRuntimeHitsBundle_ReturnsProvidedEdges() { // Arrange var runtimeEdges = new List { new("func_a", "func_b", "call", EdgeReason.RuntimeHit, false, 1.0, null, null, "evidence_1"), new("func_b", "func_c", "call", EdgeReason.RuntimeHit, false, 1.0, null, null, "evidence_2"), }; // Act var bundle = EdgeBundleExtractor.ExtractRuntimeHitsBundle(runtimeEdges, TestGraphHash); // Assert Assert.NotNull(bundle); Assert.Equal(EdgeBundleReason.RuntimeHits, bundle.BundleReason); Assert.Equal(2, bundle.Edges.Count); Assert.All(bundle.Edges, e => Assert.Equal(EdgeReason.RuntimeHit, e.Reason)); } [Trait("Category", TestCategories.Unit)] [Fact] public void ExtractRuntimeHitsBundle_ReturnsNullForEmptyList() { // Act var bundle = EdgeBundleExtractor.ExtractRuntimeHitsBundle(new List(), TestGraphHash); // Assert Assert.Null(bundle); } [Trait("Category", TestCategories.Unit)] [Fact] public void ExtractInitArrayBundle_ReturnsEdgesFromInitRoots() { // Arrange var edges = new[] { new RichGraphEdge("init_func", "target_a", "call", null, null, null, 1.0, null), new RichGraphEdge("init_func", "target_b", "call", null, null, null, 1.0, null), new RichGraphEdge("main_func", "target_c", "call", null, null, null, 0.9, null), // Not from init }; var nodes = edges .SelectMany(e => new[] { e.From, e.To }) .Distinct() .Select(id => new RichGraphNode(id, id, null, null, "native", "function", id, null, null, null, null, null, null)) .ToList(); var roots = new List { new("init_func", "init", ".init_array") }; var graph = new RichGraph(nodes, edges.ToList(), roots, new RichGraphAnalyzer("test", "1.0", null)); // Act var bundle = EdgeBundleExtractor.ExtractInitArrayBundle(graph, TestGraphHash); // Assert Assert.NotNull(bundle); Assert.Equal(EdgeBundleReason.InitArray, bundle.BundleReason); Assert.Equal(2, bundle.Edges.Count); } } public class EdgeBundlePublisherTests { private const string TestGraphHash = "blake3:abc123def456"; private static CancellationToken TestCancellationToken => TestContext.Current.CancellationToken; [Trait("Category", TestCategories.Unit)] [Fact] public async Task PublishAsync_StoresBundleAndDsseInCas() { // Arrange var cas = new FakeFileContentAddressableStore(); var publisher = new EdgeBundlePublisher(); var edges = new List { new("func_a", "func_b", "call", EdgeReason.RuntimeHit, false, 0.9, "pkg:npm/test@1.0.0", "sha256:abc", null), }; var bundle = new EdgeBundle("bundle:test123", TestGraphHash, EdgeBundleReason.RuntimeHits, edges, DateTimeOffset.UtcNow); // Act var result = await publisher.PublishAsync(bundle, cas, TestCancellationToken); // Assert Assert.NotNull(result); Assert.Equal("bundle:test123", result.BundleId); Assert.Equal(TestGraphHash, result.GraphHash); Assert.Equal(EdgeBundleReason.RuntimeHits, result.BundleReason); Assert.Equal(1, result.EdgeCount); Assert.StartsWith("sha256:", result.ContentHash); Assert.StartsWith("sha256:", result.DsseDigest); // Verify CAS paths Assert.Contains("/edges/", result.CasUri); Assert.Contains("/edges/", result.DsseCasUri); Assert.EndsWith(".dsse", result.DsseCasUri); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task PublishAsync_DsseContainsValidPayload() { // Arrange var cas = new FakeFileContentAddressableStore(); var publisher = new EdgeBundlePublisher(); var edges = new List { new("func_a", "func_b", "call", EdgeReason.RuntimeHit, false, 0.9, null, null, null), new("func_b", "func_c", "call", EdgeReason.ThirdPartyCall, true, 0.8, null, null, null), }; var bundle = new EdgeBundle("bundle:test456", TestGraphHash, EdgeBundleReason.RuntimeHits, edges, DateTimeOffset.UtcNow); // Act var result = await publisher.PublishAsync(bundle, cas, TestCancellationToken); // Assert - verify DSSE was stored var dsseKey = result.DsseRelativePath.Replace(".zip", ""); var dsseBytes = cas.GetBytes(dsseKey); Assert.NotNull(dsseBytes); // Parse DSSE envelope var dsseJson = System.Text.Encoding.UTF8.GetString(dsseBytes); var envelope = JsonDocument.Parse(dsseJson); Assert.Equal("application/vnd.stellaops.edgebundle.predicate+json", envelope.RootElement.GetProperty("payloadType").GetString()); Assert.True(envelope.RootElement.TryGetProperty("payload", out _)); Assert.True(envelope.RootElement.TryGetProperty("signatures", out var signatures)); Assert.Single(signatures.EnumerateArray()); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task PublishAsync_BundleJsonContainsAllFields() { // Arrange var cas = new FakeFileContentAddressableStore(); var publisher = new EdgeBundlePublisher(); var edges = new List { new("func_a", "func_b", "call", EdgeReason.Revoked, true, 0.5, "pkg:npm/test@1.0.0", "sha256:digest", "cas://evidence/123"), }; var bundle = new EdgeBundle("bundle:revoked", TestGraphHash, EdgeBundleReason.Revoked, edges, DateTimeOffset.UtcNow); // Act var result = await publisher.PublishAsync(bundle, cas, TestCancellationToken); // Assert - verify bundle JSON was stored var bundleKey = result.RelativePath.Replace(".zip", ""); var bundleBytes = cas.GetBytes(bundleKey); Assert.NotNull(bundleBytes); // Parse bundle JSON var bundleJsonStr = System.Text.Encoding.UTF8.GetString(bundleBytes); var bundleJson = JsonDocument.Parse(bundleJsonStr); Assert.Equal("edge-bundle-v1", bundleJson.RootElement.GetProperty("schema").GetString()); Assert.Equal("Revoked", bundleJson.RootElement.GetProperty("bundleReason").GetString()); var edgesArray = bundleJson.RootElement.GetProperty("edges"); Assert.Single(edgesArray.EnumerateArray()); var edge = edgesArray[0]; Assert.Equal("func_a", edge.GetProperty("from").GetString()); Assert.Equal("func_b", edge.GetProperty("to").GetString()); Assert.Equal("Revoked", edge.GetProperty("reason").GetString()); Assert.True(edge.GetProperty("revoked").GetBoolean()); Assert.Equal("pkg:npm/test@1.0.0", edge.GetProperty("purl").GetString()); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task PublishAsync_CasPathFollowsContract() { // Arrange var cas = new FakeFileContentAddressableStore(); var publisher = new EdgeBundlePublisher(); var edges = new List { new("func_a", "func_b", "call", EdgeReason.InitArray, false, 1.0, null, null, null), }; var bundle = new EdgeBundle("bundle:init123", TestGraphHash, EdgeBundleReason.InitArray, edges, DateTimeOffset.UtcNow); // Act var result = await publisher.PublishAsync(bundle, cas, TestCancellationToken); // Assert - CAS path follows contract: cas://reachability/edges/{graph_hash}/{bundle_id} var expectedGraphHashDigest = "abc123def456"; // Graph hash without prefix Assert.StartsWith($"cas://reachability/edges/{expectedGraphHashDigest}/", result.CasUri); Assert.StartsWith($"cas://reachability/edges/{expectedGraphHashDigest}/", result.DsseCasUri); Assert.EndsWith(".dsse", result.DsseCasUri); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task PublishAsync_ProducesDeterministicResults() { // Arrange var cas1 = new FakeFileContentAddressableStore(); var cas2 = new FakeFileContentAddressableStore(); var publisher = new EdgeBundlePublisher(); var edges = new List { new("func_a", "func_b", "call", EdgeReason.RuntimeHit, false, 0.9, null, null, null), }; var bundle1 = new EdgeBundle("bundle:det", TestGraphHash, EdgeBundleReason.RuntimeHits, edges, DateTimeOffset.UtcNow); var bundle2 = new EdgeBundle("bundle:det", TestGraphHash, EdgeBundleReason.RuntimeHits, edges, DateTimeOffset.UtcNow.AddHours(1)); // Act var result1 = await publisher.PublishAsync(bundle1, cas1, TestCancellationToken); var result2 = await publisher.PublishAsync(bundle2, cas2, TestCancellationToken); // Assert - content hash should be same for same content Assert.Equal(result1.ContentHash, result2.ContentHash); } }