audit, advisories and doctors/setup work

This commit is contained in:
master
2026-01-13 18:53:39 +02:00
parent 9ca7cb183e
commit d7be6ba34b
811 changed files with 54242 additions and 4056 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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