save development progress

This commit is contained in:
StellaOps Bot
2025-12-25 23:09:58 +02:00
parent d71853ad7e
commit aa70af062e
351 changed files with 37683 additions and 150156 deletions

View File

@@ -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:*");
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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