using System.Collections.Generic; using System.Collections.Immutable; using System.IO; using System.Linq; using System.Text.Json; using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; using FluentAssertions; using StellaOps.Graph.Indexer.Documents; using StellaOps.Graph.Indexer.Ingestion.Advisory; using StellaOps.Graph.Indexer.Ingestion.Policy; using StellaOps.Graph.Indexer.Ingestion.Sbom; using StellaOps.Graph.Indexer.Ingestion.Vex; using Xunit; namespace StellaOps.Graph.Indexer.Tests; public sealed class SbomSnapshotExporterTests { private static readonly string FixturesRoot = Path.Combine(AppContext.BaseDirectory, "Fixtures", "v1"); [Fact] public async Task ExportAsync_writes_manifest_adjacency_nodes_and_edges() { var sbomSnapshot = Load("sbom-snapshot.json"); var linksetSnapshot = Load("concelier-linkset.json"); var vexSnapshot = Load("excititor-vex.json"); var policySnapshot = Load("policy-overlay.json"); var sbomBatch = new SbomIngestTransformer().Transform(sbomSnapshot); var advisoryBatch = new AdvisoryLinksetTransformer().Transform(linksetSnapshot); var vexBatch = new VexOverlayTransformer().Transform(vexSnapshot); var policyBatch = new PolicyOverlayTransformer().Transform(policySnapshot); var combinedBatch = MergeBatches(sbomBatch, advisoryBatch, vexBatch, policyBatch); var builder = new GraphSnapshotBuilder(); var writer = new InMemorySnapshotFileWriter(); var exporter = new SbomSnapshotExporter(builder, writer); await exporter.ExportAsync(sbomSnapshot, combinedBatch, CancellationToken.None); writer.JsonFiles.Should().ContainKey("manifest.json"); writer.JsonFiles.Should().ContainKey("adjacency.json"); writer.JsonLinesFiles.Should().ContainKey("nodes.jsonl"); writer.JsonLinesFiles.Should().ContainKey("edges.jsonl"); var manifest = writer.JsonFiles["manifest.json"]; manifest["tenant"]!.GetValue().Should().Be("tenant-alpha"); manifest["node_count"]!.GetValue().Should().Be(combinedBatch.Nodes.Length); manifest["edge_count"]!.GetValue().Should().Be(combinedBatch.Edges.Length); manifest["hash"]!.GetValue().Should().NotBeNullOrEmpty(); var adjacency = writer.JsonFiles["adjacency.json"]; adjacency["tenant"]!.GetValue().Should().Be("tenant-alpha"); adjacency["nodes"]!.AsArray().Should().HaveCount(combinedBatch.Nodes.Length); writer.JsonLinesFiles["nodes.jsonl"].Should().HaveCount(combinedBatch.Nodes.Length); writer.JsonLinesFiles["edges.jsonl"].Should().HaveCount(combinedBatch.Edges.Length); } private static GraphBuildBatch MergeBatches(params GraphBuildBatch[] batches) { var nodes = new Dictionary(StringComparer.Ordinal); var edges = new Dictionary(StringComparer.Ordinal); foreach (var batch in batches) { foreach (var node in batch.Nodes) { nodes[node["id"]!.GetValue()] = node; } foreach (var edge in batch.Edges) { edges[edge["id"]!.GetValue()] = edge; } } var orderedNodes = nodes.Values .OrderBy(node => node["kind"]!.GetValue(), StringComparer.Ordinal) .ThenBy(node => node["id"]!.GetValue(), StringComparer.Ordinal) .ToImmutableArray(); var orderedEdges = edges.Values .OrderBy(edge => edge["kind"]!.GetValue(), StringComparer.Ordinal) .ThenBy(edge => edge["id"]!.GetValue(), StringComparer.Ordinal) .ToImmutableArray(); return new GraphBuildBatch(orderedNodes, orderedEdges); } private static T Load(string fixtureFile) { var path = Path.Combine(FixturesRoot, fixtureFile); var json = File.ReadAllText(path); return JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; } private sealed class InMemorySnapshotFileWriter : ISnapshotFileWriter { public Dictionary JsonFiles { get; } = new(StringComparer.Ordinal); public Dictionary> JsonLinesFiles { get; } = new(StringComparer.Ordinal); public Task WriteJsonAsync(string relativePath, JsonObject content, CancellationToken cancellationToken) { JsonFiles[relativePath] = (JsonObject)content.DeepClone(); return Task.CompletedTask; } public Task WriteJsonLinesAsync(string relativePath, IEnumerable items, CancellationToken cancellationToken) { JsonLinesFiles[relativePath] = items .Select(item => (JsonObject)item.DeepClone()) .ToList(); return Task.CompletedTask; } } }