// ============================================================================= // CycloneDxParserTests.cs // Golden-file tests for CycloneDX 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 CycloneDxParserTests { private static readonly string FixturesPath = Path.Combine( AppDomain.CurrentDomain.BaseDirectory, "Reconciliation", "Fixtures"); [Fact] public async Task ParseAsync_ValidCycloneDx_ExtractsAllSubjects() { // Arrange var parser = new CycloneDxParser(); var filePath = Path.Combine(FixturesPath, "sample.cdx.json"); // Skip if fixtures not available if (!File.Exists(filePath)) { return; } // Act var result = await parser.ParseAsync(filePath); // Assert result.IsSuccess.Should().BeTrue(); result.Format.Should().Be(SbomFormat.CycloneDx); result.SpecVersion.Should().Be("1.6"); result.SerialNumber.Should().Be("urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79"); result.GeneratorTool.Should().Contain("syft"); // Should have 3 subjects with SHA-256 hashes (primary + 2 components) 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 CycloneDxParser(); var filePath = Path.Combine(FixturesPath, "sample.cdx.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.Digest.Should().StartWith("sha256:"); } [Fact] public async Task ParseAsync_SubjectDigestsAreNormalized() { // Arrange var parser = new CycloneDxParser(); var filePath = Path.Combine(FixturesPath, "sample.cdx.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_CycloneDxFile_ReturnsCycloneDx() { var parser = new CycloneDxParser(); parser.DetectFormat("test.cdx.json").Should().Be(SbomFormat.CycloneDx); parser.DetectFormat("test.bom.json").Should().Be(SbomFormat.CycloneDx); } [Fact] public void DetectFormat_NonCycloneDxFile_ReturnsUnknown() { var parser = new CycloneDxParser(); parser.DetectFormat("test.spdx.json").Should().Be(SbomFormat.Unknown); parser.DetectFormat("test.json").Should().Be(SbomFormat.Unknown); } [Fact] public async Task ParseAsync_Deterministic_SameOutputForSameInput() { // Arrange var parser = new CycloneDxParser(); var filePath = Path.Combine(FixturesPath, "sample.cdx.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 result1.Subjects.Select(s => s.Digest) .Should().BeEquivalentTo(result2.Subjects.Select(s => s.Digest)); result1.Subjects.Select(s => s.Name) .Should().BeEquivalentTo(result2.Subjects.Select(s => s.Name)); // Order should be the same result1.Subjects.Select(s => s.Digest).Should().Equal(result2.Subjects.Select(s => s.Digest)); } }