partly or unimplemented features - now implemented
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.AdvisoryLens.Models;
|
||||
using StellaOps.AdvisoryLens.Services;
|
||||
|
||||
namespace StellaOps.AdvisoryLens.DependencyInjection;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddAdvisoryLens(
|
||||
this IServiceCollection services,
|
||||
IReadOnlyList<CasePattern>? patterns = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
var resolvedPatterns = patterns ?? Array.Empty<CasePattern>();
|
||||
services.AddSingleton<IAdvisoryLensService>(
|
||||
new AdvisoryLensService(resolvedPatterns, timeProvider));
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.AdvisoryLens.Models;
|
||||
|
||||
namespace StellaOps.AdvisoryLens.Matching;
|
||||
|
||||
public sealed class CaseMatcher
|
||||
{
|
||||
public ImmutableArray<CaseMatchResult> Match(AdvisoryCase advisoryCase, IReadOnlyList<CasePattern> patterns)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(advisoryCase);
|
||||
ArgumentNullException.ThrowIfNull(patterns);
|
||||
|
||||
var results = new List<CaseMatchResult>();
|
||||
|
||||
foreach (var pattern in patterns)
|
||||
{
|
||||
var score = ComputeScore(advisoryCase, pattern);
|
||||
if (score > 0.0)
|
||||
{
|
||||
results.Add(new CaseMatchResult
|
||||
{
|
||||
PatternId = pattern.PatternId,
|
||||
Score = score,
|
||||
Pattern = pattern
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
.OrderByDescending(r => r.Score)
|
||||
.ThenBy(r => r.PatternId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static double ComputeScore(AdvisoryCase advisoryCase, CasePattern pattern)
|
||||
{
|
||||
var factors = new List<double>();
|
||||
|
||||
if (pattern.SeverityRange is not null)
|
||||
{
|
||||
if (advisoryCase.Severity >= pattern.SeverityRange.Min &&
|
||||
advisoryCase.Severity <= pattern.SeverityRange.Max)
|
||||
{
|
||||
factors.Add(1.0);
|
||||
}
|
||||
else
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(pattern.EcosystemMatch))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(advisoryCase.Purl) &&
|
||||
advisoryCase.Purl.StartsWith($"pkg:{pattern.EcosystemMatch}/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
factors.Add(1.0);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(advisoryCase.Purl))
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(pattern.CvePattern))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(advisoryCase.Cve) &&
|
||||
advisoryCase.Cve.Contains(pattern.CvePattern, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
factors.Add(1.0);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(advisoryCase.Cve))
|
||||
{
|
||||
factors.Add(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
if (factors.Count == 0)
|
||||
{
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
return factors.Average();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record CaseMatchResult
|
||||
{
|
||||
public required string PatternId { get; init; }
|
||||
public required double Score { get; init; }
|
||||
public required CasePattern Pattern { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.AdvisoryLens.Models;
|
||||
|
||||
public sealed record AdvisoryCase
|
||||
{
|
||||
[JsonPropertyName("advisoryId")]
|
||||
public required string AdvisoryId { get; init; }
|
||||
|
||||
[JsonPropertyName("cve")]
|
||||
public string? Cve { get; init; }
|
||||
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public required AdvisorySeverity Severity { get; init; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } = ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum AdvisorySeverity
|
||||
{
|
||||
Unknown = 0,
|
||||
None = 1,
|
||||
Low = 2,
|
||||
Medium = 3,
|
||||
High = 4,
|
||||
Critical = 5
|
||||
}
|
||||
43
src/__Libraries/StellaOps.AdvisoryLens/Models/CasePattern.cs
Normal file
43
src/__Libraries/StellaOps.AdvisoryLens/Models/CasePattern.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.AdvisoryLens.Models;
|
||||
|
||||
public sealed record CasePattern
|
||||
{
|
||||
[JsonPropertyName("patternId")]
|
||||
public required string PatternId { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("severityRange")]
|
||||
public SeverityRange? SeverityRange { get; init; }
|
||||
|
||||
[JsonPropertyName("ecosystemMatch")]
|
||||
public string? EcosystemMatch { get; init; }
|
||||
|
||||
[JsonPropertyName("cvePattern")]
|
||||
public string? CvePattern { get; init; }
|
||||
|
||||
[JsonPropertyName("requiredVexStatus")]
|
||||
public ImmutableArray<string> RequiredVexStatus { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
[JsonPropertyName("defaultAction")]
|
||||
public required SuggestionAction DefaultAction { get; init; }
|
||||
|
||||
[JsonPropertyName("suggestionTitle")]
|
||||
public required string SuggestionTitle { get; init; }
|
||||
|
||||
[JsonPropertyName("suggestionRationale")]
|
||||
public required string SuggestionRationale { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SeverityRange
|
||||
{
|
||||
[JsonPropertyName("min")]
|
||||
public required AdvisorySeverity Min { get; init; }
|
||||
|
||||
[JsonPropertyName("max")]
|
||||
public required AdvisorySeverity Max { get; init; }
|
||||
}
|
||||
25
src/__Libraries/StellaOps.AdvisoryLens/Models/LensContext.cs
Normal file
25
src/__Libraries/StellaOps.AdvisoryLens/Models/LensContext.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.AdvisoryLens.Models;
|
||||
|
||||
public sealed record LensContext
|
||||
{
|
||||
[JsonPropertyName("advisoryCase")]
|
||||
public required AdvisoryCase AdvisoryCase { get; init; }
|
||||
|
||||
[JsonPropertyName("tenantId")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
[JsonPropertyName("vexStatements")]
|
||||
public ImmutableArray<string> VexStatements { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
[JsonPropertyName("policyTraces")]
|
||||
public ImmutableArray<string> PolicyTraces { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
[JsonPropertyName("reachabilityData")]
|
||||
public ImmutableArray<string> ReachabilityData { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
[JsonPropertyName("evaluationTimestampUtc")]
|
||||
public DateTime? EvaluationTimestampUtc { get; init; }
|
||||
}
|
||||
25
src/__Libraries/StellaOps.AdvisoryLens/Models/LensHint.cs
Normal file
25
src/__Libraries/StellaOps.AdvisoryLens/Models/LensHint.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.AdvisoryLens.Models;
|
||||
|
||||
public sealed record LensHint
|
||||
{
|
||||
[JsonPropertyName("text")]
|
||||
public required string Text { get; init; }
|
||||
|
||||
[JsonPropertyName("category")]
|
||||
public required HintCategory Category { get; init; }
|
||||
|
||||
[JsonPropertyName("evidenceRefs")]
|
||||
public ImmutableArray<string> EvidenceRefs { get; init; } = ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum HintCategory
|
||||
{
|
||||
Severity = 0,
|
||||
Reachability = 1,
|
||||
Vex = 2,
|
||||
Policy = 3
|
||||
}
|
||||
22
src/__Libraries/StellaOps.AdvisoryLens/Models/LensResult.cs
Normal file
22
src/__Libraries/StellaOps.AdvisoryLens/Models/LensResult.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.AdvisoryLens.Models;
|
||||
|
||||
public sealed record LensResult
|
||||
{
|
||||
[JsonPropertyName("suggestions")]
|
||||
public required ImmutableArray<LensSuggestion> Suggestions { get; init; }
|
||||
|
||||
[JsonPropertyName("hints")]
|
||||
public required ImmutableArray<LensHint> Hints { get; init; }
|
||||
|
||||
[JsonPropertyName("matchedPatterns")]
|
||||
public required ImmutableArray<string> MatchedPatterns { get; init; }
|
||||
|
||||
[JsonPropertyName("evaluatedAtUtc")]
|
||||
public required DateTime EvaluatedAtUtc { get; init; }
|
||||
|
||||
[JsonPropertyName("inputHash")]
|
||||
public required string InputHash { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.AdvisoryLens.Models;
|
||||
|
||||
public sealed record LensSuggestion
|
||||
{
|
||||
[JsonPropertyName("rank")]
|
||||
public required int Rank { get; init; }
|
||||
|
||||
[JsonPropertyName("title")]
|
||||
public required string Title { get; init; }
|
||||
|
||||
[JsonPropertyName("rationale")]
|
||||
public required string Rationale { get; init; }
|
||||
|
||||
[JsonPropertyName("confidence")]
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
[JsonPropertyName("action")]
|
||||
public required SuggestionAction Action { get; init; }
|
||||
|
||||
[JsonPropertyName("patternId")]
|
||||
public string? PatternId { get; init; }
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum SuggestionAction
|
||||
{
|
||||
Accept = 0,
|
||||
Mitigate = 1,
|
||||
Defer = 2,
|
||||
Escalate = 3
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.AdvisoryLens.Matching;
|
||||
using StellaOps.AdvisoryLens.Models;
|
||||
|
||||
namespace StellaOps.AdvisoryLens.Services;
|
||||
|
||||
public sealed class AdvisoryLensService : IAdvisoryLensService
|
||||
{
|
||||
private static readonly JsonSerializerOptions s_hashJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly IReadOnlyList<CasePattern> _patterns;
|
||||
private readonly CaseMatcher _matcher;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public AdvisoryLensService(IReadOnlyList<CasePattern> patterns, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_patterns = patterns ?? throw new ArgumentNullException(nameof(patterns));
|
||||
_matcher = new CaseMatcher();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public LensResult Evaluate(LensContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(context.AdvisoryCase);
|
||||
|
||||
var matches = _matcher.Match(context.AdvisoryCase, _patterns);
|
||||
var suggestions = BuildSuggestions(matches);
|
||||
var hints = BuildHints(context);
|
||||
var inputHash = ComputeInputHash(context);
|
||||
var timestamp = context.EvaluationTimestampUtc ?? _timeProvider.GetUtcNow().UtcDateTime;
|
||||
|
||||
return new LensResult
|
||||
{
|
||||
Suggestions = suggestions,
|
||||
Hints = hints,
|
||||
MatchedPatterns = matches.Select(m => m.PatternId).ToImmutableArray(),
|
||||
EvaluatedAtUtc = timestamp,
|
||||
InputHash = inputHash
|
||||
};
|
||||
}
|
||||
|
||||
private static ImmutableArray<LensSuggestion> BuildSuggestions(ImmutableArray<CaseMatchResult> matches)
|
||||
{
|
||||
return matches
|
||||
.Select((m, idx) => new LensSuggestion
|
||||
{
|
||||
Rank = idx + 1,
|
||||
Title = m.Pattern.SuggestionTitle,
|
||||
Rationale = m.Pattern.SuggestionRationale,
|
||||
Confidence = m.Score,
|
||||
Action = m.Pattern.DefaultAction,
|
||||
PatternId = m.PatternId
|
||||
})
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<LensHint> BuildHints(LensContext context)
|
||||
{
|
||||
var hints = new List<LensHint>();
|
||||
|
||||
if (context.AdvisoryCase.Severity >= AdvisorySeverity.High)
|
||||
{
|
||||
hints.Add(new LensHint
|
||||
{
|
||||
Text = $"Advisory severity is {context.AdvisoryCase.Severity}. Prioritize remediation.",
|
||||
Category = HintCategory.Severity,
|
||||
EvidenceRefs = ImmutableArray<string>.Empty
|
||||
});
|
||||
}
|
||||
|
||||
if (!context.VexStatements.IsDefaultOrEmpty && context.VexStatements.Length > 0)
|
||||
{
|
||||
hints.Add(new LensHint
|
||||
{
|
||||
Text = $"{context.VexStatements.Length} VEX statement(s) available for this advisory.",
|
||||
Category = HintCategory.Vex,
|
||||
EvidenceRefs = context.VexStatements
|
||||
});
|
||||
}
|
||||
|
||||
if (!context.ReachabilityData.IsDefaultOrEmpty && context.ReachabilityData.Length > 0)
|
||||
{
|
||||
hints.Add(new LensHint
|
||||
{
|
||||
Text = "Reachability data available. Check if vulnerable code paths are exercised.",
|
||||
Category = HintCategory.Reachability,
|
||||
EvidenceRefs = context.ReachabilityData
|
||||
});
|
||||
}
|
||||
|
||||
if (!context.PolicyTraces.IsDefaultOrEmpty && context.PolicyTraces.Length > 0)
|
||||
{
|
||||
hints.Add(new LensHint
|
||||
{
|
||||
Text = $"{context.PolicyTraces.Length} policy trace(s) apply to this advisory.",
|
||||
Category = HintCategory.Policy,
|
||||
EvidenceRefs = context.PolicyTraces
|
||||
});
|
||||
}
|
||||
|
||||
return hints
|
||||
.OrderBy(h => (int)h.Category)
|
||||
.ThenBy(h => h.Text, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static string ComputeInputHash(LensContext context)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(context, s_hashJsonOptions);
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
return $"sha256:{Convert.ToHexStringLower(bytes)}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using StellaOps.AdvisoryLens.Models;
|
||||
|
||||
namespace StellaOps.AdvisoryLens.Services;
|
||||
|
||||
public interface IAdvisoryLensService
|
||||
{
|
||||
LensResult Evaluate(LensContext context);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,65 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Provcache.Invalidation;
|
||||
|
||||
namespace StellaOps.Provcache;
|
||||
|
||||
/// <summary>
|
||||
/// Hosted service that manages the lifecycle of registered <see cref="IProvcacheInvalidator"/> instances,
|
||||
/// starting them on application startup and stopping them on shutdown.
|
||||
/// </summary>
|
||||
public sealed class InvalidatorHostedService : IHostedService
|
||||
{
|
||||
private readonly IReadOnlyList<IProvcacheInvalidator> _invalidators;
|
||||
private readonly ILogger<InvalidatorHostedService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InvalidatorHostedService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="invalidators">The registered invalidator instances.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public InvalidatorHostedService(
|
||||
IEnumerable<IProvcacheInvalidator> invalidators,
|
||||
ILogger<InvalidatorHostedService> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(invalidators);
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_invalidators = invalidators.ToArray();
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_invalidators.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No Provcache invalidators registered; skipping startup");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Starting {Count} Provcache invalidator(s)", _invalidators.Count);
|
||||
|
||||
foreach (var invalidator in _invalidators)
|
||||
{
|
||||
await invalidator.StartAsync(cancellationToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("All Provcache invalidators started");
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_invalidators.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Stopping {Count} Provcache invalidator(s)", _invalidators.Count);
|
||||
|
||||
foreach (var invalidator in _invalidators.Reverse())
|
||||
{
|
||||
await invalidator.StopAsync(cancellationToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("All Provcache invalidators stopped");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using StellaOps.Messaging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Provcache.Events;
|
||||
using StellaOps.Provcache.Invalidation;
|
||||
|
||||
namespace StellaOps.Provcache;
|
||||
|
||||
public static partial class ProvcacheServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds event-driven invalidator services for Provcache.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddProvcacheInvalidators(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.TryAddSingleton<IEventStream<SignerRevokedEvent>>(sp =>
|
||||
{
|
||||
var factory = sp.GetRequiredService<IEventStreamFactory>();
|
||||
return factory.Create<SignerRevokedEvent>(new EventStreamOptions
|
||||
{
|
||||
StreamName = SignerRevokedEvent.StreamName
|
||||
});
|
||||
});
|
||||
|
||||
services.TryAddSingleton<IEventStream<FeedEpochAdvancedEvent>>(sp =>
|
||||
{
|
||||
var factory = sp.GetRequiredService<IEventStreamFactory>();
|
||||
return factory.Create<FeedEpochAdvancedEvent>(new EventStreamOptions
|
||||
{
|
||||
StreamName = FeedEpochAdvancedEvent.StreamName
|
||||
});
|
||||
});
|
||||
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IProvcacheInvalidator, SignerSetInvalidator>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IProvcacheInvalidator, FeedEpochInvalidator>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService, InvalidatorHostedService>());
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ public static partial class ProvcacheServiceCollectionExtensions
|
||||
services.AddSingleton<IWriteBehindQueue, WriteBehindQueue>();
|
||||
services.AddHostedService<WriteBehindQueueHostedService>();
|
||||
services.AddHttpClient(HttpChunkFetcher.HttpClientName);
|
||||
services.AddProvcacheInvalidators();
|
||||
|
||||
return services;
|
||||
}
|
||||
@@ -76,6 +77,7 @@ public static partial class ProvcacheServiceCollectionExtensions
|
||||
services.AddSingleton<IWriteBehindQueue, WriteBehindQueue>();
|
||||
services.AddHostedService<WriteBehindQueueHostedService>();
|
||||
services.AddHttpClient(HttpChunkFetcher.HttpClientName);
|
||||
services.AddProvcacheInvalidators();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// LatticeTriageServiceTests.cs
|
||||
// Sprint: SPRINT_20260208_052_ReachGraph_8_state_reachability_lattice
|
||||
// Task: T1 - Unit tests for lattice triage service
|
||||
// Description: Deterministic tests for triage service operations including
|
||||
// evidence application, manual overrides, audit trail,
|
||||
// queries, and reset functionality.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
|
||||
namespace StellaOps.Reachability.Core.Tests;
|
||||
|
||||
public sealed class LatticeTriageServiceTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly LatticeTriageService _service;
|
||||
|
||||
public LatticeTriageServiceTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(
|
||||
new DateTimeOffset(2026, 2, 9, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<TimeProvider>(_timeProvider);
|
||||
services.AddLogging();
|
||||
services.AddSingleton<IMeterFactory>(new TestMeterFactory());
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
_service = new LatticeTriageService(
|
||||
_timeProvider,
|
||||
provider.GetRequiredService<ILogger<LatticeTriageService>>(),
|
||||
provider.GetRequiredService<IMeterFactory>());
|
||||
}
|
||||
|
||||
/// <summary>Simple no-op meter factory for tests.</summary>
|
||||
private sealed class TestMeterFactory : IMeterFactory
|
||||
{
|
||||
private readonly List<Meter> _meters = [];
|
||||
|
||||
public Meter Create(MeterOptions options)
|
||||
{
|
||||
var meter = new Meter(options);
|
||||
_meters.Add(meter);
|
||||
return meter;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var m in _meters) m.Dispose();
|
||||
_meters.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ── GetOrCreate ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrCreate_NewEntry_ReturnsUnknownState()
|
||||
{
|
||||
var entry = await _service.GetOrCreateEntryAsync("pkg:npm/lodash@4.17.20", "CVE-2026-0001");
|
||||
|
||||
entry.CurrentState.Should().Be(LatticeState.Unknown);
|
||||
entry.Confidence.Should().Be(0.0);
|
||||
entry.VexStatus.Should().Be("under_investigation");
|
||||
entry.RequiresReview.Should().BeFalse();
|
||||
entry.Transitions.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrCreate_SameKey_ReturnsSameEntry()
|
||||
{
|
||||
var entry1 = await _service.GetOrCreateEntryAsync("pkg:npm/lodash@4.17.20", "CVE-2026-0001");
|
||||
var entry2 = await _service.GetOrCreateEntryAsync("pkg:npm/lodash@4.17.20", "CVE-2026-0001");
|
||||
|
||||
entry1.EntryId.Should().Be(entry2.EntryId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrCreate_DifferentKeys_ReturnsDifferentEntries()
|
||||
{
|
||||
var entry1 = await _service.GetOrCreateEntryAsync("pkg:npm/lodash@4.17.20", "CVE-2026-0001");
|
||||
var entry2 = await _service.GetOrCreateEntryAsync("pkg:npm/lodash@4.17.21", "CVE-2026-0001");
|
||||
|
||||
entry1.EntryId.Should().NotBe(entry2.EntryId);
|
||||
}
|
||||
|
||||
// ── ApplyEvidence ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyEvidence_StaticReachable_TransitionsFromUnknown()
|
||||
{
|
||||
var entry = await _service.ApplyEvidenceAsync(
|
||||
"pkg:npm/lodash@4.17.20", "CVE-2026-0001",
|
||||
EvidenceType.StaticReachable, "Static analysis found path");
|
||||
|
||||
entry.CurrentState.Should().Be(LatticeState.StaticReachable);
|
||||
entry.Confidence.Should().BeGreaterThan(0.0);
|
||||
entry.VexStatus.Should().Be("under_investigation");
|
||||
entry.Transitions.Should().HaveCount(1);
|
||||
entry.Transitions[0].FromState.Should().Be(LatticeState.Unknown);
|
||||
entry.Transitions[0].ToState.Should().Be(LatticeState.StaticReachable);
|
||||
entry.Transitions[0].Trigger.Should().Be(LatticeTransitionTrigger.StaticAnalysis);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyEvidence_StaticThenRuntime_ReachesConfirmed()
|
||||
{
|
||||
await _service.ApplyEvidenceAsync(
|
||||
"pkg:npm/lodash@4.17.20", "CVE-2026-0001",
|
||||
EvidenceType.StaticReachable);
|
||||
|
||||
var entry = await _service.ApplyEvidenceAsync(
|
||||
"pkg:npm/lodash@4.17.20", "CVE-2026-0001",
|
||||
EvidenceType.RuntimeObserved);
|
||||
|
||||
entry.CurrentState.Should().Be(LatticeState.ConfirmedReachable);
|
||||
entry.VexStatus.Should().Be("affected");
|
||||
entry.Transitions.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyEvidence_ConflictingEvidence_EntersContested()
|
||||
{
|
||||
await _service.ApplyEvidenceAsync(
|
||||
"pkg:npm/lodash@4.17.20", "CVE-2026-0001",
|
||||
EvidenceType.StaticUnreachable);
|
||||
|
||||
var entry = await _service.ApplyEvidenceAsync(
|
||||
"pkg:npm/lodash@4.17.20", "CVE-2026-0001",
|
||||
EvidenceType.RuntimeObserved);
|
||||
|
||||
entry.CurrentState.Should().Be(LatticeState.Contested);
|
||||
entry.RequiresReview.Should().BeTrue();
|
||||
entry.VexStatus.Should().Be("under_investigation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyEvidence_WithDigests_RecordsInTransition()
|
||||
{
|
||||
var digests = new[] { "sha256:abc", "sha256:def" };
|
||||
|
||||
var entry = await _service.ApplyEvidenceAsync(
|
||||
"pkg:npm/lodash@4.17.20", "CVE-2026-0001",
|
||||
EvidenceType.StaticReachable,
|
||||
evidenceDigests: digests);
|
||||
|
||||
entry.Transitions[0].EvidenceDigests.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
// ── Override ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Override_SetsTargetState()
|
||||
{
|
||||
await _service.ApplyEvidenceAsync(
|
||||
"pkg:npm/lodash@4.17.20", "CVE-2026-0001",
|
||||
EvidenceType.StaticUnreachable);
|
||||
|
||||
var result = await _service.OverrideStateAsync(new LatticeOverrideRequest
|
||||
{
|
||||
ComponentPurl = "pkg:npm/lodash@4.17.20",
|
||||
Cve = "CVE-2026-0001",
|
||||
TargetState = LatticeState.ConfirmedReachable,
|
||||
Reason = "Vendor confirmed reachability",
|
||||
Actor = "security-team"
|
||||
});
|
||||
|
||||
result.Applied.Should().BeTrue();
|
||||
result.Entry.CurrentState.Should().Be(LatticeState.ConfirmedReachable);
|
||||
result.Transition.IsManualOverride.Should().BeTrue();
|
||||
result.Transition.Actor.Should().Be("security-team");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Override_FromConfirmed_ReturnsWarning()
|
||||
{
|
||||
// Reach ConfirmedReachable
|
||||
await _service.ApplyEvidenceAsync(
|
||||
"pkg:npm/lodash@4.17.20", "CVE-2026-0001",
|
||||
EvidenceType.StaticReachable);
|
||||
await _service.ApplyEvidenceAsync(
|
||||
"pkg:npm/lodash@4.17.20", "CVE-2026-0001",
|
||||
EvidenceType.RuntimeObserved);
|
||||
|
||||
var result = await _service.OverrideStateAsync(new LatticeOverrideRequest
|
||||
{
|
||||
ComponentPurl = "pkg:npm/lodash@4.17.20",
|
||||
Cve = "CVE-2026-0001",
|
||||
TargetState = LatticeState.ConfirmedUnreachable,
|
||||
Reason = "Re-analysis confirmed false positive",
|
||||
Actor = "admin"
|
||||
});
|
||||
|
||||
result.Warning.Should().NotBeNullOrEmpty();
|
||||
result.Warning.Should().Contain("Overriding from confirmed state");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Override_HasOverride_IsTrue()
|
||||
{
|
||||
var result = await _service.OverrideStateAsync(new LatticeOverrideRequest
|
||||
{
|
||||
ComponentPurl = "pkg:npm/lodash@4.17.20",
|
||||
Cve = "CVE-2026-0001",
|
||||
TargetState = LatticeState.ConfirmedUnreachable,
|
||||
Reason = "Manual verification",
|
||||
Actor = "tester"
|
||||
});
|
||||
|
||||
result.Entry.HasOverride.Should().BeTrue();
|
||||
}
|
||||
|
||||
// ── List and Query ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task List_FilterByState_ReturnsMatchingEntries()
|
||||
{
|
||||
await _service.ApplyEvidenceAsync("pkg:npm/a@1.0", "CVE-001", EvidenceType.StaticReachable);
|
||||
await _service.ApplyEvidenceAsync("pkg:npm/b@1.0", "CVE-002", EvidenceType.RuntimeObserved);
|
||||
await _service.ApplyEvidenceAsync("pkg:npm/c@1.0", "CVE-003", EvidenceType.StaticUnreachable);
|
||||
|
||||
var results = await _service.ListAsync(new LatticeTriageQuery
|
||||
{
|
||||
State = LatticeState.StaticReachable
|
||||
});
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].ComponentPurl.Should().Be("pkg:npm/a@1.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_FilterRequiresReview_ReturnsContestedOnly()
|
||||
{
|
||||
await _service.ApplyEvidenceAsync("pkg:npm/a@1.0", "CVE-001", EvidenceType.StaticUnreachable);
|
||||
await _service.ApplyEvidenceAsync("pkg:npm/a@1.0", "CVE-001", EvidenceType.RuntimeObserved); // Contested
|
||||
await _service.ApplyEvidenceAsync("pkg:npm/b@1.0", "CVE-002", EvidenceType.StaticReachable);
|
||||
|
||||
var results = await _service.ListAsync(new LatticeTriageQuery
|
||||
{
|
||||
RequiresReview = true
|
||||
});
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].RequiresReview.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_FilterByPurlPrefix_ReturnsMatching()
|
||||
{
|
||||
await _service.ApplyEvidenceAsync("pkg:npm/lodash@4.17.20", "CVE-001", EvidenceType.StaticReachable);
|
||||
await _service.ApplyEvidenceAsync("pkg:maven/log4j@2.14.0", "CVE-002", EvidenceType.StaticReachable);
|
||||
|
||||
var results = await _service.ListAsync(new LatticeTriageQuery
|
||||
{
|
||||
ComponentPurlPrefix = "pkg:npm/"
|
||||
});
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].ComponentPurl.Should().StartWith("pkg:npm/");
|
||||
}
|
||||
|
||||
// ── History ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetHistory_ReturnsFullTransitionLog()
|
||||
{
|
||||
await _service.ApplyEvidenceAsync("pkg:npm/a@1.0", "CVE-001", EvidenceType.StaticReachable);
|
||||
await _service.ApplyEvidenceAsync("pkg:npm/a@1.0", "CVE-001", EvidenceType.RuntimeObserved);
|
||||
|
||||
var history = await _service.GetHistoryAsync("pkg:npm/a@1.0", "CVE-001");
|
||||
|
||||
history.Should().HaveCount(2);
|
||||
history[0].FromState.Should().Be(LatticeState.Unknown);
|
||||
history[0].ToState.Should().Be(LatticeState.StaticReachable);
|
||||
history[1].FromState.Should().Be(LatticeState.StaticReachable);
|
||||
history[1].ToState.Should().Be(LatticeState.ConfirmedReachable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetHistory_NonexistentEntry_ReturnsEmpty()
|
||||
{
|
||||
var history = await _service.GetHistoryAsync("pkg:npm/nonexistent@1.0", "CVE-999");
|
||||
|
||||
history.Should().BeEmpty();
|
||||
}
|
||||
|
||||
// ── Reset ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Reset_ReturnsToUnknown()
|
||||
{
|
||||
await _service.ApplyEvidenceAsync("pkg:npm/a@1.0", "CVE-001", EvidenceType.StaticReachable);
|
||||
await _service.ApplyEvidenceAsync("pkg:npm/a@1.0", "CVE-001", EvidenceType.RuntimeObserved);
|
||||
|
||||
var entry = await _service.ResetAsync(
|
||||
"pkg:npm/a@1.0", "CVE-001",
|
||||
"admin", "Re-scan required");
|
||||
|
||||
entry.CurrentState.Should().Be(LatticeState.Unknown);
|
||||
entry.Confidence.Should().Be(0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reset_RecordsTransition()
|
||||
{
|
||||
await _service.ApplyEvidenceAsync("pkg:npm/a@1.0", "CVE-001", EvidenceType.StaticReachable);
|
||||
|
||||
await _service.ResetAsync(
|
||||
"pkg:npm/a@1.0", "CVE-001",
|
||||
"admin", "Re-scan");
|
||||
|
||||
var history = await _service.GetHistoryAsync("pkg:npm/a@1.0", "CVE-001");
|
||||
var last = history[^1];
|
||||
last.Trigger.Should().Be(LatticeTransitionTrigger.SystemReset);
|
||||
last.Actor.Should().Be("admin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reset_NonexistentEntry_Throws()
|
||||
{
|
||||
var act = () => _service.ResetAsync(
|
||||
"pkg:npm/nonexistent@1.0", "CVE-999",
|
||||
"admin", "test");
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
}
|
||||
|
||||
// ── Edge cases ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrCreate_ThrowsOnNullPurl()
|
||||
{
|
||||
var act = () => _service.GetOrCreateEntryAsync(null!, "CVE-001");
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Override_ThrowsOnEmptyReason()
|
||||
{
|
||||
var act = () => _service.OverrideStateAsync(new LatticeOverrideRequest
|
||||
{
|
||||
ComponentPurl = "pkg:npm/a@1.0",
|
||||
Cve = "CVE-001",
|
||||
TargetState = LatticeState.ConfirmedReachable,
|
||||
Reason = "",
|
||||
Actor = "admin"
|
||||
});
|
||||
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
// ── VEX status mapping ───────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(LatticeState.ConfirmedUnreachable, "not_affected")]
|
||||
[InlineData(LatticeState.RuntimeObserved, "affected")]
|
||||
[InlineData(LatticeState.StaticUnreachable, "not_affected")]
|
||||
public async Task VexStatus_MapsCorrectly(LatticeState targetState, string expectedVex)
|
||||
{
|
||||
var result = await _service.OverrideStateAsync(new LatticeOverrideRequest
|
||||
{
|
||||
ComponentPurl = $"pkg:npm/test-{targetState}@1.0",
|
||||
Cve = $"CVE-{(int)targetState:D3}",
|
||||
TargetState = targetState,
|
||||
Reason = "Test",
|
||||
Actor = "test"
|
||||
});
|
||||
|
||||
result.Entry.VexStatus.Should().Be(expectedVex);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ILatticeTriageService.cs
|
||||
// Sprint: SPRINT_20260208_052_ReachGraph_8_state_reachability_lattice
|
||||
// Task: T1 - Triage service interface
|
||||
// Description: Service interface for the lattice triage subsystem providing
|
||||
// state queries, evidence application, manual overrides,
|
||||
// and audit trail access.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Reachability.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing the reachability lattice triage workflow.
|
||||
/// Provides state queries, evidence application, manual overrides,
|
||||
/// and audit trail access for the 8-state lattice.
|
||||
/// </summary>
|
||||
public interface ILatticeTriageService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or creates a triage entry for a component/CVE pair.
|
||||
/// </summary>
|
||||
Task<LatticeTriageEntry> GetOrCreateEntryAsync(
|
||||
string componentPurl,
|
||||
string cve,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Applies evidence to a triage entry, triggering a state transition.
|
||||
/// </summary>
|
||||
Task<LatticeTriageEntry> ApplyEvidenceAsync(
|
||||
string componentPurl,
|
||||
string cve,
|
||||
EvidenceType evidenceType,
|
||||
string? reason = null,
|
||||
IReadOnlyList<string>? evidenceDigests = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Applies a manual override to force a specific lattice state.
|
||||
/// </summary>
|
||||
Task<LatticeOverrideResult> OverrideStateAsync(
|
||||
LatticeOverrideRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists triage entries matching the given query.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<LatticeTriageEntry>> ListAsync(
|
||||
LatticeTriageQuery query,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the transition history for a component/CVE pair.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<LatticeTransitionRecord>> GetHistoryAsync(
|
||||
string componentPurl,
|
||||
string cve,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resets a triage entry to the Unknown state.
|
||||
/// </summary>
|
||||
Task<LatticeTriageEntry> ResetAsync(
|
||||
string componentPurl,
|
||||
string cve,
|
||||
string actor,
|
||||
string reason,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// LatticeTriageModels.cs
|
||||
// Sprint: SPRINT_20260208_052_ReachGraph_8_state_reachability_lattice
|
||||
// Task: T1 - Triage models for the 8-state reachability lattice
|
||||
// Description: Models for triage workflows, state transitions, manual
|
||||
// overrides, and audit trail for the reachability lattice.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Reachability.Core;
|
||||
|
||||
/// <summary>
|
||||
/// A triage entry representing a component's current lattice state
|
||||
/// along with its full evidence and transition history.
|
||||
/// </summary>
|
||||
public sealed record LatticeTriageEntry
|
||||
{
|
||||
/// <summary>Content-addressed triage entry ID.</summary>
|
||||
[JsonPropertyName("entry_id")]
|
||||
public required string EntryId { get; init; }
|
||||
|
||||
/// <summary>Component PURL.</summary>
|
||||
[JsonPropertyName("component_purl")]
|
||||
public required string ComponentPurl { get; init; }
|
||||
|
||||
/// <summary>CVE identifier.</summary>
|
||||
[JsonPropertyName("cve")]
|
||||
public required string Cve { get; init; }
|
||||
|
||||
/// <summary>Current lattice state.</summary>
|
||||
[JsonPropertyName("current_state")]
|
||||
public required LatticeState CurrentState { get; init; }
|
||||
|
||||
/// <summary>Current confidence score (0.0-1.0).</summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>VEX status derived from the current state.</summary>
|
||||
[JsonPropertyName("vex_status")]
|
||||
public required string VexStatus { get; init; }
|
||||
|
||||
/// <summary>Ordered transition history (oldest first).</summary>
|
||||
[JsonPropertyName("transitions")]
|
||||
public required ImmutableArray<LatticeTransitionRecord> Transitions { get; init; }
|
||||
|
||||
/// <summary>Whether this entry is in a contested state requiring manual review.</summary>
|
||||
[JsonPropertyName("requires_review")]
|
||||
public bool RequiresReview => CurrentState == LatticeState.Contested;
|
||||
|
||||
/// <summary>Whether a manual override has been applied.</summary>
|
||||
[JsonPropertyName("has_override")]
|
||||
public bool HasOverride => Transitions.Any(t => t.IsManualOverride);
|
||||
|
||||
/// <summary>When this entry was created.</summary>
|
||||
[JsonPropertyName("created_at")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>When this entry was last updated.</summary>
|
||||
[JsonPropertyName("updated_at")]
|
||||
public required DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A recorded state transition in the lattice audit trail.
|
||||
/// </summary>
|
||||
public sealed record LatticeTransitionRecord
|
||||
{
|
||||
/// <summary>State before transition.</summary>
|
||||
[JsonPropertyName("from_state")]
|
||||
public required LatticeState FromState { get; init; }
|
||||
|
||||
/// <summary>State after transition.</summary>
|
||||
[JsonPropertyName("to_state")]
|
||||
public required LatticeState ToState { get; init; }
|
||||
|
||||
/// <summary>Confidence before transition.</summary>
|
||||
[JsonPropertyName("confidence_before")]
|
||||
public required double ConfidenceBefore { get; init; }
|
||||
|
||||
/// <summary>Confidence after transition.</summary>
|
||||
[JsonPropertyName("confidence_after")]
|
||||
public required double ConfidenceAfter { get; init; }
|
||||
|
||||
/// <summary>What triggered this transition.</summary>
|
||||
[JsonPropertyName("trigger")]
|
||||
public required LatticeTransitionTrigger Trigger { get; init; }
|
||||
|
||||
/// <summary>Whether this was a manual override.</summary>
|
||||
[JsonPropertyName("is_manual_override")]
|
||||
public bool IsManualOverride => Trigger == LatticeTransitionTrigger.ManualOverride;
|
||||
|
||||
/// <summary>Reason or justification for the transition.</summary>
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>Identity of the actor who caused the transition.</summary>
|
||||
[JsonPropertyName("actor")]
|
||||
public string? Actor { get; init; }
|
||||
|
||||
/// <summary>When this transition occurred.</summary>
|
||||
[JsonPropertyName("timestamp")]
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>Evidence digests supporting this transition.</summary>
|
||||
[JsonPropertyName("evidence_digests")]
|
||||
public ImmutableArray<string> EvidenceDigests { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What triggered a lattice state transition.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum LatticeTransitionTrigger
|
||||
{
|
||||
/// <summary>Static analysis evidence.</summary>
|
||||
StaticAnalysis,
|
||||
|
||||
/// <summary>Runtime observation evidence.</summary>
|
||||
RuntimeObservation,
|
||||
|
||||
/// <summary>Manual override by an operator.</summary>
|
||||
ManualOverride,
|
||||
|
||||
/// <summary>System reset (e.g., re-scan).</summary>
|
||||
SystemReset,
|
||||
|
||||
/// <summary>Automated triage rule.</summary>
|
||||
AutomatedRule
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to manually override the lattice state for a component/CVE pair.
|
||||
/// </summary>
|
||||
public sealed record LatticeOverrideRequest
|
||||
{
|
||||
/// <summary>Component PURL.</summary>
|
||||
[JsonPropertyName("component_purl")]
|
||||
public required string ComponentPurl { get; init; }
|
||||
|
||||
/// <summary>CVE identifier.</summary>
|
||||
[JsonPropertyName("cve")]
|
||||
public required string Cve { get; init; }
|
||||
|
||||
/// <summary>Target state to set.</summary>
|
||||
[JsonPropertyName("target_state")]
|
||||
public required LatticeState TargetState { get; init; }
|
||||
|
||||
/// <summary>Justification for the override (required for audit trail).</summary>
|
||||
[JsonPropertyName("reason")]
|
||||
public required string Reason { get; init; }
|
||||
|
||||
/// <summary>Actor performing the override.</summary>
|
||||
[JsonPropertyName("actor")]
|
||||
public required string Actor { get; init; }
|
||||
|
||||
/// <summary>Supporting evidence digests.</summary>
|
||||
[JsonPropertyName("evidence_digests")]
|
||||
public ImmutableArray<string> EvidenceDigests { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a lattice state override operation.
|
||||
/// </summary>
|
||||
public sealed record LatticeOverrideResult
|
||||
{
|
||||
/// <summary>Whether the override was applied.</summary>
|
||||
[JsonPropertyName("applied")]
|
||||
public required bool Applied { get; init; }
|
||||
|
||||
/// <summary>The updated triage entry.</summary>
|
||||
[JsonPropertyName("entry")]
|
||||
public required LatticeTriageEntry Entry { get; init; }
|
||||
|
||||
/// <summary>The transition record for this override.</summary>
|
||||
[JsonPropertyName("transition")]
|
||||
public required LatticeTransitionRecord Transition { get; init; }
|
||||
|
||||
/// <summary>Warning message if the override was unusual.</summary>
|
||||
[JsonPropertyName("warning")]
|
||||
public string? Warning { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query filter for listing triage entries.
|
||||
/// </summary>
|
||||
public sealed record LatticeTriageQuery
|
||||
{
|
||||
/// <summary>Filter by state.</summary>
|
||||
public LatticeState? State { get; init; }
|
||||
|
||||
/// <summary>Filter entries requiring review (Contested state).</summary>
|
||||
public bool? RequiresReview { get; init; }
|
||||
|
||||
/// <summary>Filter by component PURL prefix.</summary>
|
||||
public string? ComponentPurlPrefix { get; init; }
|
||||
|
||||
/// <summary>Filter by CVE identifier.</summary>
|
||||
public string? Cve { get; init; }
|
||||
|
||||
/// <summary>Maximum entries to return.</summary>
|
||||
public int Limit { get; init; } = 100;
|
||||
|
||||
/// <summary>Offset for pagination.</summary>
|
||||
public int Offset { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// LatticeTriageService.cs
|
||||
// Sprint: SPRINT_20260208_052_ReachGraph_8_state_reachability_lattice
|
||||
// Task: T1 - Triage service implementation
|
||||
// Description: In-memory implementation of the lattice triage service with
|
||||
// full state machine integration, override support, and
|
||||
// audit trail. Thread-safe via ConcurrentDictionary.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Reachability.Core;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="ILatticeTriageService"/>.
|
||||
/// Thread-safe via <see cref="ConcurrentDictionary{TKey,TValue}"/>.
|
||||
/// </summary>
|
||||
public sealed class LatticeTriageService : ILatticeTriageService
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, TriageState> _entries = new(StringComparer.Ordinal);
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<LatticeTriageService> _logger;
|
||||
|
||||
// OTel metrics
|
||||
private readonly Counter<long> _entriesCreated;
|
||||
private readonly Counter<long> _evidenceApplied;
|
||||
private readonly Counter<long> _overridesApplied;
|
||||
private readonly Counter<long> _resetsPerformed;
|
||||
private readonly Counter<long> _contestedEntries;
|
||||
|
||||
public LatticeTriageService(
|
||||
TimeProvider timeProvider,
|
||||
ILogger<LatticeTriageService> logger,
|
||||
IMeterFactory meterFactory)
|
||||
{
|
||||
_timeProvider = timeProvider;
|
||||
_logger = logger;
|
||||
|
||||
var meter = meterFactory.Create("StellaOps.Reachability.Core.LatticeTriage");
|
||||
_entriesCreated = meter.CreateCounter<long>(
|
||||
"stellaops.lattice.triage.entries_created_total",
|
||||
description: "Total triage entries created");
|
||||
_evidenceApplied = meter.CreateCounter<long>(
|
||||
"stellaops.lattice.triage.evidence_applied_total",
|
||||
description: "Total evidence applications");
|
||||
_overridesApplied = meter.CreateCounter<long>(
|
||||
"stellaops.lattice.triage.overrides_applied_total",
|
||||
description: "Total manual overrides applied");
|
||||
_resetsPerformed = meter.CreateCounter<long>(
|
||||
"stellaops.lattice.triage.resets_total",
|
||||
description: "Total resets performed");
|
||||
_contestedEntries = meter.CreateCounter<long>(
|
||||
"stellaops.lattice.triage.contested_total",
|
||||
description: "Total entries that entered Contested state");
|
||||
}
|
||||
|
||||
public Task<LatticeTriageEntry> GetOrCreateEntryAsync(
|
||||
string componentPurl,
|
||||
string cve,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(componentPurl);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cve);
|
||||
|
||||
var key = MakeKey(componentPurl, cve);
|
||||
var state = _entries.GetOrAdd(key, _ =>
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
_entriesCreated.Add(1);
|
||||
_logger.LogDebug(
|
||||
"Created triage entry for {Purl} / {Cve}",
|
||||
componentPurl, cve);
|
||||
|
||||
return new TriageState
|
||||
{
|
||||
ComponentPurl = componentPurl,
|
||||
Cve = cve,
|
||||
Lattice = new ReachabilityLattice(),
|
||||
Transitions = [],
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
});
|
||||
|
||||
return Task.FromResult(state.ToEntry());
|
||||
}
|
||||
|
||||
public Task<LatticeTriageEntry> ApplyEvidenceAsync(
|
||||
string componentPurl,
|
||||
string cve,
|
||||
EvidenceType evidenceType,
|
||||
string? reason = null,
|
||||
IReadOnlyList<string>? evidenceDigests = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(componentPurl);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cve);
|
||||
|
||||
var key = MakeKey(componentPurl, cve);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var state = _entries.GetOrAdd(key, _ =>
|
||||
{
|
||||
_entriesCreated.Add(1);
|
||||
return new TriageState
|
||||
{
|
||||
ComponentPurl = componentPurl,
|
||||
Cve = cve,
|
||||
Lattice = new ReachabilityLattice(),
|
||||
Transitions = [],
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
});
|
||||
|
||||
lock (state)
|
||||
{
|
||||
var fromState = state.Lattice.CurrentState;
|
||||
var fromConfidence = state.Lattice.Confidence;
|
||||
|
||||
var transition = state.Lattice.ApplyEvidence(evidenceType);
|
||||
|
||||
var trigger = evidenceType switch
|
||||
{
|
||||
EvidenceType.StaticReachable or EvidenceType.StaticUnreachable
|
||||
=> LatticeTransitionTrigger.StaticAnalysis,
|
||||
EvidenceType.RuntimeObserved or EvidenceType.RuntimeUnobserved
|
||||
=> LatticeTransitionTrigger.RuntimeObservation,
|
||||
_ => LatticeTransitionTrigger.AutomatedRule
|
||||
};
|
||||
|
||||
var record = new LatticeTransitionRecord
|
||||
{
|
||||
FromState = fromState,
|
||||
ToState = state.Lattice.CurrentState,
|
||||
ConfidenceBefore = fromConfidence,
|
||||
ConfidenceAfter = state.Lattice.Confidence,
|
||||
Trigger = trigger,
|
||||
Reason = reason ?? $"Evidence applied: {evidenceType}",
|
||||
Timestamp = now,
|
||||
EvidenceDigests = evidenceDigests is not null
|
||||
? [.. evidenceDigests]
|
||||
: []
|
||||
};
|
||||
|
||||
state.Transitions.Add(record);
|
||||
state.UpdatedAt = now;
|
||||
|
||||
if (state.Lattice.CurrentState == LatticeState.Contested)
|
||||
{
|
||||
_contestedEntries.Add(1);
|
||||
}
|
||||
|
||||
_evidenceApplied.Add(1);
|
||||
_logger.LogDebug(
|
||||
"Applied {Evidence} to {Purl}/{Cve}: {From} → {To} (confidence {Conf:F2})",
|
||||
evidenceType, componentPurl, cve,
|
||||
fromState, state.Lattice.CurrentState, state.Lattice.Confidence);
|
||||
}
|
||||
|
||||
return Task.FromResult(state.ToEntry());
|
||||
}
|
||||
|
||||
public Task<LatticeOverrideResult> OverrideStateAsync(
|
||||
LatticeOverrideRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.ComponentPurl);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.Cve);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.Reason);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.Actor);
|
||||
|
||||
var key = MakeKey(request.ComponentPurl, request.Cve);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var state = _entries.GetOrAdd(key, _ =>
|
||||
{
|
||||
_entriesCreated.Add(1);
|
||||
return new TriageState
|
||||
{
|
||||
ComponentPurl = request.ComponentPurl,
|
||||
Cve = request.Cve,
|
||||
Lattice = new ReachabilityLattice(),
|
||||
Transitions = [],
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
});
|
||||
|
||||
LatticeTransitionRecord transitionRecord;
|
||||
string? warning = null;
|
||||
|
||||
lock (state)
|
||||
{
|
||||
var fromState = state.Lattice.CurrentState;
|
||||
var fromConfidence = state.Lattice.Confidence;
|
||||
|
||||
// Warn if overriding from a confirmed state
|
||||
if (fromState is LatticeState.ConfirmedReachable or LatticeState.ConfirmedUnreachable)
|
||||
{
|
||||
warning = $"Overriding from confirmed state '{fromState}' — " +
|
||||
"this may invalidate prior evidence-based decisions.";
|
||||
}
|
||||
|
||||
// Force the state via reset + targeted state injection
|
||||
state.Lattice.Reset();
|
||||
ForceState(state.Lattice, request.TargetState);
|
||||
|
||||
var targetConfidence = ConfidenceCalculator.GetConfidenceRange(request.TargetState);
|
||||
// Set confidence to mid-range of the target state
|
||||
var midConfidence = (targetConfidence.Min + targetConfidence.Max) / 2.0;
|
||||
|
||||
transitionRecord = new LatticeTransitionRecord
|
||||
{
|
||||
FromState = fromState,
|
||||
ToState = request.TargetState,
|
||||
ConfidenceBefore = fromConfidence,
|
||||
ConfidenceAfter = midConfidence,
|
||||
Trigger = LatticeTransitionTrigger.ManualOverride,
|
||||
Reason = request.Reason,
|
||||
Actor = request.Actor,
|
||||
Timestamp = now,
|
||||
EvidenceDigests = request.EvidenceDigests
|
||||
};
|
||||
|
||||
state.Transitions.Add(transitionRecord);
|
||||
state.UpdatedAt = now;
|
||||
}
|
||||
|
||||
_overridesApplied.Add(1);
|
||||
_logger.LogInformation(
|
||||
"Manual override by {Actor} on {Purl}/{Cve}: → {TargetState}. Reason: {Reason}",
|
||||
request.Actor, request.ComponentPurl, request.Cve,
|
||||
request.TargetState, request.Reason);
|
||||
|
||||
return Task.FromResult(new LatticeOverrideResult
|
||||
{
|
||||
Applied = true,
|
||||
Entry = state.ToEntry(),
|
||||
Transition = transitionRecord,
|
||||
Warning = warning
|
||||
});
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<LatticeTriageEntry>> ListAsync(
|
||||
LatticeTriageQuery query,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
IEnumerable<TriageState> entries = _entries.Values;
|
||||
|
||||
if (query.State.HasValue)
|
||||
{
|
||||
entries = entries.Where(s => s.Lattice.CurrentState == query.State.Value);
|
||||
}
|
||||
|
||||
if (query.RequiresReview == true)
|
||||
{
|
||||
entries = entries.Where(s => s.Lattice.CurrentState == LatticeState.Contested);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.ComponentPurlPrefix))
|
||||
{
|
||||
entries = entries.Where(s =>
|
||||
s.ComponentPurl.StartsWith(query.ComponentPurlPrefix, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Cve))
|
||||
{
|
||||
entries = entries.Where(s =>
|
||||
s.Cve.Equals(query.Cve, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
var result = entries
|
||||
.OrderByDescending(s => s.UpdatedAt)
|
||||
.Skip(query.Offset)
|
||||
.Take(query.Limit)
|
||||
.Select(s => s.ToEntry())
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<LatticeTriageEntry>>(result);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<LatticeTransitionRecord>> GetHistoryAsync(
|
||||
string componentPurl,
|
||||
string cve,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(componentPurl);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cve);
|
||||
|
||||
var key = MakeKey(componentPurl, cve);
|
||||
|
||||
if (!_entries.TryGetValue(key, out var state))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<LatticeTransitionRecord>>([]);
|
||||
}
|
||||
|
||||
lock (state)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<LatticeTransitionRecord>>(
|
||||
[.. state.Transitions]);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<LatticeTriageEntry> ResetAsync(
|
||||
string componentPurl,
|
||||
string cve,
|
||||
string actor,
|
||||
string reason,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(componentPurl);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cve);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(reason);
|
||||
|
||||
var key = MakeKey(componentPurl, cve);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
if (!_entries.TryGetValue(key, out var state))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"No triage entry found for {componentPurl} / {cve}");
|
||||
}
|
||||
|
||||
lock (state)
|
||||
{
|
||||
var fromState = state.Lattice.CurrentState;
|
||||
var fromConfidence = state.Lattice.Confidence;
|
||||
|
||||
state.Lattice.Reset();
|
||||
|
||||
var record = new LatticeTransitionRecord
|
||||
{
|
||||
FromState = fromState,
|
||||
ToState = LatticeState.Unknown,
|
||||
ConfidenceBefore = fromConfidence,
|
||||
ConfidenceAfter = 0.0,
|
||||
Trigger = LatticeTransitionTrigger.SystemReset,
|
||||
Reason = reason,
|
||||
Actor = actor,
|
||||
Timestamp = now
|
||||
};
|
||||
|
||||
state.Transitions.Add(record);
|
||||
state.UpdatedAt = now;
|
||||
}
|
||||
|
||||
_resetsPerformed.Add(1);
|
||||
_logger.LogInformation(
|
||||
"Reset triage entry for {Purl}/{Cve} by {Actor}: {Reason}",
|
||||
componentPurl, cve, actor, reason);
|
||||
|
||||
return Task.FromResult(state.ToEntry());
|
||||
}
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────────
|
||||
|
||||
private static string MakeKey(string purl, string cve)
|
||||
{
|
||||
return $"{purl}|{cve}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forces the lattice into a specific state by applying appropriate evidence.
|
||||
/// Used for manual overrides.
|
||||
/// </summary>
|
||||
private static void ForceState(ReachabilityLattice lattice, LatticeState target)
|
||||
{
|
||||
// The lattice starts at Unknown after Reset.
|
||||
// Apply evidence to reach the target state.
|
||||
switch (target)
|
||||
{
|
||||
case LatticeState.Unknown:
|
||||
// Already at Unknown after reset
|
||||
break;
|
||||
case LatticeState.StaticReachable:
|
||||
lattice.ApplyEvidence(EvidenceType.StaticReachable);
|
||||
break;
|
||||
case LatticeState.StaticUnreachable:
|
||||
lattice.ApplyEvidence(EvidenceType.StaticUnreachable);
|
||||
break;
|
||||
case LatticeState.RuntimeObserved:
|
||||
lattice.ApplyEvidence(EvidenceType.RuntimeObserved);
|
||||
break;
|
||||
case LatticeState.RuntimeUnobserved:
|
||||
lattice.ApplyEvidence(EvidenceType.RuntimeUnobserved);
|
||||
break;
|
||||
case LatticeState.ConfirmedReachable:
|
||||
lattice.ApplyEvidence(EvidenceType.StaticReachable);
|
||||
lattice.ApplyEvidence(EvidenceType.RuntimeObserved);
|
||||
break;
|
||||
case LatticeState.ConfirmedUnreachable:
|
||||
lattice.ApplyEvidence(EvidenceType.StaticUnreachable);
|
||||
lattice.ApplyEvidence(EvidenceType.RuntimeUnobserved);
|
||||
break;
|
||||
case LatticeState.Contested:
|
||||
lattice.ApplyEvidence(EvidenceType.StaticReachable);
|
||||
lattice.ApplyEvidence(EvidenceType.RuntimeUnobserved);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeEntryId(string purl, string cve)
|
||||
{
|
||||
var input = $"{purl}|{cve}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return $"triage:sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal mutable state holder. Thread-safety via lock.
|
||||
/// </summary>
|
||||
private sealed class TriageState
|
||||
{
|
||||
public required string ComponentPurl { get; init; }
|
||||
public required string Cve { get; init; }
|
||||
public required ReachabilityLattice Lattice { get; init; }
|
||||
public required List<LatticeTransitionRecord> Transitions { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
|
||||
public LatticeTriageEntry ToEntry()
|
||||
{
|
||||
lock (this)
|
||||
{
|
||||
return new LatticeTriageEntry
|
||||
{
|
||||
EntryId = ComputeEntryId(ComponentPurl, Cve),
|
||||
ComponentPurl = ComponentPurl,
|
||||
Cve = Cve,
|
||||
CurrentState = Lattice.CurrentState,
|
||||
Confidence = Lattice.Confidence,
|
||||
VexStatus = MapToVexStatus(Lattice.CurrentState),
|
||||
Transitions = [.. Transitions],
|
||||
CreatedAt = CreatedAt,
|
||||
UpdatedAt = UpdatedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static string MapToVexStatus(LatticeState state) => state switch
|
||||
{
|
||||
LatticeState.Unknown => "under_investigation",
|
||||
LatticeState.StaticReachable => "under_investigation",
|
||||
LatticeState.StaticUnreachable => "not_affected",
|
||||
LatticeState.RuntimeObserved => "affected",
|
||||
LatticeState.RuntimeUnobserved => "not_affected",
|
||||
LatticeState.ConfirmedReachable => "affected",
|
||||
LatticeState.ConfirmedUnreachable => "not_affected",
|
||||
LatticeState.Contested => "under_investigation",
|
||||
_ => "under_investigation"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
services.TryAddSingleton<ConfidenceCalculator>();
|
||||
services.TryAddSingleton<IReachabilityIndex, ReachabilityIndex>();
|
||||
services.TryAddSingleton<ILatticeTriageService, LatticeTriageService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.AdvisoryLens.DependencyInjection;
|
||||
using StellaOps.AdvisoryLens.Models;
|
||||
using StellaOps.AdvisoryLens.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryLens.Tests;
|
||||
|
||||
public sealed class AdvisoryLensIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void DI_Registration_Resolves_Service()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddAdvisoryLens();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var service = provider.GetService<IAdvisoryLensService>();
|
||||
|
||||
service.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tenant_Scoping_Flows_Through_Context()
|
||||
{
|
||||
var service = new AdvisoryLensService(CreatePatterns(), new FakeTimeProvider(new DateTimeOffset(2026, 2, 8, 0, 0, 0, TimeSpan.Zero)));
|
||||
|
||||
var tenantA = service.Evaluate(CreateContext("tenant-a"));
|
||||
var tenantB = service.Evaluate(CreateContext("tenant-b"));
|
||||
|
||||
tenantA.Should().NotBeNull();
|
||||
tenantA.InputHash.Should().StartWith("sha256:");
|
||||
tenantA.InputHash.Should().NotBe(tenantB.InputHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Error_Mapping_For_Invalid_Input()
|
||||
{
|
||||
var service = new AdvisoryLensService(Array.Empty<CasePattern>());
|
||||
|
||||
Action evaluate = () => service.Evaluate(null!);
|
||||
|
||||
evaluate.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Offline_Execution_No_Network()
|
||||
{
|
||||
var service = new AdvisoryLensService(CreatePatterns());
|
||||
|
||||
var result = service.Evaluate(CreateContext("tenant-offline"));
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.MatchedPatterns.Should().ContainSingle().Which.Should().Be("custom-pattern");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DI_Registration_With_Custom_Patterns()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddAdvisoryLens(
|
||||
CreatePatterns(),
|
||||
new FakeTimeProvider(new DateTimeOffset(2026, 2, 8, 1, 0, 0, TimeSpan.Zero)));
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var service = provider.GetRequiredService<IAdvisoryLensService>();
|
||||
var result = service.Evaluate(CreateContext("tenant-custom"));
|
||||
|
||||
result.MatchedPatterns.Should().ContainSingle().Which.Should().Be("custom-pattern");
|
||||
result.Suggestions.Should().ContainSingle();
|
||||
result.Suggestions[0].Title.Should().Be("Custom escalation");
|
||||
result.EvaluatedAtUtc.Should().Be(new DateTime(2026, 2, 8, 1, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
private static LensContext CreateContext(string tenantId)
|
||||
{
|
||||
return new LensContext
|
||||
{
|
||||
AdvisoryCase = new AdvisoryCase
|
||||
{
|
||||
AdvisoryId = "ADV-INT-01",
|
||||
Cve = "CVE-2026-7777",
|
||||
Purl = "pkg:nuget/integration.demo@1.2.3",
|
||||
Severity = AdvisorySeverity.High,
|
||||
Source = "NVD"
|
||||
},
|
||||
TenantId = tenantId,
|
||||
VexStatements = ImmutableArray.Create("vex-int-1"),
|
||||
ReachabilityData = ImmutableArray.Create("reach-int-1"),
|
||||
PolicyTraces = ImmutableArray.Create("policy-int-1")
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CasePattern> CreatePatterns()
|
||||
{
|
||||
return
|
||||
[
|
||||
new CasePattern
|
||||
{
|
||||
PatternId = "custom-pattern",
|
||||
SeverityRange = new SeverityRange { Min = AdvisorySeverity.Medium, Max = AdvisorySeverity.Critical },
|
||||
EcosystemMatch = "nuget",
|
||||
CvePattern = "CVE-2026",
|
||||
DefaultAction = SuggestionAction.Escalate,
|
||||
SuggestionTitle = "Custom escalation",
|
||||
SuggestionRationale = "Integration-registered pattern should be selected"
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.AdvisoryLens.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryLens.Tests;
|
||||
|
||||
public sealed class AdvisoryLensModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void Models_SerializeRoundTrip_AllTypes_ShouldRemainEquivalent()
|
||||
{
|
||||
var advisoryCase = new AdvisoryCase
|
||||
{
|
||||
AdvisoryId = "ADV-001",
|
||||
Cve = "CVE-2026-1234",
|
||||
Purl = "pkg:nuget/test.pkg@1.2.3",
|
||||
Severity = AdvisorySeverity.High,
|
||||
Source = "NVD",
|
||||
Title = "Sample advisory",
|
||||
Description = "Sample description",
|
||||
Metadata = ImmutableDictionary<string, string>.Empty.Add("region", "us")
|
||||
};
|
||||
|
||||
var suggestion = new LensSuggestion
|
||||
{
|
||||
Rank = 1,
|
||||
Title = "Patch now",
|
||||
Rationale = "Exploitability is high",
|
||||
Confidence = 0.95,
|
||||
Action = SuggestionAction.Mitigate,
|
||||
PatternId = "pat-critical"
|
||||
};
|
||||
|
||||
var hint = new LensHint
|
||||
{
|
||||
Text = "Reachability data available",
|
||||
Category = HintCategory.Reachability,
|
||||
EvidenceRefs = ImmutableArray.Create("reach-1")
|
||||
};
|
||||
|
||||
var pattern = new CasePattern
|
||||
{
|
||||
PatternId = "pat-critical",
|
||||
Description = "Critical nuget pattern",
|
||||
SeverityRange = new SeverityRange { Min = AdvisorySeverity.High, Max = AdvisorySeverity.Critical },
|
||||
EcosystemMatch = "nuget",
|
||||
CvePattern = "CVE-2026",
|
||||
RequiredVexStatus = ImmutableArray.Create("affected"),
|
||||
DefaultAction = SuggestionAction.Mitigate,
|
||||
SuggestionTitle = "Patch now",
|
||||
SuggestionRationale = "Critical package issue"
|
||||
};
|
||||
|
||||
var context = new LensContext
|
||||
{
|
||||
AdvisoryCase = advisoryCase,
|
||||
TenantId = "tenant-a",
|
||||
VexStatements = ImmutableArray.Create("vex-1"),
|
||||
PolicyTraces = ImmutableArray.Create("policy-1"),
|
||||
ReachabilityData = ImmutableArray.Create("reach-1"),
|
||||
EvaluationTimestampUtc = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)
|
||||
};
|
||||
|
||||
var result = new LensResult
|
||||
{
|
||||
Suggestions = ImmutableArray.Create(suggestion),
|
||||
Hints = ImmutableArray.Create(hint),
|
||||
MatchedPatterns = ImmutableArray.Create(pattern.PatternId),
|
||||
EvaluatedAtUtc = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
InputHash = "sha256:abc"
|
||||
};
|
||||
|
||||
RoundTrip(advisoryCase).Should().BeEquivalentTo(advisoryCase);
|
||||
RoundTrip(suggestion).Should().BeEquivalentTo(suggestion);
|
||||
RoundTrip(hint).Should().BeEquivalentTo(hint);
|
||||
RoundTrip(pattern).Should().BeEquivalentTo(pattern);
|
||||
RoundTrip(pattern.SeverityRange!).Should().BeEquivalentTo(pattern.SeverityRange);
|
||||
RoundTrip(context).Should().BeEquivalentTo(context);
|
||||
RoundTrip(result).Should().BeEquivalentTo(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Models_SerializeTwice_SameInput_ShouldProduceSameJson()
|
||||
{
|
||||
var payload = new LensContext
|
||||
{
|
||||
AdvisoryCase = new AdvisoryCase
|
||||
{
|
||||
AdvisoryId = "ADV-002",
|
||||
Cve = "CVE-2026-2222",
|
||||
Purl = "pkg:nuget/pkg@2.0.0",
|
||||
Severity = AdvisorySeverity.Medium,
|
||||
Source = "OSV",
|
||||
Metadata = ImmutableDictionary<string, string>.Empty.Add("k", "v")
|
||||
},
|
||||
TenantId = "tenant-deterministic",
|
||||
VexStatements = ImmutableArray.Create("vex-a"),
|
||||
PolicyTraces = ImmutableArray.Create("policy-a"),
|
||||
ReachabilityData = ImmutableArray.Create("reach-a"),
|
||||
EvaluationTimestampUtc = new DateTime(2026, 2, 2, 0, 0, 0, DateTimeKind.Utc)
|
||||
};
|
||||
|
||||
var first = JsonSerializer.Serialize(payload);
|
||||
var second = JsonSerializer.Serialize(payload);
|
||||
|
||||
second.Should().Be(first);
|
||||
}
|
||||
|
||||
private static T RoundTrip<T>(T instance)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(instance);
|
||||
return JsonSerializer.Deserialize<T>(json)!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.AdvisoryLens.DependencyInjection;
|
||||
using StellaOps.AdvisoryLens.Models;
|
||||
using StellaOps.AdvisoryLens.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryLens.Tests;
|
||||
|
||||
public sealed class AdvisoryLensServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Evaluate_FullFlow_ReturnsExpectedResult()
|
||||
{
|
||||
var context = CreateContext();
|
||||
var service = new AdvisoryLensService(CreatePatterns(), new FakeTimeProvider(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)));
|
||||
|
||||
var result = service.Evaluate(context);
|
||||
|
||||
result.Suggestions.Should().NotBeEmpty();
|
||||
result.Hints.Should().HaveCount(4);
|
||||
result.MatchedPatterns.Should().ContainSingle().Which.Should().Be("pat-core");
|
||||
result.EvaluatedAtUtc.Should().Be(context.EvaluationTimestampUtc);
|
||||
result.InputHash.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_SameFrozenInput_IsDeterministic()
|
||||
{
|
||||
var context = CreateContext();
|
||||
var service = new AdvisoryLensService(CreatePatterns(), new FakeTimeProvider(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)));
|
||||
|
||||
var first = service.Evaluate(context);
|
||||
var second = service.Evaluate(context);
|
||||
|
||||
JsonSerializer.Serialize(second).Should().Be(JsonSerializer.Serialize(first));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_HintsGeneration_ContainsSeverityVexReachabilityPolicy()
|
||||
{
|
||||
var context = CreateContext();
|
||||
var service = new AdvisoryLensService(Array.Empty<CasePattern>(), new FakeTimeProvider(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)));
|
||||
|
||||
var result = service.Evaluate(context);
|
||||
|
||||
result.Hints.Select(h => h.Category).Should().Equal(
|
||||
HintCategory.Severity,
|
||||
HintCategory.Reachability,
|
||||
HintCategory.Vex,
|
||||
HintCategory.Policy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_EmptyPatterns_ReturnsEmptySuggestionsWithValidResult()
|
||||
{
|
||||
var context = CreateContext();
|
||||
var service = new AdvisoryLensService(Array.Empty<CasePattern>(), new FakeTimeProvider(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)));
|
||||
|
||||
var result = service.Evaluate(context);
|
||||
|
||||
result.Suggestions.Should().BeEmpty();
|
||||
result.MatchedPatterns.Should().BeEmpty();
|
||||
result.InputHash.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_InputHashStability_SameContextProducesSameHash()
|
||||
{
|
||||
var context = CreateContext();
|
||||
var service = new AdvisoryLensService(Array.Empty<CasePattern>(), new FakeTimeProvider(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)));
|
||||
|
||||
var first = service.Evaluate(context);
|
||||
var second = service.Evaluate(context);
|
||||
|
||||
second.InputHash.Should().Be(first.InputHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddAdvisoryLens_RegistersResolvableService()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddAdvisoryLens(CreatePatterns(), new FakeTimeProvider(new DateTimeOffset(2026, 3, 1, 0, 0, 0, TimeSpan.Zero)));
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var service = provider.GetService<IAdvisoryLensService>();
|
||||
|
||||
service.Should().NotBeNull();
|
||||
var result = service!.Evaluate(CreateContext(withEvaluationTimestamp: false));
|
||||
result.EvaluatedAtUtc.Should().Be(new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
private static LensContext CreateContext(bool withEvaluationTimestamp = true)
|
||||
{
|
||||
return new LensContext
|
||||
{
|
||||
AdvisoryCase = new AdvisoryCase
|
||||
{
|
||||
AdvisoryId = "ADV-900",
|
||||
Cve = "CVE-2026-9000",
|
||||
Purl = "pkg:nuget/test.lib@9.0.0",
|
||||
Severity = AdvisorySeverity.Critical,
|
||||
Source = "NVD"
|
||||
},
|
||||
TenantId = "tenant-1",
|
||||
VexStatements = ImmutableArray.Create("vex-1"),
|
||||
PolicyTraces = ImmutableArray.Create("policy-1"),
|
||||
ReachabilityData = ImmutableArray.Create("reach-1"),
|
||||
EvaluationTimestampUtc = withEvaluationTimestamp
|
||||
? new DateTime(2026, 1, 5, 12, 0, 0, DateTimeKind.Utc)
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CasePattern> CreatePatterns()
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new CasePattern
|
||||
{
|
||||
PatternId = "pat-core",
|
||||
SeverityRange = new SeverityRange { Min = AdvisorySeverity.High, Max = AdvisorySeverity.Critical },
|
||||
EcosystemMatch = "nuget",
|
||||
CvePattern = "CVE-2026",
|
||||
DefaultAction = SuggestionAction.Escalate,
|
||||
SuggestionTitle = "Escalate review",
|
||||
SuggestionRationale = "Critical advisory in primary ecosystem"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.AdvisoryLens.Matching;
|
||||
using StellaOps.AdvisoryLens.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryLens.Tests;
|
||||
|
||||
public sealed class CaseMatcherTests
|
||||
{
|
||||
private static AdvisoryCase CreateCase(AdvisorySeverity severity = AdvisorySeverity.High)
|
||||
=> new()
|
||||
{
|
||||
AdvisoryId = "ADV-101",
|
||||
Cve = "CVE-2026-1001",
|
||||
Purl = "pkg:nuget/demo.package@1.0.0",
|
||||
Severity = severity
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Match_HappyPath_ReturnsPositiveScore()
|
||||
{
|
||||
var matcher = new CaseMatcher();
|
||||
var patterns = new[]
|
||||
{
|
||||
new CasePattern
|
||||
{
|
||||
PatternId = "pat-1",
|
||||
SeverityRange = new SeverityRange { Min = AdvisorySeverity.Medium, Max = AdvisorySeverity.Critical },
|
||||
EcosystemMatch = "nuget",
|
||||
DefaultAction = SuggestionAction.Mitigate,
|
||||
SuggestionTitle = "Mitigate",
|
||||
SuggestionRationale = "Matching pattern"
|
||||
}
|
||||
};
|
||||
|
||||
var results = matcher.Match(CreateCase(), patterns);
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].PatternId.Should().Be("pat-1");
|
||||
results[0].Score.Should().BeGreaterThan(0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_SeverityOutsideRange_ReturnsEmpty()
|
||||
{
|
||||
var matcher = new CaseMatcher();
|
||||
var patterns = new[]
|
||||
{
|
||||
new CasePattern
|
||||
{
|
||||
PatternId = "pat-2",
|
||||
SeverityRange = new SeverityRange { Min = AdvisorySeverity.Critical, Max = AdvisorySeverity.Critical },
|
||||
DefaultAction = SuggestionAction.Escalate,
|
||||
SuggestionTitle = "Escalate",
|
||||
SuggestionRationale = "Severity mismatch"
|
||||
}
|
||||
};
|
||||
|
||||
var results = matcher.Match(CreateCase(AdvisorySeverity.Low), patterns);
|
||||
|
||||
results.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_MultiplePatterns_OrdersByScoreThenPatternId()
|
||||
{
|
||||
var matcher = new CaseMatcher();
|
||||
var patterns = new[]
|
||||
{
|
||||
new CasePattern
|
||||
{
|
||||
PatternId = "b-pattern",
|
||||
SeverityRange = new SeverityRange { Min = AdvisorySeverity.Medium, Max = AdvisorySeverity.Critical },
|
||||
DefaultAction = SuggestionAction.Mitigate,
|
||||
SuggestionTitle = "B",
|
||||
SuggestionRationale = "B"
|
||||
},
|
||||
new CasePattern
|
||||
{
|
||||
PatternId = "a-pattern",
|
||||
SeverityRange = new SeverityRange { Min = AdvisorySeverity.Medium, Max = AdvisorySeverity.Critical },
|
||||
DefaultAction = SuggestionAction.Mitigate,
|
||||
SuggestionTitle = "A",
|
||||
SuggestionRationale = "A"
|
||||
},
|
||||
new CasePattern
|
||||
{
|
||||
PatternId = "c-pattern",
|
||||
DefaultAction = SuggestionAction.Defer,
|
||||
SuggestionTitle = "C",
|
||||
SuggestionRationale = "C"
|
||||
}
|
||||
};
|
||||
|
||||
var results = matcher.Match(CreateCase(), patterns);
|
||||
|
||||
results.Select(r => r.PatternId).Should().Equal("a-pattern", "b-pattern", "c-pattern");
|
||||
results[0].Score.Should().Be(1.0);
|
||||
results[2].Score.Should().Be(0.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_SameInputRepeated_IsDeterministic()
|
||||
{
|
||||
var matcher = new CaseMatcher();
|
||||
var patterns = new[]
|
||||
{
|
||||
new CasePattern
|
||||
{
|
||||
PatternId = "pat-det-1",
|
||||
SeverityRange = new SeverityRange { Min = AdvisorySeverity.Low, Max = AdvisorySeverity.Critical },
|
||||
DefaultAction = SuggestionAction.Accept,
|
||||
SuggestionTitle = "Det",
|
||||
SuggestionRationale = "Det"
|
||||
},
|
||||
new CasePattern
|
||||
{
|
||||
PatternId = "pat-det-2",
|
||||
DefaultAction = SuggestionAction.Defer,
|
||||
SuggestionTitle = "Det2",
|
||||
SuggestionRationale = "Det2"
|
||||
}
|
||||
};
|
||||
|
||||
var first = matcher.Match(CreateCase(), patterns);
|
||||
var second = matcher.Match(CreateCase(), patterns);
|
||||
|
||||
second.Should().Equal(first);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_EmptyPatterns_ReturnsEmpty()
|
||||
{
|
||||
var matcher = new CaseMatcher();
|
||||
|
||||
var results = matcher.Match(CreateCase(), Array.Empty<CasePattern>());
|
||||
|
||||
results.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_NullArguments_ThrowArgumentNullException()
|
||||
{
|
||||
var matcher = new CaseMatcher();
|
||||
|
||||
Action nullCase = () => matcher.Match(null!, Array.Empty<CasePattern>());
|
||||
Action nullPatterns = () => matcher.Match(CreateCase(), null!);
|
||||
|
||||
nullCase.Should().Throw<ArgumentNullException>();
|
||||
nullPatterns.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.AdvisoryLens\StellaOps.AdvisoryLens.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"parallelizeTestCollections": false,
|
||||
"maxParallelThreads": 1
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (C) 2025 StellaOps Contributors
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using StellaOps.Messaging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Provcache.Events;
|
||||
using StellaOps.Provcache.Invalidation;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Provcache.Tests;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Intent", "Operational")]
|
||||
public sealed partial class FeedEpochInvalidatorTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2026, 2, 9, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private readonly Mock<IEventStream<FeedEpochAdvancedEvent>> _eventStreamMock = new();
|
||||
private readonly Mock<IProvcacheService> _provcacheServiceMock = new();
|
||||
private readonly Mock<ILogger<FeedEpochInvalidator>> _loggerMock = new();
|
||||
private readonly FixedTimeProvider _timeProvider = new(FixedNow);
|
||||
|
||||
private FeedEpochInvalidator CreateSut()
|
||||
{
|
||||
return new FeedEpochInvalidator(
|
||||
_eventStreamMock.Object,
|
||||
_provcacheServiceMock.Object,
|
||||
_loggerMock.Object,
|
||||
_timeProvider);
|
||||
}
|
||||
|
||||
private static FeedEpochAdvancedEvent CreateEvent()
|
||||
{
|
||||
return FeedEpochAdvancedEvent.Create(
|
||||
feedId: "cve",
|
||||
previousEpoch: "2026-02-08T00:00:00Z",
|
||||
newEpoch: "2026-02-09T00:00:00Z",
|
||||
effectiveAt: FixedNow,
|
||||
advisoriesAdded: 1,
|
||||
advisoriesModified: 2,
|
||||
advisoriesWithdrawn: 0,
|
||||
tenantId: null,
|
||||
correlationId: "corr-feed-1",
|
||||
eventId: Guid.Parse("44444444-4444-4444-4444-444444444444"),
|
||||
timestamp: FixedNow);
|
||||
}
|
||||
|
||||
private static StreamEvent<FeedEpochAdvancedEvent> ToStreamEvent(FeedEpochAdvancedEvent @event)
|
||||
{
|
||||
return new StreamEvent<FeedEpochAdvancedEvent>("2-0", @event, @event.Timestamp, null, @event.CorrelationId);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<StreamEvent<FeedEpochAdvancedEvent>> StreamEvents(IEnumerable<StreamEvent<FeedEpochAdvancedEvent>> events, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var streamEvent in events)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
yield return streamEvent;
|
||||
await Task.Yield();
|
||||
}
|
||||
|
||||
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<StreamEvent<FeedEpochAdvancedEvent>> WaitUntilCancelled([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||
yield break;
|
||||
}
|
||||
|
||||
private static void VerifyLog(Mock<ILogger<FeedEpochInvalidator>> logger, LogLevel level, string containsText, Times times)
|
||||
{
|
||||
logger.Verify(x => x.Log(
|
||||
level,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, _) => v.ToString() != null && v.ToString()!.Contains(containsText, StringComparison.Ordinal)),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
times);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (C) 2025 StellaOps Contributors
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using StellaOps.Messaging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Provcache.Events;
|
||||
using StellaOps.Provcache.Invalidation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Provcache.Tests;
|
||||
|
||||
public sealed partial class FeedEpochInvalidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_ValidatesDependencies()
|
||||
{
|
||||
var es = new Mock<IEventStream<FeedEpochAdvancedEvent>>();
|
||||
var svc = new Mock<IProvcacheService>();
|
||||
var log = new Mock<ILogger<FeedEpochInvalidator>>();
|
||||
FluentActions.Invoking(() => new FeedEpochInvalidator(null!, svc.Object, log.Object)).Should().Throw<ArgumentNullException>().WithParameterName("eventStream");
|
||||
FluentActions.Invoking(() => new FeedEpochInvalidator(es.Object, null!, log.Object)).Should().Throw<ArgumentNullException>().WithParameterName("provcacheService");
|
||||
FluentActions.Invoking(() => new FeedEpochInvalidator(es.Object, svc.Object, null!)).Should().Throw<ArgumentNullException>().WithParameterName("logger");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAndStop_ManageRunningState()
|
||||
{
|
||||
_eventStreamMock.Setup(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>())).Returns((StreamPosition _, CancellationToken ct) => WaitUntilCancelled(ct));
|
||||
await using var sut = CreateSut();
|
||||
await sut.StartAsync();
|
||||
sut.IsRunning.Should().BeTrue();
|
||||
await sut.StopAsync();
|
||||
sut.IsRunning.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_WhenAlreadyRunning_LogsWarningAndReturns()
|
||||
{
|
||||
_eventStreamMock.Setup(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>())).Returns((StreamPosition _, CancellationToken ct) => WaitUntilCancelled(ct));
|
||||
await using var sut = CreateSut();
|
||||
await sut.StartAsync();
|
||||
await sut.StartAsync();
|
||||
_eventStreamMock.Verify(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>()), Times.Once);
|
||||
VerifyLog(_loggerMock, LogLevel.Warning, "already running", Times.Once());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessingEvent_CallsInvalidateByWithFeedEpochRequest()
|
||||
{
|
||||
var evt = CreateEvent();
|
||||
var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
InvalidationRequest? captured = null;
|
||||
_eventStreamMock.Setup(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>())).Returns((StreamPosition _, CancellationToken ct) => StreamEvents([ToStreamEvent(evt)], ct));
|
||||
_provcacheServiceMock.Setup(x => x.InvalidateByAsync(It.IsAny<InvalidationRequest>(), It.IsAny<CancellationToken>())).Callback<InvalidationRequest, CancellationToken>((r, _) => { captured = r; done.TrySetResult(); }).ReturnsAsync(new InvalidationResult { EntriesAffected = 4, Request = InvalidationRequest.ByFeedEpochOlderThan(evt.NewEpoch), Timestamp = FixedNow });
|
||||
await using var sut = CreateSut();
|
||||
await sut.StartAsync();
|
||||
await done.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
captured.Should().NotBeNull();
|
||||
captured!.Type.Should().Be(InvalidationType.FeedEpochOlderThan);
|
||||
captured.Value.Should().Be(evt.NewEpoch);
|
||||
captured.Reason.Should().Contain("Feed cve");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessingEvent_SuccessUpdatesMetrics()
|
||||
{
|
||||
var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_eventStreamMock.Setup(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>())).Returns((StreamPosition _, CancellationToken ct) => StreamEvents([ToStreamEvent(CreateEvent())], ct));
|
||||
_provcacheServiceMock.Setup(x => x.InvalidateByAsync(It.IsAny<InvalidationRequest>(), It.IsAny<CancellationToken>())).Callback(() => done.TrySetResult()).ReturnsAsync(new InvalidationResult { EntriesAffected = 9, Request = InvalidationRequest.ByFeedEpochOlderThan("2026-02-09T00:00:00Z"), Timestamp = FixedNow });
|
||||
await using var sut = CreateSut();
|
||||
await sut.StartAsync();
|
||||
await done.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
var metrics = sut.GetMetrics();
|
||||
metrics.EventsProcessed.Should().Be(1);
|
||||
metrics.EntriesInvalidated.Should().Be(9);
|
||||
metrics.Errors.Should().Be(0);
|
||||
metrics.LastEventAt.Should().Be(FixedNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessingEvent_ErrorIsCaughtLoggedAndCounted()
|
||||
{
|
||||
var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_eventStreamMock.Setup(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>())).Returns((StreamPosition _, CancellationToken ct) => StreamEvents([ToStreamEvent(CreateEvent())], ct));
|
||||
_provcacheServiceMock.Setup(x => x.InvalidateByAsync(It.IsAny<InvalidationRequest>(), It.IsAny<CancellationToken>())).Callback(() => done.TrySetResult()).ThrowsAsync(new InvalidOperationException("boom"));
|
||||
await using var sut = CreateSut();
|
||||
await sut.StartAsync();
|
||||
await done.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
var metrics = sut.GetMetrics();
|
||||
metrics.Errors.Should().Be(1);
|
||||
metrics.EventsProcessed.Should().Be(0);
|
||||
VerifyLog(_loggerMock, LogLevel.Error, "Error processing FeedEpochAdvancedEvent", Times.Once());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMetrics_ReturnsAccurateCountersAfterMultipleEvents()
|
||||
{
|
||||
var e1 = ToStreamEvent(CreateEvent());
|
||||
var e2 = ToStreamEvent(CreateEvent() with { EventId = Guid.Parse("55555555-5555-5555-5555-555555555555"), CorrelationId = "corr-feed-2" });
|
||||
var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var calls = 0;
|
||||
_eventStreamMock.Setup(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>())).Returns((StreamPosition _, CancellationToken ct) => StreamEvents([e1, e2], ct));
|
||||
_provcacheServiceMock.Setup(x => x.InvalidateByAsync(It.IsAny<InvalidationRequest>(), It.IsAny<CancellationToken>())).ReturnsAsync(() =>
|
||||
{
|
||||
calls++;
|
||||
if (calls >= 2)
|
||||
{
|
||||
done.TrySetResult();
|
||||
return new InvalidationResult { EntriesAffected = 6, Request = InvalidationRequest.ByFeedEpochOlderThan("2026-02-09T00:00:00Z"), Timestamp = FixedNow };
|
||||
}
|
||||
|
||||
return new InvalidationResult { EntriesAffected = 1, Request = InvalidationRequest.ByFeedEpochOlderThan("2026-02-09T00:00:00Z"), Timestamp = FixedNow };
|
||||
});
|
||||
await using var sut = CreateSut();
|
||||
await sut.StartAsync();
|
||||
await done.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
var metrics = sut.GetMetrics();
|
||||
metrics.EventsProcessed.Should().Be(2);
|
||||
metrics.EntriesInvalidated.Should().Be(7);
|
||||
metrics.Errors.Should().Be(0);
|
||||
metrics.CollectedAt.Should().Be(FixedNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsync_StopsAndIsIdempotent()
|
||||
{
|
||||
_eventStreamMock.Setup(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>())).Returns((StreamPosition _, CancellationToken ct) => WaitUntilCancelled(ct));
|
||||
var sut = CreateSut();
|
||||
await sut.StartAsync();
|
||||
await sut.DisposeAsync();
|
||||
sut.IsRunning.Should().BeFalse();
|
||||
await FluentActions.Awaiting(() => sut.DisposeAsync().AsTask()).Should().NotThrowAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (C) 2025 StellaOps Contributors
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using StellaOps.Provcache.Invalidation;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Provcache.Tests;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Intent", "Operational")]
|
||||
public sealed class InvalidatorHostedServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_ValidatesDependencies()
|
||||
{
|
||||
var logger = new Mock<ILogger<InvalidatorHostedService>>();
|
||||
FluentActions.Invoking(() => new InvalidatorHostedService(null!, logger.Object)).Should().Throw<ArgumentNullException>().WithParameterName("invalidators");
|
||||
FluentActions.Invoking(() => new InvalidatorHostedService([], null!)).Should().Throw<ArgumentNullException>().WithParameterName("logger");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_StartsAllInvalidators()
|
||||
{
|
||||
var one = new Mock<IProvcacheInvalidator>();
|
||||
var two = new Mock<IProvcacheInvalidator>();
|
||||
var logger = new Mock<ILogger<InvalidatorHostedService>>();
|
||||
one.Setup(x => x.StartAsync(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
|
||||
two.Setup(x => x.StartAsync(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
|
||||
|
||||
var sut = new InvalidatorHostedService([one.Object, two.Object], logger.Object);
|
||||
await sut.StartAsync(CancellationToken.None);
|
||||
|
||||
one.Verify(x => x.StartAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||
two.Verify(x => x.StartAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopAsync_StopsAllInvalidators()
|
||||
{
|
||||
var one = new Mock<IProvcacheInvalidator>();
|
||||
var two = new Mock<IProvcacheInvalidator>();
|
||||
var logger = new Mock<ILogger<InvalidatorHostedService>>();
|
||||
one.Setup(x => x.StopAsync(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
|
||||
two.Setup(x => x.StopAsync(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
|
||||
|
||||
var sut = new InvalidatorHostedService([one.Object, two.Object], logger.Object);
|
||||
await sut.StopAsync(CancellationToken.None);
|
||||
|
||||
one.Verify(x => x.StopAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||
two.Verify(x => x.StopAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAndStop_WithNoInvalidators_DoesNotThrow()
|
||||
{
|
||||
var logger = new Mock<ILogger<InvalidatorHostedService>>();
|
||||
var sut = new InvalidatorHostedService([], logger.Object);
|
||||
await FluentActions.Awaiting(() => sut.StartAsync(CancellationToken.None)).Should().NotThrowAsync();
|
||||
await FluentActions.Awaiting(() => sut.StopAsync(CancellationToken.None)).Should().NotThrowAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (C) 2025 StellaOps Contributors
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using StellaOps.Messaging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Provcache.Events;
|
||||
using StellaOps.Provcache.Invalidation;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Provcache.Tests;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Intent", "Operational")]
|
||||
public sealed partial class SignerSetInvalidatorTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2026, 2, 9, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private readonly Mock<IEventStream<SignerRevokedEvent>> _eventStreamMock = new();
|
||||
private readonly Mock<IProvcacheService> _provcacheServiceMock = new();
|
||||
private readonly Mock<ILogger<SignerSetInvalidator>> _loggerMock = new();
|
||||
private readonly FixedTimeProvider _timeProvider = new(FixedNow);
|
||||
|
||||
private SignerSetInvalidator CreateSut()
|
||||
{
|
||||
return new SignerSetInvalidator(
|
||||
_eventStreamMock.Object,
|
||||
_provcacheServiceMock.Object,
|
||||
_loggerMock.Object,
|
||||
_timeProvider);
|
||||
}
|
||||
|
||||
private static SignerRevokedEvent CreateEvent()
|
||||
{
|
||||
return SignerRevokedEvent.Create(
|
||||
anchorId: Guid.Parse("11111111-1111-1111-1111-111111111111"),
|
||||
keyId: "key-1",
|
||||
signerHash: "sha256:signer-hash",
|
||||
effectiveAt: FixedNow.AddMinutes(-1),
|
||||
reason: "key compromise",
|
||||
actor: "authority",
|
||||
correlationId: "corr-1",
|
||||
eventId: Guid.Parse("22222222-2222-2222-2222-222222222222"),
|
||||
timestamp: FixedNow);
|
||||
}
|
||||
|
||||
private static StreamEvent<SignerRevokedEvent> ToStreamEvent(SignerRevokedEvent @event)
|
||||
{
|
||||
return new StreamEvent<SignerRevokedEvent>(
|
||||
EntryId: "1-0",
|
||||
Event: @event,
|
||||
Timestamp: @event.Timestamp,
|
||||
TenantId: null,
|
||||
CorrelationId: @event.CorrelationId);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<StreamEvent<SignerRevokedEvent>> StreamEvents(
|
||||
IEnumerable<StreamEvent<SignerRevokedEvent>> events,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var streamEvent in events)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
yield return streamEvent;
|
||||
await Task.Yield();
|
||||
}
|
||||
|
||||
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<StreamEvent<SignerRevokedEvent>> WaitUntilCancelled(
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||
yield break;
|
||||
}
|
||||
|
||||
private static void VerifyLog(
|
||||
Mock<ILogger<SignerSetInvalidator>> logger,
|
||||
LogLevel level,
|
||||
string containsText,
|
||||
Times times)
|
||||
{
|
||||
logger.Verify(x => x.Log(
|
||||
level,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, _) => v.ToString() != null && v.ToString()!.Contains(containsText, StringComparison.Ordinal)),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
times);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (C) 2025 StellaOps Contributors
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using StellaOps.Messaging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Provcache.Events;
|
||||
using StellaOps.Provcache.Invalidation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Provcache.Tests;
|
||||
|
||||
public sealed partial class SignerSetInvalidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_ValidatesDependencies()
|
||||
{
|
||||
var es = new Mock<IEventStream<SignerRevokedEvent>>();
|
||||
var svc = new Mock<IProvcacheService>();
|
||||
var log = new Mock<ILogger<SignerSetInvalidator>>();
|
||||
FluentActions.Invoking(() => new SignerSetInvalidator(null!, svc.Object, log.Object)).Should().Throw<ArgumentNullException>().WithParameterName("eventStream");
|
||||
FluentActions.Invoking(() => new SignerSetInvalidator(es.Object, null!, log.Object)).Should().Throw<ArgumentNullException>().WithParameterName("provcacheService");
|
||||
FluentActions.Invoking(() => new SignerSetInvalidator(es.Object, svc.Object, null!)).Should().Throw<ArgumentNullException>().WithParameterName("logger");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAndStop_ManageRunningState()
|
||||
{
|
||||
_eventStreamMock.Setup(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>())).Returns((StreamPosition _, CancellationToken ct) => WaitUntilCancelled(ct));
|
||||
await using var sut = CreateSut();
|
||||
await sut.StartAsync();
|
||||
sut.IsRunning.Should().BeTrue();
|
||||
await sut.StopAsync();
|
||||
sut.IsRunning.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_WhenAlreadyRunning_LogsWarningAndReturns()
|
||||
{
|
||||
_eventStreamMock.Setup(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>())).Returns((StreamPosition _, CancellationToken ct) => WaitUntilCancelled(ct));
|
||||
await using var sut = CreateSut();
|
||||
await sut.StartAsync();
|
||||
await sut.StartAsync();
|
||||
_eventStreamMock.Verify(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>()), Times.Once);
|
||||
VerifyLog(_loggerMock, LogLevel.Warning, "already running", Times.Once());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessingEvent_CallsInvalidateByWithSignerHashRequest()
|
||||
{
|
||||
var evt = CreateEvent();
|
||||
var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
InvalidationRequest? captured = null;
|
||||
_eventStreamMock.Setup(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>())).Returns((StreamPosition _, CancellationToken ct) => StreamEvents([ToStreamEvent(evt)], ct));
|
||||
_provcacheServiceMock.Setup(x => x.InvalidateByAsync(It.IsAny<InvalidationRequest>(), It.IsAny<CancellationToken>())).Callback<InvalidationRequest, CancellationToken>((r, _) => { captured = r; done.TrySetResult(); }).ReturnsAsync(new InvalidationResult { EntriesAffected = 3, Request = InvalidationRequest.BySignerSetHash("sha256:signer-hash"), Timestamp = FixedNow });
|
||||
await using var sut = CreateSut();
|
||||
await sut.StartAsync();
|
||||
await done.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
captured.Should().NotBeNull();
|
||||
captured!.Type.Should().Be(InvalidationType.SignerSetHash);
|
||||
captured.Value.Should().Be(evt.SignerHash);
|
||||
captured.Actor.Should().Be(evt.Actor);
|
||||
captured.Reason.Should().Contain("Signer revoked");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessingEvent_SuccessUpdatesMetrics()
|
||||
{
|
||||
var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_eventStreamMock.Setup(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>())).Returns((StreamPosition _, CancellationToken ct) => StreamEvents([ToStreamEvent(CreateEvent())], ct));
|
||||
_provcacheServiceMock.Setup(x => x.InvalidateByAsync(It.IsAny<InvalidationRequest>(), It.IsAny<CancellationToken>())).Callback(() => done.TrySetResult()).ReturnsAsync(new InvalidationResult { EntriesAffected = 7, Request = InvalidationRequest.BySignerSetHash("sha256:signer-hash"), Timestamp = FixedNow });
|
||||
await using var sut = CreateSut();
|
||||
await sut.StartAsync();
|
||||
await done.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
var metrics = sut.GetMetrics();
|
||||
metrics.EventsProcessed.Should().Be(1);
|
||||
metrics.EntriesInvalidated.Should().Be(7);
|
||||
metrics.Errors.Should().Be(0);
|
||||
metrics.LastEventAt.Should().Be(FixedNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessingEvent_ErrorIsCaughtLoggedAndCounted()
|
||||
{
|
||||
var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_eventStreamMock.Setup(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>())).Returns((StreamPosition _, CancellationToken ct) => StreamEvents([ToStreamEvent(CreateEvent())], ct));
|
||||
_provcacheServiceMock.Setup(x => x.InvalidateByAsync(It.IsAny<InvalidationRequest>(), It.IsAny<CancellationToken>())).Callback(() => done.TrySetResult()).ThrowsAsync(new InvalidOperationException("boom"));
|
||||
await using var sut = CreateSut();
|
||||
await sut.StartAsync();
|
||||
await done.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
var metrics = sut.GetMetrics();
|
||||
metrics.Errors.Should().Be(1);
|
||||
metrics.EventsProcessed.Should().Be(0);
|
||||
VerifyLog(_loggerMock, LogLevel.Error, "Error processing SignerRevokedEvent", Times.Once());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMetrics_ReturnsAccurateCountersAfterMultipleEvents()
|
||||
{
|
||||
var e1 = ToStreamEvent(CreateEvent());
|
||||
var e2 = ToStreamEvent(CreateEvent() with { EventId = Guid.Parse("33333333-3333-3333-3333-333333333333"), CorrelationId = "corr-2" });
|
||||
var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var calls = 0;
|
||||
_eventStreamMock.Setup(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>())).Returns((StreamPosition _, CancellationToken ct) => StreamEvents([e1, e2], ct));
|
||||
_provcacheServiceMock.Setup(x => x.InvalidateByAsync(It.IsAny<InvalidationRequest>(), It.IsAny<CancellationToken>())).ReturnsAsync(() =>
|
||||
{
|
||||
calls++;
|
||||
if (calls >= 2)
|
||||
{
|
||||
done.TrySetResult();
|
||||
return new InvalidationResult { EntriesAffected = 5, Request = InvalidationRequest.BySignerSetHash("sha256:signer-hash"), Timestamp = FixedNow };
|
||||
}
|
||||
|
||||
return new InvalidationResult { EntriesAffected = 2, Request = InvalidationRequest.BySignerSetHash("sha256:signer-hash"), Timestamp = FixedNow };
|
||||
});
|
||||
await using var sut = CreateSut();
|
||||
await sut.StartAsync();
|
||||
await done.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
var metrics = sut.GetMetrics();
|
||||
metrics.EventsProcessed.Should().Be(2);
|
||||
metrics.EntriesInvalidated.Should().Be(7);
|
||||
metrics.Errors.Should().Be(0);
|
||||
metrics.CollectedAt.Should().Be(FixedNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsync_StopsAndIsIdempotent()
|
||||
{
|
||||
_eventStreamMock.Setup(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>())).Returns((StreamPosition _, CancellationToken ct) => WaitUntilCancelled(ct));
|
||||
var sut = CreateSut();
|
||||
await sut.StartAsync();
|
||||
await sut.DisposeAsync();
|
||||
sut.IsRunning.Should().BeFalse();
|
||||
await FluentActions.Awaiting(() => sut.DisposeAsync().AsTask()).Should().NotThrowAsync();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user