Add unit tests for VexLens normalizer, CPE parser, product mapper, and PURL parser
- Implemented comprehensive tests for VexLensNormalizer including format detection and normalization scenarios. - Added tests for CpeParser covering CPE 2.3 and 2.2 formats, invalid inputs, and canonical key generation. - Created tests for ProductMapper to validate parsing and matching logic across different strictness levels. - Developed tests for PurlParser to ensure correct parsing of various PURL formats and validation of identifiers. - Introduced stubs for Monaco editor and worker to facilitate testing in the web application. - Updated project file for the test project to include necessary dependencies.
This commit is contained in:
@@ -0,0 +1,476 @@
|
||||
using StellaOps.VexLens.Api;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Cache interface for consensus rationale storage.
|
||||
/// Used by Advisory AI for efficient rationale retrieval.
|
||||
/// </summary>
|
||||
public interface IConsensusRationaleCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a cached rationale by key.
|
||||
/// </summary>
|
||||
Task<DetailedConsensusRationale?> GetAsync(
|
||||
string cacheKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sets a rationale in the cache.
|
||||
/// </summary>
|
||||
Task SetAsync(
|
||||
string cacheKey,
|
||||
DetailedConsensusRationale rationale,
|
||||
CacheOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates a rationale using the factory if not cached.
|
||||
/// </summary>
|
||||
Task<DetailedConsensusRationale> GetOrCreateAsync(
|
||||
string cacheKey,
|
||||
Func<CancellationToken, Task<DetailedConsensusRationale>> factory,
|
||||
CacheOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a rationale from the cache.
|
||||
/// </summary>
|
||||
Task RemoveAsync(
|
||||
string cacheKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes all rationales for a vulnerability-product pair.
|
||||
/// </summary>
|
||||
Task InvalidateAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Clears all cached rationales.
|
||||
/// </summary>
|
||||
Task ClearAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets cache statistics.
|
||||
/// </summary>
|
||||
Task<CacheStatistics> GetStatisticsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for cache entries.
|
||||
/// </summary>
|
||||
public sealed record CacheOptions(
|
||||
/// <summary>
|
||||
/// Absolute expiration time.
|
||||
/// </summary>
|
||||
DateTimeOffset? AbsoluteExpiration = null,
|
||||
|
||||
/// <summary>
|
||||
/// Sliding expiration duration.
|
||||
/// </summary>
|
||||
TimeSpan? SlidingExpiration = null,
|
||||
|
||||
/// <summary>
|
||||
/// Cache entry priority.
|
||||
/// </summary>
|
||||
CachePriority Priority = CachePriority.Normal,
|
||||
|
||||
/// <summary>
|
||||
/// Tags for grouping cache entries.
|
||||
/// </summary>
|
||||
IReadOnlyList<string>? Tags = null);
|
||||
|
||||
/// <summary>
|
||||
/// Cache entry priority.
|
||||
/// </summary>
|
||||
public enum CachePriority
|
||||
{
|
||||
Low,
|
||||
Normal,
|
||||
High,
|
||||
NeverRemove
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cache statistics.
|
||||
/// </summary>
|
||||
public sealed record CacheStatistics(
|
||||
/// <summary>
|
||||
/// Total number of cached entries.
|
||||
/// </summary>
|
||||
int EntryCount,
|
||||
|
||||
/// <summary>
|
||||
/// Total cache hits.
|
||||
/// </summary>
|
||||
long HitCount,
|
||||
|
||||
/// <summary>
|
||||
/// Total cache misses.
|
||||
/// </summary>
|
||||
long MissCount,
|
||||
|
||||
/// <summary>
|
||||
/// Estimated memory usage in bytes.
|
||||
/// </summary>
|
||||
long EstimatedMemoryBytes,
|
||||
|
||||
/// <summary>
|
||||
/// Hit rate percentage.
|
||||
/// </summary>
|
||||
double HitRate,
|
||||
|
||||
/// <summary>
|
||||
/// When the cache was last cleared.
|
||||
/// </summary>
|
||||
DateTimeOffset? LastCleared);
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of consensus rationale cache.
|
||||
/// </summary>
|
||||
public sealed class InMemoryConsensusRationaleCache : IConsensusRationaleCache
|
||||
{
|
||||
private readonly Dictionary<string, CacheEntry> _cache = new();
|
||||
private readonly object _lock = new();
|
||||
private readonly int _maxEntries;
|
||||
|
||||
private long _hitCount;
|
||||
private long _missCount;
|
||||
private DateTimeOffset? _lastCleared;
|
||||
|
||||
public InMemoryConsensusRationaleCache(int maxEntries = 10000)
|
||||
{
|
||||
_maxEntries = maxEntries;
|
||||
}
|
||||
|
||||
public Task<DetailedConsensusRationale?> GetAsync(
|
||||
string cacheKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_cache.TryGetValue(cacheKey, out var entry))
|
||||
{
|
||||
if (IsExpired(entry))
|
||||
{
|
||||
_cache.Remove(cacheKey);
|
||||
Interlocked.Increment(ref _missCount);
|
||||
return Task.FromResult<DetailedConsensusRationale?>(null);
|
||||
}
|
||||
|
||||
entry.LastAccessed = DateTimeOffset.UtcNow;
|
||||
Interlocked.Increment(ref _hitCount);
|
||||
return Task.FromResult<DetailedConsensusRationale?>(entry.Rationale);
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _missCount);
|
||||
return Task.FromResult<DetailedConsensusRationale?>(null);
|
||||
}
|
||||
}
|
||||
|
||||
public Task SetAsync(
|
||||
string cacheKey,
|
||||
DetailedConsensusRationale rationale,
|
||||
CacheOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
// Evict if at capacity
|
||||
if (_cache.Count >= _maxEntries && !_cache.ContainsKey(cacheKey))
|
||||
{
|
||||
EvictOldestEntry();
|
||||
}
|
||||
|
||||
_cache[cacheKey] = new CacheEntry
|
||||
{
|
||||
Rationale = rationale,
|
||||
Options = options ?? new CacheOptions(),
|
||||
Created = DateTimeOffset.UtcNow,
|
||||
LastAccessed = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<DetailedConsensusRationale> GetOrCreateAsync(
|
||||
string cacheKey,
|
||||
Func<CancellationToken, Task<DetailedConsensusRationale>> factory,
|
||||
CacheOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cached = await GetAsync(cacheKey, cancellationToken);
|
||||
if (cached != null)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var rationale = await factory(cancellationToken);
|
||||
await SetAsync(cacheKey, rationale, options, cancellationToken);
|
||||
return rationale;
|
||||
}
|
||||
|
||||
public Task RemoveAsync(
|
||||
string cacheKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_cache.Remove(cacheKey);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public Task InvalidateAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var keysToRemove = _cache
|
||||
.Where(kvp => kvp.Value.Rationale.VulnerabilityId == vulnerabilityId &&
|
||||
kvp.Value.Rationale.ProductKey == productKey)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in keysToRemove)
|
||||
{
|
||||
_cache.Remove(key);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public Task ClearAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_cache.Clear();
|
||||
_lastCleared = DateTimeOffset.UtcNow;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<CacheStatistics> GetStatisticsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var hits = Interlocked.Read(ref _hitCount);
|
||||
var misses = Interlocked.Read(ref _missCount);
|
||||
var total = hits + misses;
|
||||
|
||||
return Task.FromResult(new CacheStatistics(
|
||||
EntryCount: _cache.Count,
|
||||
HitCount: hits,
|
||||
MissCount: misses,
|
||||
EstimatedMemoryBytes: EstimateMemoryUsage(),
|
||||
HitRate: total > 0 ? (double)hits / total : 0,
|
||||
LastCleared: _lastCleared));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsExpired(CacheEntry entry)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
if (entry.Options.AbsoluteExpiration.HasValue &&
|
||||
now >= entry.Options.AbsoluteExpiration.Value)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (entry.Options.SlidingExpiration.HasValue &&
|
||||
now - entry.LastAccessed >= entry.Options.SlidingExpiration.Value)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void EvictOldestEntry()
|
||||
{
|
||||
var oldest = _cache
|
||||
.Where(kvp => kvp.Value.Options.Priority != CachePriority.NeverRemove)
|
||||
.OrderBy(kvp => kvp.Value.Options.Priority)
|
||||
.ThenBy(kvp => kvp.Value.LastAccessed)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (oldest.Key != null)
|
||||
{
|
||||
_cache.Remove(oldest.Key);
|
||||
}
|
||||
}
|
||||
|
||||
private long EstimateMemoryUsage()
|
||||
{
|
||||
// Rough estimate: 1KB per entry on average
|
||||
return _cache.Count * 1024L;
|
||||
}
|
||||
|
||||
private sealed class CacheEntry
|
||||
{
|
||||
public required DetailedConsensusRationale Rationale { get; init; }
|
||||
public required CacheOptions Options { get; init; }
|
||||
public required DateTimeOffset Created { get; init; }
|
||||
public DateTimeOffset LastAccessed { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cached consensus rationale service that wraps the base service with caching.
|
||||
/// </summary>
|
||||
public sealed class CachedConsensusRationaleService : IConsensusRationaleService
|
||||
{
|
||||
private readonly IConsensusRationaleService _inner;
|
||||
private readonly IConsensusRationaleCache _cache;
|
||||
private readonly CacheOptions _defaultOptions;
|
||||
|
||||
public CachedConsensusRationaleService(
|
||||
IConsensusRationaleService inner,
|
||||
IConsensusRationaleCache cache,
|
||||
CacheOptions? defaultOptions = null)
|
||||
{
|
||||
_inner = inner;
|
||||
_cache = cache;
|
||||
_defaultOptions = defaultOptions ?? new CacheOptions(
|
||||
SlidingExpiration: TimeSpan.FromMinutes(30));
|
||||
}
|
||||
|
||||
public async Task<GenerateRationaleResponse> GenerateRationaleAsync(
|
||||
GenerateRationaleRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cacheKey = BuildCacheKey(request);
|
||||
var startTime = DateTime.UtcNow;
|
||||
|
||||
var rationale = await _cache.GetOrCreateAsync(
|
||||
cacheKey,
|
||||
async ct =>
|
||||
{
|
||||
var response = await _inner.GenerateRationaleAsync(request, ct);
|
||||
return response.Rationale;
|
||||
},
|
||||
_defaultOptions,
|
||||
cancellationToken);
|
||||
|
||||
var elapsedMs = (DateTime.UtcNow - startTime).TotalMilliseconds;
|
||||
|
||||
return new GenerateRationaleResponse(
|
||||
Rationale: rationale,
|
||||
Stats: new RationaleGenerationStats(
|
||||
StatementsAnalyzed: 0, // Not tracked in cache hit
|
||||
IssuersInvolved: 0,
|
||||
ConflictsDetected: 0,
|
||||
FactorsIdentified: rationale.DecisionFactors.Count,
|
||||
GenerationTimeMs: elapsedMs));
|
||||
}
|
||||
|
||||
public async Task<BatchRationaleResponse> GenerateBatchRationaleAsync(
|
||||
BatchRationaleRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = DateTime.UtcNow;
|
||||
var responses = new List<GenerateRationaleResponse>();
|
||||
var errors = new List<RationaleError>();
|
||||
|
||||
foreach (var req in request.Requests)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await GenerateRationaleAsync(req, cancellationToken);
|
||||
responses.Add(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add(new RationaleError(
|
||||
VulnerabilityId: req.VulnerabilityId,
|
||||
ProductKey: req.ProductKey,
|
||||
Code: "GENERATION_FAILED",
|
||||
Message: ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
return new BatchRationaleResponse(
|
||||
Responses: responses,
|
||||
Errors: errors,
|
||||
TotalTimeMs: (DateTime.UtcNow - startTime).TotalMilliseconds);
|
||||
}
|
||||
|
||||
public Task<DetailedConsensusRationale> GenerateFromResultAsync(
|
||||
VexConsensusResult result,
|
||||
string explanationFormat = "human",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Direct passthrough - results are ephemeral and shouldn't be cached
|
||||
return _inner.GenerateFromResultAsync(result, explanationFormat, cancellationToken);
|
||||
}
|
||||
|
||||
private static string BuildCacheKey(GenerateRationaleRequest request)
|
||||
{
|
||||
return $"rationale:{request.VulnerabilityId}:{request.ProductKey}:{request.TenantId ?? "default"}:{request.Verbosity}:{request.ExplanationFormat}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event arguments for cache invalidation.
|
||||
/// </summary>
|
||||
public sealed record CacheInvalidationEvent(
|
||||
string VulnerabilityId,
|
||||
string ProductKey,
|
||||
string? TenantId,
|
||||
string Reason,
|
||||
DateTimeOffset OccurredAt);
|
||||
|
||||
/// <summary>
|
||||
/// Interface for observing cache invalidations.
|
||||
/// </summary>
|
||||
public interface ICacheInvalidationObserver
|
||||
{
|
||||
/// <summary>
|
||||
/// Called when cache entries are invalidated.
|
||||
/// </summary>
|
||||
Task OnInvalidationAsync(
|
||||
CacheInvalidationEvent invalidation,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for cache configuration.
|
||||
/// </summary>
|
||||
public static class ConsensusCacheExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a cache key for a vulnerability-product pair.
|
||||
/// </summary>
|
||||
public static string CreateCacheKey(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
string? tenantId = null,
|
||||
string verbosity = "standard",
|
||||
string format = "human")
|
||||
{
|
||||
return $"rationale:{vulnerabilityId}:{productKey}:{tenantId ?? "default"}:{verbosity}:{format}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates default cache options for Advisory AI usage.
|
||||
/// </summary>
|
||||
public static CacheOptions CreateAdvisoryAiOptions(
|
||||
TimeSpan? slidingExpiration = null,
|
||||
CachePriority priority = CachePriority.High)
|
||||
{
|
||||
return new CacheOptions(
|
||||
SlidingExpiration: slidingExpiration ?? TimeSpan.FromHours(1),
|
||||
Priority: priority,
|
||||
Tags: ["advisory-ai"]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user