audit, advisories and doctors/setup work
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Determinism;
|
||||
@@ -64,7 +65,7 @@ public sealed class ConsensusRationaleService : IConsensusRationaleService
|
||||
GenerateRationaleRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = DateTime.UtcNow;
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
// Get the latest projection
|
||||
var projection = await _projectionStore.GetLatestAsync(
|
||||
@@ -82,7 +83,7 @@ public sealed class ConsensusRationaleService : IConsensusRationaleService
|
||||
// Build rationale from projection
|
||||
var rationale = BuildRationale(projection, request);
|
||||
|
||||
var elapsedMs = (DateTime.UtcNow - startTime).TotalMilliseconds;
|
||||
var elapsedMs = stopwatch.Elapsed.TotalMilliseconds;
|
||||
|
||||
return new GenerateRationaleResponse(
|
||||
Rationale: rationale,
|
||||
@@ -98,7 +99,7 @@ public sealed class ConsensusRationaleService : IConsensusRationaleService
|
||||
BatchRationaleRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = DateTime.UtcNow;
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var responses = new List<GenerateRationaleResponse>();
|
||||
var errors = new List<RationaleError>();
|
||||
|
||||
@@ -132,7 +133,7 @@ public sealed class ConsensusRationaleService : IConsensusRationaleService
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
var totalMs = (DateTime.UtcNow - startTime).TotalMilliseconds;
|
||||
var totalMs = stopwatch.Elapsed.TotalMilliseconds;
|
||||
|
||||
return new BatchRationaleResponse(
|
||||
Responses: responses,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using StellaOps.VexLens.Api;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
@@ -353,7 +354,7 @@ public sealed class CachedConsensusRationaleService : IConsensusRationaleService
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cacheKey = BuildCacheKey(request);
|
||||
var startTime = DateTime.UtcNow;
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
var rationale = await _cache.GetOrCreateAsync(
|
||||
cacheKey,
|
||||
@@ -365,7 +366,7 @@ public sealed class CachedConsensusRationaleService : IConsensusRationaleService
|
||||
_defaultOptions,
|
||||
cancellationToken);
|
||||
|
||||
var elapsedMs = (DateTime.UtcNow - startTime).TotalMilliseconds;
|
||||
var elapsedMs = stopwatch.Elapsed.TotalMilliseconds;
|
||||
|
||||
return new GenerateRationaleResponse(
|
||||
Rationale: rationale,
|
||||
@@ -381,7 +382,7 @@ public sealed class CachedConsensusRationaleService : IConsensusRationaleService
|
||||
BatchRationaleRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = DateTime.UtcNow;
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var responses = new List<GenerateRationaleResponse>();
|
||||
var errors = new List<RationaleError>();
|
||||
|
||||
@@ -405,7 +406,7 @@ public sealed class CachedConsensusRationaleService : IConsensusRationaleService
|
||||
return new BatchRationaleResponse(
|
||||
Responses: responses,
|
||||
Errors: errors,
|
||||
TotalTimeMs: (DateTime.UtcNow - startTime).TotalMilliseconds);
|
||||
TotalTimeMs: stopwatch.Elapsed.TotalMilliseconds);
|
||||
}
|
||||
|
||||
public Task<DetailedConsensusRationale> GenerateFromResultAsync(
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
using StellaOps.ReachGraph.Deduplication;
|
||||
using StellaOps.VexLens.Api;
|
||||
@@ -77,11 +78,14 @@ public static class VexLensServiceCollectionExtensions
|
||||
this IServiceCollection services,
|
||||
VexLensOptions options)
|
||||
{
|
||||
services.AddDeterminismDefaults();
|
||||
|
||||
// Normalization
|
||||
services.TryAddSingleton<IVexNormalizerRegistry>(sp =>
|
||||
{
|
||||
var registry = new VexNormalizerRegistry();
|
||||
RegisterNormalizers(registry, options.Normalization);
|
||||
var guidProvider = sp.GetService<IGuidProvider>() ?? SystemGuidProvider.Instance;
|
||||
RegisterNormalizers(registry, options.Normalization, guidProvider);
|
||||
return registry;
|
||||
});
|
||||
|
||||
@@ -152,7 +156,8 @@ public static class VexLensServiceCollectionExtensions
|
||||
|
||||
private static void RegisterNormalizers(
|
||||
VexNormalizerRegistry registry,
|
||||
VexLensNormalizationOptions options)
|
||||
VexLensNormalizationOptions options,
|
||||
IGuidProvider guidProvider)
|
||||
{
|
||||
var enabledFormats = new HashSet<string>(
|
||||
options.EnabledFormats,
|
||||
@@ -160,17 +165,17 @@ public static class VexLensServiceCollectionExtensions
|
||||
|
||||
if (enabledFormats.Contains("OpenVEX"))
|
||||
{
|
||||
registry.Register(new OpenVexNormalizer());
|
||||
registry.Register(new OpenVexNormalizer(guidProvider));
|
||||
}
|
||||
|
||||
if (enabledFormats.Contains("CSAF"))
|
||||
{
|
||||
registry.Register(new CsafVexNormalizer());
|
||||
registry.Register(new CsafVexNormalizer(guidProvider));
|
||||
}
|
||||
|
||||
if (enabledFormats.Contains("CycloneDX"))
|
||||
{
|
||||
registry.Register(new CycloneDxVexNormalizer());
|
||||
registry.Register(new CycloneDxVexNormalizer(guidProvider));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -14,9 +15,9 @@ public sealed class OpenVexNormalizer : IVexNormalizer
|
||||
{
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public OpenVexNormalizer(IGuidProvider? guidProvider = null)
|
||||
public OpenVexNormalizer(IGuidProvider guidProvider)
|
||||
{
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
|
||||
}
|
||||
|
||||
public VexSourceFormat SourceFormat => VexSourceFormat.OpenVex;
|
||||
@@ -206,7 +207,11 @@ public sealed class OpenVexNormalizer : IVexNormalizer
|
||||
prop.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var str = prop.GetString();
|
||||
if (DateTimeOffset.TryParse(str, out var result))
|
||||
if (DateTimeOffset.TryParse(
|
||||
str,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.RoundtripKind,
|
||||
out var result))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
@@ -252,7 +257,7 @@ public sealed class OpenVexNormalizer : IVexNormalizer
|
||||
int index,
|
||||
List<NormalizationWarning> warnings,
|
||||
ref int skipped,
|
||||
IGuidProvider? guidProvider = null)
|
||||
IGuidProvider guidProvider)
|
||||
{
|
||||
// Extract vulnerability
|
||||
string? vulnerabilityId = null;
|
||||
@@ -387,7 +392,7 @@ public sealed class OpenVexNormalizer : IVexNormalizer
|
||||
LastSeen: timestamp);
|
||||
}
|
||||
|
||||
private static NormalizedProduct? ExtractProduct(JsonElement prod, IGuidProvider? guidProvider = null)
|
||||
private static NormalizedProduct? ExtractProduct(JsonElement prod, IGuidProvider guidProvider)
|
||||
{
|
||||
string? key = null;
|
||||
string? name = null;
|
||||
@@ -432,7 +437,7 @@ public sealed class OpenVexNormalizer : IVexNormalizer
|
||||
return null;
|
||||
}
|
||||
|
||||
var fallbackGuid = (guidProvider ?? SystemGuidProvider.Instance).NewGuid();
|
||||
var fallbackGuid = guidProvider.NewGuid();
|
||||
return new NormalizedProduct(
|
||||
Key: key ?? purl ?? cpe ?? $"unknown-{fallbackGuid:N}",
|
||||
Name: name,
|
||||
@@ -472,7 +477,11 @@ public sealed class OpenVexNormalizer : IVexNormalizer
|
||||
if (element.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var str = element.GetString();
|
||||
if (DateTimeOffset.TryParse(str, out var result))
|
||||
if (DateTimeOffset.TryParse(
|
||||
str,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.RoundtripKind,
|
||||
out var result))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ public sealed class DualWriteConsensusProjectionStore : IConsensusProjectionStor
|
||||
{
|
||||
try
|
||||
{
|
||||
var secondaryResult = await SecondaryStore.GetAsync(projectionId, CancellationToken.None);
|
||||
var secondaryResult = await SecondaryStore.GetAsync(projectionId, cancellationToken);
|
||||
if (secondaryResult is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
@@ -156,7 +156,7 @@ public sealed class DualWriteConsensusProjectionStore : IConsensusProjectionStor
|
||||
try
|
||||
{
|
||||
var secondaryResult = await SecondaryStore.GetLatestAsync(
|
||||
vulnerabilityId, productKey, tenantId, CancellationToken.None);
|
||||
vulnerabilityId, productKey, tenantId, cancellationToken);
|
||||
|
||||
ValidateProjectionConsistency(result, secondaryResult, vulnerabilityId, productKey);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// © StellaOps Contributors. See LICENSE and NOTICE.md in the repository root.
|
||||
|
||||
using System.Data.Common;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -340,7 +341,7 @@ public sealed class PostgresConsensusProjectionStoreProxy : IConsensusProjection
|
||||
return deleted;
|
||||
}
|
||||
|
||||
private static ConsensusProjection MapProjection(NpgsqlDataReader reader)
|
||||
private static ConsensusProjection MapProjection(DbDataReader reader)
|
||||
{
|
||||
return new ConsensusProjection(
|
||||
ProjectionId: reader.GetGuid(0).ToString(),
|
||||
@@ -354,8 +355,8 @@ public sealed class PostgresConsensusProjectionStoreProxy : IConsensusProjection
|
||||
StatementCount: reader.GetInt32(8),
|
||||
ConflictCount: reader.GetInt32(9),
|
||||
RationaleSummary: reader.IsDBNull(10) ? string.Empty : reader.GetString(10),
|
||||
ComputedAt: reader.GetDateTime(11),
|
||||
StoredAt: reader.GetDateTime(12),
|
||||
ComputedAt: reader.GetFieldValue<DateTimeOffset>(11),
|
||||
StoredAt: reader.GetFieldValue<DateTimeOffset>(12),
|
||||
PreviousProjectionId: reader.IsDBNull(13) ? null : reader.GetGuid(13).ToString(),
|
||||
StatusChanged: reader.GetBoolean(14));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Normalization;
|
||||
@@ -22,17 +23,26 @@ public sealed class VexLensTestHarness : IDisposable
|
||||
private readonly InMemoryConsensusProjectionStore _projectionStore;
|
||||
private readonly TrustWeightEngine _trustWeightEngine;
|
||||
private readonly VexConsensusEngine _consensusEngine;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private static readonly DateTimeOffset DefaultTimestamp = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public VexLensTestHarness()
|
||||
public VexLensTestHarness(TimeProvider? timeProvider = null, IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_timeProvider = ResolveTimeProvider(timeProvider);
|
||||
_guidProvider = ResolveGuidProvider(guidProvider);
|
||||
_normalizerRegistry = new VexNormalizerRegistry();
|
||||
_normalizerRegistry.Register(new OpenVexNormalizer());
|
||||
_normalizerRegistry.Register(new CsafVexNormalizer());
|
||||
_normalizerRegistry.Register(new CycloneDxVexNormalizer());
|
||||
_normalizerRegistry.Register(new OpenVexNormalizer(_guidProvider));
|
||||
_normalizerRegistry.Register(new CsafVexNormalizer(_guidProvider));
|
||||
_normalizerRegistry.Register(new CycloneDxVexNormalizer(_guidProvider));
|
||||
|
||||
_issuerDirectory = new InMemoryIssuerDirectory();
|
||||
_eventEmitter = new InMemoryConsensusEventEmitter();
|
||||
_projectionStore = new InMemoryConsensusProjectionStore(_eventEmitter);
|
||||
_projectionStore = new InMemoryConsensusProjectionStore(
|
||||
_eventEmitter,
|
||||
deltaComputeService: null,
|
||||
timeProvider: _timeProvider,
|
||||
guidProvider: _guidProvider);
|
||||
_trustWeightEngine = new TrustWeightEngine();
|
||||
_consensusEngine = new VexConsensusEngine();
|
||||
}
|
||||
@@ -61,7 +71,7 @@ public sealed class VexLensTestHarness : IDisposable
|
||||
|
||||
var context = new NormalizationContext(
|
||||
SourceUri: sourceUri,
|
||||
NormalizedAt: DateTimeOffset.UtcNow,
|
||||
NormalizedAt: _timeProvider.GetUtcNow(),
|
||||
Normalizer: "VexLensTestHarness",
|
||||
Options: null);
|
||||
|
||||
@@ -84,7 +94,7 @@ public sealed class VexLensTestHarness : IDisposable
|
||||
DocumentIssuedAt: documentIssuedAt,
|
||||
Context: new TrustWeightContext(
|
||||
TenantId: null,
|
||||
EvaluationTime: DateTimeOffset.UtcNow,
|
||||
EvaluationTime: _timeProvider.GetUtcNow(),
|
||||
CustomFactors: null));
|
||||
|
||||
return await _trustWeightEngine.ComputeWeightAsync(request, cancellationToken);
|
||||
@@ -106,7 +116,7 @@ public sealed class VexLensTestHarness : IDisposable
|
||||
Statements: statements.ToList(),
|
||||
Context: new ConsensusContext(
|
||||
TenantId: null,
|
||||
EvaluationTime: DateTimeOffset.UtcNow,
|
||||
EvaluationTime: _timeProvider.GetUtcNow(),
|
||||
Policy: new ConsensusPolicy(
|
||||
Mode: mode,
|
||||
MinimumWeightThreshold: 0.1,
|
||||
@@ -146,10 +156,18 @@ public sealed class VexLensTestHarness : IDisposable
|
||||
string productKey,
|
||||
VexStatus status,
|
||||
VexJustification? justification = null,
|
||||
string? statementId = null)
|
||||
string? statementId = null,
|
||||
TimeProvider? timeProvider = null,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
var actualTimeProvider = ResolveTimeProvider(timeProvider);
|
||||
var now = actualTimeProvider.GetUtcNow();
|
||||
var resolvedStatementId = statementId ?? (guidProvider is null
|
||||
? CreateDeterministicStatementId(vulnerabilityId, productKey, status, justification)
|
||||
: $"stmt-{guidProvider.NewGuid():N}");
|
||||
|
||||
return new NormalizedStatement(
|
||||
StatementId: statementId ?? $"stmt-{Guid.NewGuid():N}",
|
||||
StatementId: resolvedStatementId,
|
||||
VulnerabilityId: vulnerabilityId,
|
||||
VulnerabilityAliases: null,
|
||||
Product: new NormalizedProduct(
|
||||
@@ -167,8 +185,8 @@ public sealed class VexLensTestHarness : IDisposable
|
||||
ActionStatementTimestamp: null,
|
||||
Versions: null,
|
||||
Subcomponents: null,
|
||||
FirstSeen: DateTimeOffset.UtcNow,
|
||||
LastSeen: DateTimeOffset.UtcNow);
|
||||
FirstSeen: now,
|
||||
LastSeen: now);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -196,6 +214,33 @@ public sealed class VexLensTestHarness : IDisposable
|
||||
_eventEmitter.Clear();
|
||||
}
|
||||
|
||||
internal static TimeProvider ResolveTimeProvider(TimeProvider? timeProvider)
|
||||
{
|
||||
return timeProvider ?? new FixedTimeProvider(DefaultTimestamp);
|
||||
}
|
||||
|
||||
internal static IGuidProvider ResolveGuidProvider(IGuidProvider? guidProvider)
|
||||
{
|
||||
return guidProvider ?? new SequentialGuidProvider();
|
||||
}
|
||||
|
||||
private static string CreateDeterministicStatementId(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
VexStatus status,
|
||||
VexJustification? justification)
|
||||
{
|
||||
var data = string.Join(
|
||||
"|",
|
||||
vulnerabilityId,
|
||||
productKey,
|
||||
status.ToString(),
|
||||
justification?.ToString() ?? "none");
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(data));
|
||||
var guid = new Guid(hash.AsSpan(0, 16));
|
||||
return $"stmt-{guid:N}";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Cleanup if needed
|
||||
@@ -208,10 +253,13 @@ public sealed class VexLensTestHarness : IDisposable
|
||||
public sealed class DeterminismHarness
|
||||
{
|
||||
private readonly VexLensTestHarness _harness;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public DeterminismHarness()
|
||||
public DeterminismHarness(TimeProvider? timeProvider = null, IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_harness = new VexLensTestHarness();
|
||||
_timeProvider = VexLensTestHarness.ResolveTimeProvider(timeProvider);
|
||||
var resolvedGuidProvider = VexLensTestHarness.ResolveGuidProvider(guidProvider);
|
||||
_harness = new VexLensTestHarness(_timeProvider, resolvedGuidProvider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -358,7 +406,7 @@ public sealed class DeterminismHarness
|
||||
return new DeterminismReport(
|
||||
Results: results,
|
||||
AllDeterministic: results.All(r => r.IsDeterministic),
|
||||
GeneratedAt: DateTimeOffset.UtcNow);
|
||||
GeneratedAt: _timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
private static string ComputeDocumentHash(NormalizedVexDocument doc)
|
||||
@@ -432,14 +480,18 @@ public static class VexLensTestData
|
||||
string vulnerabilityId,
|
||||
string productPurl,
|
||||
VexStatus status,
|
||||
VexJustification? justification = null)
|
||||
VexJustification? justification = null,
|
||||
TimeProvider? timeProvider = null,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
var actualTimeProvider = VexLensTestHarness.ResolveTimeProvider(timeProvider);
|
||||
var actualGuidProvider = VexLensTestHarness.ResolveGuidProvider(guidProvider);
|
||||
var doc = new
|
||||
{
|
||||
@context = "https://openvex.dev/ns/v0.2.0",
|
||||
@id = $"urn:uuid:{Guid.NewGuid()}",
|
||||
@id = $"urn:uuid:{actualGuidProvider.NewGuid()}",
|
||||
author = new { @id = "test-vendor", name = "Test Vendor" },
|
||||
timestamp = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture),
|
||||
timestamp = actualTimeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture),
|
||||
statements = new[]
|
||||
{
|
||||
new
|
||||
@@ -475,3 +527,12 @@ public static class VexLensTestData
|
||||
VexLensTestHarness.CreateTestIssuer("aggregator-1", "VEX Aggregator", IssuerCategory.Aggregator, TrustTier.Unknown));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _now;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset now) => _now = now;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.VexLens.Api;
|
||||
using StellaOps.VexLens.Caching;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.VexLens.Tests.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for consensus rationale caching behavior.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ConsensusRationaleCacheTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTime = new(2026, 1, 10, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public async Task InMemoryCache_ExpiresSlidingEntries()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(FixedTime);
|
||||
var cache = new InMemoryConsensusRationaleCache(timeProvider: timeProvider);
|
||||
var rationale = CreateRationale("rat-1", FixedTime);
|
||||
|
||||
await cache.SetAsync(
|
||||
"cache-key",
|
||||
rationale,
|
||||
new CacheOptions(SlidingExpiration: TimeSpan.FromMinutes(1)));
|
||||
|
||||
(await cache.GetAsync("cache-key")).Should().NotBeNull();
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromMinutes(2));
|
||||
|
||||
(await cache.GetAsync("cache-key")).Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CachedConsensusRationaleService_UsesCacheOnRepeat()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(FixedTime);
|
||||
var cache = new InMemoryConsensusRationaleCache(timeProvider: timeProvider);
|
||||
var inner = new StubRationaleService(timeProvider);
|
||||
var service = new CachedConsensusRationaleService(
|
||||
inner,
|
||||
cache,
|
||||
new CacheOptions(SlidingExpiration: TimeSpan.FromMinutes(30)));
|
||||
|
||||
var request = CreateRequest();
|
||||
|
||||
var first = await service.GenerateRationaleAsync(request);
|
||||
var second = await service.GenerateRationaleAsync(request);
|
||||
|
||||
inner.Calls.Should().Be(1);
|
||||
first.Rationale.RationaleId.Should().Be(second.Rationale.RationaleId);
|
||||
}
|
||||
|
||||
private static GenerateRationaleRequest CreateRequest()
|
||||
=> new(
|
||||
VulnerabilityId: "CVE-2026-0001",
|
||||
ProductKey: "pkg:npm/example@1.0.0",
|
||||
TenantId: null,
|
||||
IncludeContributions: true,
|
||||
IncludeAlternatives: true,
|
||||
IncludeAdjustments: false,
|
||||
Verbosity: "standard",
|
||||
ExplanationFormat: "human");
|
||||
|
||||
private static DetailedConsensusRationale CreateRationale(string id, DateTimeOffset computedAt)
|
||||
=> new(
|
||||
RationaleId: id,
|
||||
VulnerabilityId: "CVE-2026-0001",
|
||||
ProductKey: "pkg:npm/example@1.0.0",
|
||||
ConsensusStatus: VexStatus.NotAffected,
|
||||
ConsensusJustification: null,
|
||||
ConfidenceScore: 0.9,
|
||||
Outcome: ConsensusOutcome.Majority,
|
||||
Mode: ConsensusMode.WeightedVote,
|
||||
Summary: "summary",
|
||||
Explanation: "explanation",
|
||||
Contributions: Array.Empty<RationaleContribution>(),
|
||||
Conflicts: Array.Empty<RationaleConflict>(),
|
||||
DecisionFactors: Array.Empty<RationaleFactor>(),
|
||||
Alternatives: Array.Empty<AlternativeOutcome>(),
|
||||
Metadata: new RationaleMetadata(
|
||||
ComputedAt: computedAt,
|
||||
AlgorithmVersion: "1.0.0",
|
||||
InputHash: "input",
|
||||
OutputHash: "output",
|
||||
TenantId: null,
|
||||
PolicyId: null,
|
||||
CorrelationId: null));
|
||||
|
||||
private sealed class StubRationaleService : IConsensusRationaleService
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public StubRationaleService(FakeTimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public int Calls { get; private set; }
|
||||
|
||||
public Task<GenerateRationaleResponse> GenerateRationaleAsync(
|
||||
GenerateRationaleRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Calls++;
|
||||
var rationale = CreateRationale($"rat-{Calls}", _timeProvider.GetUtcNow());
|
||||
var stats = new RationaleGenerationStats(
|
||||
StatementsAnalyzed: 1,
|
||||
IssuersInvolved: 1,
|
||||
ConflictsDetected: 0,
|
||||
FactorsIdentified: rationale.DecisionFactors.Count,
|
||||
GenerationTimeMs: 0);
|
||||
|
||||
return Task.FromResult(new GenerateRationaleResponse(rationale, stats));
|
||||
}
|
||||
|
||||
public Task<BatchRationaleResponse> GenerateBatchRationaleAsync(
|
||||
BatchRationaleRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<DetailedConsensusRationale> GenerateFromResultAsync(
|
||||
VexConsensusResult result,
|
||||
string explanationFormat = "human",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Normalization;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.VexLens.Tests.Normalization;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for OpenVEX normalization behavior.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpenVexNormalizerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task NormalizeAsync_GeneratesDocumentIdWithInjectedGuidProvider()
|
||||
{
|
||||
var fixedGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
var guidProvider = new FixedGuidProvider(fixedGuid);
|
||||
var normalizer = new OpenVexNormalizer(guidProvider);
|
||||
|
||||
var doc = new
|
||||
{
|
||||
@context = "https://openvex.dev/ns/v0.2.0",
|
||||
author = new { @id = "vendor-1", name = "Vendor 1" },
|
||||
timestamp = "2026-01-15T10:00:00Z",
|
||||
statements = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
vulnerability = "CVE-2026-0001",
|
||||
products = new[] { "pkg:npm/example@1.0.0" },
|
||||
status = "affected"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var content = JsonSerializer.Serialize(doc);
|
||||
var context = new NormalizationContext(
|
||||
SourceUri: null,
|
||||
NormalizedAt: DateTimeOffset.Parse("2026-01-15T10:05:00Z", CultureInfo.InvariantCulture),
|
||||
Normalizer: "OpenVEX",
|
||||
Options: null);
|
||||
|
||||
var result = await normalizer.NormalizeAsync(content, context);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Document.Should().NotBeNull();
|
||||
result.Document!.DocumentId.Should().Be($"openvex:{fixedGuid:N}");
|
||||
}
|
||||
|
||||
private sealed class FixedGuidProvider : IGuidProvider
|
||||
{
|
||||
private readonly Guid _value;
|
||||
|
||||
public FixedGuidProvider(Guid value)
|
||||
{
|
||||
_value = value;
|
||||
}
|
||||
|
||||
public Guid NewGuid() => _value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Options;
|
||||
using StellaOps.VexLens.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.VexLens.Tests.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for dual-write consensus projection store behavior.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DualWriteConsensusProjectionStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetAsync_PropagatesCancellationToDiscrepancyCheck()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
var projection = CreateProjection("proj-1");
|
||||
|
||||
var primary = new FakeConsensusProjectionStore(projection);
|
||||
var secondary = new FakeConsensusProjectionStore(null);
|
||||
|
||||
var options = Options.Create(new VexLensStorageOptions
|
||||
{
|
||||
DualWriteReadFrom = "memory",
|
||||
LogDualWriteDiscrepancies = true
|
||||
});
|
||||
|
||||
var store = new DualWriteConsensusProjectionStore(
|
||||
primary,
|
||||
secondary,
|
||||
options,
|
||||
NullLogger<DualWriteConsensusProjectionStore>.Instance);
|
||||
|
||||
var result = await store.GetAsync("proj-1", cts.Token);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
|
||||
var captured = await secondary.GetAsyncToken.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
captured.Should().Be(cts.Token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLatestAsync_PropagatesCancellationToDiscrepancyCheck()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
var projection = CreateProjection("proj-2");
|
||||
|
||||
var primary = new FakeConsensusProjectionStore(projection);
|
||||
var secondary = new FakeConsensusProjectionStore(projection);
|
||||
|
||||
var options = Options.Create(new VexLensStorageOptions
|
||||
{
|
||||
DualWriteReadFrom = "memory",
|
||||
LogDualWriteDiscrepancies = true
|
||||
});
|
||||
|
||||
var store = new DualWriteConsensusProjectionStore(
|
||||
primary,
|
||||
secondary,
|
||||
options,
|
||||
NullLogger<DualWriteConsensusProjectionStore>.Instance);
|
||||
|
||||
var result = await store.GetLatestAsync("CVE-2026-0002", "pkg:npm/example@2.0.0", null, cts.Token);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
|
||||
var captured = await secondary.GetLatestAsyncToken.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
captured.Should().Be(cts.Token);
|
||||
}
|
||||
|
||||
private static ConsensusProjection CreateProjection(string id)
|
||||
=> new(
|
||||
ProjectionId: id,
|
||||
VulnerabilityId: "CVE-2026-0002",
|
||||
ProductKey: "pkg:npm/example@2.0.0",
|
||||
TenantId: null,
|
||||
Status: VexStatus.Affected,
|
||||
Justification: null,
|
||||
ConfidenceScore: 0.75,
|
||||
Outcome: ConsensusOutcome.Majority,
|
||||
StatementCount: 2,
|
||||
ConflictCount: 0,
|
||||
RationaleSummary: "summary",
|
||||
ComputedAt: new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero),
|
||||
StoredAt: new DateTimeOffset(2026, 1, 10, 12, 5, 0, TimeSpan.Zero),
|
||||
PreviousProjectionId: null,
|
||||
StatusChanged: false);
|
||||
|
||||
private sealed class FakeConsensusProjectionStore : IConsensusProjectionStore
|
||||
{
|
||||
private readonly ConsensusProjection? _projection;
|
||||
|
||||
public FakeConsensusProjectionStore(ConsensusProjection? projection)
|
||||
{
|
||||
_projection = projection;
|
||||
}
|
||||
|
||||
public TaskCompletionSource<CancellationToken> GetAsyncToken { get; } = new();
|
||||
|
||||
public TaskCompletionSource<CancellationToken> GetLatestAsyncToken { get; } = new();
|
||||
|
||||
public Task<ConsensusProjection> StoreAsync(
|
||||
VexConsensusResult result,
|
||||
StoreProjectionOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public Task<ConsensusProjection?> GetAsync(
|
||||
string projectionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
GetAsyncToken.TrySetResult(cancellationToken);
|
||||
return Task.FromResult(_projection);
|
||||
}
|
||||
|
||||
public Task<ConsensusProjection?> GetLatestAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
GetLatestAsyncToken.TrySetResult(cancellationToken);
|
||||
return Task.FromResult(_projection);
|
||||
}
|
||||
|
||||
public Task<ProjectionListResult> ListAsync(
|
||||
ProjectionQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ConsensusProjection>> GetHistoryAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
string? tenantId = null,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public Task<int> PurgeAsync(
|
||||
DateTimeOffset olderThan,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
|
||||
|
||||
using System.Data;
|
||||
using System.Reflection;
|
||||
using FluentAssertions;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.VexLens.Tests.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for Postgres consensus projection proxy mapping.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class PostgresConsensusProjectionStoreProxyTests
|
||||
{
|
||||
[Fact]
|
||||
public void MapProjection_PreservesDateTimeOffsets()
|
||||
{
|
||||
var computedAt = new DateTimeOffset(2026, 1, 15, 9, 30, 0, TimeSpan.FromHours(2));
|
||||
var storedAt = new DateTimeOffset(2026, 1, 15, 10, 0, 0, TimeSpan.FromHours(-5));
|
||||
|
||||
var table = new DataTable();
|
||||
table.Columns.Add("id", typeof(Guid));
|
||||
table.Columns.Add("vulnerability_id", typeof(string));
|
||||
table.Columns.Add("product_key", typeof(string));
|
||||
table.Columns.Add("tenant_id", typeof(string));
|
||||
table.Columns.Add("status", typeof(string));
|
||||
table.Columns.Add("justification", typeof(string));
|
||||
table.Columns.Add("confidence_score", typeof(double));
|
||||
table.Columns.Add("outcome", typeof(string));
|
||||
table.Columns.Add("statement_count", typeof(int));
|
||||
table.Columns.Add("conflict_count", typeof(int));
|
||||
table.Columns.Add("rationale_summary", typeof(string));
|
||||
table.Columns.Add("computed_at", typeof(DateTimeOffset));
|
||||
table.Columns.Add("stored_at", typeof(DateTimeOffset));
|
||||
table.Columns.Add("previous_projection_id", typeof(Guid));
|
||||
table.Columns.Add("status_changed", typeof(bool));
|
||||
|
||||
table.Rows.Add(
|
||||
Guid.Parse("11111111-1111-1111-1111-111111111111"),
|
||||
"CVE-2026-0003",
|
||||
"pkg:npm/example@3.0.0",
|
||||
DBNull.Value,
|
||||
"affected",
|
||||
DBNull.Value,
|
||||
0.88,
|
||||
"majority",
|
||||
3,
|
||||
0,
|
||||
"summary",
|
||||
computedAt,
|
||||
storedAt,
|
||||
DBNull.Value,
|
||||
false);
|
||||
|
||||
using var reader = table.CreateDataReader();
|
||||
reader.Read();
|
||||
|
||||
var method = typeof(PostgresConsensusProjectionStoreProxy).GetMethod(
|
||||
"MapProjection",
|
||||
BindingFlags.NonPublic | BindingFlags.Static);
|
||||
|
||||
method.Should().NotBeNull();
|
||||
var projection = (ConsensusProjection)method!.Invoke(null, new object[] { reader })!;
|
||||
|
||||
projection.ComputedAt.Should().Be(computedAt);
|
||||
projection.StoredAt.Should().Be(storedAt);
|
||||
projection.Status.Should().Be(VexStatus.Affected);
|
||||
projection.Outcome.Should().Be(ConsensusOutcome.Majority);
|
||||
}
|
||||
}
|
||||
@@ -11,17 +11,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user