Add unit tests for SBOM ingestion and transformation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Implement `SbomIngestServiceCollectionExtensionsTests` to verify the SBOM ingestion pipeline exports snapshots correctly. - Create `SbomIngestTransformerTests` to ensure the transformation produces expected nodes and edges, including deduplication of license nodes and normalization of timestamps. - Add `SbomSnapshotExporterTests` to test the export functionality for manifest, adjacency, nodes, and edges. - Introduce `VexOverlayTransformerTests` to validate the transformation of VEX nodes and edges. - Set up project file for the test project with necessary dependencies and configurations. - Include JSON fixture files for testing purposes.
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
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<string> ExpectedNodeKinds = new(StringComparer.Ordinal)
|
||||
{
|
||||
"artifact",
|
||||
"component",
|
||||
"file"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> 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<JsonObject>()
|
||||
.Where(node => ExpectedNodeKinds.Contains(node["kind"]!.GetValue<string>()))
|
||||
.OrderBy(node => node["id"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var expectedEdges = LoadArray("edges.json")
|
||||
.Cast<JsonObject>()
|
||||
.Where(edge => ExpectedEdgeKinds.Contains(edge["kind"]!.GetValue<string>()))
|
||||
.OrderBy(edge => edge["id"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var actualNodes = batch.Nodes
|
||||
.Where(node => ExpectedNodeKinds.Contains(node["kind"]!.GetValue<string>()))
|
||||
.OrderBy(node => node["id"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var actualEdges = batch.Edges
|
||||
.Where(edge => ExpectedEdgeKinds.Contains(edge["kind"]!.GetValue<string>()))
|
||||
.OrderBy(edge => edge["id"]!.GetValue<string>(), 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<string>(), "license", StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
|
||||
licenseNodes.Should().HaveCount(1);
|
||||
var canonicalKey = licenseNodes[0]["canonical_key"]!.AsObject();
|
||||
canonicalKey["license_spdx"]!.GetValue<string>().Should().Be("MIT");
|
||||
canonicalKey["source_digest"]!.GetValue<string>().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<string>() == "BUILT_FROM");
|
||||
|
||||
var attributes = builtFrom["attributes"]!.AsObject();
|
||||
attributes["build_type"]!.GetValue<string>().Should().Be(snapshot.Build.BuildType);
|
||||
attributes["builder_id"]!.GetValue<string>().Should().Be(snapshot.Build.BuilderId);
|
||||
attributes["attestation_digest"]!.GetValue<string>().Should().Be(snapshot.Build.AttestationDigest);
|
||||
|
||||
var provenance = builtFrom["provenance"]!.AsObject();
|
||||
provenance["source"]!.GetValue<string>().Should().Be(snapshot.Build.Source);
|
||||
provenance["collected_at"]!.GetValue<string>()
|
||||
.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<string>() == "component");
|
||||
componentNode["valid_from"]!.GetValue<string>().Should().Be("2025-11-01T13:30:45Z");
|
||||
|
||||
var containsEdge = batch.Edges.Single(edge => edge["kind"]!.GetValue<string>() == "CONTAINS");
|
||||
containsEdge["valid_from"]!.GetValue<string>().Should().Be("2025-11-01T13:30:46Z");
|
||||
}
|
||||
|
||||
private static SbomSnapshot CreateSnapshot(
|
||||
IEnumerable<SbomComponent>? components = null,
|
||||
IEnumerable<SbomBaseArtifact>? 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<SbomComponent>()).ToArray(),
|
||||
BaseArtifacts = (baseArtifacts ?? Array.Empty<SbomBaseArtifact>()).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<SbomComponentFile>? files = null,
|
||||
IEnumerable<SbomDependency>? 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<SbomComponentFile>()).ToArray(),
|
||||
Dependencies = (dependencies ?? Array.Empty<SbomDependency>()).ToArray(),
|
||||
SourceType = "inventory"
|
||||
};
|
||||
}
|
||||
|
||||
private static SbomSnapshot LoadSnapshot(string fileName)
|
||||
{
|
||||
var path = Path.Combine(FixturesRoot, fileName);
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<SbomSnapshot>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
})!;
|
||||
}
|
||||
|
||||
private static JsonArray LoadArray(string fileName)
|
||||
{
|
||||
var path = Path.Combine(FixturesRoot, fileName);
|
||||
return (JsonArray)JsonNode.Parse(File.ReadAllText(path))!;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user