// ----------------------------------------------------------------------------- // DeterminismValidationTests.cs // Sprint: SPRINT_3500_0004_0003_integration_tests_corpus // Task: T5 - Determinism Validation Suite // Description: Tests to validate scoring determinism across runs, platforms, and time // ----------------------------------------------------------------------------- using System.Security.Cryptography; using System.Text; using System.Text.Json; using FluentAssertions; using Xunit; namespace StellaOps.Integration.Determinism; /// /// Determinism validation tests for the scoring engine. /// Ensures identical inputs produce identical outputs across: /// - Multiple runs /// - Different timestamps (with frozen time) /// - Parallel execution /// public class DeterminismValidationTests { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false }; #region T5-AC1: Same input produces identical score hash [Fact] public void IdenticalInput_ProducesIdenticalHash_AcrossRuns() { // Arrange var input = new ScoringInput { ScanId = "test-scan-001", SbomHash = "sha256:abc123", RulesHash = "sha256:def456", PolicyHash = "sha256:ghi789", FeedHash = "sha256:jkl012", Timestamp = DateTimeOffset.Parse("2024-01-01T00:00:00Z") }; // Act - Compute hash multiple times var hash1 = ComputeInputHash(input); var hash2 = ComputeInputHash(input); var hash3 = ComputeInputHash(input); // Assert hash1.Should().Be(hash2); hash2.Should().Be(hash3); } [Fact] public void DifferentInput_ProducesDifferentHash() { // Arrange var input1 = new ScoringInput { ScanId = "scan-001", SbomHash = "sha256:abc", RulesHash = "sha256:def", PolicyHash = "sha256:ghi", FeedHash = "sha256:jkl", Timestamp = DateTimeOffset.Parse("2024-01-01T00:00:00Z") }; var input2 = new ScoringInput { ScanId = "scan-001", SbomHash = "sha256:DIFFERENT", // Changed RulesHash = "sha256:def", PolicyHash = "sha256:ghi", FeedHash = "sha256:jkl", Timestamp = DateTimeOffset.Parse("2024-01-01T00:00:00Z") }; // Act var hash1 = ComputeInputHash(input1); var hash2 = ComputeInputHash(input2); // Assert hash1.Should().NotBe(hash2); } #endregion #region T5-AC2: Cross-platform determinism [Fact] public void HashComputation_IsConsistent_WithKnownVector() { // Arrange - Known test vector for cross-platform verification var input = new ScoringInput { ScanId = "determinism-test-001", SbomHash = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", RulesHash = "sha256:0000000000000000000000000000000000000000000000000000000000000000", PolicyHash = "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", FeedHash = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", Timestamp = DateTimeOffset.Parse("2024-06-15T12:00:00Z") }; // Act var hash = ComputeInputHash(input); // Assert - This hash should be identical on any platform hash.Should().NotBeNullOrEmpty(); hash.Should().HaveLength(64); // SHA-256 hex = 64 chars hash.Should().MatchRegex("^[a-f0-9]{64}$"); } [Fact] public void CanonicalJson_ProducesStableOutput() { // Arrange - Same data, different property order var obj1 = new Dictionary { ["zebra"] = "last", ["alpha"] = "first", ["middle"] = 123 }; var obj2 = new Dictionary { ["alpha"] = "first", ["middle"] = 123, ["zebra"] = "last" }; // Act var json1 = ToCanonicalJson(obj1); var json2 = ToCanonicalJson(obj2); // Assert - Canonical JSON should sort keys json1.Should().Be(json2); } #endregion #region T5-AC3: Timestamp independence (frozen time tests) [Fact] public void ScoringWithFrozenTime_IsDeterministic() { // Arrange - Freeze timestamp var frozenTime = DateTimeOffset.Parse("2024-06-15T00:00:00Z"); var input1 = new ScoringInput { ScanId = "frozen-time-001", SbomHash = "sha256:sbom", RulesHash = "sha256:rules", PolicyHash = "sha256:policy", FeedHash = "sha256:feed", Timestamp = frozenTime }; var input2 = new ScoringInput { ScanId = "frozen-time-001", SbomHash = "sha256:sbom", RulesHash = "sha256:rules", PolicyHash = "sha256:policy", FeedHash = "sha256:feed", Timestamp = frozenTime }; // Act var hash1 = ComputeInputHash(input1); var hash2 = ComputeInputHash(input2); // Assert hash1.Should().Be(hash2); } [Fact] public void DifferentTimestamps_ProduceDifferentHashes() { // Arrange var input1 = new ScoringInput { ScanId = "time-test-001", SbomHash = "sha256:same", RulesHash = "sha256:same", PolicyHash = "sha256:same", FeedHash = "sha256:same", Timestamp = DateTimeOffset.Parse("2024-01-01T00:00:00Z") }; var input2 = new ScoringInput { ScanId = "time-test-001", SbomHash = "sha256:same", RulesHash = "sha256:same", PolicyHash = "sha256:same", FeedHash = "sha256:same", Timestamp = DateTimeOffset.Parse("2024-01-02T00:00:00Z") // Different }; // Act var hash1 = ComputeInputHash(input1); var hash2 = ComputeInputHash(input2); // Assert hash1.Should().NotBe(hash2); } #endregion #region T5-AC4: Parallel execution determinism [Fact] public async Task ParallelExecution_ProducesIdenticalHashes() { // Arrange var input = new ScoringInput { ScanId = "parallel-test-001", SbomHash = "sha256:parallel", RulesHash = "sha256:parallel", PolicyHash = "sha256:parallel", FeedHash = "sha256:parallel", Timestamp = DateTimeOffset.Parse("2024-01-01T00:00:00Z") }; // Act - Compute hash in parallel 100 times var tasks = Enumerable.Range(0, 100) .Select(_ => Task.Run(() => ComputeInputHash(input))) .ToArray(); var hashes = await Task.WhenAll(tasks); // Assert - All hashes should be identical hashes.Should().AllBe(hashes[0]); } [Fact] public async Task ConcurrentScoring_MaintainsDeterminism() { // Arrange - Multiple different inputs var inputs = Enumerable.Range(0, 50) .Select(i => new ScoringInput { ScanId = $"concurrent-{i:D3}", SbomHash = $"sha256:sbom{i:D3}", RulesHash = "sha256:rules", PolicyHash = "sha256:policy", FeedHash = "sha256:feed", Timestamp = DateTimeOffset.Parse("2024-01-01T00:00:00Z") }) .ToArray(); // Act - Run twice in parallel var hashes1 = await Task.WhenAll(inputs.Select(i => Task.Run(() => ComputeInputHash(i)))); var hashes2 = await Task.WhenAll(inputs.Select(i => Task.Run(() => ComputeInputHash(i)))); // Assert - Both runs should produce identical results hashes1.Should().BeEquivalentTo(hashes2); } #endregion #region T5-AC5: Replay after code changes produces same result [Fact] public void GoldenVectorReplay_ProducesExpectedHash() { // Arrange - Golden test vector (version-locked) // This test ensures code changes don't break determinism var goldenInput = new ScoringInput { ScanId = "golden-vector-001", SbomHash = "sha256:goldensbom0000000000000000000000000000000000000000000000000", RulesHash = "sha256:goldenrule0000000000000000000000000000000000000000000000000", PolicyHash = "sha256:goldenpoli0000000000000000000000000000000000000000000000000", FeedHash = "sha256:goldenfeed0000000000000000000000000000000000000000000000000", Timestamp = DateTimeOffset.Parse("2024-01-01T00:00:00Z") }; // Act var hash = ComputeInputHash(goldenInput); // Assert - This is the expected hash for the golden vector // If this test fails after a code change, it indicates a breaking change to determinism hash.Should().NotBeNullOrEmpty(); // The actual expected hash would be computed once and stored here: // hash.Should().Be("expected_golden_hash_here"); // For now, verify it's a valid hash format hash.Should().MatchRegex("^[a-f0-9]{64}$"); } [Fact] public void MerkleRoot_IsStable_ForSameNodes() { // Arrange var nodes = new[] { "sha256:node1", "sha256:node2", "sha256:node3", "sha256:node4" }; // Act - Compute merkle root multiple times var root1 = ComputeMerkleRoot(nodes); var root2 = ComputeMerkleRoot(nodes); var root3 = ComputeMerkleRoot(nodes); // Assert root1.Should().Be(root2); root2.Should().Be(root3); } [Fact] public void MerkleRoot_ChangesWhenNodeChanges() { // Arrange var nodes1 = new[] { "sha256:a", "sha256:b", "sha256:c" }; var nodes2 = new[] { "sha256:a", "sha256:DIFFERENT", "sha256:c" }; // Act var root1 = ComputeMerkleRoot(nodes1); var root2 = ComputeMerkleRoot(nodes2); // Assert root1.Should().NotBe(root2); } #endregion #region Helper Methods private static string ComputeInputHash(ScoringInput input) { var canonical = ToCanonicalJson(input); return ComputeSha256(canonical); } private static string ToCanonicalJson(T obj) { // Sort keys for canonical JSON if (obj is IDictionary dict) { var sorted = dict.OrderBy(kvp => kvp.Key, StringComparer.Ordinal) .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); return JsonSerializer.Serialize(sorted, JsonOptions); } return JsonSerializer.Serialize(obj, JsonOptions); } private static string ComputeSha256(string input) { var bytes = Encoding.UTF8.GetBytes(input); var hash = SHA256.HashData(bytes); return Convert.ToHexStringLower(hash); } private static string ComputeMerkleRoot(string[] nodes) { if (nodes.Length == 0) return ComputeSha256(""); if (nodes.Length == 1) return nodes[0]; var current = nodes.ToList(); while (current.Count > 1) { var next = new List(); for (var i = 0; i < current.Count; i += 2) { var left = current[i]; var right = i + 1 < current.Count ? current[i + 1] : left; var combined = left + right; next.Add("sha256:" + ComputeSha256(combined)); } current = next; } return current[0]; } #endregion #region DTOs private sealed record ScoringInput { public required string ScanId { get; init; } public required string SbomHash { get; init; } public required string RulesHash { get; init; } public required string PolicyHash { get; init; } public required string FeedHash { get; init; } public required DateTimeOffset Timestamp { get; init; } } #endregion }