using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using System.Text.Json; using StellaOps.Scanner.Core.Contracts; using StellaOps.Scanner.Emit.Composition; using Xunit; namespace StellaOps.Scanner.Emit.Tests.Composition; public sealed class SpdxComposerTests { [Fact] public void Compose_ProducesJsonLdArtifact() { var request = BuildRequest(); var composer = new SpdxComposer(); var result = composer.Compose(request, new SpdxCompositionOptions()); Assert.Equal("application/spdx+json; version=3.0.1", result.JsonMediaType); Assert.Equal(result.JsonSha256, result.ContentHash); Assert.Equal(64, result.JsonSha256.Length); Assert.Null(result.TagValueBytes); using var document = JsonDocument.Parse(result.JsonBytes); var root = document.RootElement; Assert.Equal("https://spdx.org/rdf/3.0.1/spdx-context.jsonld", root.GetProperty("@context").GetString()); var graph = root.GetProperty("@graph").EnumerateArray().ToArray(); Assert.NotEmpty(graph); var docNode = graph.Single(node => node.GetProperty("type").GetString() == "SpdxDocument"); var rootElement = docNode.GetProperty("rootElement").EnumerateArray().Select(element => element.GetString()).ToArray(); Assert.Single(rootElement); var packages = graph .Where(node => node.GetProperty("type").GetString() == "software_Package") .ToArray(); Assert.Equal(3, packages.Length); var lodash = packages.Single(node => node.GetProperty("name").GetString() == "component-b"); Assert.Equal("pkg:npm/b@2.0.0", lodash.GetProperty("software_packageUrl").GetString()); } [Fact] public void Compose_WithTagValue_IncludesLegacyOutput() { var request = BuildRequest(); var composer = new SpdxComposer(); var result = composer.Compose(request, new SpdxCompositionOptions { IncludeTagValue = true }); Assert.NotNull(result.TagValueBytes); var tagValue = System.Text.Encoding.UTF8.GetString(result.TagValueBytes!); Assert.Contains("SPDXVersion: SPDX-2.3", tagValue, StringComparison.Ordinal); Assert.Contains("DocumentNamespace:", tagValue, StringComparison.Ordinal); } [Fact] public void Compose_IsDeterministic() { var request = BuildRequest(); var composer = new SpdxComposer(); var first = composer.Compose(request, new SpdxCompositionOptions()); var second = composer.Compose(request, new SpdxCompositionOptions()); Assert.Equal(first.JsonSha256, second.JsonSha256); } private static SbomCompositionRequest BuildRequest() { var fragments = new[] { LayerComponentFragment.Create("sha256:layer1", new[] { new ComponentRecord { Identity = ComponentIdentity.Create("pkg:npm/a", "component-a", "1.0.0", "pkg:npm/a@1.0.0", "library"), LayerDigest = "sha256:layer1", Evidence = ImmutableArray.Create(ComponentEvidence.FromPath("/app/node_modules/a/package.json")), Dependencies = ImmutableArray.Create("pkg:npm/b"), Usage = ComponentUsage.Create(true, new[] { "/app/start.sh" }), Metadata = new ComponentMetadata { Scope = "runtime", Licenses = new[] { "MIT" }, Properties = new Dictionary { ["stellaops:source"] = "package-lock.json", }, BuildId = "ABCDEF1234567890ABCDEF1234567890ABCDEF12", }, } }), LayerComponentFragment.Create("sha256:layer2", new[] { new ComponentRecord { Identity = ComponentIdentity.Create("pkg:npm/b", "component-b", "2.0.0", "pkg:npm/b@2.0.0", "library"), LayerDigest = "sha256:layer2", Evidence = ImmutableArray.Create(ComponentEvidence.FromPath("/app/node_modules/b/package.json")), Usage = ComponentUsage.Create(false), Metadata = new ComponentMetadata { Scope = "development", Licenses = new[] { "Apache-2.0" }, Properties = new Dictionary { ["stellaops.os.analyzer"] = "language-node", }, }, } }) }; var image = new ImageArtifactDescriptor { ImageDigest = "sha256:1234567890abcdef", ImageReference = "registry.example.com/app/service:1.2.3", Repository = "registry.example.com/app/service", Tag = "1.2.3", Architecture = "amd64", }; return SbomCompositionRequest.Create( image, fragments, new DateTimeOffset(2025, 10, 19, 12, 0, 0, TimeSpan.Zero), generatorName: "StellaOps.Scanner", generatorVersion: "0.10.0", properties: new Dictionary { ["stellaops:scanId"] = "scan-1234", }); } }