using System.Collections.Generic; using System.Linq; using System.Text.Json; using System.Text.Json.Nodes; using FluentAssertions; using StellaOps.Graph.Indexer.Ingestion.Sbom; using Xunit; using Xunit.Abstractions; namespace StellaOps.Graph.Indexer.Tests; public sealed class SbomIngestTransformerTests { private readonly ITestOutputHelper _output; public SbomIngestTransformerTests(ITestOutputHelper output) { _output = output; } private static readonly string FixturesRoot = Path.Combine(AppContext.BaseDirectory, "Fixtures", "v1"); private static readonly HashSet ExpectedNodeKinds = new(StringComparer.Ordinal) { "artifact", "component", "file" }; private static readonly HashSet ExpectedEdgeKinds = new(StringComparer.Ordinal) { "CONTAINS", "DEPENDS_ON", "DECLARED_IN", "BUILT_FROM" }; [Fact] public void Transform_produces_expected_nodes_and_edges() { var snapshot = LoadSnapshot("sbom-snapshot.json"); var transformer = new SbomIngestTransformer(); var batch = transformer.Transform(snapshot); var expectedNodes = LoadArray("nodes.json") .Cast() .Where(node => ExpectedNodeKinds.Contains(node["kind"]!.GetValue())) .OrderBy(node => node["id"]!.GetValue(), StringComparer.Ordinal) .ToArray(); var expectedEdges = LoadArray("edges.json") .Cast() .Where(edge => ExpectedEdgeKinds.Contains(edge["kind"]!.GetValue())) .OrderBy(edge => edge["id"]!.GetValue(), StringComparer.Ordinal) .ToArray(); var actualNodes = batch.Nodes .Where(node => ExpectedNodeKinds.Contains(node["kind"]!.GetValue())) .OrderBy(node => node["id"]!.GetValue(), StringComparer.Ordinal) .ToArray(); var actualEdges = batch.Edges .Where(edge => ExpectedEdgeKinds.Contains(edge["kind"]!.GetValue())) .OrderBy(edge => edge["id"]!.GetValue(), StringComparer.Ordinal) .ToArray(); actualNodes.Length.Should().Be(expectedNodes.Length); actualEdges.Length.Should().Be(expectedEdges.Length); for (var i = 0; i < expectedNodes.Length; i++) { if (!JsonNode.DeepEquals(expectedNodes[i], actualNodes[i])) { _output.WriteLine($"Expected Node: {expectedNodes[i]}"); _output.WriteLine($"Actual Node: {actualNodes[i]}"); } JsonNode.DeepEquals(expectedNodes[i], actualNodes[i]).Should().BeTrue(); } for (var i = 0; i < expectedEdges.Length; i++) { if (!JsonNode.DeepEquals(expectedEdges[i], actualEdges[i])) { _output.WriteLine($"Expected Edge: {expectedEdges[i]}"); _output.WriteLine($"Actual Edge: {actualEdges[i]}"); } JsonNode.DeepEquals(expectedEdges[i], actualEdges[i]).Should().BeTrue(); } } [Fact] public void Transform_deduplicates_license_nodes_case_insensitive() { var baseCollectedAt = DateTimeOffset.Parse("2025-10-30T12:00:00Z"); var components = new[] { CreateComponent( purl: "pkg:nuget/Example.Primary@1.0.0", spdx: "MIT", sourceDigest: "sha256:license001", collectedAt: baseCollectedAt.AddSeconds(1), eventOffset: 1201, source: "scanner.component.v1"), CreateComponent( purl: "pkg:nuget/Example.Secondary@2.0.0", spdx: "mit", sourceDigest: "SHA256:LICENSE001", collectedAt: baseCollectedAt.AddSeconds(2), eventOffset: 1202, usage: "transitive", source: "scanner.component.v1") }; var snapshot = CreateSnapshot(components: components); var transformer = new SbomIngestTransformer(); var batch = transformer.Transform(snapshot); var licenseNodes = batch.Nodes .Where(node => string.Equals(node["kind"]!.GetValue(), "license", StringComparison.Ordinal)) .ToArray(); licenseNodes.Should().HaveCount(1); var canonicalKey = licenseNodes[0]["canonical_key"]!.AsObject(); canonicalKey["license_spdx"]!.GetValue().Should().Be("MIT"); canonicalKey["source_digest"]!.GetValue().Should().Be("sha256:license001"); } [Fact] public void Transform_emits_built_from_edge_with_provenance() { var snapshot = LoadSnapshot("sbom-snapshot.json"); var transformer = new SbomIngestTransformer(); var batch = transformer.Transform(snapshot); var builtFrom = batch.Edges.Single(edge => edge["kind"]!.GetValue() == "BUILT_FROM"); var attributes = builtFrom["attributes"]!.AsObject(); attributes["build_type"]!.GetValue().Should().Be(snapshot.Build.BuildType); attributes["builder_id"]!.GetValue().Should().Be(snapshot.Build.BuilderId); attributes["attestation_digest"]!.GetValue().Should().Be(snapshot.Build.AttestationDigest); var provenance = builtFrom["provenance"]!.AsObject(); provenance["source"]!.GetValue().Should().Be(snapshot.Build.Source); provenance["collected_at"]!.GetValue() .Should().Be(snapshot.Build.CollectedAt.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ssZ")); var canonicalKey = builtFrom["canonical_key"]!.AsObject(); canonicalKey.ContainsKey("parent_artifact_node_id").Should().BeTrue(); canonicalKey.ContainsKey("child_artifact_digest").Should().BeTrue(); } [Fact] public void Transform_normalizes_valid_from_to_utc() { var componentCollectedAt = new DateTimeOffset(2025, 11, 1, 15, 30, 45, TimeSpan.FromHours(2)); var components = new[] { CreateComponent( purl: "pkg:nuget/Example.Primary@1.0.0", spdx: "Apache-2.0", sourceDigest: "sha256:license002", collectedAt: componentCollectedAt, eventOffset: 2101, source: "scanner.component.v1") }; var snapshot = CreateSnapshot( components: components, collectedAt: componentCollectedAt.AddSeconds(-1), eventOffset: 2000); var transformer = new SbomIngestTransformer(); var batch = transformer.Transform(snapshot); var componentNode = batch.Nodes.Single(node => node["kind"]!.GetValue() == "component"); componentNode["valid_from"]!.GetValue().Should().Be("2025-11-01T13:30:45Z"); var containsEdge = batch.Edges.Single(edge => edge["kind"]!.GetValue() == "CONTAINS"); containsEdge["valid_from"]!.GetValue().Should().Be("2025-11-01T13:30:46Z"); } private static SbomSnapshot CreateSnapshot( IEnumerable? components = null, IEnumerable? baseArtifacts = null, DateTimeOffset? collectedAt = null, long eventOffset = 1000, string? source = null, SbomArtifactMetadata? artifact = null, SbomBuildMetadata? build = null) { return new SbomSnapshot { Tenant = "tenant-alpha", Source = source ?? "scanner.sbom.v1", ArtifactDigest = "sha256:test-artifact", SbomDigest = "sha256:test-sbom", CollectedAt = collectedAt ?? DateTimeOffset.Parse("2025-10-30T12:00:00Z"), EventOffset = eventOffset, Artifact = artifact ?? new SbomArtifactMetadata { DisplayName = "registry.example.com/app:latest", Environment = "prod", Labels = new[] { "critical" }, OriginRegistry = "registry.example.com", SupplyChainStage = "deploy" }, Build = build ?? new SbomBuildMetadata { BuilderId = "builder://tekton/default", BuildType = "https://slsa.dev/provenance/v1", AttestationDigest = "sha256:attestation", Source = "scanner.build.v1", CollectedAt = (collectedAt ?? DateTimeOffset.Parse("2025-10-30T12:00:00Z")).AddSeconds(5), EventOffset = eventOffset + 100 }, Components = (components ?? Array.Empty()).ToArray(), BaseArtifacts = (baseArtifacts ?? Array.Empty()).ToArray() }; } private static SbomComponent CreateComponent( string purl, string spdx, string sourceDigest, DateTimeOffset collectedAt, long eventOffset, string version = "1.0.0", string usage = "direct", string? source = null, string detectedBy = "sbom.analyzer.transformer", string scope = "runtime", IEnumerable? files = null, IEnumerable? dependencies = null) { return new SbomComponent { Purl = purl, Version = version, Ecosystem = "nuget", Scope = scope, License = new SbomLicense { Spdx = spdx, Name = $"{spdx} License", Classification = "permissive", SourceDigest = sourceDigest, NoticeUri = null }, Usage = usage, DetectedBy = detectedBy, LayerDigest = "sha256:layer", EvidenceDigest = "sha256:evidence", CollectedAt = collectedAt, EventOffset = eventOffset, Source = source ?? "scanner.component.v1", Files = (files ?? Array.Empty()).ToArray(), Dependencies = (dependencies ?? Array.Empty()).ToArray(), SourceType = "inventory" }; } private static SbomSnapshot LoadSnapshot(string fileName) { var path = Path.Combine(FixturesRoot, fileName); var json = File.ReadAllText(path); return JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; } private static JsonArray LoadArray(string fileName) { var path = Path.Combine(FixturesRoot, fileName); return (JsonArray)JsonNode.Parse(File.ReadAllText(path))!; } }