Refactor code structure for improved readability and maintainability; optimize performance in key functions.

This commit is contained in:
master
2025-12-22 19:06:31 +02:00
parent dfaa2079aa
commit 4602ccc3a3
1444 changed files with 109919 additions and 8058 deletions

View File

@@ -0,0 +1,58 @@
using FluentAssertions;
using StellaOps.Canonicalization.Json;
using StellaOps.Canonicalization.Ordering;
using Xunit;
namespace StellaOps.Canonicalization.Tests;
public class CanonicalJsonSerializerTests
{
[Fact]
public void Serialize_Dictionary_OrdersKeysAlphabetically()
{
var dict = new Dictionary<string, int> { ["z"] = 1, ["a"] = 2, ["m"] = 3 };
var json = CanonicalJsonSerializer.Serialize(dict);
json.Should().Be("{\"a\":2,\"m\":3,\"z\":1}");
}
[Fact]
public void Serialize_DateTimeOffset_UsesUtcIso8601()
{
var dt = new DateTimeOffset(2024, 1, 15, 10, 30, 0, TimeSpan.FromHours(5));
var obj = new { Timestamp = dt };
var json = CanonicalJsonSerializer.Serialize(obj);
json.Should().Contain("2024-01-15T05:30:00.000Z");
}
[Fact]
public void Serialize_NullValues_AreOmitted()
{
var obj = new { Name = "test", Value = (string?)null };
var json = CanonicalJsonSerializer.Serialize(obj);
json.Should().NotContain("value");
}
[Fact]
public void SerializeWithDigest_ProducesConsistentDigest()
{
var obj = new { Name = "test", Value = 123 };
var (_, digest1) = CanonicalJsonSerializer.SerializeWithDigest(obj);
var (_, digest2) = CanonicalJsonSerializer.SerializeWithDigest(obj);
digest1.Should().Be(digest2);
}
}
public class PackageOrdererTests
{
[Fact]
public void StableOrder_OrdersByPurlFirst()
{
var packages = new[]
{
(purl: "pkg:npm/b@1.0.0", name: "b", version: "1.0.0"),
(purl: "pkg:npm/a@1.0.0", name: "a", version: "1.0.0")
};
var ordered = packages.StableOrder(p => p.purl, p => p.name, p => p.version, _ => null).ToList();
ordered[0].purl.Should().Be("pkg:npm/a@1.0.0");
}
}

View File

@@ -0,0 +1,46 @@
using FsCheck;
using FsCheck.Xunit;
using StellaOps.Canonicalization.Json;
using StellaOps.Canonicalization.Ordering;
namespace StellaOps.Canonicalization.Tests.Properties;
public class CanonicalJsonProperties
{
[Property]
public Property Serialize_IsIdempotent(Dictionary<string, int> dict)
{
var json1 = CanonicalJsonSerializer.Serialize(dict);
var json2 = CanonicalJsonSerializer.Serialize(dict);
return (json1 == json2).ToProperty();
}
[Property]
public Property Serialize_OrderIndependent(Dictionary<string, int> dict)
{
var reversed = dict.Reverse().ToDictionary(x => x.Key, x => x.Value);
var json1 = CanonicalJsonSerializer.Serialize(dict);
var json2 = CanonicalJsonSerializer.Serialize(reversed);
return (json1 == json2).ToProperty();
}
[Property]
public Property Digest_IsDeterministic(string input)
{
var obj = new { Value = input ?? string.Empty };
var (_, digest1) = CanonicalJsonSerializer.SerializeWithDigest(obj);
var (_, digest2) = CanonicalJsonSerializer.SerializeWithDigest(obj);
return (digest1 == digest2).ToProperty();
}
}
public class OrderingProperties
{
[Property]
public Property PackageOrdering_IsStable(List<(string purl, string name, string version)> packages)
{
var ordered1 = packages.StableOrder(p => p.purl, p => p.name, p => p.version, _ => null).ToList();
var ordered2 = packages.StableOrder(p => p.purl, p => p.name, p => p.version, _ => null).ToList();
return ordered1.SequenceEqual(ordered2).ToProperty();
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FsCheck.Xunit" Version="2.16.6" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Canonicalization\StellaOps.Canonicalization.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,160 @@
using System.Collections.Immutable;
using System.Text;
using FluentAssertions;
using StellaOps.DeltaVerdict.Engine;
using StellaOps.DeltaVerdict.Models;
using StellaOps.DeltaVerdict.Policy;
using StellaOps.DeltaVerdict.Serialization;
using StellaOps.DeltaVerdict.Signing;
using Xunit;
namespace StellaOps.DeltaVerdict.Tests;
public class DeltaVerdictTests
{
[Fact]
public void ComputeDelta_TracksComponentAndVulnerabilityChanges()
{
var baseVerdict = CreateVerdict(
verdictId: "base",
riskScore: 10,
components:
[
new Component("pkg:apk/openssl@1.0", "openssl", "1.0", "apk", ["CVE-1"])
],
vulnerabilities:
[
new Vulnerability("CVE-1", "high", 7.1m, "pkg:apk/openssl@1.0", "reachable", "open")
]);
var headVerdict = CreateVerdict(
verdictId: "head",
riskScore: 20,
components:
[
new Component("pkg:apk/openssl@1.1", "openssl", "1.1", "apk", ["CVE-2"]),
new Component("pkg:apk/zlib@2.0", "zlib", "2.0", "apk", [])
],
vulnerabilities:
[
new Vulnerability("CVE-2", "critical", 9.5m, "pkg:apk/openssl@1.1", "reachable", "open")
]);
var engine = new DeltaComputationEngine(new FakeTimeProvider());
var delta = engine.ComputeDelta(baseVerdict, headVerdict);
delta.AddedComponents.Should().Contain(c => c.Purl == "pkg:apk/zlib@2.0");
delta.RemovedComponents.Should().Contain(c => c.Purl == "pkg:apk/openssl@1.0");
delta.ChangedComponents.Should().Contain(c => c.Purl == "pkg:apk/openssl@1.0");
delta.AddedVulnerabilities.Should().Contain(v => v.VulnerabilityId == "CVE-2");
delta.RemovedVulnerabilities.Should().Contain(v => v.VulnerabilityId == "CVE-1");
delta.RiskScoreDelta.Change.Should().Be(10);
delta.Summary.TotalChanges.Should().BeGreaterThan(0);
}
[Fact]
public void RiskBudgetEvaluator_FlagsCriticalViolations()
{
var delta = new DeltaVerdict.Models.DeltaVerdict
{
DeltaId = "delta",
SchemaVersion = "1.0.0",
BaseVerdict = new VerdictReference("base", null, null, DateTimeOffset.UnixEpoch),
HeadVerdict = new VerdictReference("head", null, null, DateTimeOffset.UnixEpoch),
AddedVulnerabilities = [new VulnerabilityDelta("CVE-9", "critical", 9.9m, null, "reachable")],
RemovedVulnerabilities = [],
AddedComponents = [],
RemovedComponents = [],
ChangedComponents = [],
ChangedVulnerabilityStatuses = [],
RiskScoreDelta = new RiskScoreDelta(10, 15, 5, 50, RiskTrend.Degraded),
Summary = new DeltaSummary(0, 0, 0, 1, 0, 0, 1, DeltaMagnitude.Minimal),
ComputedAt = DateTimeOffset.UnixEpoch
};
var budget = new RiskBudget
{
MaxNewCriticalVulnerabilities = 0,
MaxRiskScoreIncrease = 2
};
var evaluator = new RiskBudgetEvaluator();
var result = evaluator.Evaluate(delta, budget);
result.IsWithinBudget.Should().BeFalse();
result.Violations.Should().NotBeEmpty();
}
[Fact]
public async Task SigningService_RoundTrip_VerifiesEnvelope()
{
var delta = new DeltaVerdict.Models.DeltaVerdict
{
DeltaId = "delta",
SchemaVersion = "1.0.0",
BaseVerdict = new VerdictReference("base", null, null, DateTimeOffset.UnixEpoch),
HeadVerdict = new VerdictReference("head", null, null, DateTimeOffset.UnixEpoch),
AddedComponents = [],
RemovedComponents = [],
ChangedComponents = [],
AddedVulnerabilities = [],
RemovedVulnerabilities = [],
ChangedVulnerabilityStatuses = [],
RiskScoreDelta = new RiskScoreDelta(0, 0, 0, 0, RiskTrend.Stable),
Summary = new DeltaSummary(0, 0, 0, 0, 0, 0, 0, DeltaMagnitude.None),
ComputedAt = DateTimeOffset.UnixEpoch
};
var service = new DeltaSigningService();
var options = new SigningOptions
{
KeyId = "test-key",
SecretBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("delta-secret"))
};
var signed = await service.SignAsync(delta, options);
var verify = await service.VerifyAsync(signed, new VerificationOptions
{
KeyId = "test-key",
SecretBase64 = options.SecretBase64
});
verify.IsValid.Should().BeTrue();
}
[Fact]
public void Serializer_ComputesDeterministicDigest()
{
var verdict = CreateVerdict(
verdictId: "verdict",
riskScore: 0,
components: [],
vulnerabilities: []);
var withDigest = VerdictSerializer.WithDigest(verdict);
withDigest.Digest.Should().NotBeNullOrWhiteSpace();
}
private static Verdict CreateVerdict(
string verdictId,
decimal riskScore,
ImmutableArray<Component> components,
ImmutableArray<Vulnerability> vulnerabilities)
{
return new Verdict
{
VerdictId = verdictId,
Digest = null,
ArtifactRef = "local",
ScannedAt = DateTimeOffset.UnixEpoch,
RiskScore = riskScore,
Components = components,
Vulnerabilities = vulnerabilities
};
}
private sealed class FakeTimeProvider : TimeProvider
{
public override DateTimeOffset GetUtcNow() => DateTimeOffset.UnixEpoch;
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.DeltaVerdict\StellaOps.DeltaVerdict.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,240 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Nulls Logger;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Evidence.Budgets;
using Xunit;
namespace StellaOps.Evidence.Tests.Budgets;
public class EvidenceBudgetServiceTests
{
private readonly Mock<IEvidenceRepository> _repository = new();
private readonly Mock<IOptionsMonitor<EvidenceBudget>> _options = new();
private readonly EvidenceBudgetService _service;
public EvidenceBudgetServiceTests()
{
_options.Setup(o => o.CurrentValue).Returns(EvidenceBudget.Default);
_service = new EvidenceBudgetService(
_repository.Object,
_options.Object,
NullLogger<EvidenceBudgetService>.Instance);
// Default setup: empty scan
_repository.Setup(r => r.GetByScanIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<EvidenceItem>());
}
[Fact]
public void CheckBudget_WithinLimit_ReturnsSuccess()
{
var scanId = Guid.NewGuid();
var item = CreateItem(type: EvidenceType.CallGraph, sizeBytes: 1024);
var result = _service.CheckBudget(scanId, item);
result.IsWithinBudget.Should().BeTrue();
result.Issues.Should().BeEmpty();
}
[Fact]
public void CheckBudget_ExceedsTotal_ReturnsViolation()
{
var scanId = SetupScanAtBudgetLimit();
var item = CreateItem(type: EvidenceType.CallGraph, sizeBytes: 10 * 1024 * 1024); // 10 MB over
var result = _service.CheckBudget(scanId, item);
result.IsWithinBudget.Should().BeFalse();
result.Issues.Should().Contain(i => i.Contains("total budget"));
result.BytesToFree.Should().BeGreaterThan(0);
}
[Fact]
public void CheckBudget_ExceedsTypeLimit_ReturnsViolation()
{
var scanId = Guid.NewGuid();
var existingCallGraph = CreateItem(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 result = _service.CheckBudget(scanId, item);
result.IsWithinBudget.Should().BeFalse();
result.Issues.Should().Contain(i => i.Contains("CallGraph budget"));
}
[Fact]
public async Task PruneToFitAsync_NoExcess_NoPruning()
{
var scanId = Guid.NewGuid();
var items = new List<EvidenceItem>
{
CreateItem(type: EvidenceType.Sbom, sizeBytes: 5 * 1024 * 1024)
};
_repository.Setup(r => r.GetByScanIdAsync(scanId, It.IsAny<CancellationToken>()))
.ReturnsAsync(items);
var result = await _service.PruneToFitAsync(scanId, 50 * 1024 * 1024, CancellationToken.None);
result.Success.Should().BeTrue();
result.BytesPruned.Should().Be(0);
result.ItemsPruned.Should().BeEmpty();
}
[Fact]
public async Task PruneToFitAsync_PreservesAlwaysPreserveTypes()
{
var scanId = SetupScanOverBudget();
var result = await _service.PruneToFitAsync(scanId, 50 * 1024 * 1024, CancellationToken.None);
result.ItemsPruned.Should().NotContain(i => i.Type == EvidenceType.Verdict);
result.ItemsPruned.Should().NotContain(i => i.Type == EvidenceType.Attestation);
}
[Fact]
public async Task PruneToFitAsync_PrunesLowestPriorityFirst()
{
var scanId = Guid.NewGuid();
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)
};
_repository.Setup(r => r.GetByScanIdAsync(scanId, It.IsAny<CancellationToken>()))
.ReturnsAsync(items);
// Prune to 20MB (need to remove 11MB)
var result = await _service.PruneToFitAsync(scanId, 20 * 1024 * 1024, CancellationToken.None);
result.Success.Should().BeTrue();
result.ItemsPruned.Should().HaveCount(2);
result.ItemsPruned[0].Type.Should().Be(EvidenceType.RuntimeCapture); // Pruned first
result.ItemsPruned[1].Type.Should().Be(EvidenceType.CallGraph); // Pruned second
}
[Fact]
public void GetBudgetStatus_CalculatesUtilization()
{
var scanId = Guid.NewGuid();
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
};
_repository.Setup(r => r.GetByScanIdAsync(scanId, It.IsAny<CancellationToken>()))
.ReturnsAsync(items);
var status = _service.GetBudgetStatus(scanId);
status.ScanId.Should().Be(scanId);
status.TotalBudgetBytes.Should().Be(100 * 1024 * 1024); // 100 MB
status.UsedBytes.Should().Be(30 * 1024 * 1024); // 30 MB
status.RemainingBytes.Should().Be(70 * 1024 * 1024); // 70 MB
status.UtilizationPercent.Should().Be(30); // 30%
}
[Fact]
public void GetBudgetStatus_CalculatesPerTypeUtilization()
{
var scanId = Guid.NewGuid();
var items = new List<EvidenceItem>
{
CreateItem(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);
status.ByType.Should().ContainKey(EvidenceType.CallGraph);
var callGraphStatus = status.ByType[EvidenceType.CallGraph];
callGraphStatus.UsedBytes.Should().Be(25 * 1024 * 1024);
callGraphStatus.LimitBytes.Should().Be(50 * 1024 * 1024);
callGraphStatus.UtilizationPercent.Should().Be(50);
}
[Fact]
public void CheckBudget_AutoPruneAction_SetsCanAutoPrune()
{
var budget = new EvidenceBudget
{
MaxScanSizeBytes = 1024,
RetentionPolicies = EvidenceBudget.Default.RetentionPolicies,
ExceededAction = BudgetExceededAction.AutoPrune
};
_options.Setup(o => o.CurrentValue).Returns(budget);
var scanId = Guid.NewGuid();
var items = new List<EvidenceItem>
{
CreateItem(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);
result.IsWithinBudget.Should().BeFalse();
result.RecommendedAction.Should().Be(BudgetExceededAction.AutoPrune);
result.CanAutoPrune.Should().BeTrue();
}
private Guid SetupScanAtBudgetLimit()
{
var scanId = Guid.NewGuid();
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)
};
_repository.Setup(r => r.GetByScanIdAsync(scanId, It.IsAny<CancellationToken>()))
.ReturnsAsync(items);
return scanId;
}
private Guid SetupScanOverBudget()
{
var scanId = Guid.NewGuid();
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)
};
_repository.Setup(r => r.GetByScanIdAsync(scanId, It.IsAny<CancellationToken>()))
.ReturnsAsync(items);
return scanId;
}
private static EvidenceItem CreateItem(
Guid? id = null,
EvidenceType type = EvidenceType.CallGraph,
long sizeBytes = 1024)
{
return new EvidenceItem
{
Id = id ?? Guid.NewGuid(),
ScanId = Guid.NewGuid(),
Type = type,
SizeBytes = sizeBytes,
Tier = RetentionTier.Hot,
CreatedAt = DateTimeOffset.UtcNow
};
}
}

View File

@@ -0,0 +1,82 @@
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Evidence.Models;
using StellaOps.Evidence.Serialization;
using StellaOps.Evidence.Services;
using StellaOps.Evidence.Validation;
using Xunit;
namespace StellaOps.Evidence.Tests;
public class EvidenceIndexTests
{
[Fact]
public void EvidenceLinker_BuildsIndexWithDigest()
{
var linker = new EvidenceLinker();
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));
var index = linker.Build(new VerdictReference("verdict-1", new string('c', 64), VerdictOutcome.Pass, "1.0.0"), "digest");
index.IndexDigest.Should().NotBeNullOrEmpty();
index.Sboms.Should().HaveCount(1);
}
[Fact]
public void EvidenceValidator_FlagsMissingSbom()
{
var index = CreateIndex() with { Sboms = [] };
var validator = new EvidenceIndexValidator();
var result = validator.Validate(index);
result.IsValid.Should().BeFalse();
}
[Fact]
public void EvidenceSerializer_RoundTrip_PreservesFields()
{
var index = CreateIndex();
var json = EvidenceIndexSerializer.Serialize(index);
var deserialized = EvidenceIndexSerializer.Deserialize(json);
deserialized.Should().BeEquivalentTo(index);
}
[Fact]
public void EvidenceQueryService_BuildsSummary()
{
var index = CreateIndex();
var service = new EvidenceQueryService();
var report = service.BuildChainReport(index);
report.SbomCount.Should().Be(1);
report.AttestationCount.Should().Be(1);
}
private static EvidenceIndex CreateIndex()
{
return new EvidenceIndex
{
IndexId = Guid.NewGuid().ToString(),
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)),
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
};
}
private static ToolChainEvidence CreateToolChain() => new(
"1.0.0",
"1.0.0",
"1.0.0",
"1.0.0",
"1.0.0",
ImmutableDictionary<string, string>.Empty);
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Evidence\StellaOps.Evidence.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,150 @@
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Evidence.Models;
using StellaOps.Replay.Engine;
using StellaOps.Replay.Models;
using StellaOps.Testing.Manifests.Models;
using Xunit;
namespace StellaOps.Replay.Tests;
public class ReplayEngineTests
{
[Fact]
public async Task Replay_SameManifest_ProducesIdenticalVerdict()
{
var manifest = CreateManifest();
var engine = CreateEngine();
var result1 = await engine.ReplayAsync(manifest, new ReplayOptions());
var result2 = await engine.ReplayAsync(manifest, new ReplayOptions());
result1.VerdictDigest.Should().Be(result2.VerdictDigest);
}
[Fact]
public async Task Replay_DifferentManifest_ProducesDifferentVerdict()
{
var manifest1 = CreateManifest();
var manifest2 = manifest1 with
{
FeedSnapshot = manifest1.FeedSnapshot with { Version = "v2" }
};
var engine = CreateEngine();
var result1 = await engine.ReplayAsync(manifest1, new ReplayOptions());
var result2 = await engine.ReplayAsync(manifest2, new ReplayOptions());
result1.VerdictDigest.Should().NotBe(result2.VerdictDigest);
}
[Fact]
public void CheckDeterminism_IdenticalResults_ReturnsTrue()
{
var engine = CreateEngine();
var result1 = new ReplayResult { RunId = "1", VerdictDigest = "abc123", Success = true, ExecutedAt = DateTimeOffset.UtcNow };
var result2 = new ReplayResult { RunId = "1", VerdictDigest = "abc123", Success = true, ExecutedAt = DateTimeOffset.UtcNow };
var check = engine.CheckDeterminism(result1, result2);
check.IsDeterministic.Should().BeTrue();
}
[Fact]
public void CheckDeterminism_DifferentResults_ReturnsDifferences()
{
var engine = CreateEngine();
var result1 = new ReplayResult
{
RunId = "1",
VerdictJson = "{\"score\":100}",
VerdictDigest = "abc123",
Success = true,
ExecutedAt = DateTimeOffset.UtcNow
};
var result2 = new ReplayResult
{
RunId = "1",
VerdictJson = "{\"score\":99}",
VerdictDigest = "def456",
Success = true,
ExecutedAt = DateTimeOffset.UtcNow
};
var check = engine.CheckDeterminism(result1, result2);
check.IsDeterministic.Should().BeFalse();
check.Differences.Should().NotBeEmpty();
}
private static ReplayEngine CreateEngine()
{
return new ReplayEngine(
new FakeFeedLoader(),
new FakePolicyLoader(),
new FakeScannerFactory(),
NullLogger<ReplayEngine>.Instance);
}
private static RunManifest CreateManifest()
{
return new RunManifest
{
RunId = Guid.NewGuid().ToString(),
SchemaVersion = "1.0.0",
ArtifactDigests = ImmutableArray.Create(new ArtifactDigest("sha256", new string('a', 64), null, null)),
FeedSnapshot = new FeedSnapshot("nvd", "v1", new string('b', 64), DateTimeOffset.UtcNow.AddHours(-1)),
PolicySnapshot = new PolicySnapshot("1.0.0", new string('c', 64), ImmutableArray<string>.Empty),
ToolVersions = new ToolVersions("1.0.0", "1.0.0", "1.0.0", "1.0.0", ImmutableDictionary<string, string>.Empty),
CryptoProfile = new CryptoProfile("default", ImmutableArray<string>.Empty, ImmutableArray<string>.Empty),
EnvironmentProfile = new EnvironmentProfile("postgres-only", false, null, null),
CanonicalizationVersion = "1.0.0",
InitiatedAt = DateTimeOffset.UtcNow
};
}
private sealed class FakeFeedLoader : IFeedLoader
{
public Task<FeedSnapshot> LoadByDigestAsync(string digest, CancellationToken ct = default)
=> Task.FromResult(new FeedSnapshot("nvd", "v1", digest, DateTimeOffset.UtcNow.AddHours(-1)));
}
private sealed class FakePolicyLoader : IPolicyLoader
{
public Task<PolicySnapshot> LoadByDigestAsync(string digest, CancellationToken ct = default)
=> Task.FromResult(new PolicySnapshot("1.0.0", digest, ImmutableArray<string>.Empty));
}
private sealed class FakeScannerFactory : IScannerFactory
{
public IScanner Create(ScannerOptions options) => new FakeScanner(options);
}
private sealed class FakeScanner : IScanner
{
private readonly ScannerOptions _options;
public FakeScanner(ScannerOptions options) => _options = options;
public Task<ScanResult> ScanAsync(ImmutableArray<ArtifactDigest> artifacts, CancellationToken ct = default)
{
var verdict = new
{
feedVersion = _options.FeedSnapshot.Version,
policyDigest = _options.PolicySnapshot.LatticeRulesDigest
};
var evidence = new EvidenceIndex
{
IndexId = Guid.NewGuid().ToString(),
SchemaVersion = "1.0.0",
Verdict = new VerdictReference("v1", new string('d', 64), VerdictOutcome.Pass, null),
Sboms = ImmutableArray<SbomEvidence>.Empty,
Attestations = ImmutableArray<AttestationEvidence>.Empty,
ToolChain = new ToolChainEvidence("1", "1", "1", "1", "1", ImmutableDictionary<string, string>.Empty),
RunManifestDigest = new string('e', 64),
CreatedAt = DateTimeOffset.UtcNow
};
return Task.FromResult(new ScanResult(verdict, evidence, 10));
}
}
}

View File

@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Replay\StellaOps.Replay.csproj" />
<ProjectReference Include="..\..\StellaOps.Testing.Manifests\StellaOps.Testing.Manifests.csproj" />
<ProjectReference Include="..\..\StellaOps.Evidence\StellaOps.Evidence.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,87 @@
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Testing.Manifests.Models;
using StellaOps.Testing.Manifests.Serialization;
using StellaOps.Testing.Manifests.Validation;
using Xunit;
namespace StellaOps.Testing.Manifests.Tests;
public class RunManifestTests
{
[Fact]
public void Serialize_ValidManifest_ProducesCanonicalJson()
{
var manifest = CreateTestManifest();
var json1 = RunManifestSerializer.Serialize(manifest);
var json2 = RunManifestSerializer.Serialize(manifest);
json1.Should().Be(json2);
}
[Fact]
public void ComputeDigest_SameManifest_ProducesSameDigest()
{
var manifest = CreateTestManifest();
var digest1 = RunManifestSerializer.ComputeDigest(manifest);
var digest2 = RunManifestSerializer.ComputeDigest(manifest);
digest1.Should().Be(digest2);
}
[Fact]
public void ComputeDigest_DifferentManifest_ProducesDifferentDigest()
{
var manifest1 = CreateTestManifest();
var manifest2 = manifest1 with { RunId = Guid.NewGuid().ToString() };
var digest1 = RunManifestSerializer.ComputeDigest(manifest1);
var digest2 = RunManifestSerializer.ComputeDigest(manifest2);
digest1.Should().NotBe(digest2);
}
[Fact]
public void Validate_ValidManifest_ReturnsSuccess()
{
var manifest = CreateTestManifest();
var validator = new RunManifestValidator();
var result = validator.Validate(manifest);
result.IsValid.Should().BeTrue();
}
[Fact]
public void Validate_EmptyArtifacts_ReturnsFalse()
{
var manifest = CreateTestManifest() with { ArtifactDigests = [] };
var validator = new RunManifestValidator();
var result = validator.Validate(manifest);
result.IsValid.Should().BeFalse();
}
[Fact]
public void RoundTrip_PreservesAllFields()
{
var manifest = CreateTestManifest();
var json = RunManifestSerializer.Serialize(manifest);
var deserialized = RunManifestSerializer.Deserialize(json);
deserialized.Should().BeEquivalentTo(manifest);
}
private static RunManifest CreateTestManifest()
{
return new RunManifest
{
RunId = Guid.NewGuid().ToString(),
SchemaVersion = "1.0.0",
ArtifactDigests = ImmutableArray.Create(
new ArtifactDigest("sha256", new string('a', 64), "application/vnd.oci.image.layer.v1.tar", "example")),
SbomDigests = ImmutableArray.Create(
new SbomReference("cyclonedx-1.6", new string('b', 64), "sbom.json")),
FeedSnapshot = new FeedSnapshot("nvd", "2025.12.01", new string('c', 64), DateTimeOffset.UtcNow.AddHours(-1)),
PolicySnapshot = new PolicySnapshot("1.0.0", new string('d', 64), ImmutableArray.Create("rule-1")),
ToolVersions = new ToolVersions("1.0.0", "1.0.0", "1.0.0", "1.0.0", ImmutableDictionary<string, string>.Empty),
CryptoProfile = new CryptoProfile("default", ImmutableArray.Create("root-1"), ImmutableArray.Create("sha256")),
EnvironmentProfile = new EnvironmentProfile("postgres-only", false, "16", null),
PrngSeed = 1234,
CanonicalizationVersion = "1.0.0",
InitiatedAt = DateTimeOffset.UtcNow
};
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Testing.Manifests\StellaOps.Testing.Manifests.csproj" />
</ItemGroup>
</Project>