using FluentAssertions; using Xunit; namespace StellaOps.Provcache.Tests; /// /// Determinism tests for DecisionDigestBuilder. /// Verifies that same inputs always produce the same DecisionDigest. /// public class DecisionDigestBuilderDeterminismTests { private readonly ProvcacheOptions _options = new() { DigestVersion = "v1", DefaultTtl = TimeSpan.FromHours(24) }; private readonly FakeTimeProvider _timeProvider; public DecisionDigestBuilderDeterminismTests() { _timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 12, 24, 12, 0, 0, TimeSpan.Zero)); } [Fact] public void Build_SameInputs_ProducesSameDigest() { // Arrange var dispositions = new Dictionary { ["CVE-2024-001"] = "fixed", ["CVE-2024-002"] = "affected", ["CVE-2024-003"] = "not_affected" }; var evidenceChunks = new List { "sha256:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "sha256:b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", "sha256:c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" }; // Act var digest1 = CreateBuilder() .WithDispositions(dispositions) .WithEvidenceChunks(evidenceChunks) .Build(); var digest2 = CreateBuilder() .WithDispositions(dispositions) .WithEvidenceChunks(evidenceChunks) .Build(); // Assert digest1.VerdictHash.Should().Be(digest2.VerdictHash); digest1.ProofRoot.Should().Be(digest2.ProofRoot); digest1.TrustScore.Should().Be(digest2.TrustScore); } [Fact] public void Build_DispositionsInDifferentOrder_ProducesSameVerdictHash() { // Arrange - Same dispositions, different insertion order var dispositions1 = new Dictionary { ["CVE-2024-001"] = "fixed", ["CVE-2024-002"] = "affected", ["CVE-2024-003"] = "not_affected" }; var dispositions2 = new Dictionary { ["CVE-2024-003"] = "not_affected", ["CVE-2024-001"] = "fixed", ["CVE-2024-002"] = "affected" }; // Act var digest1 = CreateBuilder().WithDispositions(dispositions1).Build(); var digest2 = CreateBuilder().WithDispositions(dispositions2).Build(); // Assert - Should be same because dispositions are sorted by key digest1.VerdictHash.Should().Be(digest2.VerdictHash); } [Fact] public void Build_DifferentDispositions_ProducesDifferentVerdictHash() { // Arrange var dispositions1 = new Dictionary { ["CVE-2024-001"] = "fixed" }; var dispositions2 = new Dictionary { ["CVE-2024-001"] = "affected" }; // Act var digest1 = CreateBuilder().WithDispositions(dispositions1).Build(); var digest2 = CreateBuilder().WithDispositions(dispositions2).Build(); // Assert digest1.VerdictHash.Should().NotBe(digest2.VerdictHash); } [Fact] public void Build_SameEvidenceChunks_ProducesSameMerkleRoot() { // Arrange - valid SHA256 hex hashes (64 characters each) var chunks = new List { "sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "sha256:b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2", "sha256:c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", "sha256:d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4" }; // Act var digest1 = CreateBuilder().WithEvidenceChunks(chunks).Build(); var digest2 = CreateBuilder().WithEvidenceChunks(chunks).Build(); // Assert digest1.ProofRoot.Should().Be(digest2.ProofRoot); } [Fact] public void Build_DifferentEvidenceChunkOrder_ProducesDifferentMerkleRoot() { // Arrange - Merkle tree is order-sensitive (valid SHA256 hex hashes) var chunks1 = new List { "sha256:aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111", "sha256:bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222" }; var chunks2 = new List { "sha256:bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222", "sha256:aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111" }; // Act var digest1 = CreateBuilder().WithEvidenceChunks(chunks1).Build(); var digest2 = CreateBuilder().WithEvidenceChunks(chunks2).Build(); // Assert - Merkle tree preserves order, so roots should differ digest1.ProofRoot.Should().NotBe(digest2.ProofRoot); } [Fact] public void WithTrustScore_ComponentWeights_ProducesConsistentScore() { // Arrange - Using weighted formula: 25% reach + 20% sbom + 20% vex + 15% policy + 20% signer // 100 * 0.25 + 100 * 0.20 + 100 * 0.20 + 100 * 0.15 + 100 * 0.20 = 100 // Act var digest = CreateBuilder() .WithTrustScore( reachabilityScore: 100, sbomCompletenessScore: 100, vexCoverageScore: 100, policyFreshnessScore: 100, signerTrustScore: 100) .Build(); // Assert digest.TrustScore.Should().Be(100); } [Fact] public void WithTrustScore_MixedScores_CalculatesCorrectWeight() { // Arrange - 80 * 0.25 + 60 * 0.20 + 70 * 0.20 + 50 * 0.15 + 90 * 0.20 // = 20 + 12 + 14 + 7.5 + 18 = 71.5 → 72 // Act var digest = CreateBuilder() .WithTrustScore( reachabilityScore: 80, sbomCompletenessScore: 60, vexCoverageScore: 70, policyFreshnessScore: 50, signerTrustScore: 90) .Build(); // Assert digest.TrustScore.Should().Be(72); } [Fact] public void WithDefaultTimestamps_UsesFrozenTime() { // Arrange var frozenTime = new DateTimeOffset(2024, 12, 24, 12, 0, 0, TimeSpan.Zero); var timeProvider = new FakeTimeProvider(frozenTime); var builder = new DecisionDigestBuilder(_options, timeProvider); // Act var digest = builder .WithVeriKey("sha256:verikey") .WithVerdictHash("sha256:verdict") .WithProofRoot("sha256:proof") .WithReplaySeed(["feed1"], ["rule1"]) .WithTrustScore(85) .WithDefaultTimestamps() .Build(); // Assert digest.CreatedAt.Should().Be(frozenTime); digest.ExpiresAt.Should().Be(frozenTime.Add(_options.DefaultTtl)); } [Fact] public void Build_MultipleTimes_ReturnsConsistentDigest() { // Arrange var dispositions = new Dictionary { ["CVE-1"] = "fixed" }; var builder = CreateBuilder().WithDispositions(dispositions); // Act - Build multiple times var digests = Enumerable.Range(0, 100) .Select(_ => builder.Build()) .Select(d => (d.VerdictHash, d.ProofRoot)) .Distinct() .ToList(); // Assert - All should be identical digests.Should().HaveCount(1); } [Fact] public void Build_EmptyDispositions_ProducesConsistentHash() { // Arrange var empty1 = new Dictionary(); var empty2 = new Dictionary(); // Act var digest1 = CreateBuilder().WithDispositions(empty1).Build(); var digest2 = CreateBuilder().WithDispositions(empty2).Build(); // Assert digest1.VerdictHash.Should().Be(digest2.VerdictHash); digest1.VerdictHash.Should().StartWith("sha256:"); } [Fact] public void Build_EmptyEvidenceChunks_ProducesConsistentHash() { // Arrange var empty1 = new List(); var empty2 = Array.Empty(); // Act var digest1 = CreateBuilder().WithEvidenceChunks(empty1).Build(); var digest2 = CreateBuilder().WithEvidenceChunks(empty2).Build(); // Assert digest1.ProofRoot.Should().Be(digest2.ProofRoot); digest1.ProofRoot.Should().StartWith("sha256:"); } [Fact] public void Build_ReplaySeedPreservedCorrectly() { // Arrange var feedIds = new[] { "cve-2024", "ghsa-2024" }; var ruleIds = new[] { "policy-v1", "exceptions" }; var frozenEpoch = new DateTimeOffset(2024, 12, 24, 0, 0, 0, TimeSpan.Zero); // Act var digest = CreateBuilder() .WithReplaySeed(feedIds, ruleIds, frozenEpoch) .Build(); // Assert digest.ReplaySeed.FeedIds.Should().BeEquivalentTo(feedIds); digest.ReplaySeed.RuleIds.Should().BeEquivalentTo(ruleIds); digest.ReplaySeed.FrozenEpoch.Should().Be(frozenEpoch); } [Fact] public void Build_MissingComponent_ThrowsInvalidOperationException() { // Arrange var builder = new DecisionDigestBuilder(_options, _timeProvider) .WithVeriKey("sha256:abc"); // Missing other required components // Act var act = () => builder.Build(); // Assert act.Should().Throw() .WithMessage("*missing required components*"); } private DecisionDigestBuilder CreateBuilder() { return new DecisionDigestBuilder(_options, _timeProvider) .WithVeriKey("sha256:testverikey") .WithVerdictHash("sha256:defaultverdict") .WithProofRoot("sha256:defaultproof") .WithReplaySeed(["feed1"], ["rule1"]) .WithTrustScore(85) .WithTimestamps( _timeProvider.GetUtcNow(), _timeProvider.GetUtcNow().AddHours(24)); } /// /// Fake time provider for deterministic timestamp testing. /// private sealed class FakeTimeProvider : TimeProvider { private readonly DateTimeOffset _frozenTime; public FakeTimeProvider(DateTimeOffset frozenTime) { _frozenTime = frozenTime; } public override DateTimeOffset GetUtcNow() => _frozenTime; } }