using System.Collections.Immutable; using System.Text.Json.Nodes; using StellaOps.Graph.Indexer.Documents; using StellaOps.Graph.Indexer.Ingestion.Sbom; using StellaOps.Graph.Indexer.Schema; using StellaOps.TestKit; namespace StellaOps.Graph.Indexer.Tests; public sealed class SbomSnapshotExporterTests { [Trait("Category", TestCategories.Unit)] [Fact] public async Task ExportAsync_WritesCanonicalFilesWithStableHash() { var snapshot = new SbomSnapshot { Tenant = "tenant-c", ArtifactDigest = "sha256:artifact-c", SbomDigest = "sha256:sbom-c", BaseArtifacts = Array.Empty() }; var nodes = new[] { CreateArtifactNode("art", snapshot.ArtifactDigest, snapshot.SbomDigest), CreateComponentNode("comp", "pkg:npm/test@1.0.0") }.ToImmutableArray(); var edges = new[] { CreateEdge("edge-1", source: "art", target: "comp") }.ToImmutableArray(); var batch = new GraphBuildBatch(nodes, edges); var exporter = new SbomSnapshotExporter(new GraphSnapshotBuilder(), new FileSystemSnapshotFileWriter(_tempRoot)); await exporter.ExportAsync(snapshot, batch, CancellationToken.None); var manifestPath = Path.Combine(_tempRoot, "manifest.json"); var manifestJson = JsonNode.Parse(await File.ReadAllTextAsync(manifestPath))!.AsObject(); // Hash in manifest should equal recomputed canonical hash. var computed = GraphIdentity.ComputeDocumentHash(manifestJson); Assert.Equal(computed, manifestJson["hash"]!.GetValue()); // Adjacency should contain both nodes and edges, deterministic ids. var adjacency = JsonNode.Parse(await File.ReadAllTextAsync(Path.Combine(_tempRoot, "adjacency.json")))!.AsObject(); var nodesArray = adjacency["nodes"]!.AsArray(); Assert.Equal(2, nodesArray.Count); Assert.Equal("art", nodesArray[0]! ["node_id"]!.GetValue()); // nodes.jsonl and edges.jsonl should both exist and be non-empty. Assert.True(new FileInfo(Path.Combine(_tempRoot, "nodes.jsonl")).Length > 0); Assert.True(new FileInfo(Path.Combine(_tempRoot, "edges.jsonl")).Length > 0); } public SbomSnapshotExporterTests() { _tempRoot = Path.Combine(Path.GetTempPath(), "graph-snapshot-tests", Guid.NewGuid().ToString("n")); Directory.CreateDirectory(_tempRoot); } public void Dispose() { try { Directory.Delete(_tempRoot, recursive: true); } catch { /* ignore */ } } private readonly string _tempRoot; private static JsonObject CreateArtifactNode(string id, string artifactDigest, string sbomDigest) { var attributes = new JsonObject { ["artifact_digest"] = artifactDigest, ["sbom_digest"] = sbomDigest }; return new JsonObject { ["id"] = id, ["kind"] = "artifact", ["attributes"] = attributes }; } private static JsonObject CreateComponentNode(string id, string purl) { var attributes = new JsonObject { ["purl"] = purl }; return new JsonObject { ["id"] = id, ["kind"] = "component", ["attributes"] = attributes }; } private static JsonObject CreateEdge(string id, string source, string target) { return new JsonObject { ["id"] = id, ["source"] = source, ["target"] = target }; } }