Files
git.stella-ops.org/src/__Libraries/__Tests/StellaOps.Provcache.Tests/DecisionDigestBuilderDeterminismTests.cs
StellaOps Bot 2a06f780cf sprints work
2025-12-25 12:19:12 +02:00

320 lines
10 KiB
C#

using FluentAssertions;
using Xunit;
namespace StellaOps.Provcache.Tests;
/// <summary>
/// Determinism tests for DecisionDigestBuilder.
/// Verifies that same inputs always produce the same DecisionDigest.
/// </summary>
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<string, string>
{
["CVE-2024-001"] = "fixed",
["CVE-2024-002"] = "affected",
["CVE-2024-003"] = "not_affected"
};
var evidenceChunks = new List<string>
{
"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<string, string>
{
["CVE-2024-001"] = "fixed",
["CVE-2024-002"] = "affected",
["CVE-2024-003"] = "not_affected"
};
var dispositions2 = new Dictionary<string, string>
{
["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<string, string> { ["CVE-2024-001"] = "fixed" };
var dispositions2 = new Dictionary<string, string> { ["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<string>
{
"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<string>
{
"sha256:aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111",
"sha256:bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222"
};
var chunks2 = new List<string>
{
"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<string, string> { ["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<string, string>();
var empty2 = new Dictionary<string, string>();
// 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<string>();
var empty2 = Array.Empty<string>();
// 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<InvalidOperationException>()
.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));
}
/// <summary>
/// Fake time provider for deterministic timestamp testing.
/// </summary>
private sealed class FakeTimeProvider : TimeProvider
{
private readonly DateTimeOffset _frozenTime;
public FakeTimeProvider(DateTimeOffset frozenTime)
{
_frozenTime = frozenTime;
}
public override DateTimeOffset GetUtcNow() => _frozenTime;
}
}