save dev progress

This commit is contained in:
StellaOps Bot
2025-12-26 00:32:35 +02:00
parent aa70af062e
commit ed3079543c
142 changed files with 23771 additions and 232 deletions

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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>