notify doctors work, audit work, new product advisory sprints
This commit is contained in:
@@ -60,7 +60,7 @@ public sealed class DiagnosticBundleGenerator
|
||||
// Run full doctor check
|
||||
var report = await _engine.RunAsync(
|
||||
new DoctorRunOptions { Mode = DoctorRunMode.Full },
|
||||
cancellationToken: ct);
|
||||
ct: ct);
|
||||
|
||||
var sanitizer = new ConfigurationSanitizer();
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -5,8 +6,8 @@ namespace StellaOps.Evidence.Budgets;
|
||||
|
||||
public interface IEvidenceBudgetService
|
||||
{
|
||||
BudgetCheckResult CheckBudget(Guid scanId, EvidenceItem item);
|
||||
BudgetStatus GetBudgetStatus(Guid scanId);
|
||||
Task<BudgetCheckResult> CheckBudgetAsync(Guid scanId, EvidenceItem item, CancellationToken ct);
|
||||
Task<BudgetStatus> GetBudgetStatusAsync(Guid scanId, CancellationToken ct);
|
||||
Task<PruneResult> PruneToFitAsync(Guid scanId, long targetBytes, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -26,10 +27,11 @@ public sealed class EvidenceBudgetService : IEvidenceBudgetService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public BudgetCheckResult CheckBudget(Guid scanId, EvidenceItem item)
|
||||
public async Task<BudgetCheckResult> CheckBudgetAsync(Guid scanId, EvidenceItem item, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
var budget = _options.CurrentValue;
|
||||
var currentUsage = GetCurrentUsage(scanId);
|
||||
var currentUsage = await GetCurrentUsageAsync(scanId, ct);
|
||||
|
||||
var issues = new List<string>();
|
||||
|
||||
@@ -37,7 +39,9 @@ public sealed class EvidenceBudgetService : IEvidenceBudgetService
|
||||
var projectedTotal = currentUsage.TotalBytes + item.SizeBytes;
|
||||
if (projectedTotal > budget.MaxScanSizeBytes)
|
||||
{
|
||||
issues.Add($"Would exceed total budget: {projectedTotal:N0} > {budget.MaxScanSizeBytes:N0} bytes");
|
||||
issues.Add(
|
||||
$"Would exceed total budget: {projectedTotal.ToString("N0", CultureInfo.InvariantCulture)} > " +
|
||||
$"{budget.MaxScanSizeBytes.ToString("N0", CultureInfo.InvariantCulture)} bytes");
|
||||
}
|
||||
|
||||
// Check per-type budget
|
||||
@@ -47,7 +51,9 @@ public sealed class EvidenceBudgetService : IEvidenceBudgetService
|
||||
var projectedType = typeUsage + item.SizeBytes;
|
||||
if (projectedType > typeLimit)
|
||||
{
|
||||
issues.Add($"Would exceed {item.Type} budget: {projectedType:N0} > {typeLimit:N0} bytes");
|
||||
issues.Add(
|
||||
$"Would exceed {item.Type} budget: {projectedType.ToString("N0", CultureInfo.InvariantCulture)} > " +
|
||||
$"{typeLimit.ToString("N0", CultureInfo.InvariantCulture)} bytes");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,10 +72,10 @@ public sealed class EvidenceBudgetService : IEvidenceBudgetService
|
||||
};
|
||||
}
|
||||
|
||||
public BudgetStatus GetBudgetStatus(Guid scanId)
|
||||
public async Task<BudgetStatus> GetBudgetStatusAsync(Guid scanId, CancellationToken ct)
|
||||
{
|
||||
var budget = _options.CurrentValue;
|
||||
var usage = GetCurrentUsage(scanId);
|
||||
var usage = await GetCurrentUsageAsync(scanId, ct);
|
||||
|
||||
return new BudgetStatus
|
||||
{
|
||||
@@ -92,13 +98,10 @@ public sealed class EvidenceBudgetService : IEvidenceBudgetService
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<PruneResult> PruneToFitAsync(
|
||||
Guid scanId,
|
||||
long targetBytes,
|
||||
CancellationToken ct)
|
||||
public async Task<PruneResult> PruneToFitAsync(Guid scanId, long targetBytes, CancellationToken ct)
|
||||
{
|
||||
var budget = _options.CurrentValue;
|
||||
var usage = GetCurrentUsage(scanId);
|
||||
var usage = await GetCurrentUsageAsync(scanId, ct);
|
||||
|
||||
if (usage.TotalBytes <= targetBytes)
|
||||
{
|
||||
@@ -113,6 +116,8 @@ public sealed class EvidenceBudgetService : IEvidenceBudgetService
|
||||
var candidates = items
|
||||
.Where(i => !budget.AlwaysPreserve.Contains(i.Type))
|
||||
.OrderBy(i => GetPrunePriority(i))
|
||||
.ThenBy(i => i.CreatedAt.UtcDateTime.Ticks)
|
||||
.ThenBy(i => i.Id)
|
||||
.ToList();
|
||||
|
||||
long prunedBytes = 0;
|
||||
@@ -158,15 +163,15 @@ public sealed class EvidenceBudgetService : IEvidenceBudgetService
|
||||
};
|
||||
}
|
||||
|
||||
private UsageStats GetCurrentUsage(Guid scanId)
|
||||
private async Task<UsageStats> GetCurrentUsageAsync(Guid scanId, CancellationToken ct)
|
||||
{
|
||||
// Implementation to calculate current usage from repository
|
||||
var items = _repository.GetByScanIdAsync(scanId, CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
var items = await _repository.GetByScanIdAsync(scanId, ct);
|
||||
|
||||
var totalBytes = items.Sum(i => i.SizeBytes);
|
||||
var byType = items
|
||||
.GroupBy(i => i.Type)
|
||||
.OrderBy(g => g.Key)
|
||||
.ToDictionary(g => g.Key, g => g.Sum(i => i.SizeBytes));
|
||||
|
||||
return new UsageStats
|
||||
|
||||
@@ -15,24 +15,27 @@ public sealed class RetentionTierManager : IRetentionTierManager
|
||||
private readonly IEvidenceRepository _repository;
|
||||
private readonly IArchiveStorage _archiveStorage;
|
||||
private readonly IOptionsMonitor<EvidenceBudget> _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public RetentionTierManager(
|
||||
IEvidenceRepository repository,
|
||||
IArchiveStorage archiveStorage,
|
||||
IOptionsMonitor<EvidenceBudget> options)
|
||||
IOptionsMonitor<EvidenceBudget> options,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_repository = repository;
|
||||
_archiveStorage = archiveStorage;
|
||||
_options = options;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<TierMigrationResult> RunMigrationAsync(CancellationToken ct)
|
||||
{
|
||||
var budget = _options.CurrentValue;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var migrated = new List<MigratedItem>();
|
||||
|
||||
// Hot → Warm
|
||||
// Hot -> Warm
|
||||
var hotExpiry = now - budget.RetentionPolicies[RetentionTier.Hot].Duration;
|
||||
var toWarm = await _repository.GetOlderThanAsync(RetentionTier.Hot, hotExpiry, ct);
|
||||
foreach (var item in toWarm)
|
||||
@@ -41,7 +44,7 @@ public sealed class RetentionTierManager : IRetentionTierManager
|
||||
migrated.Add(new MigratedItem(item.Id, RetentionTier.Hot, RetentionTier.Warm));
|
||||
}
|
||||
|
||||
// Warm → Cold
|
||||
// Warm -> Cold
|
||||
var warmExpiry = now - budget.RetentionPolicies[RetentionTier.Warm].Duration;
|
||||
var toCold = await _repository.GetOlderThanAsync(RetentionTier.Warm, warmExpiry, ct);
|
||||
foreach (var item in toCold)
|
||||
@@ -50,7 +53,7 @@ public sealed class RetentionTierManager : IRetentionTierManager
|
||||
migrated.Add(new MigratedItem(item.Id, RetentionTier.Warm, RetentionTier.Cold));
|
||||
}
|
||||
|
||||
// Cold → Archive
|
||||
// Cold -> Archive
|
||||
var coldExpiry = now - budget.RetentionPolicies[RetentionTier.Cold].Duration;
|
||||
var toArchive = await _repository.GetOlderThanAsync(RetentionTier.Cold, coldExpiry, ct);
|
||||
foreach (var item in toArchive)
|
||||
@@ -69,7 +72,7 @@ public sealed class RetentionTierManager : IRetentionTierManager
|
||||
public RetentionTier GetCurrentTier(EvidenceItem item)
|
||||
{
|
||||
var budget = _options.CurrentValue;
|
||||
var age = DateTimeOffset.UtcNow - item.CreatedAt;
|
||||
var age = _timeProvider.GetUtcNow() - item.CreatedAt;
|
||||
|
||||
if (age < budget.RetentionPolicies[RetentionTier.Hot].Duration)
|
||||
return RetentionTier.Hot;
|
||||
@@ -122,15 +125,13 @@ public sealed class RetentionTierManager : IRetentionTierManager
|
||||
await _repository.MoveToTierAsync(item.Id, RetentionTier.Hot, ct);
|
||||
}
|
||||
|
||||
private async Task<byte[]> CompressAsync(
|
||||
private Task<byte[]> CompressAsync(
|
||||
EvidenceItem item,
|
||||
CompressionLevel level,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Placeholder for compression logic
|
||||
// In real implementation, would read content, compress, and return
|
||||
await Task.CompletedTask;
|
||||
return Array.Empty<byte>();
|
||||
return Task.FromException<byte[]>(new NotSupportedException(
|
||||
"Compression requires repository content retrieval, which is not implemented."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,18 +13,22 @@ namespace StellaOps.Evidence.Serialization;
|
||||
/// </summary>
|
||||
public static class EvidenceIndexSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
Encoder = JavaScriptEncoder.Default,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Converters =
|
||||
{
|
||||
new JsonStringEnumConverter(null, allowIntegerValues: false)
|
||||
}
|
||||
};
|
||||
|
||||
public static string Serialize(EvidenceIndex index)
|
||||
{
|
||||
var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(index, JsonOptions);
|
||||
var canonicalBytes = CanonJson.CanonicalizeParsedJson(jsonBytes);
|
||||
var canonicalBytes = CanonJson.Canonicalize(index, JsonOptions);
|
||||
return Encoding.UTF8.GetString(canonicalBytes);
|
||||
}
|
||||
|
||||
@@ -37,8 +41,8 @@ public static class EvidenceIndexSerializer
|
||||
public static string ComputeDigest(EvidenceIndex index)
|
||||
{
|
||||
var withoutDigest = index with { IndexDigest = null };
|
||||
var json = Serialize(withoutDigest);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
var canonicalBytes = CanonJson.Canonicalize(withoutDigest, JsonOptions);
|
||||
var hash = SHA256.HashData(canonicalBytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Evidence.Models;
|
||||
using StellaOps.Evidence.Serialization;
|
||||
using System.Globalization;
|
||||
using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.Evidence.Services;
|
||||
|
||||
@@ -9,6 +11,8 @@ namespace StellaOps.Evidence.Services;
|
||||
/// </summary>
|
||||
public sealed class EvidenceLinker : IEvidenceLinker
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private readonly object _lock = new();
|
||||
private readonly List<SbomEvidence> _sboms = [];
|
||||
private readonly List<AttestationEvidence> _attestations = [];
|
||||
@@ -17,6 +21,17 @@ public sealed class EvidenceLinker : IEvidenceLinker
|
||||
private readonly List<UnknownEvidence> _unknowns = [];
|
||||
private ToolChainEvidence? _toolChain;
|
||||
|
||||
public EvidenceLinker()
|
||||
: this(TimeProvider.System, SystemGuidProvider.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
public EvidenceLinker(TimeProvider timeProvider, IGuidProvider guidProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
|
||||
}
|
||||
|
||||
public void AddSbom(SbomEvidence sbom)
|
||||
{
|
||||
lock (_lock)
|
||||
@@ -84,23 +99,76 @@ public sealed class EvidenceLinker : IEvidenceLinker
|
||||
unknowns = _unknowns.ToImmutableArray();
|
||||
}
|
||||
|
||||
var orderedSboms = sboms
|
||||
.OrderBy(s => s.Digest, StringComparer.Ordinal)
|
||||
.ThenBy(s => s.SbomId, StringComparer.Ordinal)
|
||||
.ThenBy(s => s.Format, StringComparer.Ordinal)
|
||||
.ThenBy(s => NormalizeSortKey(s.Uri), StringComparer.Ordinal)
|
||||
.ThenBy(s => s.ComponentCount)
|
||||
.ThenBy(s => s.GeneratedAt.UtcDateTime.Ticks)
|
||||
.ToImmutableArray();
|
||||
|
||||
var orderedAttestations = attestations
|
||||
.OrderBy(a => a.Type, StringComparer.Ordinal)
|
||||
.ThenBy(a => a.Digest, StringComparer.Ordinal)
|
||||
.ThenBy(a => a.SignerKeyId, StringComparer.Ordinal)
|
||||
.ThenBy(a => a.AttestationId, StringComparer.Ordinal)
|
||||
.ThenBy(a => a.SignedAt.UtcDateTime.Ticks)
|
||||
.ThenBy(a => a.SignatureValid)
|
||||
.ThenBy(a => NormalizeSortKey(a.RekorLogIndex), StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var orderedVex = vexDocuments
|
||||
.Select(v => v with
|
||||
{
|
||||
AffectedVulnerabilities = v.AffectedVulnerabilities
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray()
|
||||
})
|
||||
.OrderBy(v => v.Digest, StringComparer.Ordinal)
|
||||
.ThenBy(v => v.VexId, StringComparer.Ordinal)
|
||||
.ThenBy(v => v.Format, StringComparer.Ordinal)
|
||||
.ThenBy(v => v.Source, StringComparer.Ordinal)
|
||||
.ThenBy(v => v.StatementCount)
|
||||
.ToImmutableArray();
|
||||
|
||||
var orderedReachability = reachabilityProofs
|
||||
.OrderBy(r => r.VulnerabilityId, StringComparer.Ordinal)
|
||||
.ThenBy(r => r.ComponentPurl, StringComparer.Ordinal)
|
||||
.ThenBy(r => r.Status)
|
||||
.ThenBy(r => NormalizeSortKey(r.EntryPoint), StringComparer.Ordinal)
|
||||
.ThenBy(r => r.Digest, StringComparer.Ordinal)
|
||||
.ThenBy(r => r.ProofId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var orderedUnknowns = unknowns
|
||||
.OrderBy(u => u.ReasonCode, StringComparer.Ordinal)
|
||||
.ThenBy(u => NormalizeSortKey(u.VulnerabilityId), StringComparer.Ordinal)
|
||||
.ThenBy(u => NormalizeSortKey(u.ComponentPurl), StringComparer.Ordinal)
|
||||
.ThenBy(u => u.Severity)
|
||||
.ThenBy(u => u.UnknownId, StringComparer.Ordinal)
|
||||
.ThenBy(u => u.Description, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var index = new EvidenceIndex
|
||||
{
|
||||
IndexId = Guid.NewGuid().ToString(),
|
||||
IndexId = _guidProvider.NewGuid().ToString("D", CultureInfo.InvariantCulture),
|
||||
SchemaVersion = "1.0.0",
|
||||
Verdict = verdict,
|
||||
Sboms = sboms,
|
||||
Attestations = attestations,
|
||||
VexDocuments = vexDocuments,
|
||||
ReachabilityProofs = reachabilityProofs,
|
||||
Unknowns = unknowns,
|
||||
Sboms = orderedSboms,
|
||||
Attestations = orderedAttestations,
|
||||
VexDocuments = orderedVex,
|
||||
ReachabilityProofs = orderedReachability,
|
||||
Unknowns = orderedUnknowns,
|
||||
ToolChain = toolChain,
|
||||
RunManifestDigest = runManifestDigest,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
return EvidenceIndexSerializer.WithDigest(index);
|
||||
}
|
||||
|
||||
private static string NormalizeSortKey(string? value) => value ?? string.Empty;
|
||||
}
|
||||
|
||||
public interface IEvidenceLinker
|
||||
|
||||
@@ -10,8 +10,15 @@ public sealed class EvidenceQueryService : IEvidenceQueryService
|
||||
public IEnumerable<AttestationEvidence> GetAttestationsForSbom(
|
||||
EvidenceIndex index, string sbomDigest)
|
||||
{
|
||||
var sbomExists = index.Sboms.Any(s => string.Equals(s.Digest, sbomDigest, StringComparison.Ordinal));
|
||||
if (!sbomExists)
|
||||
{
|
||||
return Array.Empty<AttestationEvidence>();
|
||||
}
|
||||
|
||||
return index.Attestations
|
||||
.Where(a => a.Type == "sbom" && index.Sboms.Any(s => s.Digest == sbomDigest));
|
||||
.Where(a => string.Equals(a.Type, "sbom", StringComparison.Ordinal) &&
|
||||
string.Equals(a.Digest, sbomDigest, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
public IEnumerable<ReachabilityEvidence> GetReachabilityForVulnerability(
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0082-M | DONE | Revalidated 2026-01-08; open findings tracked in audit report. |
|
||||
| AUDIT-0082-T | DONE | Revalidated 2026-01-08; open findings tracked in audit report. |
|
||||
| AUDIT-0082-A | TODO | Revalidated 2026-01-08 (open findings). |
|
||||
| AUDIT-0082-A | DONE | Applied 2026-01-13; determinism, schema validation, budget async, retention safeguards, tests. |
|
||||
|
||||
@@ -1,14 +1,46 @@
|
||||
using StellaOps.Evidence.Models;
|
||||
using StellaOps.Evidence.Serialization;
|
||||
using System.Text.Json;
|
||||
using Json.Schema;
|
||||
|
||||
namespace StellaOps.Evidence.Validation;
|
||||
|
||||
public sealed class EvidenceIndexValidator : IEvidenceIndexValidator
|
||||
{
|
||||
private readonly JsonSchema _schema;
|
||||
|
||||
public EvidenceIndexValidator()
|
||||
{
|
||||
var schemaJson = SchemaLoader.LoadSchema("evidence-index.schema.json");
|
||||
_schema = JsonSchema.FromText(schemaJson, new BuildOptions
|
||||
{
|
||||
SchemaRegistry = new SchemaRegistry()
|
||||
});
|
||||
}
|
||||
|
||||
public ValidationResult Validate(EvidenceIndex index)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(index);
|
||||
var errors = new List<ValidationError>();
|
||||
|
||||
var json = EvidenceIndexSerializer.Serialize(index);
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var schemaResult = _schema.Evaluate(document.RootElement);
|
||||
if (!schemaResult.IsValid)
|
||||
{
|
||||
if (schemaResult.Errors is not null)
|
||||
{
|
||||
foreach (var error in schemaResult.Errors)
|
||||
{
|
||||
errors.Add(new ValidationError("Schema", error.Value ?? "Unknown error"));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
errors.Add(new ValidationError("Schema", "Schema validation failed"));
|
||||
}
|
||||
}
|
||||
|
||||
if (index.Sboms.Length == 0)
|
||||
{
|
||||
errors.Add(new ValidationError("Sboms", "At least one SBOM required"));
|
||||
@@ -25,7 +57,7 @@ public sealed class EvidenceIndexValidator : IEvidenceIndexValidator
|
||||
foreach (var proof in index.ReachabilityProofs)
|
||||
{
|
||||
if (proof.Status == ReachabilityStatus.Inconclusive &&
|
||||
!index.Unknowns.Any(u => u.VulnerabilityId == proof.VulnerabilityId))
|
||||
!index.Unknowns.Any(u => string.Equals(u.VulnerabilityId, proof.VulnerabilityId, StringComparison.Ordinal)))
|
||||
{
|
||||
errors.Add(new ValidationError("ReachabilityProofs",
|
||||
$"Inconclusive reachability for {proof.VulnerabilityId} not recorded as unknown"));
|
||||
|
||||
@@ -3,12 +3,16 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Evidence.Budgets;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Evidence.Tests.Budgets;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public class EvidenceBudgetServiceTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2026, 1, 13, 0, 0, 0, TimeSpan.Zero);
|
||||
private static readonly Guid DefaultScanId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
|
||||
private readonly Mock<IEvidenceRepository> _repository = new();
|
||||
private readonly Mock<IOptionsMonitor<EvidenceBudget>> _options = new();
|
||||
private readonly EvidenceBudgetService _service;
|
||||
@@ -27,24 +31,32 @@ public class EvidenceBudgetServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckBudget_WithinLimit_ReturnsSuccess()
|
||||
public async Task CheckBudget_WithinLimit_ReturnsSuccess()
|
||||
{
|
||||
var scanId = Guid.NewGuid();
|
||||
var item = CreateItem(type: EvidenceType.CallGraph, sizeBytes: 1024);
|
||||
var scanId = DefaultScanId;
|
||||
var item = CreateItem(
|
||||
id: Guid.Parse("00000000-0000-0000-0000-000000000010"),
|
||||
scanId: scanId,
|
||||
type: EvidenceType.CallGraph,
|
||||
sizeBytes: 1024);
|
||||
|
||||
var result = _service.CheckBudget(scanId, item);
|
||||
var result = await _service.CheckBudgetAsync(scanId, item, CancellationToken.None);
|
||||
|
||||
result.IsWithinBudget.Should().BeTrue();
|
||||
result.Issues.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckBudget_ExceedsTotal_ReturnsViolation()
|
||||
public async Task CheckBudget_ExceedsTotal_ReturnsViolation()
|
||||
{
|
||||
var scanId = SetupScanAtBudgetLimit();
|
||||
var item = CreateItem(type: EvidenceType.CallGraph, sizeBytes: 10 * 1024 * 1024); // 10 MB over
|
||||
var item = CreateItem(
|
||||
id: Guid.Parse("00000000-0000-0000-0000-000000000011"),
|
||||
scanId: scanId,
|
||||
type: EvidenceType.CallGraph,
|
||||
sizeBytes: 10 * 1024 * 1024); // 10 MB over
|
||||
|
||||
var result = _service.CheckBudget(scanId, item);
|
||||
var result = await _service.CheckBudgetAsync(scanId, item, CancellationToken.None);
|
||||
|
||||
result.IsWithinBudget.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Contains("total budget"));
|
||||
@@ -52,17 +64,25 @@ public class EvidenceBudgetServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckBudget_ExceedsTypeLimit_ReturnsViolation()
|
||||
public async Task CheckBudget_ExceedsTypeLimit_ReturnsViolation()
|
||||
{
|
||||
var scanId = Guid.NewGuid();
|
||||
var existingCallGraph = CreateItem(type: EvidenceType.CallGraph, sizeBytes: 49 * 1024 * 1024);
|
||||
var scanId = Guid.Parse("11111111-1111-1111-1111-111111111112");
|
||||
var existingCallGraph = CreateItem(
|
||||
id: Guid.Parse("00000000-0000-0000-0000-000000000012"),
|
||||
scanId: scanId,
|
||||
type: EvidenceType.CallGraph,
|
||||
sizeBytes: 49 * 1024 * 1024);
|
||||
_repository.Setup(r => r.GetByScanIdAsync(scanId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<EvidenceItem> { existingCallGraph });
|
||||
|
||||
// CallGraph limit is 50MB, adding 2MB would exceed
|
||||
var item = CreateItem(type: EvidenceType.CallGraph, sizeBytes: 2 * 1024 * 1024);
|
||||
var item = CreateItem(
|
||||
id: Guid.Parse("00000000-0000-0000-0000-000000000013"),
|
||||
scanId: scanId,
|
||||
type: EvidenceType.CallGraph,
|
||||
sizeBytes: 2 * 1024 * 1024);
|
||||
|
||||
var result = _service.CheckBudget(scanId, item);
|
||||
var result = await _service.CheckBudgetAsync(scanId, item, CancellationToken.None);
|
||||
|
||||
result.IsWithinBudget.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Contains("CallGraph budget"));
|
||||
@@ -71,10 +91,14 @@ public class EvidenceBudgetServiceTests
|
||||
[Fact]
|
||||
public async Task PruneToFitAsync_NoExcess_NoPruning()
|
||||
{
|
||||
var scanId = Guid.NewGuid();
|
||||
var scanId = Guid.Parse("11111111-1111-1111-1111-111111111113");
|
||||
var items = new List<EvidenceItem>
|
||||
{
|
||||
CreateItem(type: EvidenceType.Sbom, sizeBytes: 5 * 1024 * 1024)
|
||||
CreateItem(
|
||||
id: Guid.Parse("00000000-0000-0000-0000-000000000014"),
|
||||
scanId: scanId,
|
||||
type: EvidenceType.Sbom,
|
||||
sizeBytes: 5 * 1024 * 1024)
|
||||
};
|
||||
_repository.Setup(r => r.GetByScanIdAsync(scanId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(items);
|
||||
@@ -100,13 +124,29 @@ public class EvidenceBudgetServiceTests
|
||||
[Fact]
|
||||
public async Task PruneToFitAsync_PrunesLowestPriorityFirst()
|
||||
{
|
||||
var scanId = Guid.NewGuid();
|
||||
var scanId = Guid.Parse("11111111-1111-1111-1111-111111111114");
|
||||
var items = new List<EvidenceItem>
|
||||
{
|
||||
CreateItem(id: Guid.NewGuid(), type: EvidenceType.RuntimeCapture, sizeBytes: 10 * 1024 * 1024), // Priority 1
|
||||
CreateItem(id: Guid.NewGuid(), type: EvidenceType.CallGraph, sizeBytes: 10 * 1024 * 1024), // Priority 2
|
||||
CreateItem(id: Guid.NewGuid(), type: EvidenceType.Sbom, sizeBytes: 10 * 1024 * 1024), // Priority 6
|
||||
CreateItem(id: Guid.NewGuid(), type: EvidenceType.Verdict, sizeBytes: 1 * 1024 * 1024) // Priority 9 (never prune)
|
||||
CreateItem(
|
||||
id: Guid.Parse("00000000-0000-0000-0000-000000000020"),
|
||||
scanId: scanId,
|
||||
type: EvidenceType.RuntimeCapture,
|
||||
sizeBytes: 10 * 1024 * 1024), // Priority 1
|
||||
CreateItem(
|
||||
id: Guid.Parse("00000000-0000-0000-0000-000000000021"),
|
||||
scanId: scanId,
|
||||
type: EvidenceType.CallGraph,
|
||||
sizeBytes: 10 * 1024 * 1024), // Priority 2
|
||||
CreateItem(
|
||||
id: Guid.Parse("00000000-0000-0000-0000-000000000022"),
|
||||
scanId: scanId,
|
||||
type: EvidenceType.Sbom,
|
||||
sizeBytes: 10 * 1024 * 1024), // Priority 6
|
||||
CreateItem(
|
||||
id: Guid.Parse("00000000-0000-0000-0000-000000000023"),
|
||||
scanId: scanId,
|
||||
type: EvidenceType.Verdict,
|
||||
sizeBytes: 1 * 1024 * 1024) // Priority 9 (never prune)
|
||||
};
|
||||
_repository.Setup(r => r.GetByScanIdAsync(scanId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(items);
|
||||
@@ -121,18 +161,57 @@ public class EvidenceBudgetServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBudgetStatus_CalculatesUtilization()
|
||||
public async Task PruneToFitAsync_UsesCreatedAtTieBreaker()
|
||||
{
|
||||
var scanId = Guid.NewGuid();
|
||||
var scanId = Guid.Parse("11111111-1111-1111-1111-111111111120");
|
||||
var olderId = Guid.Parse("00000000-0000-0000-0000-000000000060");
|
||||
var newerId = Guid.Parse("00000000-0000-0000-0000-000000000061");
|
||||
var items = new List<EvidenceItem>
|
||||
{
|
||||
CreateItem(type: EvidenceType.CallGraph, sizeBytes: 25 * 1024 * 1024), // 25 MB
|
||||
CreateItem(type: EvidenceType.Sbom, sizeBytes: 5 * 1024 * 1024) // 5 MB
|
||||
CreateItem(
|
||||
id: newerId,
|
||||
scanId: scanId,
|
||||
type: EvidenceType.RuntimeCapture,
|
||||
sizeBytes: 5 * 1024 * 1024,
|
||||
createdAt: FixedNow.AddMinutes(2)),
|
||||
CreateItem(
|
||||
id: olderId,
|
||||
scanId: scanId,
|
||||
type: EvidenceType.RuntimeCapture,
|
||||
sizeBytes: 5 * 1024 * 1024,
|
||||
createdAt: FixedNow.AddMinutes(1))
|
||||
};
|
||||
_repository.Setup(r => r.GetByScanIdAsync(scanId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(items);
|
||||
|
||||
var status = _service.GetBudgetStatus(scanId);
|
||||
var result = await _service.PruneToFitAsync(scanId, 0, CancellationToken.None);
|
||||
|
||||
result.ItemsPruned.Should().HaveCount(2);
|
||||
result.ItemsPruned[0].ItemId.Should().Be(olderId);
|
||||
result.ItemsPruned[1].ItemId.Should().Be(newerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBudgetStatus_CalculatesUtilization()
|
||||
{
|
||||
var scanId = Guid.Parse("11111111-1111-1111-1111-111111111115");
|
||||
var items = new List<EvidenceItem>
|
||||
{
|
||||
CreateItem(
|
||||
id: Guid.Parse("00000000-0000-0000-0000-000000000030"),
|
||||
scanId: scanId,
|
||||
type: EvidenceType.CallGraph,
|
||||
sizeBytes: 25 * 1024 * 1024), // 25 MB
|
||||
CreateItem(
|
||||
id: Guid.Parse("00000000-0000-0000-0000-000000000031"),
|
||||
scanId: scanId,
|
||||
type: EvidenceType.Sbom,
|
||||
sizeBytes: 5 * 1024 * 1024) // 5 MB
|
||||
};
|
||||
_repository.Setup(r => r.GetByScanIdAsync(scanId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(items);
|
||||
|
||||
var status = await _service.GetBudgetStatusAsync(scanId, CancellationToken.None);
|
||||
|
||||
status.ScanId.Should().Be(scanId);
|
||||
status.TotalBudgetBytes.Should().Be(100 * 1024 * 1024); // 100 MB
|
||||
@@ -142,17 +221,21 @@ public class EvidenceBudgetServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBudgetStatus_CalculatesPerTypeUtilization()
|
||||
public async Task GetBudgetStatus_CalculatesPerTypeUtilization()
|
||||
{
|
||||
var scanId = Guid.NewGuid();
|
||||
var scanId = Guid.Parse("11111111-1111-1111-1111-111111111116");
|
||||
var items = new List<EvidenceItem>
|
||||
{
|
||||
CreateItem(type: EvidenceType.CallGraph, sizeBytes: 25 * 1024 * 1024) // 25 of 50 MB limit
|
||||
CreateItem(
|
||||
id: Guid.Parse("00000000-0000-0000-0000-000000000032"),
|
||||
scanId: scanId,
|
||||
type: EvidenceType.CallGraph,
|
||||
sizeBytes: 25 * 1024 * 1024) // 25 of 50 MB limit
|
||||
};
|
||||
_repository.Setup(r => r.GetByScanIdAsync(scanId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(items);
|
||||
|
||||
var status = _service.GetBudgetStatus(scanId);
|
||||
var status = await _service.GetBudgetStatusAsync(scanId, CancellationToken.None);
|
||||
|
||||
status.ByType.Should().ContainKey(EvidenceType.CallGraph);
|
||||
var callGraphStatus = status.ByType[EvidenceType.CallGraph];
|
||||
@@ -162,7 +245,7 @@ public class EvidenceBudgetServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckBudget_AutoPruneAction_SetsCanAutoPrune()
|
||||
public async Task CheckBudget_AutoPruneAction_SetsCanAutoPrune()
|
||||
{
|
||||
var budget = new EvidenceBudget
|
||||
{
|
||||
@@ -172,16 +255,24 @@ public class EvidenceBudgetServiceTests
|
||||
};
|
||||
_options.Setup(o => o.CurrentValue).Returns(budget);
|
||||
|
||||
var scanId = Guid.NewGuid();
|
||||
var scanId = Guid.Parse("11111111-1111-1111-1111-111111111117");
|
||||
var items = new List<EvidenceItem>
|
||||
{
|
||||
CreateItem(type: EvidenceType.Sbom, sizeBytes: 1000)
|
||||
CreateItem(
|
||||
id: Guid.Parse("00000000-0000-0000-0000-000000000033"),
|
||||
scanId: scanId,
|
||||
type: EvidenceType.Sbom,
|
||||
sizeBytes: 1000)
|
||||
};
|
||||
_repository.Setup(r => r.GetByScanIdAsync(scanId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(items);
|
||||
|
||||
var item = CreateItem(type: EvidenceType.CallGraph, sizeBytes: 100);
|
||||
var result = _service.CheckBudget(scanId, item);
|
||||
var item = CreateItem(
|
||||
id: Guid.Parse("00000000-0000-0000-0000-000000000034"),
|
||||
scanId: scanId,
|
||||
type: EvidenceType.CallGraph,
|
||||
sizeBytes: 100);
|
||||
var result = await _service.CheckBudgetAsync(scanId, item, CancellationToken.None);
|
||||
|
||||
result.IsWithinBudget.Should().BeFalse();
|
||||
result.RecommendedAction.Should().Be(BudgetExceededAction.AutoPrune);
|
||||
@@ -190,15 +281,39 @@ public class EvidenceBudgetServiceTests
|
||||
|
||||
private Guid SetupScanAtBudgetLimit()
|
||||
{
|
||||
var scanId = Guid.NewGuid();
|
||||
var scanId = Guid.Parse("11111111-1111-1111-1111-111111111118");
|
||||
var items = new List<EvidenceItem>
|
||||
{
|
||||
CreateItem(type: EvidenceType.CallGraph, sizeBytes: 50 * 1024 * 1024),
|
||||
CreateItem(type: EvidenceType.RuntimeCapture, sizeBytes: 20 * 1024 * 1024),
|
||||
CreateItem(type: EvidenceType.Sbom, sizeBytes: 10 * 1024 * 1024),
|
||||
CreateItem(type: EvidenceType.PolicyTrace, sizeBytes: 5 * 1024 * 1024),
|
||||
CreateItem(type: EvidenceType.Verdict, sizeBytes: 5 * 1024 * 1024),
|
||||
CreateItem(type: EvidenceType.Advisory, sizeBytes: 10 * 1024 * 1024)
|
||||
CreateItem(
|
||||
id: Guid.Parse("00000000-0000-0000-0000-000000000040"),
|
||||
scanId: scanId,
|
||||
type: EvidenceType.CallGraph,
|
||||
sizeBytes: 50 * 1024 * 1024),
|
||||
CreateItem(
|
||||
id: Guid.Parse("00000000-0000-0000-0000-000000000041"),
|
||||
scanId: scanId,
|
||||
type: EvidenceType.RuntimeCapture,
|
||||
sizeBytes: 20 * 1024 * 1024),
|
||||
CreateItem(
|
||||
id: Guid.Parse("00000000-0000-0000-0000-000000000042"),
|
||||
scanId: scanId,
|
||||
type: EvidenceType.Sbom,
|
||||
sizeBytes: 10 * 1024 * 1024),
|
||||
CreateItem(
|
||||
id: Guid.Parse("00000000-0000-0000-0000-000000000043"),
|
||||
scanId: scanId,
|
||||
type: EvidenceType.PolicyTrace,
|
||||
sizeBytes: 5 * 1024 * 1024),
|
||||
CreateItem(
|
||||
id: Guid.Parse("00000000-0000-0000-0000-000000000044"),
|
||||
scanId: scanId,
|
||||
type: EvidenceType.Verdict,
|
||||
sizeBytes: 5 * 1024 * 1024),
|
||||
CreateItem(
|
||||
id: Guid.Parse("00000000-0000-0000-0000-000000000045"),
|
||||
scanId: scanId,
|
||||
type: EvidenceType.Advisory,
|
||||
sizeBytes: 10 * 1024 * 1024)
|
||||
};
|
||||
_repository.Setup(r => r.GetByScanIdAsync(scanId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(items);
|
||||
@@ -207,15 +322,39 @@ public class EvidenceBudgetServiceTests
|
||||
|
||||
private Guid SetupScanOverBudget()
|
||||
{
|
||||
var scanId = Guid.NewGuid();
|
||||
var scanId = Guid.Parse("11111111-1111-1111-1111-111111111119");
|
||||
var items = new List<EvidenceItem>
|
||||
{
|
||||
CreateItem(type: EvidenceType.CallGraph, sizeBytes: 40 * 1024 * 1024),
|
||||
CreateItem(type: EvidenceType.RuntimeCapture, sizeBytes: 30 * 1024 * 1024),
|
||||
CreateItem(type: EvidenceType.Sbom, sizeBytes: 20 * 1024 * 1024),
|
||||
CreateItem(type: EvidenceType.PolicyTrace, sizeBytes: 10 * 1024 * 1024),
|
||||
CreateItem(type: EvidenceType.Verdict, sizeBytes: 5 * 1024 * 1024),
|
||||
CreateItem(type: EvidenceType.Attestation, sizeBytes: 5 * 1024 * 1024)
|
||||
CreateItem(
|
||||
id: Guid.Parse("00000000-0000-0000-0000-000000000046"),
|
||||
scanId: scanId,
|
||||
type: EvidenceType.CallGraph,
|
||||
sizeBytes: 40 * 1024 * 1024),
|
||||
CreateItem(
|
||||
id: Guid.Parse("00000000-0000-0000-0000-000000000047"),
|
||||
scanId: scanId,
|
||||
type: EvidenceType.RuntimeCapture,
|
||||
sizeBytes: 30 * 1024 * 1024),
|
||||
CreateItem(
|
||||
id: Guid.Parse("00000000-0000-0000-0000-000000000048"),
|
||||
scanId: scanId,
|
||||
type: EvidenceType.Sbom,
|
||||
sizeBytes: 20 * 1024 * 1024),
|
||||
CreateItem(
|
||||
id: Guid.Parse("00000000-0000-0000-0000-000000000049"),
|
||||
scanId: scanId,
|
||||
type: EvidenceType.PolicyTrace,
|
||||
sizeBytes: 10 * 1024 * 1024),
|
||||
CreateItem(
|
||||
id: Guid.Parse("00000000-0000-0000-0000-000000000050"),
|
||||
scanId: scanId,
|
||||
type: EvidenceType.Verdict,
|
||||
sizeBytes: 5 * 1024 * 1024),
|
||||
CreateItem(
|
||||
id: Guid.Parse("00000000-0000-0000-0000-000000000051"),
|
||||
scanId: scanId,
|
||||
type: EvidenceType.Attestation,
|
||||
sizeBytes: 5 * 1024 * 1024)
|
||||
};
|
||||
_repository.Setup(r => r.GetByScanIdAsync(scanId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(items);
|
||||
@@ -223,18 +362,21 @@ public class EvidenceBudgetServiceTests
|
||||
}
|
||||
|
||||
private static EvidenceItem CreateItem(
|
||||
Guid? id = null,
|
||||
Guid id,
|
||||
Guid? scanId = null,
|
||||
EvidenceType type = EvidenceType.CallGraph,
|
||||
long sizeBytes = 1024)
|
||||
long sizeBytes = 1024,
|
||||
RetentionTier tier = RetentionTier.Hot,
|
||||
DateTimeOffset? createdAt = null)
|
||||
{
|
||||
return new EvidenceItem
|
||||
{
|
||||
Id = id ?? Guid.NewGuid(),
|
||||
ScanId = Guid.NewGuid(),
|
||||
Id = id,
|
||||
ScanId = scanId ?? DefaultScanId,
|
||||
Type = type,
|
||||
SizeBytes = sizeBytes,
|
||||
Tier = RetentionTier.Hot,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
Tier = tier,
|
||||
CreatedAt = createdAt ?? FixedNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using FluentAssertions;
|
||||
using StellaOps.Evidence.Models;
|
||||
using StellaOps.Evidence.Serialization;
|
||||
using StellaOps.Evidence.Services;
|
||||
using StellaOps.Evidence.Tests.TestUtilities;
|
||||
using StellaOps.Evidence.Validation;
|
||||
using Xunit;
|
||||
|
||||
@@ -11,21 +12,76 @@ namespace StellaOps.Evidence.Tests;
|
||||
|
||||
public class EvidenceIndexTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2026, 1, 13, 0, 0, 0, TimeSpan.Zero);
|
||||
private static readonly Guid FixedGuid = Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EvidenceLinker_BuildsIndexWithDigest()
|
||||
{
|
||||
var linker = new EvidenceLinker();
|
||||
var linker = new EvidenceLinker(new FixedTimeProvider(FixedNow), new FixedGuidProvider(FixedGuid));
|
||||
linker.SetToolChain(CreateToolChain());
|
||||
linker.AddSbom(new SbomEvidence("sbom-1", "cyclonedx-1.6", new string('a', 64), null, 10, DateTimeOffset.UtcNow));
|
||||
linker.AddAttestation(new AttestationEvidence("att-1", "sbom", new string('b', 64), "key", true, DateTimeOffset.UtcNow, null));
|
||||
linker.AddSbom(new SbomEvidence("sbom-1", "cyclonedx-1.6", new string('a', 64), null, 10, FixedNow));
|
||||
linker.AddAttestation(new AttestationEvidence("att-1", "sbom", new string('b', 64), "key", true, FixedNow, null));
|
||||
|
||||
var index = linker.Build(new VerdictReference("verdict-1", new string('c', 64), VerdictOutcome.Pass, "1.0.0"), "digest");
|
||||
|
||||
index.IndexId.Should().Be(FixedGuid.ToString("D"));
|
||||
index.CreatedAt.Should().Be(FixedNow);
|
||||
index.IndexDigest.Should().NotBeNullOrEmpty();
|
||||
index.Sboms.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EvidenceLinker_SortsEvidenceDeterministically()
|
||||
{
|
||||
var linker = new EvidenceLinker(new FixedTimeProvider(FixedNow), new FixedGuidProvider(FixedGuid));
|
||||
linker.SetToolChain(CreateToolChain());
|
||||
linker.AddSbom(new SbomEvidence("sbom-b", "cyclonedx-1.6", new string('b', 64), null, 12, FixedNow.AddMinutes(1)));
|
||||
linker.AddSbom(new SbomEvidence("sbom-a", "cyclonedx-1.6", new string('a', 64), null, 10, FixedNow));
|
||||
linker.AddAttestation(new AttestationEvidence("att-b", "sbom", new string('b', 64), "key", true, FixedNow.AddMinutes(2), null));
|
||||
linker.AddAttestation(new AttestationEvidence("att-a", "sbom", new string('a', 64), "key", true, FixedNow.AddMinutes(1), null));
|
||||
linker.AddVex(new VexEvidence(
|
||||
"vex-1",
|
||||
"openvex",
|
||||
new string('c', 64),
|
||||
"vendor",
|
||||
2,
|
||||
ImmutableArray.Create("CVE-2024-0002", "CVE-2024-0001")));
|
||||
linker.AddReachabilityProof(new ReachabilityEvidence(
|
||||
"proof-2",
|
||||
"CVE-2024-0002",
|
||||
"pkg:npm/zzz@1.0.0",
|
||||
ReachabilityStatus.NotReachable,
|
||||
"main",
|
||||
ImmutableArray.Create("main"),
|
||||
new string('e', 64)));
|
||||
linker.AddReachabilityProof(new ReachabilityEvidence(
|
||||
"proof-1",
|
||||
"CVE-2024-0001",
|
||||
"pkg:npm/aaa@1.0.0",
|
||||
ReachabilityStatus.Reachable,
|
||||
"init",
|
||||
ImmutableArray.Create("init"),
|
||||
new string('d', 64)));
|
||||
linker.AddUnknown(new UnknownEvidence("unk-b", "B", "Second", null, "CVE-2024-0002", UnknownSeverity.Medium));
|
||||
linker.AddUnknown(new UnknownEvidence("unk-a", "A", "First", null, "CVE-2024-0001", UnknownSeverity.Low));
|
||||
|
||||
var index = linker.Build(new VerdictReference("verdict-1", new string('f', 64), VerdictOutcome.Pass, "1.0.0"), "digest");
|
||||
|
||||
index.Sboms.Select(s => s.Digest).Should()
|
||||
.ContainInOrder(new string('a', 64), new string('b', 64));
|
||||
index.Attestations.Select(a => a.Digest).Should()
|
||||
.ContainInOrder(new string('a', 64), new string('b', 64));
|
||||
index.VexDocuments[0].AffectedVulnerabilities.Should()
|
||||
.ContainInOrder("CVE-2024-0001", "CVE-2024-0002");
|
||||
index.ReachabilityProofs.Select(r => r.VulnerabilityId).Should()
|
||||
.ContainInOrder("CVE-2024-0001", "CVE-2024-0002");
|
||||
index.Unknowns.Select(u => u.ReasonCode).Should()
|
||||
.ContainInOrder("A", "B");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EvidenceValidator_FlagsMissingSbom()
|
||||
@@ -37,6 +93,70 @@ public class EvidenceIndexTests
|
||||
result.IsValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EvidenceValidator_FlagsInvalidSignature()
|
||||
{
|
||||
var index = CreateIndex() with
|
||||
{
|
||||
Attestations = ImmutableArray.Create(
|
||||
new AttestationEvidence("att-1", "sbom", new string('b', 64), "key", false, FixedNow, null))
|
||||
};
|
||||
var validator = new EvidenceIndexValidator();
|
||||
var result = validator.Validate(index);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Field == "Attestations");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EvidenceValidator_FlagsDigestMismatch()
|
||||
{
|
||||
var index = EvidenceIndexSerializer.WithDigest(CreateIndex()) with { SchemaVersion = "2.0.0" };
|
||||
var validator = new EvidenceIndexValidator();
|
||||
var result = validator.Validate(index);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Field == "IndexDigest");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EvidenceValidator_FlagsMissingUnknownForInconclusive()
|
||||
{
|
||||
var index = CreateIndex() with
|
||||
{
|
||||
ReachabilityProofs = ImmutableArray.Create(
|
||||
new ReachabilityEvidence(
|
||||
"proof-1",
|
||||
"CVE-2024-0001",
|
||||
"pkg:npm/foo@1.0.0",
|
||||
ReachabilityStatus.Inconclusive,
|
||||
null,
|
||||
ImmutableArray.Create("main"),
|
||||
new string('e', 64))),
|
||||
Unknowns = []
|
||||
};
|
||||
var validator = new EvidenceIndexValidator();
|
||||
var result = validator.Validate(index);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Field == "ReachabilityProofs");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EvidenceValidator_FlagsSchemaError()
|
||||
{
|
||||
var index = CreateIndex() with { IndexId = null! };
|
||||
var validator = new EvidenceIndexValidator();
|
||||
var result = validator.Validate(index);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Field == "Schema");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EvidenceSerializer_RoundTrip_PreservesFields()
|
||||
@@ -59,21 +179,44 @@ public class EvidenceIndexTests
|
||||
report.AttestationCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EvidenceQueryService_FiltersAttestationsBySbomDigest()
|
||||
{
|
||||
var sbomA = new SbomEvidence("sbom-a", "cyclonedx-1.6", new string('a', 64), null, 10, FixedNow);
|
||||
var sbomB = new SbomEvidence("sbom-b", "cyclonedx-1.6", new string('b', 64), null, 10, FixedNow);
|
||||
var attA = new AttestationEvidence("att-a", "sbom", new string('a', 64), "key", true, FixedNow, null);
|
||||
var attB = new AttestationEvidence("att-b", "sbom", new string('b', 64), "key", true, FixedNow, null);
|
||||
var index = CreateIndex() with
|
||||
{
|
||||
Sboms = ImmutableArray.Create(sbomA, sbomB),
|
||||
Attestations = ImmutableArray.Create(attA, attB)
|
||||
};
|
||||
|
||||
var service = new EvidenceQueryService();
|
||||
var results = service.GetAttestationsForSbom(index, new string('b', 64)).ToList();
|
||||
var missing = service.GetAttestationsForSbom(index, new string('z', 64)).ToList();
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].AttestationId.Should().Be("att-b");
|
||||
missing.Should().BeEmpty();
|
||||
}
|
||||
|
||||
private static EvidenceIndex CreateIndex()
|
||||
{
|
||||
return new EvidenceIndex
|
||||
{
|
||||
IndexId = Guid.NewGuid().ToString(),
|
||||
IndexId = FixedGuid.ToString("D"),
|
||||
SchemaVersion = "1.0.0",
|
||||
Verdict = new VerdictReference("verdict-1", new string('c', 64), VerdictOutcome.Pass, "1.0.0"),
|
||||
Sboms = ImmutableArray.Create(new SbomEvidence("sbom-1", "cyclonedx-1.6", new string('a', 64), null, 10, DateTimeOffset.UtcNow)),
|
||||
Attestations = ImmutableArray.Create(new AttestationEvidence("att-1", "sbom", new string('b', 64), "key", true, DateTimeOffset.UtcNow, null)),
|
||||
Sboms = ImmutableArray.Create(new SbomEvidence("sbom-1", "cyclonedx-1.6", new string('a', 64), null, 10, FixedNow)),
|
||||
Attestations = ImmutableArray.Create(new AttestationEvidence("att-1", "sbom", new string('b', 64), "key", true, FixedNow, null)),
|
||||
VexDocuments = ImmutableArray.Create(new VexEvidence("vex-1", "openvex", new string('d', 64), "vendor", 1, ImmutableArray.Create("CVE-2024-0001"))),
|
||||
ReachabilityProofs = ImmutableArray.Create(new ReachabilityEvidence("proof-1", "CVE-2024-0001", "pkg:npm/foo@1.0.0", ReachabilityStatus.Reachable, "main", ImmutableArray.Create("main"), new string('e', 64))),
|
||||
Unknowns = ImmutableArray.Create(new UnknownEvidence("unk-1", "U-RCH", "Reachability inconclusive", "pkg:npm/foo", "CVE-2024-0001", UnknownSeverity.Medium)),
|
||||
ToolChain = CreateToolChain(),
|
||||
RunManifestDigest = new string('f', 64),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
CreatedAt = FixedNow
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Evidence.Budgets;
|
||||
using StellaOps.Evidence.Retention;
|
||||
using StellaOps.Evidence.Tests.TestUtilities;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Evidence.Tests.Retention;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public class RetentionTierManagerTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2026, 1, 13, 0, 0, 0, TimeSpan.Zero);
|
||||
private static readonly Guid DefaultScanId = Guid.Parse("22222222-2222-2222-2222-222222222222");
|
||||
|
||||
[Fact]
|
||||
public async Task RunMigrationAsync_MigratesItemsAcrossTiers()
|
||||
{
|
||||
var budget = new EvidenceBudget
|
||||
{
|
||||
MaxScanSizeBytes = 100,
|
||||
RetentionPolicies = new Dictionary<RetentionTier, RetentionPolicy>
|
||||
{
|
||||
[RetentionTier.Hot] = new RetentionPolicy { Duration = TimeSpan.FromDays(1) },
|
||||
[RetentionTier.Warm] = new RetentionPolicy { Duration = TimeSpan.FromDays(2) },
|
||||
[RetentionTier.Cold] = new RetentionPolicy { Duration = TimeSpan.FromDays(3) },
|
||||
[RetentionTier.Archive] = new RetentionPolicy { Duration = TimeSpan.FromDays(4) }
|
||||
}
|
||||
};
|
||||
var options = new Mock<IOptionsMonitor<EvidenceBudget>>();
|
||||
options.Setup(o => o.CurrentValue).Returns(budget);
|
||||
var repository = new Mock<IEvidenceRepository>();
|
||||
var archiveStorage = new Mock<IArchiveStorage>();
|
||||
|
||||
var hotItem = CreateItem(Guid.Parse("00000000-0000-0000-0000-000000000101"), EvidenceType.CallGraph, RetentionTier.Hot);
|
||||
var warmItem = CreateItem(Guid.Parse("00000000-0000-0000-0000-000000000102"), EvidenceType.Sbom, RetentionTier.Warm);
|
||||
var coldItem = CreateItem(Guid.Parse("00000000-0000-0000-0000-000000000103"), EvidenceType.Vex, RetentionTier.Cold);
|
||||
|
||||
repository.Setup(r => r.GetOlderThanAsync(RetentionTier.Hot, It.IsAny<DateTimeOffset>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<EvidenceItem> { hotItem });
|
||||
repository.Setup(r => r.GetOlderThanAsync(RetentionTier.Warm, It.IsAny<DateTimeOffset>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<EvidenceItem> { warmItem });
|
||||
repository.Setup(r => r.GetOlderThanAsync(RetentionTier.Cold, It.IsAny<DateTimeOffset>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<EvidenceItem> { coldItem });
|
||||
|
||||
var manager = new RetentionTierManager(
|
||||
repository.Object,
|
||||
archiveStorage.Object,
|
||||
options.Object,
|
||||
new FixedTimeProvider(FixedNow));
|
||||
|
||||
var result = await manager.RunMigrationAsync(CancellationToken.None);
|
||||
|
||||
result.MigratedCount.Should().Be(3);
|
||||
result.Items.Should().Contain(i => i.ItemId == hotItem.Id && i.FromTier == RetentionTier.Hot && i.ToTier == RetentionTier.Warm);
|
||||
result.Items.Should().Contain(i => i.ItemId == warmItem.Id && i.FromTier == RetentionTier.Warm && i.ToTier == RetentionTier.Cold);
|
||||
result.Items.Should().Contain(i => i.ItemId == coldItem.Id && i.FromTier == RetentionTier.Cold && i.ToTier == RetentionTier.Archive);
|
||||
|
||||
repository.Verify(r => r.MoveToTierAsync(hotItem.Id, RetentionTier.Warm, It.IsAny<CancellationToken>()), Times.Once);
|
||||
repository.Verify(r => r.MoveToTierAsync(warmItem.Id, RetentionTier.Cold, It.IsAny<CancellationToken>()), Times.Once);
|
||||
repository.Verify(r => r.MoveToTierAsync(coldItem.Id, RetentionTier.Archive, It.IsAny<CancellationToken>()), Times.Once);
|
||||
repository.Verify(r => r.UpdateContentAsync(It.IsAny<Guid>(), It.IsAny<byte[]>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureAuditCompleteAsync_RestoresArchiveItems()
|
||||
{
|
||||
var budget = new EvidenceBudget
|
||||
{
|
||||
MaxScanSizeBytes = 100,
|
||||
RetentionPolicies = EvidenceBudget.Default.RetentionPolicies,
|
||||
AlwaysPreserve = new HashSet<EvidenceType> { EvidenceType.Verdict }
|
||||
};
|
||||
var options = new Mock<IOptionsMonitor<EvidenceBudget>>();
|
||||
options.Setup(o => o.CurrentValue).Returns(budget);
|
||||
var repository = new Mock<IEvidenceRepository>();
|
||||
var archiveStorage = new Mock<IArchiveStorage>();
|
||||
|
||||
var archiveItem = CreateItem(
|
||||
Guid.Parse("00000000-0000-0000-0000-000000000201"),
|
||||
EvidenceType.Verdict,
|
||||
RetentionTier.Archive,
|
||||
archiveKey: "archive-1");
|
||||
var coldItem = CreateItem(
|
||||
Guid.Parse("00000000-0000-0000-0000-000000000202"),
|
||||
EvidenceType.Verdict,
|
||||
RetentionTier.Cold);
|
||||
|
||||
repository.Setup(r => r.GetByScanIdAndTypeAsync(DefaultScanId, EvidenceType.Verdict, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<EvidenceItem> { archiveItem, coldItem });
|
||||
archiveStorage.Setup(a => a.RetrieveAsync("archive-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new byte[] { 0x01, 0x02 });
|
||||
|
||||
var manager = new RetentionTierManager(
|
||||
repository.Object,
|
||||
archiveStorage.Object,
|
||||
options.Object,
|
||||
new FixedTimeProvider(FixedNow));
|
||||
|
||||
await manager.EnsureAuditCompleteAsync(DefaultScanId, CancellationToken.None);
|
||||
|
||||
archiveStorage.Verify(a => a.RetrieveAsync("archive-1", It.IsAny<CancellationToken>()), Times.Once);
|
||||
repository.Verify(r => r.UpdateContentAsync(archiveItem.Id, It.IsAny<byte[]>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
repository.Verify(r => r.MoveToTierAsync(archiveItem.Id, RetentionTier.Hot, It.IsAny<CancellationToken>()), Times.Once);
|
||||
repository.Verify(r => r.MoveToTierAsync(coldItem.Id, RetentionTier.Hot, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
private static EvidenceItem CreateItem(
|
||||
Guid id,
|
||||
EvidenceType type,
|
||||
RetentionTier tier,
|
||||
string? archiveKey = null)
|
||||
{
|
||||
return new EvidenceItem
|
||||
{
|
||||
Id = id,
|
||||
ScanId = DefaultScanId,
|
||||
Type = type,
|
||||
SizeBytes = 1024,
|
||||
Tier = tier,
|
||||
CreatedAt = FixedNow.AddDays(-10),
|
||||
ArchiveKey = archiveKey
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,8 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Evidence\StellaOps.Evidence.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.Evidence.Tests.TestUtilities;
|
||||
|
||||
internal sealed class FixedGuidProvider : IGuidProvider
|
||||
{
|
||||
private readonly Guid _guid;
|
||||
|
||||
public FixedGuidProvider(Guid guid)
|
||||
{
|
||||
_guid = guid;
|
||||
}
|
||||
|
||||
public Guid NewGuid() => _guid;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace StellaOps.Evidence.Tests.TestUtilities;
|
||||
|
||||
internal sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _now;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset now)
|
||||
{
|
||||
_now = now;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
Reference in New Issue
Block a user