// ============================================================================= // SpdxParserTests.cs // Golden-file tests for SPDX SBOM parsing // Part of Task T24: Golden-file tests for determinism // ============================================================================= using FluentAssertions; using StellaOps.AirGap.Importer.Reconciliation; using StellaOps.AirGap.Importer.Reconciliation.Parsers; namespace StellaOps.AirGap.Importer.Tests.Reconciliation; public sealed class SpdxParserTests { private static readonly string FixturesPath = Path.Combine( AppDomain.CurrentDomain.BaseDirectory, "Reconciliation", "Fixtures"); [Fact] public async Task ParseAsync_ValidSpdx_ExtractsAllSubjects() { // Arrange var parser = new SpdxParser(); var filePath = Path.Combine(FixturesPath, "sample.spdx.json"); if (!File.Exists(filePath)) { return; } // Act var result = await parser.ParseAsync(filePath); // Assert result.IsSuccess.Should().BeTrue(); result.Format.Should().Be(SbomFormat.Spdx); result.SpecVersion.Should().Be("2.3"); result.SerialNumber.Should().Be("https://example.com/test-app/1.0.0"); result.GeneratorTool.Should().Contain("syft"); // Should have 3 packages with SHA256 checksums result.Subjects.Should().HaveCount(3); // Verify subjects are sorted by digest result.Subjects.Should().BeInAscendingOrder(s => s.Digest, StringComparer.Ordinal); } [Fact] public async Task ParseAsync_ExtractsPrimarySubject() { // Arrange var parser = new SpdxParser(); var filePath = Path.Combine(FixturesPath, "sample.spdx.json"); if (!File.Exists(filePath)) { return; } // Act var result = await parser.ParseAsync(filePath); // Assert result.PrimarySubject.Should().NotBeNull(); result.PrimarySubject!.Name.Should().Be("test-app"); result.PrimarySubject.Version.Should().Be("1.0.0"); result.PrimarySubject.SpdxId.Should().Be("SPDXRef-Package-test-app"); } [Fact] public async Task ParseAsync_ExtractsPurls() { // Arrange var parser = new SpdxParser(); var filePath = Path.Combine(FixturesPath, "sample.spdx.json"); if (!File.Exists(filePath)) { return; } // Act var result = await parser.ParseAsync(filePath); // Assert - check for components with purls var zlib = result.Subjects.FirstOrDefault(s => s.Name == "zlib"); zlib.Should().NotBeNull(); zlib!.Purl.Should().Be("pkg:generic/zlib@1.2.11"); } [Fact] public async Task ParseAsync_SubjectDigestsAreNormalized() { // Arrange var parser = new SpdxParser(); var filePath = Path.Combine(FixturesPath, "sample.spdx.json"); if (!File.Exists(filePath)) { return; } // Act var result = await parser.ParseAsync(filePath); // Assert - all digests should be normalized sha256:lowercase format foreach (var subject in result.Subjects) { subject.Digest.Should().StartWith("sha256:"); subject.Digest[7..].Should().MatchRegex("^[a-f0-9]{64}$"); } } [Fact] public void DetectFormat_SpdxFile_ReturnsSpdx() { var parser = new SpdxParser(); parser.DetectFormat("test.spdx.json").Should().Be(SbomFormat.Spdx); } [Fact] public void DetectFormat_NonSpdxFile_ReturnsUnknown() { var parser = new SpdxParser(); parser.DetectFormat("test.cdx.json").Should().Be(SbomFormat.Unknown); parser.DetectFormat("test.json").Should().Be(SbomFormat.Unknown); } [Fact] public async Task ParseAsync_Deterministic_SameOutputForSameInput() { // Arrange var parser = new SpdxParser(); var filePath = Path.Combine(FixturesPath, "sample.spdx.json"); if (!File.Exists(filePath)) { return; } // Act - parse twice var result1 = await parser.ParseAsync(filePath); var result2 = await parser.ParseAsync(filePath); // Assert - results should be identical and in same order result1.Subjects.Select(s => s.Digest).Should().Equal(result2.Subjects.Select(s => s.Digest)); result1.Subjects.Select(s => s.Name).Should().Equal(result2.Subjects.Select(s => s.Name)); } }