save dev progress
This commit is contained in:
@@ -0,0 +1,503 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Tests;
|
||||
|
||||
public class SbomParserTests
|
||||
{
|
||||
private readonly SbomParser _parser;
|
||||
|
||||
public SbomParserTests()
|
||||
{
|
||||
var loggerMock = new Mock<ILogger<SbomParser>>();
|
||||
_parser = new SbomParser(loggerMock.Object);
|
||||
}
|
||||
|
||||
#region CycloneDX Tests
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
|
||||
[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
|
||||
|
||||
[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
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_NullStream_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
_parser.ParseAsync(null!, SbomFormat.CycloneDX));
|
||||
}
|
||||
|
||||
[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
|
||||
}
|
||||
Reference in New Issue
Block a user