notify doctors work, audit work, new product advisory sprints

This commit is contained in:
master
2026-01-13 08:36:29 +02:00
parent b8868a5f13
commit 9ca7cb183e
343 changed files with 24492 additions and 3544 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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