320 lines
10 KiB
C#
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;
|
|
}
|
|
}
|