// ----------------------------------------------------------------------------- // SbomParserTests.cs // Sprint: SPRINT_8200_0013_0003_SCAN_sbom_intersection_scoring // Task: SBOM-8200-007 // Description: Unit tests for SBOM parsing and PURL extraction // Supports CycloneDX 1.4-1.7 and SPDX 2.2-2.3, 3.0 // ----------------------------------------------------------------------------- using System.Text; using FluentAssertions; using Microsoft.Extensions.Logging; using Moq; using StellaOps.Concelier.SbomIntegration.Models; using StellaOps.Concelier.SbomIntegration.Parsing; using Xunit; using StellaOps.TestKit; namespace StellaOps.Concelier.SbomIntegration.Tests; public class SbomParserTests { private readonly SbomParser _parser; public SbomParserTests() { var loggerMock = new Mock>(); _parser = new SbomParser(loggerMock.Object); } #region CycloneDX Tests [Trait("Category", TestCategories.Unit)] [Fact] public async Task ParseAsync_CycloneDX_ExtractsPurls() { // Arrange var cycloneDxContent = """ { "bomFormat": "CycloneDX", "specVersion": "1.6", "version": 1, "metadata": { "component": { "type": "application", "name": "myapp", "version": "1.0.0" } }, "components": [ { "type": "library", "name": "lodash", "version": "4.17.21", "purl": "pkg:npm/lodash@4.17.21" }, { "type": "library", "name": "express", "version": "4.18.2", "purl": "pkg:npm/express@4.18.2" } ] } """; using var stream = new MemoryStream(Encoding.UTF8.GetBytes(cycloneDxContent)); // Act var result = await _parser.ParseAsync(stream, SbomFormat.CycloneDX); // Assert result.Should().NotBeNull(); result.PrimaryName.Should().Be("myapp"); result.PrimaryVersion.Should().Be("1.0.0"); result.Purls.Should().HaveCount(2); result.Purls.Should().Contain("pkg:npm/lodash@4.17.21"); result.Purls.Should().Contain("pkg:npm/express@4.18.2"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ParseAsync_CycloneDX_HandlesNestedComponents() { // Arrange var cycloneDxContent = """ { "bomFormat": "CycloneDX", "specVersion": "1.5", "components": [ { "type": "library", "name": "parent", "version": "1.0.0", "purl": "pkg:npm/parent@1.0.0", "components": [ { "type": "library", "name": "child", "version": "2.0.0", "purl": "pkg:npm/child@2.0.0" } ] } ] } """; using var stream = new MemoryStream(Encoding.UTF8.GetBytes(cycloneDxContent)); // Act var result = await _parser.ParseAsync(stream, SbomFormat.CycloneDX); // Assert result.Purls.Should().Contain("pkg:npm/parent@1.0.0"); result.Purls.Should().Contain("pkg:npm/child@2.0.0"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ParseAsync_CycloneDX_SkipsComponentsWithoutPurl() { // Arrange var cycloneDxContent = """ { "bomFormat": "CycloneDX", "specVersion": "1.6", "components": [ { "type": "library", "name": "with-purl", "version": "1.0.0", "purl": "pkg:npm/with-purl@1.0.0" }, { "type": "library", "name": "without-purl", "version": "1.0.0" } ] } """; using var stream = new MemoryStream(Encoding.UTF8.GetBytes(cycloneDxContent)); // Act var result = await _parser.ParseAsync(stream, SbomFormat.CycloneDX); // Assert result.Purls.Should().HaveCount(1); result.Purls.Should().Contain("pkg:npm/with-purl@1.0.0"); result.UnresolvedComponents.Should().HaveCount(1); result.UnresolvedComponents[0].Name.Should().Be("without-purl"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ParseAsync_CycloneDX_DeduplicatesPurls() { // Arrange var cycloneDxContent = """ { "bomFormat": "CycloneDX", "specVersion": "1.6", "components": [ { "type": "library", "purl": "pkg:npm/lodash@4.17.21" }, { "type": "library", "purl": "pkg:npm/lodash@4.17.21" } ] } """; using var stream = new MemoryStream(Encoding.UTF8.GetBytes(cycloneDxContent)); // Act var result = await _parser.ParseAsync(stream, SbomFormat.CycloneDX); // Assert result.Purls.Should().HaveCount(1); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ParseAsync_CycloneDX17_ExtractsPurls() { // Arrange - CycloneDX 1.7 format var cycloneDxContent = """ { "bomFormat": "CycloneDX", "specVersion": "1.7", "version": 1, "metadata": { "component": { "type": "application", "name": "myapp", "version": "2.0.0" } }, "components": [ { "type": "library", "name": "axios", "version": "1.6.0", "purl": "pkg:npm/axios@1.6.0" } ] } """; using var stream = new MemoryStream(Encoding.UTF8.GetBytes(cycloneDxContent)); // Act var result = await _parser.ParseAsync(stream, SbomFormat.CycloneDX); // Assert result.Should().NotBeNull(); result.PrimaryName.Should().Be("myapp"); result.Purls.Should().Contain("pkg:npm/axios@1.6.0"); } #endregion #region SPDX Tests [Trait("Category", TestCategories.Unit)] [Fact] public async Task ParseAsync_SPDX_ExtractsPurls() { // Arrange var spdxContent = """ { "spdxVersion": "SPDX-2.3", "SPDXID": "SPDXRef-DOCUMENT", "name": "myapp-sbom", "packages": [ { "SPDXID": "SPDXRef-Package-npm-lodash", "name": "lodash", "versionInfo": "4.17.21", "externalRefs": [ { "referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", "referenceLocator": "pkg:npm/lodash@4.17.21" } ] }, { "SPDXID": "SPDXRef-Package-npm-express", "name": "express", "versionInfo": "4.18.2", "externalRefs": [ { "referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", "referenceLocator": "pkg:npm/express@4.18.2" } ] } ] } """; using var stream = new MemoryStream(Encoding.UTF8.GetBytes(spdxContent)); // Act var result = await _parser.ParseAsync(stream, SbomFormat.SPDX); // Assert result.Purls.Should().HaveCount(2); result.Purls.Should().Contain("pkg:npm/lodash@4.17.21"); result.Purls.Should().Contain("pkg:npm/express@4.18.2"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ParseAsync_SPDX_IgnoresNonPurlExternalRefs() { // Arrange var spdxContent = """ { "spdxVersion": "SPDX-2.3", "packages": [ { "SPDXID": "SPDXRef-Package", "name": "mypackage", "externalRefs": [ { "referenceCategory": "SECURITY", "referenceType": "cpe23Type", "referenceLocator": "cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*" }, { "referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", "referenceLocator": "pkg:npm/mypackage@1.0.0" } ] } ] } """; using var stream = new MemoryStream(Encoding.UTF8.GetBytes(spdxContent)); // Act var result = await _parser.ParseAsync(stream, SbomFormat.SPDX); // Assert result.Purls.Should().HaveCount(1); result.Purls.Should().Contain("pkg:npm/mypackage@1.0.0"); result.Cpes.Should().HaveCount(1); result.Cpes.Should().Contain("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*"); } #endregion #region Format Detection Tests [Trait("Category", TestCategories.Unit)] [Theory] [InlineData("1.4")] [InlineData("1.5")] [InlineData("1.6")] [InlineData("1.7")] public async Task DetectFormatAsync_CycloneDX_DetectsAllVersions(string specVersion) { // Arrange var content = $$""" { "bomFormat": "CycloneDX", "specVersion": "{{specVersion}}", "components": [] } """; using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content)); // Act var result = await _parser.DetectFormatAsync(stream); // Assert result.IsDetected.Should().BeTrue(); result.Format.Should().Be(SbomFormat.CycloneDX); result.SpecVersion.Should().Be(specVersion); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task DetectFormatAsync_SPDX2_DetectsFormat() { // Arrange var content = """ { "spdxVersion": "SPDX-2.3", "packages": [] } """; using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content)); // Act var result = await _parser.DetectFormatAsync(stream); // Assert result.IsDetected.Should().BeTrue(); result.Format.Should().Be(SbomFormat.SPDX); result.SpecVersion.Should().Be("SPDX-2.3"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task DetectFormatAsync_UnknownFormat_ReturnsNotDetected() { // Arrange var content = """ { "unknownField": "value" } """; using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content)); // Act var result = await _parser.DetectFormatAsync(stream); // Assert result.IsDetected.Should().BeFalse(); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task DetectFormatAsync_InvalidJson_ReturnsNotDetected() { // Arrange var content = "not valid json {{{"; using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content)); // Act var result = await _parser.DetectFormatAsync(stream); // Assert result.IsDetected.Should().BeFalse(); } #endregion #region PURL Ecosystem Tests [Trait("Category", TestCategories.Unit)] [Theory] [InlineData("pkg:npm/lodash@4.17.21")] [InlineData("pkg:pypi/requests@2.28.0")] [InlineData("pkg:maven/org.apache.commons/commons-lang3@3.12.0")] [InlineData("pkg:nuget/Newtonsoft.Json@13.0.1")] [InlineData("pkg:cargo/serde@1.0.150")] [InlineData("pkg:golang/github.com/gin-gonic/gin@1.9.0")] [InlineData("pkg:gem/rails@7.0.4")] [InlineData("pkg:deb/debian/openssl@1.1.1n-0+deb11u3")] [InlineData("pkg:rpm/fedora/kernel@5.19.0-43.fc37")] [InlineData("pkg:apk/alpine/openssl@1.1.1q-r0")] public async Task ParseAsync_CycloneDX_SupportsVariousEcosystems(string purl) { // Arrange var content = $$""" { "bomFormat": "CycloneDX", "specVersion": "1.6", "components": [ { "type": "library", "purl": "{{purl}}" } ] } """; using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content)); // Act var result = await _parser.ParseAsync(stream, SbomFormat.CycloneDX); // Assert result.Purls.Should().Contain(purl); } #endregion #region Edge Cases [Trait("Category", TestCategories.Unit)] [Fact] public async Task ParseAsync_EmptyComponents_ReturnsEmptyPurls() { // Arrange var content = """ { "bomFormat": "CycloneDX", "specVersion": "1.6", "components": [] } """; using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content)); // Act var result = await _parser.ParseAsync(stream, SbomFormat.CycloneDX); // Assert result.Purls.Should().BeEmpty(); result.TotalComponents.Should().Be(0); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ParseAsync_NullStream_ThrowsArgumentNullException() { // Act & Assert await Assert.ThrowsAsync(() => _parser.ParseAsync(null!, SbomFormat.CycloneDX)); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ParseAsync_ExtractsCpes() { // Arrange var content = """ { "bomFormat": "CycloneDX", "specVersion": "1.6", "components": [ { "type": "library", "name": "openssl", "cpe": "cpe:2.3:a:openssl:openssl:1.1.1:*:*:*:*:*:*:*", "purl": "pkg:deb/debian/openssl@1.1.1" } ] } """; using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content)); // Act var result = await _parser.ParseAsync(stream, SbomFormat.CycloneDX); // Assert result.Cpes.Should().HaveCount(1); result.Cpes.Should().Contain("cpe:2.3:a:openssl:openssl:1.1.1:*:*:*:*:*:*:*"); } #endregion }