save development progress
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AdvisoryCacheKeysTests.cs
|
||||
// Sprint: SPRINT_8200_0013_0001_GW_valkey_advisory_cache
|
||||
// Task: VCACHE-8200-009
|
||||
// Description: Unit tests for AdvisoryCacheKeys
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Cache.Valkey.Tests;
|
||||
|
||||
public class AdvisoryCacheKeysTests
|
||||
{
|
||||
[Fact]
|
||||
public void Advisory_WithDefaultPrefix_GeneratesCorrectKey()
|
||||
{
|
||||
// Arrange
|
||||
var mergeHash = "abc123def456";
|
||||
|
||||
// Act
|
||||
var key = AdvisoryCacheKeys.Advisory(mergeHash);
|
||||
|
||||
// Assert
|
||||
key.Should().Be("concelier:advisory:abc123def456");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Advisory_WithCustomPrefix_GeneratesCorrectKey()
|
||||
{
|
||||
// Arrange
|
||||
var mergeHash = "abc123def456";
|
||||
var prefix = "custom:";
|
||||
|
||||
// Act
|
||||
var key = AdvisoryCacheKeys.Advisory(mergeHash, prefix);
|
||||
|
||||
// Assert
|
||||
key.Should().Be("custom:advisory:abc123def456");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HotSet_WithDefaultPrefix_GeneratesCorrectKey()
|
||||
{
|
||||
// Act
|
||||
var key = AdvisoryCacheKeys.HotSet();
|
||||
|
||||
// Assert
|
||||
key.Should().Be("concelier:rank:hot");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ByPurl_NormalizesPurl()
|
||||
{
|
||||
// Arrange
|
||||
var purl = "pkg:npm/@angular/core@12.0.0";
|
||||
|
||||
// Act
|
||||
var key = AdvisoryCacheKeys.ByPurl(purl);
|
||||
|
||||
// Assert
|
||||
key.Should().Be("concelier:by:purl:pkg:npm/@angular/core@12.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ByPurl_NormalizesToLowercase()
|
||||
{
|
||||
// Arrange
|
||||
var purl = "pkg:NPM/@Angular/Core@12.0.0";
|
||||
|
||||
// Act
|
||||
var key = AdvisoryCacheKeys.ByPurl(purl);
|
||||
|
||||
// Assert
|
||||
key.Should().Be("concelier:by:purl:pkg:npm/@angular/core@12.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ByCve_NormalizesToUppercase()
|
||||
{
|
||||
// Arrange
|
||||
var cve = "cve-2024-1234";
|
||||
|
||||
// Act
|
||||
var key = AdvisoryCacheKeys.ByCve(cve);
|
||||
|
||||
// Assert
|
||||
key.Should().Be("concelier:by:cve:CVE-2024-1234");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StatsHits_GeneratesCorrectKey()
|
||||
{
|
||||
// Act
|
||||
var key = AdvisoryCacheKeys.StatsHits();
|
||||
|
||||
// Assert
|
||||
key.Should().Be("concelier:cache:stats:hits");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StatsMisses_GeneratesCorrectKey()
|
||||
{
|
||||
// Act
|
||||
var key = AdvisoryCacheKeys.StatsMisses();
|
||||
|
||||
// Assert
|
||||
key.Should().Be("concelier:cache:stats:misses");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarmupLast_GeneratesCorrectKey()
|
||||
{
|
||||
// Act
|
||||
var key = AdvisoryCacheKeys.WarmupLast();
|
||||
|
||||
// Assert
|
||||
key.Should().Be("concelier:cache:warmup:last");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizePurl_HandlesEmptyString()
|
||||
{
|
||||
// Act
|
||||
var result = AdvisoryCacheKeys.NormalizePurl("");
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizePurl_HandlesNull()
|
||||
{
|
||||
// Act
|
||||
var result = AdvisoryCacheKeys.NormalizePurl(null!);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizePurl_ReplacesSpecialCharacters()
|
||||
{
|
||||
// Arrange - PURL with unusual characters
|
||||
var purl = "pkg:npm/test?query=value#fragment";
|
||||
|
||||
// Act
|
||||
var result = AdvisoryCacheKeys.NormalizePurl(purl);
|
||||
|
||||
// Assert
|
||||
// ? and # should be replaced with _
|
||||
result.Should().Be("pkg:npm/test_query_value_fragment");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizePurl_TruncatesLongPurls()
|
||||
{
|
||||
// Arrange - Very long PURL
|
||||
var purl = "pkg:npm/" + new string('a', 600);
|
||||
|
||||
// Act
|
||||
var result = AdvisoryCacheKeys.NormalizePurl(purl);
|
||||
|
||||
// Assert
|
||||
result.Length.Should().BeLessThanOrEqualTo(500);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractMergeHash_ReturnsHashFromAdvisoryKey()
|
||||
{
|
||||
// Arrange
|
||||
var key = "concelier:advisory:abc123def456";
|
||||
|
||||
// Act
|
||||
var result = AdvisoryCacheKeys.ExtractMergeHash(key);
|
||||
|
||||
// Assert
|
||||
result.Should().Be("abc123def456");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractMergeHash_ReturnsNullForInvalidKey()
|
||||
{
|
||||
// Arrange
|
||||
var key = "concelier:by:purl:pkg:npm/test";
|
||||
|
||||
// Act
|
||||
var result = AdvisoryCacheKeys.ExtractMergeHash(key);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractPurl_ReturnsPurlFromIndexKey()
|
||||
{
|
||||
// Arrange
|
||||
var key = "concelier:by:purl:pkg:npm/test@1.0.0";
|
||||
|
||||
// Act
|
||||
var result = AdvisoryCacheKeys.ExtractPurl(key);
|
||||
|
||||
// Assert
|
||||
result.Should().Be("pkg:npm/test@1.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractCve_ReturnsCveFromMappingKey()
|
||||
{
|
||||
// Arrange
|
||||
var key = "concelier:by:cve:CVE-2024-1234";
|
||||
|
||||
// Act
|
||||
var result = AdvisoryCacheKeys.ExtractCve(key);
|
||||
|
||||
// Assert
|
||||
result.Should().Be("CVE-2024-1234");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdvisoryPattern_GeneratesCorrectPattern()
|
||||
{
|
||||
// Act
|
||||
var pattern = AdvisoryCacheKeys.AdvisoryPattern();
|
||||
|
||||
// Assert
|
||||
pattern.Should().Be("concelier:advisory:*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PurlIndexPattern_GeneratesCorrectPattern()
|
||||
{
|
||||
// Act
|
||||
var pattern = AdvisoryCacheKeys.PurlIndexPattern();
|
||||
|
||||
// Assert
|
||||
pattern.Should().Be("concelier:by:purl:*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CveMappingPattern_GeneratesCorrectPattern()
|
||||
{
|
||||
// Act
|
||||
var pattern = AdvisoryCacheKeys.CveMappingPattern();
|
||||
|
||||
// Assert
|
||||
pattern.Should().Be("concelier:by:cve:*");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CacheTtlPolicyTests.cs
|
||||
// Sprint: SPRINT_8200_0013_0001_GW_valkey_advisory_cache
|
||||
// Task: VCACHE-8200-009
|
||||
// Description: Unit tests for CacheTtlPolicy
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Cache.Valkey.Tests;
|
||||
|
||||
public class CacheTtlPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetTtl_WithHighScore_ReturnsHighScoreTtl()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new CacheTtlPolicy();
|
||||
|
||||
// Act
|
||||
var ttl = policy.GetTtl(0.85);
|
||||
|
||||
// Assert
|
||||
ttl.Should().Be(TimeSpan.FromHours(24));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTtl_WithScoreAtHighThreshold_ReturnsHighScoreTtl()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new CacheTtlPolicy();
|
||||
|
||||
// Act
|
||||
var ttl = policy.GetTtl(0.7);
|
||||
|
||||
// Assert
|
||||
ttl.Should().Be(TimeSpan.FromHours(24));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTtl_WithMediumScore_ReturnsMediumScoreTtl()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new CacheTtlPolicy();
|
||||
|
||||
// Act
|
||||
var ttl = policy.GetTtl(0.5);
|
||||
|
||||
// Assert
|
||||
ttl.Should().Be(TimeSpan.FromHours(4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTtl_WithScoreAtMediumThreshold_ReturnsMediumScoreTtl()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new CacheTtlPolicy();
|
||||
|
||||
// Act
|
||||
var ttl = policy.GetTtl(0.4);
|
||||
|
||||
// Assert
|
||||
ttl.Should().Be(TimeSpan.FromHours(4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTtl_WithLowScore_ReturnsLowScoreTtl()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new CacheTtlPolicy();
|
||||
|
||||
// Act
|
||||
var ttl = policy.GetTtl(0.2);
|
||||
|
||||
// Assert
|
||||
ttl.Should().Be(TimeSpan.FromHours(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTtl_WithZeroScore_ReturnsLowScoreTtl()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new CacheTtlPolicy();
|
||||
|
||||
// Act
|
||||
var ttl = policy.GetTtl(0.0);
|
||||
|
||||
// Assert
|
||||
ttl.Should().Be(TimeSpan.FromHours(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTtl_WithNullScore_ReturnsLowScoreTtl()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new CacheTtlPolicy();
|
||||
|
||||
// Act
|
||||
var ttl = policy.GetTtl(null);
|
||||
|
||||
// Assert
|
||||
ttl.Should().Be(TimeSpan.FromHours(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTtl_WithCustomThresholds_UsesCustomValues()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new CacheTtlPolicy
|
||||
{
|
||||
HighScoreThreshold = 0.8,
|
||||
MediumScoreThreshold = 0.5,
|
||||
HighScoreTtl = TimeSpan.FromHours(48),
|
||||
MediumScoreTtl = TimeSpan.FromHours(12),
|
||||
LowScoreTtl = TimeSpan.FromMinutes(30)
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
policy.GetTtl(0.9).Should().Be(TimeSpan.FromHours(48));
|
||||
policy.GetTtl(0.6).Should().Be(TimeSpan.FromHours(12));
|
||||
policy.GetTtl(0.3).Should().Be(TimeSpan.FromMinutes(30));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultValues_AreCorrect()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new CacheTtlPolicy();
|
||||
|
||||
// Assert
|
||||
policy.HighScoreTtl.Should().Be(TimeSpan.FromHours(24));
|
||||
policy.MediumScoreTtl.Should().Be(TimeSpan.FromHours(4));
|
||||
policy.LowScoreTtl.Should().Be(TimeSpan.FromHours(1));
|
||||
policy.HighScoreThreshold.Should().Be(0.7);
|
||||
policy.MediumScoreThreshold.Should().Be(0.4);
|
||||
policy.PurlIndexTtl.Should().Be(TimeSpan.FromHours(24));
|
||||
policy.CveMappingTtl.Should().Be(TimeSpan.FromHours(24));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTtl_WithScoreBelowMediumThreshold_ReturnsLowScoreTtl()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new CacheTtlPolicy();
|
||||
|
||||
// Act
|
||||
var ttl = policy.GetTtl(0.39);
|
||||
|
||||
// Assert
|
||||
ttl.Should().Be(TimeSpan.FromHours(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTtl_WithScoreBelowHighThreshold_ReturnsMediumScoreTtl()
|
||||
{
|
||||
// Arrange
|
||||
var policy = new CacheTtlPolicy();
|
||||
|
||||
// Act
|
||||
var ttl = policy.GetTtl(0.69);
|
||||
|
||||
// Assert
|
||||
ttl.Should().Be(TimeSpan.FromHours(4));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?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.Cache.Valkey.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="8.0.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Concelier.Cache.Valkey\StellaOps.Concelier.Cache.Valkey.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,505 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CanonicalDeduplicationTests.cs
|
||||
// Sprint: SPRINT_8200_0012_0003_CONCEL_canonical_advisory_service
|
||||
// Task: CANSVC-8200-025
|
||||
// Description: End-to-end tests verifying deduplication across multiple connectors
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Concelier.Core.Canonical;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Tests.Canonical;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end tests verifying that the canonical advisory service correctly
|
||||
/// deduplicates advisories from multiple sources (OSV, NVD, GHSA, distros).
|
||||
/// </summary>
|
||||
public sealed class CanonicalDeduplicationTests
|
||||
{
|
||||
private readonly InMemoryCanonicalAdvisoryStore _store;
|
||||
private readonly RealMergeHashCalculator _hashCalculator;
|
||||
private readonly ILogger<CanonicalAdvisoryService> _logger;
|
||||
|
||||
private const string TestCve = "CVE-2025-12345";
|
||||
private const string TestAffectsKey = "pkg:npm/lodash@4.17.21";
|
||||
|
||||
public CanonicalDeduplicationTests()
|
||||
{
|
||||
_store = new InMemoryCanonicalAdvisoryStore();
|
||||
_hashCalculator = new RealMergeHashCalculator();
|
||||
_logger = NullLogger<CanonicalAdvisoryService>.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests the core deduplication scenario: same CVE ingested from 4 different sources
|
||||
/// should result in a single canonical with 4 source edges.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MultiSourceIngestion_ProducesSingleCanonical_WithMultipleSourceEdges()
|
||||
{
|
||||
// Arrange
|
||||
var service = new CanonicalAdvisoryService(_store, _hashCalculator, _logger);
|
||||
|
||||
var nvdAdvisory = CreateRawAdvisory(TestCve, TestAffectsKey, "NVD-2025-12345");
|
||||
var osvAdvisory = CreateRawAdvisory(TestCve, TestAffectsKey, "OSV-2025-12345");
|
||||
var ghsaAdvisory = CreateRawAdvisory(TestCve, TestAffectsKey, "GHSA-abcd-efgh-ijkl");
|
||||
var debianAdvisory = CreateRawAdvisory(TestCve, TestAffectsKey, "DSA-2025-12345");
|
||||
|
||||
// Act - ingest from all sources
|
||||
var nvdResult = await service.IngestAsync("nvd", nvdAdvisory);
|
||||
var osvResult = await service.IngestAsync("osv", osvAdvisory);
|
||||
var ghsaResult = await service.IngestAsync("ghsa", ghsaAdvisory);
|
||||
var debianResult = await service.IngestAsync("debian", debianAdvisory);
|
||||
|
||||
// Assert - first ingest creates, rest merge
|
||||
nvdResult.Decision.Should().Be(MergeDecision.Created);
|
||||
osvResult.Decision.Should().Be(MergeDecision.Merged);
|
||||
ghsaResult.Decision.Should().Be(MergeDecision.Merged);
|
||||
debianResult.Decision.Should().Be(MergeDecision.Merged);
|
||||
|
||||
// All should reference the same canonical
|
||||
var canonicalId = nvdResult.CanonicalId;
|
||||
osvResult.CanonicalId.Should().Be(canonicalId);
|
||||
ghsaResult.CanonicalId.Should().Be(canonicalId);
|
||||
debianResult.CanonicalId.Should().Be(canonicalId);
|
||||
|
||||
// All should have same merge hash
|
||||
nvdResult.MergeHash.Should().Be(osvResult.MergeHash);
|
||||
nvdResult.MergeHash.Should().Be(ghsaResult.MergeHash);
|
||||
nvdResult.MergeHash.Should().Be(debianResult.MergeHash);
|
||||
|
||||
// Verify canonical has 4 source edges
|
||||
var canonical = await service.GetByIdAsync(canonicalId);
|
||||
canonical.Should().NotBeNull();
|
||||
canonical!.SourceEdges.Should().HaveCount(4);
|
||||
canonical.SourceEdges.Select(e => e.SourceName).Should()
|
||||
.Contain(new[] { "nvd", "osv", "ghsa", "debian" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that querying by CVE returns the deduplicated canonical advisory.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task QueryByCve_ReturnsDeduplicated_CanonicalAdvisory()
|
||||
{
|
||||
// Arrange
|
||||
var service = new CanonicalAdvisoryService(_store, _hashCalculator, _logger);
|
||||
|
||||
await service.IngestAsync("nvd", CreateRawAdvisory(TestCve, TestAffectsKey, "NVD-ADV"));
|
||||
await service.IngestAsync("ghsa", CreateRawAdvisory(TestCve, TestAffectsKey, "GHSA-ADV"));
|
||||
await service.IngestAsync("osv", CreateRawAdvisory(TestCve, TestAffectsKey, "OSV-ADV"));
|
||||
|
||||
// Act
|
||||
var results = await service.GetByCveAsync(TestCve);
|
||||
|
||||
// Assert - single canonical for the CVE
|
||||
results.Should().HaveCount(1);
|
||||
results[0].Cve.Should().Be(TestCve);
|
||||
results[0].SourceEdges.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that distro sources have higher precedence than NVD.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SourcePrecedence_DistroHigherThanNvd()
|
||||
{
|
||||
// Arrange
|
||||
var service = new CanonicalAdvisoryService(_store, _hashCalculator, _logger);
|
||||
|
||||
// Ingest from NVD first
|
||||
await service.IngestAsync("nvd", CreateRawAdvisory(TestCve, TestAffectsKey, "NVD-ADV"));
|
||||
// Then from Debian (higher precedence)
|
||||
await service.IngestAsync("debian", CreateRawAdvisory(TestCve, TestAffectsKey, "DSA-ADV"));
|
||||
|
||||
// Act
|
||||
var results = await service.GetByCveAsync(TestCve);
|
||||
|
||||
// Assert - Debian should be primary source (lower precedence rank = higher priority)
|
||||
results.Should().HaveCount(1);
|
||||
var canonical = results[0];
|
||||
canonical.SourceEdges.Should().HaveCount(2);
|
||||
|
||||
var debianEdge = canonical.SourceEdges.First(e => e.SourceName == "debian");
|
||||
var nvdEdge = canonical.SourceEdges.First(e => e.SourceName == "nvd");
|
||||
|
||||
// Debian (distro) should have lower precedence rank than NVD
|
||||
debianEdge.PrecedenceRank.Should().BeLessThan(nvdEdge.PrecedenceRank);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that different CVEs create separate canonical advisories.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DifferentCves_CreateSeparateCanonicals()
|
||||
{
|
||||
// Arrange
|
||||
var service = new CanonicalAdvisoryService(_store, _hashCalculator, _logger);
|
||||
|
||||
var cve1 = "CVE-2025-0001";
|
||||
var cve2 = "CVE-2025-0002";
|
||||
|
||||
// Act
|
||||
var result1 = await service.IngestAsync("nvd", CreateRawAdvisory(cve1, TestAffectsKey, "NVD-1"));
|
||||
var result2 = await service.IngestAsync("nvd", CreateRawAdvisory(cve2, TestAffectsKey, "NVD-2"));
|
||||
|
||||
// Assert - different CVEs = different canonicals
|
||||
result1.Decision.Should().Be(MergeDecision.Created);
|
||||
result2.Decision.Should().Be(MergeDecision.Created);
|
||||
result1.CanonicalId.Should().NotBe(result2.CanonicalId);
|
||||
result1.MergeHash.Should().NotBe(result2.MergeHash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that same CVE but different packages create separate canonicals.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SameCve_DifferentPackages_CreateSeparateCanonicals()
|
||||
{
|
||||
// Arrange
|
||||
var service = new CanonicalAdvisoryService(_store, _hashCalculator, _logger);
|
||||
|
||||
var package1 = "pkg:npm/lodash@4.17.21";
|
||||
var package2 = "pkg:npm/underscore@1.13.6";
|
||||
|
||||
// Act
|
||||
var result1 = await service.IngestAsync("nvd", CreateRawAdvisory(TestCve, package1, "NVD-ADV"));
|
||||
var result2 = await service.IngestAsync("nvd", CreateRawAdvisory(TestCve, package2, "NVD-ADV"));
|
||||
|
||||
// Assert - same CVE but different packages = different canonicals
|
||||
result1.Decision.Should().Be(MergeDecision.Created);
|
||||
result2.Decision.Should().Be(MergeDecision.Created);
|
||||
result1.CanonicalId.Should().NotBe(result2.CanonicalId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests duplicate ingestion (same source, same advisory) returns Duplicate decision.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DuplicateIngestion_ReturnsDuplicateDecision()
|
||||
{
|
||||
// Arrange
|
||||
var service = new CanonicalAdvisoryService(_store, _hashCalculator, _logger);
|
||||
var advisory = CreateRawAdvisory(TestCve, TestAffectsKey, "NVD-ADV");
|
||||
|
||||
// Act - ingest same advisory twice from same source
|
||||
var result1 = await service.IngestAsync("nvd", advisory);
|
||||
var result2 = await service.IngestAsync("nvd", advisory);
|
||||
|
||||
// Assert
|
||||
result1.Decision.Should().Be(MergeDecision.Created);
|
||||
result2.Decision.Should().Be(MergeDecision.Duplicate);
|
||||
result1.CanonicalId.Should().Be(result2.CanonicalId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests batch ingestion produces correct deduplication.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task BatchIngestion_ProducesCorrectDeduplication()
|
||||
{
|
||||
// Arrange
|
||||
var service = new CanonicalAdvisoryService(_store, _hashCalculator, _logger);
|
||||
|
||||
var advisories = new[]
|
||||
{
|
||||
CreateRawAdvisory("CVE-2025-0001", TestAffectsKey, "ADV-1"),
|
||||
CreateRawAdvisory("CVE-2025-0001", TestAffectsKey, "ADV-2"), // Duplicate CVE
|
||||
CreateRawAdvisory("CVE-2025-0002", TestAffectsKey, "ADV-3"),
|
||||
CreateRawAdvisory("CVE-2025-0003", TestAffectsKey, "ADV-4"),
|
||||
};
|
||||
|
||||
// Act
|
||||
var results = await service.IngestBatchAsync("nvd", advisories);
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(4);
|
||||
results[0].Decision.Should().Be(MergeDecision.Created); // First CVE-0001
|
||||
results[1].Decision.Should().Be(MergeDecision.Merged); // Second CVE-0001 merges
|
||||
results[2].Decision.Should().Be(MergeDecision.Created); // CVE-0002
|
||||
results[3].Decision.Should().Be(MergeDecision.Created); // CVE-0003
|
||||
|
||||
// First two should have same canonical ID
|
||||
results[0].CanonicalId.Should().Be(results[1].CanonicalId);
|
||||
results[0].CanonicalId.Should().NotBe(results[2].CanonicalId);
|
||||
results[2].CanonicalId.Should().NotBe(results[3].CanonicalId);
|
||||
}
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static RawAdvisory CreateRawAdvisory(
|
||||
string cve,
|
||||
string affectsKey,
|
||||
string sourceAdvisoryId,
|
||||
IReadOnlyList<string>? weaknesses = null)
|
||||
{
|
||||
return new RawAdvisory
|
||||
{
|
||||
SourceAdvisoryId = sourceAdvisoryId,
|
||||
Cve = cve,
|
||||
AffectsKey = affectsKey,
|
||||
VersionRangeJson = "{\"introduced\":\"1.0.0\",\"fixed\":\"1.2.3\"}",
|
||||
Weaknesses = weaknesses ?? [],
|
||||
Severity = "high",
|
||||
Title = $"Vulnerability in {affectsKey}",
|
||||
Summary = $"Security issue {cve} affecting {affectsKey}",
|
||||
RawPayloadJson = null,
|
||||
FetchedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region In-Memory Test Implementations
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of ICanonicalAdvisoryStore for testing.
|
||||
/// </summary>
|
||||
private sealed class InMemoryCanonicalAdvisoryStore : ICanonicalAdvisoryStore
|
||||
{
|
||||
private readonly Dictionary<Guid, CanonicalAdvisory> _canonicals = new();
|
||||
private readonly Dictionary<string, Guid> _mergeHashIndex = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, Guid> _sourceIds = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<Guid, List<SourceEdge>> _sourceEdges = new();
|
||||
private readonly Dictionary<string, Guid> _edgeHashes = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public Task<CanonicalAdvisory?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
if (_canonicals.TryGetValue(id, out var canonical))
|
||||
{
|
||||
// Attach source edges
|
||||
var edges = _sourceEdges.GetValueOrDefault(id) ?? new List<SourceEdge>();
|
||||
return Task.FromResult<CanonicalAdvisory?>(canonical with
|
||||
{
|
||||
SourceEdges = edges.OrderBy(e => e.PrecedenceRank).ToList()
|
||||
});
|
||||
}
|
||||
return Task.FromResult<CanonicalAdvisory?>(null);
|
||||
}
|
||||
|
||||
public Task<CanonicalAdvisory?> GetByMergeHashAsync(string mergeHash, CancellationToken ct = default)
|
||||
{
|
||||
if (_mergeHashIndex.TryGetValue(mergeHash, out var id))
|
||||
{
|
||||
return GetByIdAsync(id, ct);
|
||||
}
|
||||
return Task.FromResult<CanonicalAdvisory?>(null);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<CanonicalAdvisory>> GetByCveAsync(string cve, CancellationToken ct = default)
|
||||
{
|
||||
var results = _canonicals.Values
|
||||
.Where(c => c.Cve.Equals(cve, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(c => c with
|
||||
{
|
||||
SourceEdges = (_sourceEdges.GetValueOrDefault(c.Id) ?? new List<SourceEdge>())
|
||||
.OrderBy(e => e.PrecedenceRank)
|
||||
.ToList()
|
||||
})
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<CanonicalAdvisory>>(results);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<CanonicalAdvisory>> GetByArtifactAsync(string artifactKey, CancellationToken ct = default)
|
||||
{
|
||||
var results = _canonicals.Values
|
||||
.Where(c => c.AffectsKey.Equals(artifactKey, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(c => c with
|
||||
{
|
||||
SourceEdges = (_sourceEdges.GetValueOrDefault(c.Id) ?? new List<SourceEdge>())
|
||||
.OrderBy(e => e.PrecedenceRank)
|
||||
.ToList()
|
||||
})
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<CanonicalAdvisory>>(results);
|
||||
}
|
||||
|
||||
public Task<PagedResult<CanonicalAdvisory>> QueryAsync(CanonicalQueryOptions options, CancellationToken ct = default)
|
||||
{
|
||||
var query = _canonicals.Values.AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Cve))
|
||||
query = query.Where(c => c.Cve.Equals(options.Cve, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(options.Severity))
|
||||
query = query.Where(c => c.Severity == options.Severity);
|
||||
|
||||
var total = query.Count();
|
||||
var items = query.Skip(options.Offset).Take(options.Limit).ToList();
|
||||
|
||||
return Task.FromResult(new PagedResult<CanonicalAdvisory>
|
||||
{
|
||||
Items = items,
|
||||
TotalCount = total,
|
||||
Offset = options.Offset,
|
||||
Limit = options.Limit
|
||||
});
|
||||
}
|
||||
|
||||
public Task<Guid> UpsertCanonicalAsync(UpsertCanonicalRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guid id;
|
||||
if (_mergeHashIndex.TryGetValue(request.MergeHash, out id))
|
||||
{
|
||||
// Update existing
|
||||
var existing = _canonicals[id];
|
||||
_canonicals[id] = existing with
|
||||
{
|
||||
Severity = request.Severity ?? existing.Severity,
|
||||
Title = request.Title ?? existing.Title,
|
||||
Summary = request.Summary ?? existing.Summary,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
// Create new
|
||||
id = Guid.NewGuid();
|
||||
_mergeHashIndex[request.MergeHash] = id;
|
||||
_canonicals[id] = new CanonicalAdvisory
|
||||
{
|
||||
Id = id,
|
||||
Cve = request.Cve,
|
||||
AffectsKey = request.AffectsKey,
|
||||
MergeHash = request.MergeHash,
|
||||
Weaknesses = request.Weaknesses ?? [],
|
||||
Severity = request.Severity,
|
||||
Title = request.Title,
|
||||
Summary = request.Summary,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
return Task.FromResult(id);
|
||||
}
|
||||
|
||||
public Task<SourceEdgeResult> AddSourceEdgeAsync(AddSourceEdgeRequest request, CancellationToken ct = default)
|
||||
{
|
||||
// Create unique key for edge (canonical + source + doc hash)
|
||||
var edgeKey = $"{request.CanonicalId}|{request.SourceId}|{request.SourceDocHash}";
|
||||
|
||||
if (_edgeHashes.TryGetValue(edgeKey, out var existingId))
|
||||
{
|
||||
// Duplicate edge - return existing
|
||||
return Task.FromResult(SourceEdgeResult.Existing(existingId));
|
||||
}
|
||||
|
||||
var edgeId = Guid.NewGuid();
|
||||
var edge = new SourceEdge
|
||||
{
|
||||
Id = edgeId,
|
||||
SourceName = GetSourceName(request.SourceId),
|
||||
SourceAdvisoryId = request.SourceAdvisoryId,
|
||||
SourceDocHash = request.SourceDocHash,
|
||||
VendorStatus = request.VendorStatus,
|
||||
PrecedenceRank = request.PrecedenceRank,
|
||||
FetchedAt = request.FetchedAt
|
||||
};
|
||||
|
||||
if (!_sourceEdges.ContainsKey(request.CanonicalId))
|
||||
{
|
||||
_sourceEdges[request.CanonicalId] = new List<SourceEdge>();
|
||||
}
|
||||
_sourceEdges[request.CanonicalId].Add(edge);
|
||||
_edgeHashes[edgeKey] = edgeId;
|
||||
|
||||
return Task.FromResult(SourceEdgeResult.Created(edgeId));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<SourceEdge>> GetSourceEdgesAsync(Guid canonicalId, CancellationToken ct = default)
|
||||
{
|
||||
var edges = _sourceEdges.GetValueOrDefault(canonicalId) ?? new List<SourceEdge>();
|
||||
return Task.FromResult<IReadOnlyList<SourceEdge>>(edges.OrderBy(e => e.PrecedenceRank).ToList());
|
||||
}
|
||||
|
||||
public Task<bool> SourceEdgeExistsAsync(Guid canonicalId, Guid sourceId, string docHash, CancellationToken ct = default)
|
||||
{
|
||||
var edgeKey = $"{canonicalId}|{sourceId}|{docHash}";
|
||||
return Task.FromResult(_edgeHashes.ContainsKey(edgeKey));
|
||||
}
|
||||
|
||||
public Task<Guid> ResolveSourceIdAsync(string sourceName, CancellationToken ct = default)
|
||||
{
|
||||
if (!_sourceIds.TryGetValue(sourceName, out var id))
|
||||
{
|
||||
id = Guid.NewGuid();
|
||||
_sourceIds[sourceName] = id;
|
||||
}
|
||||
return Task.FromResult(id);
|
||||
}
|
||||
|
||||
public Task<int> GetSourcePrecedenceAsync(string sourceKey, CancellationToken ct = default)
|
||||
{
|
||||
// Source precedence (lower = higher priority)
|
||||
var rank = sourceKey.ToLowerInvariant() switch
|
||||
{
|
||||
"cisco" or "oracle" or "microsoft" or "adobe" => 10, // Vendor PSIRT
|
||||
"redhat" or "debian" or "suse" or "ubuntu" or "alpine" => 20, // Distro
|
||||
"osv" => 30,
|
||||
"ghsa" => 35,
|
||||
"nvd" => 40,
|
||||
_ => 100 // Community
|
||||
};
|
||||
return Task.FromResult(rank);
|
||||
}
|
||||
|
||||
public Task UpdateStatusAsync(Guid id, CanonicalStatus status, CancellationToken ct = default)
|
||||
{
|
||||
if (_canonicals.TryGetValue(id, out var existing))
|
||||
{
|
||||
_canonicals[id] = existing with { Status = status, UpdatedAt = DateTimeOffset.UtcNow };
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<long> CountAsync(CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult((long)_canonicals.Count);
|
||||
}
|
||||
|
||||
private string GetSourceName(Guid sourceId)
|
||||
{
|
||||
return _sourceIds.FirstOrDefault(kvp => kvp.Value == sourceId).Key ?? "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Real implementation of IMergeHashCalculator for testing.
|
||||
/// </summary>
|
||||
private sealed class RealMergeHashCalculator : IMergeHashCalculator
|
||||
{
|
||||
public string ComputeMergeHash(MergeHashInput input)
|
||||
{
|
||||
// Compute deterministic hash from: CVE | AFFECTS | VERSION | CWE | LINEAGE
|
||||
var components = new List<string>
|
||||
{
|
||||
input.Cve?.ToUpperInvariant() ?? "",
|
||||
input.AffectsKey?.ToLowerInvariant() ?? ""
|
||||
};
|
||||
|
||||
// VersionRange is a string (JSON), include if present
|
||||
if (!string.IsNullOrWhiteSpace(input.VersionRange))
|
||||
{
|
||||
components.Add(input.VersionRange);
|
||||
}
|
||||
|
||||
if (input.Weaknesses?.Count > 0)
|
||||
{
|
||||
components.AddRange(input.Weaknesses.OrderBy(w => w, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(input.PatchLineage))
|
||||
{
|
||||
components.Add(input.PatchLineage);
|
||||
}
|
||||
|
||||
var combined = string.Join("|", components);
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
var hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(combined));
|
||||
return "sha256:" + Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// InterestScoreCalculatorTests.cs
|
||||
// Sprint: SPRINT_8200_0013_0002_CONCEL_interest_scoring
|
||||
// Task: ISCORE-8200-013
|
||||
// Description: Unit tests for InterestScoreCalculator
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Concelier.Interest.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Interest.Tests;
|
||||
|
||||
public class InterestScoreCalculatorTests
|
||||
{
|
||||
private readonly InterestScoreCalculator _calculator;
|
||||
private readonly InterestScoreWeights _defaultWeights = new();
|
||||
|
||||
public InterestScoreCalculatorTests()
|
||||
{
|
||||
_calculator = new InterestScoreCalculator(_defaultWeights);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_WithNoSignals_ReturnsBaseScore()
|
||||
{
|
||||
// Arrange
|
||||
var input = new InterestScoreInput
|
||||
{
|
||||
CanonicalId = Guid.NewGuid(),
|
||||
SbomMatches = [],
|
||||
VexStatements = [],
|
||||
RuntimeSignals = []
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _calculator.Calculate(input);
|
||||
|
||||
// Assert
|
||||
// Only no_vex_na applies (0.15) when no signals
|
||||
result.Score.Should().Be(0.15);
|
||||
result.Reasons.Should().Contain("no_vex_na");
|
||||
result.Reasons.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_WithSbomMatch_AddsInSbomFactor()
|
||||
{
|
||||
// Arrange
|
||||
var input = new InterestScoreInput
|
||||
{
|
||||
CanonicalId = Guid.NewGuid(),
|
||||
SbomMatches =
|
||||
[
|
||||
new SbomMatch
|
||||
{
|
||||
SbomDigest = "sha256:abc123",
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
IsReachable = false,
|
||||
IsDeployed = false,
|
||||
ScannedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _calculator.Calculate(input);
|
||||
|
||||
// Assert
|
||||
result.Score.Should().Be(0.45); // in_sbom (0.30) + no_vex_na (0.15)
|
||||
result.Reasons.Should().Contain("in_sbom");
|
||||
result.Reasons.Should().Contain("no_vex_na");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_WithReachableSbomMatch_AddsReachableFactor()
|
||||
{
|
||||
// Arrange
|
||||
var input = new InterestScoreInput
|
||||
{
|
||||
CanonicalId = Guid.NewGuid(),
|
||||
SbomMatches =
|
||||
[
|
||||
new SbomMatch
|
||||
{
|
||||
SbomDigest = "sha256:abc123",
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
IsReachable = true,
|
||||
IsDeployed = false,
|
||||
ScannedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _calculator.Calculate(input);
|
||||
|
||||
// Assert
|
||||
result.Score.Should().Be(0.70); // in_sbom (0.30) + reachable (0.25) + no_vex_na (0.15)
|
||||
result.Reasons.Should().Contain("in_sbom");
|
||||
result.Reasons.Should().Contain("reachable");
|
||||
result.Reasons.Should().Contain("no_vex_na");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_WithDeployedSbomMatch_AddsDeployedFactor()
|
||||
{
|
||||
// Arrange
|
||||
var input = new InterestScoreInput
|
||||
{
|
||||
CanonicalId = Guid.NewGuid(),
|
||||
SbomMatches =
|
||||
[
|
||||
new SbomMatch
|
||||
{
|
||||
SbomDigest = "sha256:abc123",
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
IsReachable = false,
|
||||
IsDeployed = true,
|
||||
ScannedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _calculator.Calculate(input);
|
||||
|
||||
// Assert
|
||||
result.Score.Should().Be(0.65); // in_sbom (0.30) + deployed (0.20) + no_vex_na (0.15)
|
||||
result.Reasons.Should().Contain("in_sbom");
|
||||
result.Reasons.Should().Contain("deployed");
|
||||
result.Reasons.Should().Contain("no_vex_na");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_WithFullSbomMatch_AddsAllSbomFactors()
|
||||
{
|
||||
// Arrange
|
||||
var input = new InterestScoreInput
|
||||
{
|
||||
CanonicalId = Guid.NewGuid(),
|
||||
SbomMatches =
|
||||
[
|
||||
new SbomMatch
|
||||
{
|
||||
SbomDigest = "sha256:abc123",
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
IsReachable = true,
|
||||
IsDeployed = true,
|
||||
ScannedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _calculator.Calculate(input);
|
||||
|
||||
// Assert
|
||||
result.Score.Should().Be(0.90); // in_sbom (0.30) + reachable (0.25) + deployed (0.20) + no_vex_na (0.15)
|
||||
result.Reasons.Should().Contain("in_sbom");
|
||||
result.Reasons.Should().Contain("reachable");
|
||||
result.Reasons.Should().Contain("deployed");
|
||||
result.Reasons.Should().Contain("no_vex_na");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_WithVexNotAffected_ExcludesVexFactor()
|
||||
{
|
||||
// Arrange
|
||||
var input = new InterestScoreInput
|
||||
{
|
||||
CanonicalId = Guid.NewGuid(),
|
||||
SbomMatches =
|
||||
[
|
||||
new SbomMatch
|
||||
{
|
||||
SbomDigest = "sha256:abc123",
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
IsReachable = true,
|
||||
IsDeployed = true,
|
||||
ScannedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
],
|
||||
VexStatements =
|
||||
[
|
||||
new VexStatement
|
||||
{
|
||||
StatementId = "VEX-001",
|
||||
Status = VexStatus.NotAffected,
|
||||
Justification = "Component not used in affected context"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _calculator.Calculate(input);
|
||||
|
||||
// Assert
|
||||
result.Score.Should().Be(0.75); // in_sbom (0.30) + reachable (0.25) + deployed (0.20) - NO no_vex_na
|
||||
result.Reasons.Should().Contain("in_sbom");
|
||||
result.Reasons.Should().Contain("reachable");
|
||||
result.Reasons.Should().Contain("deployed");
|
||||
result.Reasons.Should().NotContain("no_vex_na");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_WithRecentLastSeen_AddsRecentFactor()
|
||||
{
|
||||
// Arrange
|
||||
var input = new InterestScoreInput
|
||||
{
|
||||
CanonicalId = Guid.NewGuid(),
|
||||
SbomMatches =
|
||||
[
|
||||
new SbomMatch
|
||||
{
|
||||
SbomDigest = "sha256:abc123",
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
ScannedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
],
|
||||
LastSeenInBuild = DateTimeOffset.UtcNow.AddDays(-7) // 7 days ago
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _calculator.Calculate(input);
|
||||
|
||||
// Assert
|
||||
// in_sbom (0.30) + no_vex_na (0.15) + recent (~0.098 for 7 days)
|
||||
result.Score.Should().BeApproximately(0.55, 0.02);
|
||||
result.Reasons.Should().Contain("in_sbom");
|
||||
result.Reasons.Should().Contain("no_vex_na");
|
||||
result.Reasons.Should().Contain("recent");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_WithOldLastSeen_DecaysRecentFactor()
|
||||
{
|
||||
// Arrange
|
||||
var input = new InterestScoreInput
|
||||
{
|
||||
CanonicalId = Guid.NewGuid(),
|
||||
SbomMatches =
|
||||
[
|
||||
new SbomMatch
|
||||
{
|
||||
SbomDigest = "sha256:abc123",
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
ScannedAt = DateTimeOffset.UtcNow.AddDays(-300)
|
||||
}
|
||||
],
|
||||
LastSeenInBuild = DateTimeOffset.UtcNow.AddDays(-300) // 300 days ago
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _calculator.Calculate(input);
|
||||
|
||||
// Assert
|
||||
// in_sbom (0.30) + no_vex_na (0.15) + recent (~0.018 for 300 days, no "recent" reason)
|
||||
result.Score.Should().BeApproximately(0.47, 0.02);
|
||||
result.Reasons.Should().Contain("in_sbom");
|
||||
result.Reasons.Should().Contain("no_vex_na");
|
||||
result.Reasons.Should().NotContain("recent"); // decayFactor < 0.5
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_WithVeryOldLastSeen_NoRecentFactor()
|
||||
{
|
||||
// Arrange
|
||||
var input = new InterestScoreInput
|
||||
{
|
||||
CanonicalId = Guid.NewGuid(),
|
||||
SbomMatches = [],
|
||||
LastSeenInBuild = DateTimeOffset.UtcNow.AddDays(-400) // > 1 year
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _calculator.Calculate(input);
|
||||
|
||||
// Assert
|
||||
// Only no_vex_na (0.15), no recent factor (decayed to 0)
|
||||
result.Score.Should().Be(0.15);
|
||||
result.Reasons.Should().Contain("no_vex_na");
|
||||
result.Reasons.Should().NotContain("recent");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_MaxScore_IsCappedAt1()
|
||||
{
|
||||
// Arrange - use custom weights that exceed 1.0
|
||||
var heavyWeights = new InterestScoreWeights
|
||||
{
|
||||
InSbom = 0.50,
|
||||
Reachable = 0.40,
|
||||
Deployed = 0.30,
|
||||
NoVexNotAffected = 0.20,
|
||||
Recent = 0.10
|
||||
};
|
||||
var calculator = new InterestScoreCalculator(heavyWeights);
|
||||
|
||||
var input = new InterestScoreInput
|
||||
{
|
||||
CanonicalId = Guid.NewGuid(),
|
||||
SbomMatches =
|
||||
[
|
||||
new SbomMatch
|
||||
{
|
||||
SbomDigest = "sha256:abc123",
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
IsReachable = true,
|
||||
IsDeployed = true,
|
||||
ScannedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
],
|
||||
LastSeenInBuild = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = calculator.Calculate(input);
|
||||
|
||||
// Assert
|
||||
result.Score.Should().Be(1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_SetsComputedAtToNow()
|
||||
{
|
||||
// Arrange
|
||||
var input = new InterestScoreInput { CanonicalId = Guid.NewGuid() };
|
||||
var before = DateTimeOffset.UtcNow;
|
||||
|
||||
// Act
|
||||
var result = _calculator.Calculate(input);
|
||||
var after = DateTimeOffset.UtcNow;
|
||||
|
||||
// Assert
|
||||
result.ComputedAt.Should().BeOnOrAfter(before);
|
||||
result.ComputedAt.Should().BeOnOrBefore(after);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_PreservesCanonicalId()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalId = Guid.NewGuid();
|
||||
var input = new InterestScoreInput { CanonicalId = canonicalId };
|
||||
|
||||
// Act
|
||||
var result = _calculator.Calculate(input);
|
||||
|
||||
// Assert
|
||||
result.CanonicalId.Should().Be(canonicalId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(VexStatus.Affected)]
|
||||
[InlineData(VexStatus.Fixed)]
|
||||
[InlineData(VexStatus.UnderInvestigation)]
|
||||
public void Calculate_WithNonExcludingVexStatus_IncludesNoVexNaFactor(VexStatus status)
|
||||
{
|
||||
// Arrange
|
||||
var input = new InterestScoreInput
|
||||
{
|
||||
CanonicalId = Guid.NewGuid(),
|
||||
VexStatements =
|
||||
[
|
||||
new VexStatement
|
||||
{
|
||||
StatementId = "VEX-001",
|
||||
Status = status
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _calculator.Calculate(input);
|
||||
|
||||
// Assert
|
||||
result.Reasons.Should().Contain("no_vex_na");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InterestTier_HighScore_ReturnsHigh()
|
||||
{
|
||||
// Arrange
|
||||
var score = new InterestScore
|
||||
{
|
||||
CanonicalId = Guid.NewGuid(),
|
||||
Score = 0.75,
|
||||
Reasons = [],
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Assert
|
||||
score.Tier.Should().Be(InterestTier.High);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InterestTier_MediumScore_ReturnsMedium()
|
||||
{
|
||||
// Arrange
|
||||
var score = new InterestScore
|
||||
{
|
||||
CanonicalId = Guid.NewGuid(),
|
||||
Score = 0.50,
|
||||
Reasons = [],
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Assert
|
||||
score.Tier.Should().Be(InterestTier.Medium);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InterestTier_LowScore_ReturnsLow()
|
||||
{
|
||||
// Arrange
|
||||
var score = new InterestScore
|
||||
{
|
||||
CanonicalId = Guid.NewGuid(),
|
||||
Score = 0.30,
|
||||
Reasons = [],
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Assert
|
||||
score.Tier.Should().Be(InterestTier.Low);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InterestTier_NoneScore_ReturnsNone()
|
||||
{
|
||||
// Arrange
|
||||
var score = new InterestScore
|
||||
{
|
||||
CanonicalId = Guid.NewGuid(),
|
||||
Score = 0.10,
|
||||
Reasons = [],
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Assert
|
||||
score.Tier.Should().Be(InterestTier.None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?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.Interest.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="8.0.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Concelier.Interest\StellaOps.Concelier.Interest.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,658 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SyncLedgerRepositoryTests.cs
|
||||
// Sprint: SPRINT_8200_0014_0001_DB_sync_ledger_schema
|
||||
// Tasks: SYNC-8200-003 (migration), SYNC-8200-008 (repo), SYNC-8200-012 (cursor), SYNC-8200-016 (policy)
|
||||
// Description: Integration tests for SyncLedger repository and schema
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Dapper;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using StellaOps.Concelier.Storage.Postgres.Models;
|
||||
using StellaOps.Concelier.Storage.Postgres.Repositories;
|
||||
using StellaOps.Concelier.Storage.Postgres.Sync;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Postgres.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for SyncLedgerRepository and SitePolicyEnforcementService.
|
||||
/// Covers Tasks 3, 8, 12, and 16 from SPRINT_8200_0014_0001.
|
||||
/// </summary>
|
||||
[Collection(ConcelierPostgresCollection.Name)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Trait("Category", "SyncLedger")]
|
||||
public sealed class SyncLedgerRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ConcelierPostgresFixture _fixture;
|
||||
private readonly ConcelierDataSource _dataSource;
|
||||
private readonly SyncLedgerRepository _repository;
|
||||
private readonly SitePolicyEnforcementService _policyService;
|
||||
|
||||
public SyncLedgerRepositoryTests(ConcelierPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
var options = fixture.Fixture.CreateOptions();
|
||||
_dataSource = new ConcelierDataSource(Options.Create(options), NullLogger<ConcelierDataSource>.Instance);
|
||||
_repository = new SyncLedgerRepository(_dataSource, NullLogger<SyncLedgerRepository>.Instance);
|
||||
_policyService = new SitePolicyEnforcementService(_repository, NullLogger<SitePolicyEnforcementService>.Instance);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
#region Task 3: Migration Validation
|
||||
|
||||
[Fact]
|
||||
public async Task Migration_SyncLedgerTableExists()
|
||||
{
|
||||
// Assert
|
||||
await using var connection = new NpgsqlConnection(_fixture.Fixture.ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
var exists = await connection.ExecuteScalarAsync<bool>(
|
||||
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'vuln' AND table_name = 'sync_ledger')");
|
||||
|
||||
exists.Should().BeTrue("sync_ledger table should exist after migration");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Migration_SitePolicyTableExists()
|
||||
{
|
||||
// Assert
|
||||
await using var connection = new NpgsqlConnection(_fixture.Fixture.ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
var exists = await connection.ExecuteScalarAsync<bool>(
|
||||
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'vuln' AND table_name = 'site_policy')");
|
||||
|
||||
exists.Should().BeTrue("site_policy table should exist after migration");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Migration_IndexesExist()
|
||||
{
|
||||
// Assert
|
||||
await using var connection = new NpgsqlConnection(_fixture.Fixture.ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
var indexes = await connection.QueryAsync<string>(
|
||||
@"SELECT indexname FROM pg_indexes
|
||||
WHERE schemaname = 'vuln'
|
||||
AND (tablename = 'sync_ledger' OR tablename = 'site_policy')");
|
||||
|
||||
var indexList = indexes.ToList();
|
||||
indexList.Should().Contain("idx_sync_ledger_site");
|
||||
indexList.Should().Contain("idx_sync_ledger_site_time");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Migration_ConstraintsExist()
|
||||
{
|
||||
// Assert
|
||||
await using var connection = new NpgsqlConnection(_fixture.Fixture.ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
var constraints = await connection.QueryAsync<string>(
|
||||
@"SELECT constraint_name FROM information_schema.table_constraints
|
||||
WHERE table_schema = 'vuln'
|
||||
AND table_name = 'sync_ledger'
|
||||
AND constraint_type = 'UNIQUE'");
|
||||
|
||||
var constraintList = constraints.ToList();
|
||||
constraintList.Should().Contain("uq_sync_ledger_site_cursor");
|
||||
constraintList.Should().Contain("uq_sync_ledger_bundle");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Task 8: Repository Operations
|
||||
|
||||
[Fact]
|
||||
public async Task InsertAsync_CreatesLedgerEntry()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateLedgerEntry("site-test-001", "sha256:abc123", 100);
|
||||
|
||||
// Act
|
||||
var id = await _repository.InsertAsync(entry);
|
||||
|
||||
// Assert
|
||||
id.Should().NotBe(Guid.Empty);
|
||||
|
||||
var retrieved = await _repository.GetLatestAsync("site-test-001");
|
||||
retrieved.Should().NotBeNull();
|
||||
retrieved!.SiteId.Should().Be("site-test-001");
|
||||
retrieved.BundleHash.Should().Be("sha256:abc123");
|
||||
retrieved.ItemsCount.Should().Be(100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLatestAsync_ReturnsNewestEntry()
|
||||
{
|
||||
// Arrange
|
||||
var siteId = $"site-latest-{Guid.NewGuid():N}";
|
||||
var baseTime = DateTimeOffset.UtcNow.AddHours(-2);
|
||||
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
await _repository.InsertAsync(CreateLedgerEntry(
|
||||
siteId,
|
||||
$"hash-{i}-{Guid.NewGuid():N}",
|
||||
(i + 1) * 10,
|
||||
baseTime.AddMinutes(i * 10),
|
||||
i));
|
||||
}
|
||||
|
||||
// Act
|
||||
var latest = await _repository.GetLatestAsync(siteId);
|
||||
|
||||
// Assert
|
||||
latest.Should().NotBeNull();
|
||||
latest!.ItemsCount.Should().Be(30); // The third entry (30 items)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetHistoryAsync_ReturnsEntriesInDescendingOrder()
|
||||
{
|
||||
// Arrange
|
||||
var siteId = $"site-history-{Guid.NewGuid():N}";
|
||||
var baseTime = DateTimeOffset.UtcNow.AddHours(-1);
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await _repository.InsertAsync(CreateLedgerEntry(
|
||||
siteId,
|
||||
$"hash-hist-{i}-{Guid.NewGuid():N}",
|
||||
(i + 1) * 5,
|
||||
baseTime.AddMinutes(i * 5),
|
||||
i));
|
||||
}
|
||||
|
||||
// Act
|
||||
var history = await _repository.GetHistoryAsync(siteId, limit: 3);
|
||||
|
||||
// Assert
|
||||
history.Should().HaveCount(3);
|
||||
history[0].ItemsCount.Should().Be(25); // Most recent
|
||||
history[1].ItemsCount.Should().Be(20);
|
||||
history[2].ItemsCount.Should().Be(15);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByBundleHashAsync_FindsEntry()
|
||||
{
|
||||
// Arrange
|
||||
var uniqueHash = $"sha256:unique-{Guid.NewGuid():N}";
|
||||
await _repository.InsertAsync(CreateLedgerEntry("site-hash-test", uniqueHash, 42));
|
||||
|
||||
// Act
|
||||
var found = await _repository.GetByBundleHashAsync(uniqueHash);
|
||||
|
||||
// Assert
|
||||
found.Should().NotBeNull();
|
||||
found!.BundleHash.Should().Be(uniqueHash);
|
||||
found.ItemsCount.Should().Be(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByBundleHashAsync_ReturnsNull_WhenNotFound()
|
||||
{
|
||||
// Act
|
||||
var result = await _repository.GetByBundleHashAsync("sha256:nonexistent");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertPolicyAsync_CreatesPolicyWhenNew()
|
||||
{
|
||||
// Arrange
|
||||
var policy = CreatePolicy($"site-policy-{Guid.NewGuid():N}", "Test Site", ["nvd", "ghsa"], ["untrusted-*"], 50, 5000);
|
||||
|
||||
// Act
|
||||
await _repository.UpsertPolicyAsync(policy);
|
||||
|
||||
// Assert
|
||||
var retrieved = await _repository.GetPolicyAsync(policy.SiteId);
|
||||
retrieved.Should().NotBeNull();
|
||||
retrieved!.DisplayName.Should().Be("Test Site");
|
||||
retrieved.AllowedSources.Should().BeEquivalentTo(["nvd", "ghsa"]);
|
||||
retrieved.MaxBundleSizeMb.Should().Be(50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertPolicyAsync_UpdatesExistingPolicy()
|
||||
{
|
||||
// Arrange
|
||||
var siteId = $"site-upsert-{Guid.NewGuid():N}";
|
||||
await _repository.UpsertPolicyAsync(CreatePolicy(siteId, "Original", maxSizeMb: 100, enabled: true));
|
||||
|
||||
// Act - Update
|
||||
await _repository.UpsertPolicyAsync(CreatePolicy(siteId, "Updated", maxSizeMb: 200, enabled: false));
|
||||
|
||||
// Assert
|
||||
var retrieved = await _repository.GetPolicyAsync(siteId);
|
||||
retrieved.Should().NotBeNull();
|
||||
retrieved!.DisplayName.Should().Be("Updated");
|
||||
retrieved.MaxBundleSizeMb.Should().Be(200);
|
||||
retrieved.Enabled.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllPoliciesAsync_FiltersEnabledOnly()
|
||||
{
|
||||
// Arrange
|
||||
var prefix = $"bulk-{Guid.NewGuid():N}";
|
||||
await _repository.UpsertPolicyAsync(CreatePolicy($"{prefix}-enabled", enabled: true));
|
||||
await _repository.UpsertPolicyAsync(CreatePolicy($"{prefix}-disabled", enabled: false));
|
||||
|
||||
// Act
|
||||
var enabledOnly = await _repository.GetAllPoliciesAsync(enabledOnly: true);
|
||||
var all = await _repository.GetAllPoliciesAsync(enabledOnly: false);
|
||||
|
||||
// Assert
|
||||
enabledOnly.Should().Contain(p => p.SiteId == $"{prefix}-enabled");
|
||||
enabledOnly.Should().NotContain(p => p.SiteId == $"{prefix}-disabled");
|
||||
all.Should().Contain(p => p.SiteId == $"{prefix}-enabled");
|
||||
all.Should().Contain(p => p.SiteId == $"{prefix}-disabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatisticsAsync_ReturnsCorrectCounts()
|
||||
{
|
||||
// Arrange - Create some test data
|
||||
var siteId = $"stats-{Guid.NewGuid():N}";
|
||||
await _repository.UpsertPolicyAsync(CreatePolicy(siteId, enabled: true));
|
||||
await _repository.InsertAsync(CreateLedgerEntry(siteId, $"stats-hash-{Guid.NewGuid():N}", 100));
|
||||
|
||||
// Act
|
||||
var stats = await _repository.GetStatisticsAsync();
|
||||
|
||||
// Assert
|
||||
stats.TotalSites.Should().BeGreaterThan(0);
|
||||
stats.TotalBundlesImported.Should().BeGreaterThan(0);
|
||||
stats.TotalItemsImported.Should().BeGreaterThanOrEqualTo(100);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Task 12: Cursor Advancement and Conflict Handling
|
||||
|
||||
[Fact]
|
||||
public async Task GetCursorAsync_ReturnsLatestCursor()
|
||||
{
|
||||
// Arrange
|
||||
var siteId = $"cursor-{Guid.NewGuid():N}";
|
||||
var cursor1 = CursorFormat.Create(DateTimeOffset.UtcNow.AddMinutes(-10), 0);
|
||||
var cursor2 = CursorFormat.Create(DateTimeOffset.UtcNow, 1);
|
||||
|
||||
await _repository.AdvanceCursorAsync(siteId, cursor1, $"hash1-{Guid.NewGuid():N}", 10, DateTimeOffset.UtcNow.AddMinutes(-10));
|
||||
await _repository.AdvanceCursorAsync(siteId, cursor2, $"hash2-{Guid.NewGuid():N}", 20, DateTimeOffset.UtcNow);
|
||||
|
||||
// Act
|
||||
var currentCursor = await _repository.GetCursorAsync(siteId);
|
||||
|
||||
// Assert
|
||||
currentCursor.Should().Be(cursor2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCursorAsync_ReturnsNull_WhenNoHistory()
|
||||
{
|
||||
// Act
|
||||
var cursor = await _repository.GetCursorAsync($"nonexistent-{Guid.NewGuid():N}");
|
||||
|
||||
// Assert
|
||||
cursor.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdvanceCursorAsync_CreatesLedgerEntry()
|
||||
{
|
||||
// Arrange
|
||||
var siteId = $"advance-{Guid.NewGuid():N}";
|
||||
var cursor = CursorFormat.Create(DateTimeOffset.UtcNow, 42);
|
||||
var bundleHash = $"adv-hash-{Guid.NewGuid():N}";
|
||||
|
||||
// Act
|
||||
await _repository.AdvanceCursorAsync(siteId, cursor, bundleHash, 150, DateTimeOffset.UtcNow);
|
||||
|
||||
// Assert
|
||||
var entry = await _repository.GetLatestAsync(siteId);
|
||||
entry.Should().NotBeNull();
|
||||
entry!.Cursor.Should().Be(cursor);
|
||||
entry.BundleHash.Should().Be(bundleHash);
|
||||
entry.ItemsCount.Should().Be(150);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsCursorConflictAsync_ReturnsFalse_WhenCursorIsNewer()
|
||||
{
|
||||
// Arrange
|
||||
var siteId = $"conflict-newer-{Guid.NewGuid():N}";
|
||||
var oldCursor = CursorFormat.Create(DateTimeOffset.UtcNow.AddHours(-1), 0);
|
||||
var newCursor = CursorFormat.Create(DateTimeOffset.UtcNow, 1);
|
||||
|
||||
await _repository.AdvanceCursorAsync(siteId, oldCursor, $"ch1-{Guid.NewGuid():N}", 10, DateTimeOffset.UtcNow.AddHours(-1));
|
||||
|
||||
// Act
|
||||
var isConflict = await _repository.IsCursorConflictAsync(siteId, newCursor);
|
||||
|
||||
// Assert
|
||||
isConflict.Should().BeFalse("newer cursor should not conflict");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsCursorConflictAsync_ReturnsTrue_WhenCursorIsOlder()
|
||||
{
|
||||
// Arrange
|
||||
var siteId = $"conflict-older-{Guid.NewGuid():N}";
|
||||
var currentCursor = CursorFormat.Create(DateTimeOffset.UtcNow, 1);
|
||||
var olderCursor = CursorFormat.Create(DateTimeOffset.UtcNow.AddHours(-1), 0);
|
||||
|
||||
await _repository.AdvanceCursorAsync(siteId, currentCursor, $"ch2-{Guid.NewGuid():N}", 10, DateTimeOffset.UtcNow);
|
||||
|
||||
// Act
|
||||
var isConflict = await _repository.IsCursorConflictAsync(siteId, olderCursor);
|
||||
|
||||
// Assert
|
||||
isConflict.Should().BeTrue("older cursor should conflict with current");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsCursorConflictAsync_ReturnsFalse_WhenNoExistingCursor()
|
||||
{
|
||||
// Act
|
||||
var isConflict = await _repository.IsCursorConflictAsync(
|
||||
$"no-cursor-{Guid.NewGuid():N}",
|
||||
CursorFormat.Create(DateTimeOffset.UtcNow, 0));
|
||||
|
||||
// Assert
|
||||
isConflict.Should().BeFalse("no existing cursor means no conflict");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CursorFormat_Create_ProducesValidFormat()
|
||||
{
|
||||
// Arrange
|
||||
var timestamp = DateTimeOffset.Parse("2025-01-15T10:30:00.000Z");
|
||||
|
||||
// Act
|
||||
var cursor = CursorFormat.Create(timestamp, 42);
|
||||
|
||||
// Assert
|
||||
cursor.Should().Contain("2025-01-15");
|
||||
cursor.Should().EndWith("#0042");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CursorFormat_Parse_ExtractsComponents()
|
||||
{
|
||||
// Arrange
|
||||
var cursor = "2025-01-15T10:30:00.0000000+00:00#0042";
|
||||
|
||||
// Act
|
||||
var (timestamp, sequence) = CursorFormat.Parse(cursor);
|
||||
|
||||
// Assert
|
||||
timestamp.Year.Should().Be(2025);
|
||||
timestamp.Month.Should().Be(1);
|
||||
timestamp.Day.Should().Be(15);
|
||||
sequence.Should().Be(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CursorFormat_IsAfter_ComparesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var earlier = CursorFormat.Create(DateTimeOffset.Parse("2025-01-15T10:00:00Z"), 0);
|
||||
var later = CursorFormat.Create(DateTimeOffset.Parse("2025-01-15T11:00:00Z"), 0);
|
||||
var sameTimeHigherSeq = CursorFormat.Create(DateTimeOffset.Parse("2025-01-15T10:00:00Z"), 5);
|
||||
|
||||
// Assert
|
||||
CursorFormat.IsAfter(later, earlier).Should().BeTrue("later timestamp is after");
|
||||
CursorFormat.IsAfter(earlier, later).Should().BeFalse("earlier timestamp is not after");
|
||||
CursorFormat.IsAfter(sameTimeHigherSeq, earlier).Should().BeTrue("higher sequence is after");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Task 16: Policy Enforcement
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateSourceAsync_AllowsWhenNoPolicy()
|
||||
{
|
||||
// Act
|
||||
var result = await _policyService.ValidateSourceAsync($"no-policy-{Guid.NewGuid():N}", "nvd");
|
||||
|
||||
// Assert
|
||||
result.IsAllowed.Should().BeTrue();
|
||||
result.Reason.Should().Contain("No policy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateSourceAsync_DeniesWhenPolicyDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var siteId = $"disabled-policy-{Guid.NewGuid():N}";
|
||||
await _repository.UpsertPolicyAsync(CreatePolicy(siteId, enabled: false));
|
||||
|
||||
// Act
|
||||
var result = await _policyService.ValidateSourceAsync(siteId, "nvd");
|
||||
|
||||
// Assert
|
||||
result.IsAllowed.Should().BeFalse();
|
||||
result.Reason.Should().Contain("disabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateSourceAsync_DeniesWhenInDenyList()
|
||||
{
|
||||
// Arrange
|
||||
var siteId = $"deny-list-{Guid.NewGuid():N}";
|
||||
await _repository.UpsertPolicyAsync(CreatePolicy(siteId, deniedSources: ["untrusted", "blocked-*"], enabled: true));
|
||||
|
||||
// Act
|
||||
var result = await _policyService.ValidateSourceAsync(siteId, "untrusted");
|
||||
|
||||
// Assert
|
||||
result.IsAllowed.Should().BeFalse();
|
||||
result.Reason.Should().Contain("deny list");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateSourceAsync_DeniesWildcardMatch()
|
||||
{
|
||||
// Arrange
|
||||
var siteId = $"wildcard-deny-{Guid.NewGuid():N}";
|
||||
await _repository.UpsertPolicyAsync(CreatePolicy(siteId, deniedSources: ["blocked-*"], enabled: true));
|
||||
|
||||
// Act
|
||||
var result = await _policyService.ValidateSourceAsync(siteId, "blocked-source-1");
|
||||
|
||||
// Assert
|
||||
result.IsAllowed.Should().BeFalse("wildcard deny should match");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateSourceAsync_AllowsWhenNoAllowList()
|
||||
{
|
||||
// Arrange
|
||||
var siteId = $"no-allow-list-{Guid.NewGuid():N}";
|
||||
await _repository.UpsertPolicyAsync(CreatePolicy(siteId, allowedSources: [], deniedSources: [], enabled: true));
|
||||
|
||||
// Act
|
||||
var result = await _policyService.ValidateSourceAsync(siteId, "any-source");
|
||||
|
||||
// Assert
|
||||
result.IsAllowed.Should().BeTrue();
|
||||
result.Reason.Should().Contain("No allow list");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateSourceAsync_AllowsWhenInAllowList()
|
||||
{
|
||||
// Arrange
|
||||
var siteId = $"allow-list-{Guid.NewGuid():N}";
|
||||
await _repository.UpsertPolicyAsync(CreatePolicy(siteId, allowedSources: ["nvd", "ghsa", "osv"], enabled: true));
|
||||
|
||||
// Act
|
||||
var result = await _policyService.ValidateSourceAsync(siteId, "nvd");
|
||||
|
||||
// Assert
|
||||
result.IsAllowed.Should().BeTrue();
|
||||
result.Reason.Should().Contain("allow list");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateSourceAsync_DeniesWhenNotInAllowList()
|
||||
{
|
||||
// Arrange
|
||||
var siteId = $"not-in-allow-{Guid.NewGuid():N}";
|
||||
await _repository.UpsertPolicyAsync(CreatePolicy(siteId, allowedSources: ["nvd", "ghsa"], enabled: true));
|
||||
|
||||
// Act
|
||||
var result = await _policyService.ValidateSourceAsync(siteId, "random-source");
|
||||
|
||||
// Assert
|
||||
result.IsAllowed.Should().BeFalse();
|
||||
result.Reason.Should().Contain("not in allow list");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateBundleSizeAsync_AllowsWithinLimits()
|
||||
{
|
||||
// Arrange
|
||||
var siteId = $"size-ok-{Guid.NewGuid():N}";
|
||||
await _repository.UpsertPolicyAsync(CreatePolicy(siteId, maxSizeMb: 100, maxItems: 10000, enabled: true));
|
||||
|
||||
// Act
|
||||
var result = await _policyService.ValidateBundleSizeAsync(siteId, 50, 5000);
|
||||
|
||||
// Assert
|
||||
result.IsAllowed.Should().BeTrue();
|
||||
result.ActualSizeMb.Should().Be(50);
|
||||
result.ActualItemCount.Should().Be(5000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateBundleSizeAsync_DeniesExceedsSizeLimit()
|
||||
{
|
||||
// Arrange
|
||||
var siteId = $"size-exceed-{Guid.NewGuid():N}";
|
||||
await _repository.UpsertPolicyAsync(CreatePolicy(siteId, maxSizeMb: 50, maxItems: 10000, enabled: true));
|
||||
|
||||
// Act
|
||||
var result = await _policyService.ValidateBundleSizeAsync(siteId, 75, 100);
|
||||
|
||||
// Assert
|
||||
result.IsAllowed.Should().BeFalse();
|
||||
result.Reason.Should().Contain("exceeds limit");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateBundleSizeAsync_DeniesExceedsItemLimit()
|
||||
{
|
||||
// Arrange
|
||||
var siteId = $"items-exceed-{Guid.NewGuid():N}";
|
||||
await _repository.UpsertPolicyAsync(CreatePolicy(siteId, maxSizeMb: 100, maxItems: 1000, enabled: true));
|
||||
|
||||
// Act
|
||||
var result = await _policyService.ValidateBundleSizeAsync(siteId, 10, 5000);
|
||||
|
||||
// Assert
|
||||
result.IsAllowed.Should().BeFalse();
|
||||
result.Reason.Should().Contain("Item count");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRemainingBudgetAsync_ReturnsBudgetInfo()
|
||||
{
|
||||
// Arrange
|
||||
var siteId = $"budget-{Guid.NewGuid():N}";
|
||||
await _repository.UpsertPolicyAsync(CreatePolicy(siteId, maxSizeMb: 100, maxItems: 5000, enabled: true));
|
||||
await _repository.AdvanceCursorAsync(siteId, CursorFormat.Create(DateTimeOffset.UtcNow, 0),
|
||||
$"budget-hash-{Guid.NewGuid():N}", 200, DateTimeOffset.UtcNow);
|
||||
|
||||
// Act
|
||||
var budget = await _policyService.GetRemainingBudgetAsync(siteId, windowHours: 24);
|
||||
|
||||
// Assert
|
||||
budget.HasPolicy.Should().BeTrue();
|
||||
budget.MaxBundleSizeMb.Should().Be(100);
|
||||
budget.MaxItemsPerBundle.Should().Be(5000);
|
||||
budget.RecentItemsImported.Should().BeGreaterThanOrEqualTo(200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FilterAllowedSourcesAsync_FiltersCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var siteId = $"filter-{Guid.NewGuid():N}";
|
||||
await _repository.UpsertPolicyAsync(CreatePolicy(siteId, allowedSources: ["nvd", "ghsa"], deniedSources: ["blocked"], enabled: true));
|
||||
|
||||
// Act
|
||||
var allowed = await _policyService.FilterAllowedSourcesAsync(
|
||||
siteId,
|
||||
new[] { "nvd", "ghsa", "osv", "blocked" });
|
||||
|
||||
// Assert
|
||||
allowed.Should().BeEquivalentTo(["nvd", "ghsa"]);
|
||||
allowed.Should().NotContain("osv"); // Not in allow list
|
||||
allowed.Should().NotContain("blocked"); // In deny list
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static SyncLedgerEntity CreateLedgerEntry(
|
||||
string siteId,
|
||||
string bundleHash,
|
||||
int itemsCount,
|
||||
DateTimeOffset? signedAt = null,
|
||||
int sequence = 0)
|
||||
{
|
||||
var ts = signedAt ?? DateTimeOffset.UtcNow;
|
||||
return new SyncLedgerEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SiteId = siteId,
|
||||
Cursor = CursorFormat.Create(ts, sequence),
|
||||
BundleHash = bundleHash,
|
||||
ItemsCount = itemsCount,
|
||||
SignedAt = ts
|
||||
};
|
||||
}
|
||||
|
||||
private static SitePolicyEntity CreatePolicy(
|
||||
string siteId,
|
||||
string? displayName = null,
|
||||
string[]? allowedSources = null,
|
||||
string[]? deniedSources = null,
|
||||
int maxSizeMb = 100,
|
||||
int maxItems = 10000,
|
||||
bool enabled = true)
|
||||
{
|
||||
return new SitePolicyEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SiteId = siteId,
|
||||
DisplayName = displayName,
|
||||
AllowedSources = allowedSources ?? [],
|
||||
DeniedSources = deniedSources ?? [],
|
||||
MaxBundleSizeMb = maxSizeMb,
|
||||
MaxItemsPerBundle = maxItems,
|
||||
RequireSignature = true,
|
||||
AllowedSigners = [],
|
||||
Enabled = enabled
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user