save dev progress
This commit is contained in:
@@ -0,0 +1,477 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SbomAdvisoryMatcherTests.cs
|
||||
// Sprint: SPRINT_8200_0013_0003_SCAN_sbom_intersection_scoring
|
||||
// Task: SBOM-8200-012
|
||||
// Description: Unit tests for SBOM advisory matching with various ecosystems
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using StellaOps.Concelier.Core.Canonical;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Tests;
|
||||
|
||||
public class SbomAdvisoryMatcherTests
|
||||
{
|
||||
private readonly Mock<ICanonicalAdvisoryService> _canonicalServiceMock;
|
||||
private readonly Mock<ILogger<SbomAdvisoryMatcher>> _loggerMock;
|
||||
private readonly SbomAdvisoryMatcher _matcher;
|
||||
|
||||
public SbomAdvisoryMatcherTests()
|
||||
{
|
||||
_canonicalServiceMock = new Mock<ICanonicalAdvisoryService>();
|
||||
_loggerMock = new Mock<ILogger<SbomAdvisoryMatcher>>();
|
||||
_matcher = new SbomAdvisoryMatcher(_canonicalServiceMock.Object, _loggerMock.Object);
|
||||
}
|
||||
|
||||
#region Basic Matching Tests
|
||||
|
||||
[Fact]
|
||||
public async Task MatchAsync_WithVulnerablePurl_ReturnsMatch()
|
||||
{
|
||||
// Arrange
|
||||
var sbomId = Guid.NewGuid();
|
||||
var canonicalId = Guid.NewGuid();
|
||||
var purls = new List<string> { "pkg:npm/lodash@4.17.20" };
|
||||
|
||||
var advisory = CreateCanonicalAdvisory(canonicalId, "CVE-2021-23337", "pkg:npm/lodash@4.17.20");
|
||||
|
||||
_canonicalServiceMock
|
||||
.Setup(s => s.GetByArtifactAsync("pkg:npm/lodash@4.17.20", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<CanonicalAdvisory> { advisory });
|
||||
|
||||
// Act
|
||||
var result = await _matcher.MatchAsync(sbomId, "sha256:abc", purls, null, null);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(1);
|
||||
result[0].SbomId.Should().Be(sbomId);
|
||||
result[0].CanonicalId.Should().Be(canonicalId);
|
||||
result[0].Purl.Should().Be("pkg:npm/lodash@4.17.20");
|
||||
result[0].SbomDigest.Should().Be("sha256:abc");
|
||||
result[0].Method.Should().Be(MatchMethod.ExactPurl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MatchAsync_WithMultipleVulnerablePurls_ReturnsAllMatches()
|
||||
{
|
||||
// Arrange
|
||||
var sbomId = Guid.NewGuid();
|
||||
var canonicalId1 = Guid.NewGuid();
|
||||
var canonicalId2 = Guid.NewGuid();
|
||||
var purls = new List<string>
|
||||
{
|
||||
"pkg:npm/lodash@4.17.20",
|
||||
"pkg:npm/express@4.17.0"
|
||||
};
|
||||
|
||||
var advisory1 = CreateCanonicalAdvisory(canonicalId1, "CVE-2021-23337", "pkg:npm/lodash@4.17.20");
|
||||
var advisory2 = CreateCanonicalAdvisory(canonicalId2, "CVE-2021-12345", "pkg:npm/express@4.17.0");
|
||||
|
||||
_canonicalServiceMock
|
||||
.Setup(s => s.GetByArtifactAsync("pkg:npm/lodash@4.17.20", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<CanonicalAdvisory> { advisory1 });
|
||||
|
||||
_canonicalServiceMock
|
||||
.Setup(s => s.GetByArtifactAsync("pkg:npm/express@4.17.0", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<CanonicalAdvisory> { advisory2 });
|
||||
|
||||
// Act
|
||||
var result = await _matcher.MatchAsync(sbomId, "sha256:abc", purls, null, null);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
result.Should().Contain(m => m.CanonicalId == canonicalId1);
|
||||
result.Should().Contain(m => m.CanonicalId == canonicalId2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MatchAsync_WithSafePurl_ReturnsNoMatches()
|
||||
{
|
||||
// Arrange
|
||||
var sbomId = Guid.NewGuid();
|
||||
var purls = new List<string> { "pkg:npm/lodash@4.17.21" }; // Fixed version
|
||||
|
||||
_canonicalServiceMock
|
||||
.Setup(s => s.GetByArtifactAsync("pkg:npm/lodash@4.17.21", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<CanonicalAdvisory>());
|
||||
|
||||
// Act
|
||||
var result = await _matcher.MatchAsync(sbomId, "sha256:abc", purls, null, null);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MatchAsync_PurlAffectedByMultipleAdvisories_ReturnsMultipleMatches()
|
||||
{
|
||||
// Arrange
|
||||
var sbomId = Guid.NewGuid();
|
||||
var canonicalId1 = Guid.NewGuid();
|
||||
var canonicalId2 = Guid.NewGuid();
|
||||
var purls = new List<string> { "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1" };
|
||||
|
||||
var advisory1 = CreateCanonicalAdvisory(canonicalId1, "CVE-2021-44228", "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1");
|
||||
var advisory2 = CreateCanonicalAdvisory(canonicalId2, "CVE-2021-45046", "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1");
|
||||
|
||||
_canonicalServiceMock
|
||||
.Setup(s => s.GetByArtifactAsync("pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<CanonicalAdvisory> { advisory1, advisory2 });
|
||||
|
||||
// Act
|
||||
var result = await _matcher.MatchAsync(sbomId, "sha256:abc", purls, null, null);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
result.Select(m => m.CanonicalId).Should().Contain(canonicalId1);
|
||||
result.Select(m => m.CanonicalId).Should().Contain(canonicalId2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Reachability Tests
|
||||
|
||||
[Fact]
|
||||
public async Task MatchAsync_WithReachabilityMap_SetsIsReachable()
|
||||
{
|
||||
// Arrange
|
||||
var sbomId = Guid.NewGuid();
|
||||
var canonicalId = Guid.NewGuid();
|
||||
var purls = new List<string> { "pkg:npm/lodash@4.17.20" };
|
||||
var reachabilityMap = new Dictionary<string, bool>
|
||||
{
|
||||
["pkg:npm/lodash@4.17.20"] = true
|
||||
};
|
||||
|
||||
var advisory = CreateCanonicalAdvisory(canonicalId, "CVE-2021-23337", "pkg:npm/lodash@4.17.20");
|
||||
|
||||
_canonicalServiceMock
|
||||
.Setup(s => s.GetByArtifactAsync("pkg:npm/lodash@4.17.20", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<CanonicalAdvisory> { advisory });
|
||||
|
||||
// Act
|
||||
var result = await _matcher.MatchAsync(sbomId, "sha256:abc", purls, reachabilityMap, null);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(1);
|
||||
result[0].IsReachable.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MatchAsync_WithDeploymentMap_SetsIsDeployed()
|
||||
{
|
||||
// Arrange
|
||||
var sbomId = Guid.NewGuid();
|
||||
var canonicalId = Guid.NewGuid();
|
||||
var purls = new List<string> { "pkg:npm/lodash@4.17.20" };
|
||||
var deploymentMap = new Dictionary<string, bool>
|
||||
{
|
||||
["pkg:npm/lodash@4.17.20"] = true
|
||||
};
|
||||
|
||||
var advisory = CreateCanonicalAdvisory(canonicalId, "CVE-2021-23337", "pkg:npm/lodash@4.17.20");
|
||||
|
||||
_canonicalServiceMock
|
||||
.Setup(s => s.GetByArtifactAsync("pkg:npm/lodash@4.17.20", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<CanonicalAdvisory> { advisory });
|
||||
|
||||
// Act
|
||||
var result = await _matcher.MatchAsync(sbomId, "sha256:abc", purls, null, deploymentMap);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(1);
|
||||
result[0].IsDeployed.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MatchAsync_PurlNotInReachabilityMap_DefaultsToFalse()
|
||||
{
|
||||
// Arrange
|
||||
var sbomId = Guid.NewGuid();
|
||||
var canonicalId = Guid.NewGuid();
|
||||
var purls = new List<string> { "pkg:npm/lodash@4.17.20" };
|
||||
var reachabilityMap = new Dictionary<string, bool>
|
||||
{
|
||||
["pkg:npm/other@1.0.0"] = true // Different package
|
||||
};
|
||||
|
||||
var advisory = CreateCanonicalAdvisory(canonicalId, "CVE-2021-23337", "pkg:npm/lodash@4.17.20");
|
||||
|
||||
_canonicalServiceMock
|
||||
.Setup(s => s.GetByArtifactAsync("pkg:npm/lodash@4.17.20", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<CanonicalAdvisory> { advisory });
|
||||
|
||||
// Act
|
||||
var result = await _matcher.MatchAsync(sbomId, "sha256:abc", purls, reachabilityMap, null);
|
||||
|
||||
// Assert
|
||||
result[0].IsReachable.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Ecosystem Coverage Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("pkg:npm/lodash@4.17.20", "npm")]
|
||||
[InlineData("pkg:pypi/requests@2.27.0", "pypi")]
|
||||
[InlineData("pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1", "maven")]
|
||||
[InlineData("pkg:nuget/Newtonsoft.Json@12.0.3", "nuget")]
|
||||
[InlineData("pkg:cargo/serde@1.0.100", "cargo")]
|
||||
[InlineData("pkg:golang/github.com/gin-gonic/gin@1.8.0", "golang")]
|
||||
[InlineData("pkg:gem/rails@6.1.0", "gem")]
|
||||
public async Task MatchAsync_SupportsVariousEcosystems(string purl, string ecosystem)
|
||||
{
|
||||
// Arrange
|
||||
var sbomId = Guid.NewGuid();
|
||||
var canonicalId = Guid.NewGuid();
|
||||
|
||||
var advisory = CreateCanonicalAdvisory(canonicalId, $"CVE-2024-{ecosystem}", purl);
|
||||
|
||||
_canonicalServiceMock
|
||||
.Setup(s => s.GetByArtifactAsync(purl, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<CanonicalAdvisory> { advisory });
|
||||
|
||||
// Act
|
||||
var result = await _matcher.MatchAsync(sbomId, "sha256:abc", new List<string> { purl }, null, null);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(1);
|
||||
result[0].Purl.Should().Be(purl);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[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 MatchAsync_SupportsOsPackages(string purl)
|
||||
{
|
||||
// Arrange
|
||||
var sbomId = Guid.NewGuid();
|
||||
var canonicalId = Guid.NewGuid();
|
||||
|
||||
var advisory = CreateCanonicalAdvisory(canonicalId, "CVE-2024-OS", purl);
|
||||
|
||||
_canonicalServiceMock
|
||||
.Setup(s => s.GetByArtifactAsync(purl, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<CanonicalAdvisory> { advisory });
|
||||
|
||||
// Act
|
||||
var result = await _matcher.MatchAsync(sbomId, "sha256:abc", new List<string> { purl }, null, null);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases
|
||||
|
||||
[Fact]
|
||||
public async Task MatchAsync_EmptyPurlList_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var sbomId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
var result = await _matcher.MatchAsync(sbomId, "sha256:abc", new List<string>(), null, null);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MatchAsync_ServiceThrowsException_LogsAndContinues()
|
||||
{
|
||||
// Arrange
|
||||
var sbomId = Guid.NewGuid();
|
||||
var canonicalId = Guid.NewGuid();
|
||||
var purls = new List<string>
|
||||
{
|
||||
"pkg:npm/failing@1.0.0",
|
||||
"pkg:npm/succeeding@1.0.0"
|
||||
};
|
||||
|
||||
var advisory = CreateCanonicalAdvisory(canonicalId, "CVE-2024-SUCCESS", "pkg:npm/succeeding@1.0.0");
|
||||
|
||||
_canonicalServiceMock
|
||||
.Setup(s => s.GetByArtifactAsync("pkg:npm/failing@1.0.0", It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Service error"));
|
||||
|
||||
_canonicalServiceMock
|
||||
.Setup(s => s.GetByArtifactAsync("pkg:npm/succeeding@1.0.0", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<CanonicalAdvisory> { advisory });
|
||||
|
||||
// Act
|
||||
var result = await _matcher.MatchAsync(sbomId, "sha256:abc", purls, null, null);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(1);
|
||||
result[0].Purl.Should().Be("pkg:npm/succeeding@1.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MatchAsync_LargePurlList_ProcessesEfficiently()
|
||||
{
|
||||
// Arrange
|
||||
var sbomId = Guid.NewGuid();
|
||||
var purls = Enumerable.Range(1, 1000)
|
||||
.Select(i => $"pkg:npm/package{i}@1.0.0")
|
||||
.ToList();
|
||||
|
||||
_canonicalServiceMock
|
||||
.Setup(s => s.GetByArtifactAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<CanonicalAdvisory>());
|
||||
|
||||
// Act
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var result = await _matcher.MatchAsync(sbomId, "sha256:abc", purls, null, null);
|
||||
sw.Stop();
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
sw.ElapsedMilliseconds.Should().BeLessThan(5000); // Reasonable timeout
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MatchAsync_SetsMatchedAtTimestamp()
|
||||
{
|
||||
// Arrange
|
||||
var sbomId = Guid.NewGuid();
|
||||
var canonicalId = Guid.NewGuid();
|
||||
var purls = new List<string> { "pkg:npm/lodash@4.17.20" };
|
||||
var before = DateTimeOffset.UtcNow;
|
||||
|
||||
var advisory = CreateCanonicalAdvisory(canonicalId, "CVE-2021-23337", "pkg:npm/lodash@4.17.20");
|
||||
|
||||
_canonicalServiceMock
|
||||
.Setup(s => s.GetByArtifactAsync("pkg:npm/lodash@4.17.20", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<CanonicalAdvisory> { advisory });
|
||||
|
||||
// Act
|
||||
var result = await _matcher.MatchAsync(sbomId, "sha256:abc", purls, null, null);
|
||||
var after = DateTimeOffset.UtcNow;
|
||||
|
||||
// Assert
|
||||
result[0].MatchedAt.Should().BeOnOrAfter(before);
|
||||
result[0].MatchedAt.Should().BeOnOrBefore(after);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region FindAffectingCanonicalIdsAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task FindAffectingCanonicalIdsAsync_ReturnsDistinctIds()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalId1 = Guid.NewGuid();
|
||||
var canonicalId2 = Guid.NewGuid();
|
||||
var purl = "pkg:npm/vulnerable@1.0.0";
|
||||
|
||||
var advisory1 = CreateCanonicalAdvisory(canonicalId1, "CVE-2024-0001", purl);
|
||||
var advisory2 = CreateCanonicalAdvisory(canonicalId2, "CVE-2024-0002", purl);
|
||||
|
||||
_canonicalServiceMock
|
||||
.Setup(s => s.GetByArtifactAsync(purl, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<CanonicalAdvisory> { advisory1, advisory2 });
|
||||
|
||||
// Act
|
||||
var result = await _matcher.FindAffectingCanonicalIdsAsync(purl);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
result.Should().Contain(canonicalId1);
|
||||
result.Should().Contain(canonicalId2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindAffectingCanonicalIdsAsync_EmptyPurl_ReturnsEmpty()
|
||||
{
|
||||
// Act
|
||||
var result = await _matcher.FindAffectingCanonicalIdsAsync("");
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CheckMatchAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CheckMatchAsync_AffectedPurl_ReturnsMatch()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalId = Guid.NewGuid();
|
||||
var purl = "pkg:npm/lodash@4.17.20";
|
||||
|
||||
var advisory = CreateCanonicalAdvisory(canonicalId, "CVE-2021-23337", purl);
|
||||
|
||||
_canonicalServiceMock
|
||||
.Setup(s => s.GetByIdAsync(canonicalId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(advisory);
|
||||
|
||||
// Act
|
||||
var result = await _matcher.CheckMatchAsync(purl, canonicalId);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.CanonicalId.Should().Be(canonicalId);
|
||||
result.Purl.Should().Be(purl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckMatchAsync_AdvisoryNotFound_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalId = Guid.NewGuid();
|
||||
|
||||
_canonicalServiceMock
|
||||
.Setup(s => s.GetByIdAsync(canonicalId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((CanonicalAdvisory?)null);
|
||||
|
||||
// Act
|
||||
var result = await _matcher.CheckMatchAsync("pkg:npm/lodash@4.17.21", canonicalId);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckMatchAsync_EmptyPurl_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
var result = await _matcher.CheckMatchAsync("", canonicalId);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static CanonicalAdvisory CreateCanonicalAdvisory(Guid id, string cve, string affectsKey)
|
||||
{
|
||||
return new CanonicalAdvisory
|
||||
{
|
||||
Id = id,
|
||||
Cve = cve,
|
||||
AffectsKey = affectsKey,
|
||||
MergeHash = $"hash-{id}",
|
||||
Status = CanonicalStatus.Active,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SbomRegistryServiceTests.cs
|
||||
// Sprint: SPRINT_8200_0013_0003_SCAN_sbom_intersection_scoring
|
||||
// Task: SBOM-8200-007
|
||||
// Description: Unit tests for SBOM registration and learning
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using StellaOps.Concelier.Interest;
|
||||
using StellaOps.Concelier.SbomIntegration.Events;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using StellaOps.Messaging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Tests;
|
||||
|
||||
public class SbomRegistryServiceTests
|
||||
{
|
||||
private readonly Mock<ISbomRegistryRepository> _repositoryMock;
|
||||
private readonly Mock<ISbomAdvisoryMatcher> _matcherMock;
|
||||
private readonly Mock<IInterestScoringService> _scoringServiceMock;
|
||||
private readonly Mock<ILogger<SbomRegistryService>> _loggerMock;
|
||||
private readonly Mock<IEventStream<SbomLearnedEvent>> _eventStreamMock;
|
||||
private readonly SbomRegistryService _service;
|
||||
|
||||
public SbomRegistryServiceTests()
|
||||
{
|
||||
_repositoryMock = new Mock<ISbomRegistryRepository>();
|
||||
_matcherMock = new Mock<ISbomAdvisoryMatcher>();
|
||||
_scoringServiceMock = new Mock<IInterestScoringService>();
|
||||
_loggerMock = new Mock<ILogger<SbomRegistryService>>();
|
||||
_eventStreamMock = new Mock<IEventStream<SbomLearnedEvent>>();
|
||||
|
||||
_service = new SbomRegistryService(
|
||||
_repositoryMock.Object,
|
||||
_matcherMock.Object,
|
||||
_scoringServiceMock.Object,
|
||||
_loggerMock.Object,
|
||||
_eventStreamMock.Object);
|
||||
}
|
||||
|
||||
#region RegisterSbomAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterSbomAsync_NewSbom_CreatesRegistration()
|
||||
{
|
||||
// Arrange
|
||||
var input = new SbomRegistrationInput
|
||||
{
|
||||
Digest = "sha256:abc123",
|
||||
Format = SbomFormat.CycloneDX,
|
||||
SpecVersion = "1.6",
|
||||
PrimaryName = "myapp",
|
||||
PrimaryVersion = "1.0.0",
|
||||
Purls = ["pkg:npm/lodash@4.17.21", "pkg:npm/express@4.18.2"],
|
||||
Source = "scanner",
|
||||
TenantId = "tenant-1"
|
||||
};
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByDigestAsync(input.Digest, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((SbomRegistration?)null);
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.SaveAsync(It.IsAny<SbomRegistration>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
var result = await _service.RegisterSbomAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Digest.Should().Be(input.Digest);
|
||||
result.Format.Should().Be(SbomFormat.CycloneDX);
|
||||
result.SpecVersion.Should().Be("1.6");
|
||||
result.PrimaryName.Should().Be("myapp");
|
||||
result.ComponentCount.Should().Be(2);
|
||||
result.Source.Should().Be("scanner");
|
||||
result.TenantId.Should().Be("tenant-1");
|
||||
|
||||
_repositoryMock.Verify(r => r.SaveAsync(It.IsAny<SbomRegistration>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterSbomAsync_ExistingSbom_ReturnsExisting()
|
||||
{
|
||||
// Arrange
|
||||
var existingRegistration = new SbomRegistration
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Digest = "sha256:abc123",
|
||||
Format = SbomFormat.CycloneDX,
|
||||
SpecVersion = "1.6",
|
||||
ComponentCount = 5,
|
||||
Purls = ["pkg:npm/react@18.0.0"],
|
||||
RegisteredAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
Source = "scanner"
|
||||
};
|
||||
|
||||
var input = new SbomRegistrationInput
|
||||
{
|
||||
Digest = "sha256:abc123",
|
||||
Format = SbomFormat.CycloneDX,
|
||||
SpecVersion = "1.6",
|
||||
Purls = ["pkg:npm/lodash@4.17.21"],
|
||||
Source = "scanner"
|
||||
};
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByDigestAsync(input.Digest, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(existingRegistration);
|
||||
|
||||
// Act
|
||||
var result = await _service.RegisterSbomAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(existingRegistration);
|
||||
result.ComponentCount.Should().Be(5);
|
||||
_repositoryMock.Verify(r => r.SaveAsync(It.IsAny<SbomRegistration>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterSbomAsync_NullInput_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
_service.RegisterSbomAsync(null!));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region LearnSbomAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task LearnSbomAsync_MatchesAndUpdatesScores()
|
||||
{
|
||||
// Arrange
|
||||
var sbomId = Guid.NewGuid();
|
||||
var canonicalId1 = Guid.NewGuid();
|
||||
var canonicalId2 = Guid.NewGuid();
|
||||
|
||||
var input = new SbomRegistrationInput
|
||||
{
|
||||
Digest = "sha256:def456",
|
||||
Format = SbomFormat.CycloneDX,
|
||||
SpecVersion = "1.6",
|
||||
Purls = ["pkg:npm/lodash@4.17.21", "pkg:npm/express@4.18.2"],
|
||||
Source = "scanner"
|
||||
};
|
||||
|
||||
var matches = new List<SbomAdvisoryMatch>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SbomId = sbomId,
|
||||
SbomDigest = "sha256:def456",
|
||||
CanonicalId = canonicalId1,
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
Method = MatchMethod.ExactPurl,
|
||||
IsReachable = true,
|
||||
IsDeployed = false,
|
||||
MatchedAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SbomId = sbomId,
|
||||
SbomDigest = "sha256:def456",
|
||||
CanonicalId = canonicalId2,
|
||||
Purl = "pkg:npm/express@4.18.2",
|
||||
Method = MatchMethod.ExactPurl,
|
||||
IsReachable = false,
|
||||
IsDeployed = true,
|
||||
MatchedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByDigestAsync(input.Digest, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((SbomRegistration?)null);
|
||||
|
||||
_matcherMock
|
||||
.Setup(m => m.MatchAsync(
|
||||
It.IsAny<Guid>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<IEnumerable<string>>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, bool>?>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, bool>?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(matches);
|
||||
|
||||
// Act
|
||||
var result = await _service.LearnSbomAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Matches.Should().HaveCount(2);
|
||||
result.ScoresUpdated.Should().Be(2);
|
||||
result.ProcessingTimeMs.Should().BeGreaterThan(0);
|
||||
|
||||
_scoringServiceMock.Verify(
|
||||
s => s.RecordSbomMatchAsync(
|
||||
canonicalId1,
|
||||
input.Digest,
|
||||
"pkg:npm/lodash@4.17.21",
|
||||
true, // IsReachable
|
||||
false, // IsDeployed
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
|
||||
_scoringServiceMock.Verify(
|
||||
s => s.RecordSbomMatchAsync(
|
||||
canonicalId2,
|
||||
input.Digest,
|
||||
"pkg:npm/express@4.18.2",
|
||||
false, // IsReachable
|
||||
true, // IsDeployed
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LearnSbomAsync_NoMatches_ReturnsEmptyMatches()
|
||||
{
|
||||
// Arrange
|
||||
var input = new SbomRegistrationInput
|
||||
{
|
||||
Digest = "sha256:noMatches",
|
||||
Format = SbomFormat.SPDX,
|
||||
SpecVersion = "3.0.1",
|
||||
Purls = ["pkg:npm/obscure-package@1.0.0"],
|
||||
Source = "manual"
|
||||
};
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByDigestAsync(input.Digest, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((SbomRegistration?)null);
|
||||
|
||||
_matcherMock
|
||||
.Setup(m => m.MatchAsync(
|
||||
It.IsAny<Guid>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<IEnumerable<string>>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, bool>?>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, bool>?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<SbomAdvisoryMatch>());
|
||||
|
||||
// Act
|
||||
var result = await _service.LearnSbomAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Matches.Should().BeEmpty();
|
||||
result.ScoresUpdated.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LearnSbomAsync_EmitsEvent()
|
||||
{
|
||||
// Arrange
|
||||
var input = new SbomRegistrationInput
|
||||
{
|
||||
Digest = "sha256:eventTest",
|
||||
Format = SbomFormat.CycloneDX,
|
||||
SpecVersion = "1.6",
|
||||
Purls = ["pkg:npm/test@1.0.0"],
|
||||
Source = "scanner"
|
||||
};
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByDigestAsync(input.Digest, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((SbomRegistration?)null);
|
||||
|
||||
_matcherMock
|
||||
.Setup(m => m.MatchAsync(
|
||||
It.IsAny<Guid>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<IEnumerable<string>>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, bool>?>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, bool>?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<SbomAdvisoryMatch>());
|
||||
|
||||
// Act
|
||||
await _service.LearnSbomAsync(input);
|
||||
|
||||
// Assert
|
||||
_eventStreamMock.Verify(
|
||||
e => e.PublishAsync(
|
||||
It.Is<SbomLearnedEvent>(evt =>
|
||||
evt.SbomDigest == input.Digest &&
|
||||
evt.IsRematch == false),
|
||||
It.IsAny<EventPublishOptions?>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RematchSbomAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task RematchSbomAsync_ExistingSbom_RematcesSuccessfully()
|
||||
{
|
||||
// Arrange
|
||||
var sbomId = Guid.NewGuid();
|
||||
var registration = new SbomRegistration
|
||||
{
|
||||
Id = sbomId,
|
||||
Digest = "sha256:rematch",
|
||||
Format = SbomFormat.CycloneDX,
|
||||
SpecVersion = "1.6",
|
||||
Purls = ["pkg:npm/lodash@4.17.21"],
|
||||
AffectedCount = 1,
|
||||
RegisteredAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
Source = "scanner"
|
||||
};
|
||||
|
||||
var canonicalId = Guid.NewGuid();
|
||||
var matches = new List<SbomAdvisoryMatch>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SbomId = sbomId,
|
||||
SbomDigest = registration.Digest,
|
||||
CanonicalId = canonicalId,
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
Method = MatchMethod.ExactPurl,
|
||||
MatchedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByDigestAsync(registration.Digest, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(registration);
|
||||
|
||||
_matcherMock
|
||||
.Setup(m => m.MatchAsync(
|
||||
sbomId,
|
||||
registration.Digest,
|
||||
registration.Purls,
|
||||
null,
|
||||
null,
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(matches);
|
||||
|
||||
// Act
|
||||
var result = await _service.RematchSbomAsync(registration.Digest);
|
||||
|
||||
// Assert
|
||||
result.Matches.Should().HaveCount(1);
|
||||
result.ScoresUpdated.Should().Be(0); // Rematch doesn't update scores
|
||||
|
||||
_repositoryMock.Verify(
|
||||
r => r.DeleteMatchesAsync(sbomId, It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
|
||||
_eventStreamMock.Verify(
|
||||
e => e.PublishAsync(
|
||||
It.Is<SbomLearnedEvent>(evt => evt.IsRematch == true),
|
||||
It.IsAny<EventPublishOptions?>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RematchSbomAsync_NonExistentSbom_ThrowsInvalidOperation()
|
||||
{
|
||||
// Arrange
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByDigestAsync("sha256:notfound", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((SbomRegistration?)null);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_service.RematchSbomAsync("sha256:notfound"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateSbomDeltaAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSbomDeltaAsync_AddsPurls()
|
||||
{
|
||||
// Arrange
|
||||
var sbomId = Guid.NewGuid();
|
||||
var existingPurls = new List<string> { "pkg:npm/lodash@4.17.21" };
|
||||
var registration = new SbomRegistration
|
||||
{
|
||||
Id = sbomId,
|
||||
Digest = "sha256:delta",
|
||||
Format = SbomFormat.CycloneDX,
|
||||
SpecVersion = "1.6",
|
||||
Purls = existingPurls,
|
||||
ComponentCount = 1,
|
||||
RegisteredAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
Source = "scanner"
|
||||
};
|
||||
|
||||
var delta = new SbomDeltaInput
|
||||
{
|
||||
AddedPurls = ["pkg:npm/express@4.18.2"],
|
||||
RemovedPurls = []
|
||||
};
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByDigestAsync(registration.Digest, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(registration);
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetMatchesAsync(registration.Digest, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<SbomAdvisoryMatch>());
|
||||
|
||||
_matcherMock
|
||||
.Setup(m => m.MatchAsync(
|
||||
It.IsAny<Guid>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<IEnumerable<string>>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, bool>?>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, bool>?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<SbomAdvisoryMatch>());
|
||||
|
||||
// Act
|
||||
var result = await _service.UpdateSbomDeltaAsync(registration.Digest, delta);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
|
||||
_repositoryMock.Verify(
|
||||
r => r.UpdatePurlsAsync(
|
||||
registration.Digest,
|
||||
It.Is<IReadOnlyList<string>>(p => p.Contains("pkg:npm/express@4.18.2")),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSbomDeltaAsync_NonExistentSbom_ThrowsInvalidOperation()
|
||||
{
|
||||
// Arrange
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByDigestAsync("sha256:notfound", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((SbomRegistration?)null);
|
||||
|
||||
var delta = new SbomDeltaInput { AddedPurls = ["pkg:npm/test@1.0.0"] };
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_service.UpdateSbomDeltaAsync("sha256:notfound", delta));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UnregisterAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task UnregisterAsync_ExistingSbom_DeletesRegistrationAndMatches()
|
||||
{
|
||||
// Arrange
|
||||
var sbomId = Guid.NewGuid();
|
||||
var registration = new SbomRegistration
|
||||
{
|
||||
Id = sbomId,
|
||||
Digest = "sha256:todelete",
|
||||
Format = SbomFormat.CycloneDX,
|
||||
SpecVersion = "1.6",
|
||||
Purls = [],
|
||||
RegisteredAt = DateTimeOffset.UtcNow,
|
||||
Source = "scanner"
|
||||
};
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByDigestAsync(registration.Digest, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(registration);
|
||||
|
||||
// Act
|
||||
await _service.UnregisterAsync(registration.Digest);
|
||||
|
||||
// Assert
|
||||
_repositoryMock.Verify(
|
||||
r => r.DeleteMatchesAsync(sbomId, It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
_repositoryMock.Verify(
|
||||
r => r.DeleteAsync(registration.Digest, It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SbomScoreIntegrationTests.cs
|
||||
// Sprint: SPRINT_8200_0013_0003_SCAN_sbom_intersection_scoring
|
||||
// Tasks: SBOM-8200-017, SBOM-8200-021
|
||||
// Description: Integration tests for SBOM → score update flow and reachability scoring
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using StellaOps.Concelier.Core.Canonical;
|
||||
using StellaOps.Concelier.Interest;
|
||||
using StellaOps.Concelier.Interest.Models;
|
||||
using StellaOps.Concelier.SbomIntegration.Events;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests verifying the complete SBOM → score update flow.
|
||||
/// </summary>
|
||||
public class SbomScoreIntegrationTests
|
||||
{
|
||||
#region Helper Methods
|
||||
|
||||
private static CanonicalAdvisory CreateCanonicalAdvisory(Guid id, string cve, string affectsKey)
|
||||
{
|
||||
return new CanonicalAdvisory
|
||||
{
|
||||
Id = id,
|
||||
Cve = cve,
|
||||
AffectsKey = affectsKey,
|
||||
MergeHash = $"hash-{id}",
|
||||
Status = CanonicalStatus.Active,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SBOM → Score Update Flow Tests (Task 17)
|
||||
|
||||
[Fact]
|
||||
public async Task LearnSbom_WithMatches_UpdatesInterestScores()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalId = Guid.NewGuid();
|
||||
var repositoryMock = new Mock<ISbomRegistryRepository>();
|
||||
var canonicalServiceMock = new Mock<ICanonicalAdvisoryService>();
|
||||
var scoringServiceMock = new Mock<IInterestScoringService>();
|
||||
var matcherLoggerMock = new Mock<ILogger<SbomAdvisoryMatcher>>();
|
||||
var serviceLoggerMock = new Mock<ILogger<SbomRegistryService>>();
|
||||
|
||||
var matcher = new SbomAdvisoryMatcher(canonicalServiceMock.Object, matcherLoggerMock.Object);
|
||||
|
||||
var service = new SbomRegistryService(
|
||||
repositoryMock.Object,
|
||||
matcher,
|
||||
scoringServiceMock.Object,
|
||||
serviceLoggerMock.Object,
|
||||
null);
|
||||
|
||||
var input = new SbomRegistrationInput
|
||||
{
|
||||
Digest = "sha256:integration-test",
|
||||
Format = SbomFormat.CycloneDX,
|
||||
SpecVersion = "1.6",
|
||||
Purls = ["pkg:npm/vulnerable-package@1.0.0"],
|
||||
Source = "integration-test"
|
||||
};
|
||||
|
||||
repositoryMock
|
||||
.Setup(r => r.GetByDigestAsync(input.Digest, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((SbomRegistration?)null);
|
||||
|
||||
var advisory = CreateCanonicalAdvisory(canonicalId, "CVE-2024-0001", "pkg:npm/vulnerable-package@1.0.0");
|
||||
canonicalServiceMock
|
||||
.Setup(s => s.GetByArtifactAsync("pkg:npm/vulnerable-package@1.0.0", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<CanonicalAdvisory> { advisory });
|
||||
|
||||
// Act
|
||||
var result = await service.LearnSbomAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Matches.Should().HaveCount(1);
|
||||
result.ScoresUpdated.Should().Be(1);
|
||||
|
||||
scoringServiceMock.Verify(
|
||||
s => s.RecordSbomMatchAsync(
|
||||
canonicalId,
|
||||
input.Digest,
|
||||
"pkg:npm/vulnerable-package@1.0.0",
|
||||
false, // Not reachable
|
||||
false, // Not deployed
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LearnSbom_MultipleMatchesSameCanonical_UpdatesScoreOnce()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalId = Guid.NewGuid();
|
||||
var repositoryMock = new Mock<ISbomRegistryRepository>();
|
||||
var canonicalServiceMock = new Mock<ICanonicalAdvisoryService>();
|
||||
var scoringServiceMock = new Mock<IInterestScoringService>();
|
||||
var matcherLoggerMock = new Mock<ILogger<SbomAdvisoryMatcher>>();
|
||||
var serviceLoggerMock = new Mock<ILogger<SbomRegistryService>>();
|
||||
|
||||
var matcher = new SbomAdvisoryMatcher(canonicalServiceMock.Object, matcherLoggerMock.Object);
|
||||
|
||||
var service = new SbomRegistryService(
|
||||
repositoryMock.Object,
|
||||
matcher,
|
||||
scoringServiceMock.Object,
|
||||
serviceLoggerMock.Object,
|
||||
null);
|
||||
|
||||
var input = new SbomRegistrationInput
|
||||
{
|
||||
Digest = "sha256:multi-match",
|
||||
Format = SbomFormat.CycloneDX,
|
||||
SpecVersion = "1.6",
|
||||
Purls = ["pkg:npm/a@1.0.0", "pkg:npm/b@1.0.0"], // Both affected by same CVE
|
||||
Source = "test"
|
||||
};
|
||||
|
||||
repositoryMock
|
||||
.Setup(r => r.GetByDigestAsync(input.Digest, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((SbomRegistration?)null);
|
||||
|
||||
// Both packages affected by same canonical
|
||||
var advisory = CreateCanonicalAdvisory(canonicalId, "CVE-2024-SHARED", "pkg:npm");
|
||||
canonicalServiceMock
|
||||
.Setup(s => s.GetByArtifactAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<CanonicalAdvisory> { advisory });
|
||||
|
||||
// Act
|
||||
var result = await service.LearnSbomAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Matches.Should().HaveCount(2); // 2 matches
|
||||
result.ScoresUpdated.Should().Be(1); // But only 1 unique canonical
|
||||
|
||||
scoringServiceMock.Verify(
|
||||
s => s.RecordSbomMatchAsync(
|
||||
canonicalId,
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LearnSbom_NoMatches_NoScoreUpdates()
|
||||
{
|
||||
// Arrange
|
||||
var repositoryMock = new Mock<ISbomRegistryRepository>();
|
||||
var canonicalServiceMock = new Mock<ICanonicalAdvisoryService>();
|
||||
var scoringServiceMock = new Mock<IInterestScoringService>();
|
||||
var matcherLoggerMock = new Mock<ILogger<SbomAdvisoryMatcher>>();
|
||||
var serviceLoggerMock = new Mock<ILogger<SbomRegistryService>>();
|
||||
|
||||
var matcher = new SbomAdvisoryMatcher(canonicalServiceMock.Object, matcherLoggerMock.Object);
|
||||
|
||||
var service = new SbomRegistryService(
|
||||
repositoryMock.Object,
|
||||
matcher,
|
||||
scoringServiceMock.Object,
|
||||
serviceLoggerMock.Object,
|
||||
null);
|
||||
|
||||
var input = new SbomRegistrationInput
|
||||
{
|
||||
Digest = "sha256:no-matches",
|
||||
Format = SbomFormat.CycloneDX,
|
||||
SpecVersion = "1.6",
|
||||
Purls = ["pkg:npm/safe-package@1.0.0"],
|
||||
Source = "test"
|
||||
};
|
||||
|
||||
repositoryMock
|
||||
.Setup(r => r.GetByDigestAsync(input.Digest, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((SbomRegistration?)null);
|
||||
|
||||
canonicalServiceMock
|
||||
.Setup(s => s.GetByArtifactAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<CanonicalAdvisory>());
|
||||
|
||||
// Act
|
||||
var result = await service.LearnSbomAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Matches.Should().BeEmpty();
|
||||
result.ScoresUpdated.Should().Be(0);
|
||||
|
||||
scoringServiceMock.Verify(
|
||||
s => s.RecordSbomMatchAsync(
|
||||
It.IsAny<Guid>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LearnSbom_ScoringServiceFails_ContinuesWithOtherMatches()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalId1 = Guid.NewGuid();
|
||||
var canonicalId2 = Guid.NewGuid();
|
||||
var repositoryMock = new Mock<ISbomRegistryRepository>();
|
||||
var canonicalServiceMock = new Mock<ICanonicalAdvisoryService>();
|
||||
var scoringServiceMock = new Mock<IInterestScoringService>();
|
||||
var matcherLoggerMock = new Mock<ILogger<SbomAdvisoryMatcher>>();
|
||||
var serviceLoggerMock = new Mock<ILogger<SbomRegistryService>>();
|
||||
|
||||
var matcher = new SbomAdvisoryMatcher(canonicalServiceMock.Object, matcherLoggerMock.Object);
|
||||
|
||||
var service = new SbomRegistryService(
|
||||
repositoryMock.Object,
|
||||
matcher,
|
||||
scoringServiceMock.Object,
|
||||
serviceLoggerMock.Object,
|
||||
null);
|
||||
|
||||
var input = new SbomRegistrationInput
|
||||
{
|
||||
Digest = "sha256:partial-fail",
|
||||
Format = SbomFormat.CycloneDX,
|
||||
SpecVersion = "1.6",
|
||||
Purls = ["pkg:npm/a@1.0.0", "pkg:npm/b@1.0.0"],
|
||||
Source = "test"
|
||||
};
|
||||
|
||||
repositoryMock
|
||||
.Setup(r => r.GetByDigestAsync(input.Digest, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((SbomRegistration?)null);
|
||||
|
||||
var advisory1 = CreateCanonicalAdvisory(canonicalId1, "CVE-2024-0001", "pkg:npm/a@1.0.0");
|
||||
var advisory2 = CreateCanonicalAdvisory(canonicalId2, "CVE-2024-0002", "pkg:npm/b@1.0.0");
|
||||
|
||||
canonicalServiceMock
|
||||
.Setup(s => s.GetByArtifactAsync("pkg:npm/a@1.0.0", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<CanonicalAdvisory> { advisory1 });
|
||||
|
||||
canonicalServiceMock
|
||||
.Setup(s => s.GetByArtifactAsync("pkg:npm/b@1.0.0", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<CanonicalAdvisory> { advisory2 });
|
||||
|
||||
// First scoring call fails
|
||||
scoringServiceMock
|
||||
.Setup(s => s.RecordSbomMatchAsync(
|
||||
canonicalId1,
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Scoring failed"));
|
||||
|
||||
// Act
|
||||
var result = await service.LearnSbomAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Matches.Should().HaveCount(2);
|
||||
result.ScoresUpdated.Should().Be(1); // Only second succeeded
|
||||
|
||||
// Both were attempted
|
||||
scoringServiceMock.Verify(
|
||||
s => s.RecordSbomMatchAsync(
|
||||
It.IsAny<Guid>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Exactly(2));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Reachability-Aware Scoring Tests (Task 21)
|
||||
|
||||
[Fact]
|
||||
public async Task LearnSbom_WithReachability_PassesReachabilityToScoring()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalId = Guid.NewGuid();
|
||||
var repositoryMock = new Mock<ISbomRegistryRepository>();
|
||||
var canonicalServiceMock = new Mock<ICanonicalAdvisoryService>();
|
||||
var scoringServiceMock = new Mock<IInterestScoringService>();
|
||||
var matcherLoggerMock = new Mock<ILogger<SbomAdvisoryMatcher>>();
|
||||
var serviceLoggerMock = new Mock<ILogger<SbomRegistryService>>();
|
||||
|
||||
var matcher = new SbomAdvisoryMatcher(canonicalServiceMock.Object, matcherLoggerMock.Object);
|
||||
|
||||
var service = new SbomRegistryService(
|
||||
repositoryMock.Object,
|
||||
matcher,
|
||||
scoringServiceMock.Object,
|
||||
serviceLoggerMock.Object,
|
||||
null);
|
||||
|
||||
var input = new SbomRegistrationInput
|
||||
{
|
||||
Digest = "sha256:reachable",
|
||||
Format = SbomFormat.CycloneDX,
|
||||
SpecVersion = "1.6",
|
||||
Purls = ["pkg:npm/vulnerable@1.0.0"],
|
||||
Source = "scanner",
|
||||
ReachabilityMap = new Dictionary<string, bool>
|
||||
{
|
||||
["pkg:npm/vulnerable@1.0.0"] = true
|
||||
}
|
||||
};
|
||||
|
||||
repositoryMock
|
||||
.Setup(r => r.GetByDigestAsync(input.Digest, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((SbomRegistration?)null);
|
||||
|
||||
var advisory = CreateCanonicalAdvisory(canonicalId, "CVE-2024-REACH", "pkg:npm/vulnerable@1.0.0");
|
||||
canonicalServiceMock
|
||||
.Setup(s => s.GetByArtifactAsync("pkg:npm/vulnerable@1.0.0", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<CanonicalAdvisory> { advisory });
|
||||
|
||||
// Act
|
||||
var result = await service.LearnSbomAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Matches[0].IsReachable.Should().BeTrue();
|
||||
|
||||
scoringServiceMock.Verify(
|
||||
s => s.RecordSbomMatchAsync(
|
||||
canonicalId,
|
||||
input.Digest,
|
||||
"pkg:npm/vulnerable@1.0.0",
|
||||
true, // IsReachable = true
|
||||
false, // IsDeployed = false
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LearnSbom_WithDeployment_PassesDeploymentToScoring()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalId = Guid.NewGuid();
|
||||
var repositoryMock = new Mock<ISbomRegistryRepository>();
|
||||
var canonicalServiceMock = new Mock<ICanonicalAdvisoryService>();
|
||||
var scoringServiceMock = new Mock<IInterestScoringService>();
|
||||
var matcherLoggerMock = new Mock<ILogger<SbomAdvisoryMatcher>>();
|
||||
var serviceLoggerMock = new Mock<ILogger<SbomRegistryService>>();
|
||||
|
||||
var matcher = new SbomAdvisoryMatcher(canonicalServiceMock.Object, matcherLoggerMock.Object);
|
||||
|
||||
var service = new SbomRegistryService(
|
||||
repositoryMock.Object,
|
||||
matcher,
|
||||
scoringServiceMock.Object,
|
||||
serviceLoggerMock.Object,
|
||||
null);
|
||||
|
||||
var input = new SbomRegistrationInput
|
||||
{
|
||||
Digest = "sha256:deployed",
|
||||
Format = SbomFormat.CycloneDX,
|
||||
SpecVersion = "1.6",
|
||||
Purls = ["pkg:npm/vulnerable@1.0.0"],
|
||||
Source = "scanner",
|
||||
DeploymentMap = new Dictionary<string, bool>
|
||||
{
|
||||
["pkg:npm/vulnerable@1.0.0"] = true
|
||||
}
|
||||
};
|
||||
|
||||
repositoryMock
|
||||
.Setup(r => r.GetByDigestAsync(input.Digest, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((SbomRegistration?)null);
|
||||
|
||||
var advisory = CreateCanonicalAdvisory(canonicalId, "CVE-2024-DEPLOY", "pkg:npm/vulnerable@1.0.0");
|
||||
canonicalServiceMock
|
||||
.Setup(s => s.GetByArtifactAsync("pkg:npm/vulnerable@1.0.0", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<CanonicalAdvisory> { advisory });
|
||||
|
||||
// Act
|
||||
var result = await service.LearnSbomAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Matches[0].IsDeployed.Should().BeTrue();
|
||||
|
||||
scoringServiceMock.Verify(
|
||||
s => s.RecordSbomMatchAsync(
|
||||
canonicalId,
|
||||
input.Digest,
|
||||
"pkg:npm/vulnerable@1.0.0",
|
||||
false, // IsReachable = false
|
||||
true, // IsDeployed = true
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LearnSbom_FullReachabilityChain_PassesBothFlags()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalId = Guid.NewGuid();
|
||||
var repositoryMock = new Mock<ISbomRegistryRepository>();
|
||||
var canonicalServiceMock = new Mock<ICanonicalAdvisoryService>();
|
||||
var scoringServiceMock = new Mock<IInterestScoringService>();
|
||||
var matcherLoggerMock = new Mock<ILogger<SbomAdvisoryMatcher>>();
|
||||
var serviceLoggerMock = new Mock<ILogger<SbomRegistryService>>();
|
||||
|
||||
var matcher = new SbomAdvisoryMatcher(canonicalServiceMock.Object, matcherLoggerMock.Object);
|
||||
|
||||
var service = new SbomRegistryService(
|
||||
repositoryMock.Object,
|
||||
matcher,
|
||||
scoringServiceMock.Object,
|
||||
serviceLoggerMock.Object,
|
||||
null);
|
||||
|
||||
var input = new SbomRegistrationInput
|
||||
{
|
||||
Digest = "sha256:full-chain",
|
||||
Format = SbomFormat.CycloneDX,
|
||||
SpecVersion = "1.6",
|
||||
Purls = ["pkg:npm/critical@1.0.0"],
|
||||
Source = "scanner",
|
||||
ReachabilityMap = new Dictionary<string, bool>
|
||||
{
|
||||
["pkg:npm/critical@1.0.0"] = true
|
||||
},
|
||||
DeploymentMap = new Dictionary<string, bool>
|
||||
{
|
||||
["pkg:npm/critical@1.0.0"] = true
|
||||
}
|
||||
};
|
||||
|
||||
repositoryMock
|
||||
.Setup(r => r.GetByDigestAsync(input.Digest, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((SbomRegistration?)null);
|
||||
|
||||
var advisory = CreateCanonicalAdvisory(canonicalId, "CVE-2024-FULL", "pkg:npm/critical@1.0.0");
|
||||
canonicalServiceMock
|
||||
.Setup(s => s.GetByArtifactAsync("pkg:npm/critical@1.0.0", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<CanonicalAdvisory> { advisory });
|
||||
|
||||
// Act
|
||||
var result = await service.LearnSbomAsync(input);
|
||||
|
||||
// Assert
|
||||
result.Matches[0].IsReachable.Should().BeTrue();
|
||||
result.Matches[0].IsDeployed.Should().BeTrue();
|
||||
|
||||
scoringServiceMock.Verify(
|
||||
s => s.RecordSbomMatchAsync(
|
||||
canonicalId,
|
||||
input.Digest,
|
||||
"pkg:npm/critical@1.0.0",
|
||||
true, // IsReachable = true
|
||||
true, // IsDeployed = true
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LearnSbom_MixedReachability_CorrectFlagsPerMatch()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalId1 = Guid.NewGuid();
|
||||
var canonicalId2 = Guid.NewGuid();
|
||||
var repositoryMock = new Mock<ISbomRegistryRepository>();
|
||||
var canonicalServiceMock = new Mock<ICanonicalAdvisoryService>();
|
||||
var scoringServiceMock = new Mock<IInterestScoringService>();
|
||||
var matcherLoggerMock = new Mock<ILogger<SbomAdvisoryMatcher>>();
|
||||
var serviceLoggerMock = new Mock<ILogger<SbomRegistryService>>();
|
||||
|
||||
var matcher = new SbomAdvisoryMatcher(canonicalServiceMock.Object, matcherLoggerMock.Object);
|
||||
|
||||
var service = new SbomRegistryService(
|
||||
repositoryMock.Object,
|
||||
matcher,
|
||||
scoringServiceMock.Object,
|
||||
serviceLoggerMock.Object,
|
||||
null);
|
||||
|
||||
var input = new SbomRegistrationInput
|
||||
{
|
||||
Digest = "sha256:mixed",
|
||||
Format = SbomFormat.CycloneDX,
|
||||
SpecVersion = "1.6",
|
||||
Purls = ["pkg:npm/reachable@1.0.0", "pkg:npm/unreachable@1.0.0"],
|
||||
Source = "scanner",
|
||||
ReachabilityMap = new Dictionary<string, bool>
|
||||
{
|
||||
["pkg:npm/reachable@1.0.0"] = true,
|
||||
["pkg:npm/unreachable@1.0.0"] = false
|
||||
}
|
||||
};
|
||||
|
||||
repositoryMock
|
||||
.Setup(r => r.GetByDigestAsync(input.Digest, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((SbomRegistration?)null);
|
||||
|
||||
var advisory1 = CreateCanonicalAdvisory(canonicalId1, "CVE-2024-R", "pkg:npm/reachable@1.0.0");
|
||||
var advisory2 = CreateCanonicalAdvisory(canonicalId2, "CVE-2024-U", "pkg:npm/unreachable@1.0.0");
|
||||
|
||||
canonicalServiceMock
|
||||
.Setup(s => s.GetByArtifactAsync("pkg:npm/reachable@1.0.0", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<CanonicalAdvisory> { advisory1 });
|
||||
|
||||
canonicalServiceMock
|
||||
.Setup(s => s.GetByArtifactAsync("pkg:npm/unreachable@1.0.0", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<CanonicalAdvisory> { advisory2 });
|
||||
|
||||
// Act
|
||||
var result = await service.LearnSbomAsync(input);
|
||||
|
||||
// Assert
|
||||
var reachableMatch = result.Matches.First(m => m.Purl == "pkg:npm/reachable@1.0.0");
|
||||
var unreachableMatch = result.Matches.First(m => m.Purl == "pkg:npm/unreachable@1.0.0");
|
||||
|
||||
reachableMatch.IsReachable.Should().BeTrue();
|
||||
unreachableMatch.IsReachable.Should().BeFalse();
|
||||
|
||||
// Verify scoring calls with correct flags
|
||||
scoringServiceMock.Verify(
|
||||
s => s.RecordSbomMatchAsync(canonicalId1, It.IsAny<string>(), "pkg:npm/reachable@1.0.0", true, false, It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
|
||||
scoringServiceMock.Verify(
|
||||
s => s.RecordSbomMatchAsync(canonicalId2, It.IsAny<string>(), "pkg:npm/unreachable@1.0.0", false, false, It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Score Calculation Verification
|
||||
|
||||
[Fact]
|
||||
public void InterestScoreCalculator_WithSbomMatch_AddsSbomFactor()
|
||||
{
|
||||
// Arrange
|
||||
var calculator = new InterestScoreCalculator(new InterestScoreWeights());
|
||||
var input = new InterestScoreInput
|
||||
{
|
||||
CanonicalId = Guid.NewGuid(),
|
||||
SbomMatches =
|
||||
[
|
||||
new Interest.Models.SbomMatch
|
||||
{
|
||||
SbomDigest = "sha256:test",
|
||||
Purl = "pkg:npm/test@1.0.0",
|
||||
ScannedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = calculator.Calculate(input);
|
||||
|
||||
// Assert
|
||||
result.Reasons.Should().Contain("in_sbom");
|
||||
result.Score.Should().BeGreaterThan(0.30); // in_sbom weight + no_vex_na
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InterestScoreCalculator_WithReachableMatch_AddsReachableFactor()
|
||||
{
|
||||
// Arrange
|
||||
var calculator = new InterestScoreCalculator(new InterestScoreWeights());
|
||||
var input = new InterestScoreInput
|
||||
{
|
||||
CanonicalId = Guid.NewGuid(),
|
||||
SbomMatches =
|
||||
[
|
||||
new Interest.Models.SbomMatch
|
||||
{
|
||||
SbomDigest = "sha256:test",
|
||||
Purl = "pkg:npm/test@1.0.0",
|
||||
IsReachable = true,
|
||||
ScannedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = calculator.Calculate(input);
|
||||
|
||||
// Assert
|
||||
result.Reasons.Should().Contain("in_sbom");
|
||||
result.Reasons.Should().Contain("reachable");
|
||||
result.Score.Should().BeGreaterThan(0.55); // in_sbom + reachable + no_vex_na
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InterestScoreCalculator_WithDeployedMatch_AddsDeployedFactor()
|
||||
{
|
||||
// Arrange
|
||||
var calculator = new InterestScoreCalculator(new InterestScoreWeights());
|
||||
var input = new InterestScoreInput
|
||||
{
|
||||
CanonicalId = Guid.NewGuid(),
|
||||
SbomMatches =
|
||||
[
|
||||
new Interest.Models.SbomMatch
|
||||
{
|
||||
SbomDigest = "sha256:test",
|
||||
Purl = "pkg:npm/test@1.0.0",
|
||||
IsDeployed = true,
|
||||
ScannedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = calculator.Calculate(input);
|
||||
|
||||
// Assert
|
||||
result.Reasons.Should().Contain("in_sbom");
|
||||
result.Reasons.Should().Contain("deployed");
|
||||
result.Score.Should().BeGreaterThan(0.50); // in_sbom + deployed + no_vex_na
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InterestScoreCalculator_FullReachabilityChain_MaximizesScore()
|
||||
{
|
||||
// Arrange
|
||||
var calculator = new InterestScoreCalculator(new InterestScoreWeights());
|
||||
var input = new InterestScoreInput
|
||||
{
|
||||
CanonicalId = Guid.NewGuid(),
|
||||
SbomMatches =
|
||||
[
|
||||
new Interest.Models.SbomMatch
|
||||
{
|
||||
SbomDigest = "sha256:test",
|
||||
Purl = "pkg:npm/test@1.0.0",
|
||||
IsReachable = true,
|
||||
IsDeployed = true,
|
||||
ScannedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = calculator.Calculate(input);
|
||||
|
||||
// Assert
|
||||
result.Reasons.Should().Contain("in_sbom");
|
||||
result.Reasons.Should().Contain("reachable");
|
||||
result.Reasons.Should().Contain("deployed");
|
||||
result.Reasons.Should().Contain("no_vex_na");
|
||||
result.Score.Should().Be(0.90); // in_sbom(0.30) + reachable(0.25) + deployed(0.20) + no_vex_na(0.15)
|
||||
result.Tier.Should().Be(InterestTier.High);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>StellaOps.Concelier.SbomIntegration.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="8.0.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Concelier.SbomIntegration\StellaOps.Concelier.SbomIntegration.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Concelier.Interest\StellaOps.Concelier.Interest.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user