// ----------------------------------------------------------------------------- // TriageOutputDeterminismTests.cs // Sprint: SPRINT_5100_0009_0001 - Scanner Module Test Implementation // Task: SCANNER-5100-009 - Expand determinism tests: triage output hash stable // Description: Tests to validate triage output generation determinism // ----------------------------------------------------------------------------- using System.Text; using FluentAssertions; using StellaOps.Canonical.Json; using StellaOps.Testing.Determinism; using Xunit; namespace StellaOps.Integration.Determinism; /// /// Determinism validation tests for triage output generation. /// Ensures identical inputs produce identical triage outputs across: /// - Multiple runs with frozen time /// - Parallel execution /// - Finding ordering /// - Status transitions /// public class TriageOutputDeterminismTests { #region Basic Determinism Tests [Fact] public void TriageOutput_WithIdenticalInput_ProducesDeterministicOutput() { // Arrange var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); var input = CreateSampleTriageInput(); // Act - Generate triage output multiple times var output1 = GenerateTriageOutput(input, frozenTime); var output2 = GenerateTriageOutput(input, frozenTime); var output3 = GenerateTriageOutput(input, frozenTime); // Serialize to canonical JSON var json1 = CanonJson.Serialize(output1); var json2 = CanonJson.Serialize(output2); var json3 = CanonJson.Serialize(output3); // Assert - All outputs should be identical json1.Should().Be(json2); json2.Should().Be(json3); } [Fact] public void TriageOutput_CanonicalHash_IsStable() { // Arrange var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); var input = CreateSampleTriageInput(); // Act - Generate output and compute canonical hash twice var output1 = GenerateTriageOutput(input, frozenTime); var hash1 = ComputeCanonicalHash(output1); var output2 = GenerateTriageOutput(input, frozenTime); var hash2 = ComputeCanonicalHash(output2); // Assert hash1.Should().Be(hash2, "Same input should produce same canonical hash"); hash1.Should().MatchRegex("^[0-9a-f]{64}$"); } [Fact] public void TriageOutput_DeterminismManifest_CanBeCreated() { // Arrange var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); var input = CreateSampleTriageInput(); var output = GenerateTriageOutput(input, frozenTime); var outputBytes = Encoding.UTF8.GetBytes(CanonJson.Serialize(output)); var artifactInfo = new ArtifactInfo { Type = "triage-output", Name = "test-scan-triage", Version = "1.0.0", Format = "triage-output@1.0" }; var toolchain = new ToolchainInfo { Platform = ".NET 10.0", Components = new[] { new ComponentInfo { Name = "StellaOps.Scanner.Triage", Version = "1.0.0" } } }; // Act - Create determinism manifest var manifest = DeterminismManifestWriter.CreateManifest( outputBytes, artifactInfo, toolchain); // Assert manifest.SchemaVersion.Should().Be("1.0"); manifest.Artifact.Format.Should().Be("triage-output@1.0"); manifest.CanonicalHash.Algorithm.Should().Be("SHA-256"); manifest.CanonicalHash.Value.Should().MatchRegex("^[0-9a-f]{64}$"); } [Fact] public async Task TriageOutput_ParallelGeneration_ProducesDeterministicOutput() { // Arrange var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); var input = CreateSampleTriageInput(); // Act - Generate in parallel 20 times var tasks = Enumerable.Range(0, 20) .Select(_ => Task.Run(() => CanonJson.Serialize(GenerateTriageOutput(input, frozenTime)))) .ToArray(); var outputs = await Task.WhenAll(tasks); // Assert - All outputs should be identical outputs.Should().AllBe(outputs[0]); } #endregion #region Finding Ordering Tests [Fact] public void TriageOutput_FindingsAreDeterministicallyOrdered() { // Arrange - Create input with findings in random order var findings = new[] { CreateFinding("CVE-2024-0003", "critical"), CreateFinding("CVE-2024-0001", "high"), CreateFinding("CVE-2024-0002", "medium") }; var input = new TriageInput { ScanId = Guid.Parse("11111111-1111-1111-1111-111111111111"), Findings = findings }; var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); // Act var output1 = GenerateTriageOutput(input, frozenTime); var output2 = GenerateTriageOutput(input, frozenTime); // Assert - Outputs should be identical var json1 = CanonJson.Serialize(output1); var json2 = CanonJson.Serialize(output2); json1.Should().Be(json2); // Verify findings are sorted by CVE ID for (int i = 1; i < output1.Findings.Count; i++) { string.CompareOrdinal(output1.Findings[i - 1].CveId, output1.Findings[i].CveId) .Should().BeLessOrEqualTo(0, "Findings should be sorted by CVE ID"); } } [Fact] public void TriageOutput_FindingsWithSameCve_SortedByPackage() { // Arrange - Multiple findings for same CVE var findings = new[] { CreateFinding("CVE-2024-0001", "high", "pkg:npm/package-z@1.0.0"), CreateFinding("CVE-2024-0001", "high", "pkg:npm/package-a@1.0.0"), CreateFinding("CVE-2024-0001", "high", "pkg:npm/package-m@1.0.0") }; var input = new TriageInput { ScanId = Guid.Parse("22222222-2222-2222-2222-222222222222"), Findings = findings }; var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); // Act var output1 = GenerateTriageOutput(input, frozenTime); var output2 = GenerateTriageOutput(input, frozenTime); // Assert var json1 = CanonJson.Serialize(output1); var json2 = CanonJson.Serialize(output2); json1.Should().Be(json2); } #endregion #region Status Transition Tests [Theory] [InlineData("open")] [InlineData("acknowledged")] [InlineData("mitigated")] [InlineData("resolved")] [InlineData("false_positive")] public void TriageOutput_StatusIsPreserved(string status) { // Arrange var finding = CreateFinding("CVE-2024-0001", "high") with { Status = status }; var input = new TriageInput { ScanId = Guid.Parse("33333333-3333-3333-3333-333333333333"), Findings = new[] { finding } }; var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); // Act var output = GenerateTriageOutput(input, frozenTime); // Assert output.Findings[0].Status.Should().Be(status); } [Fact] public void TriageOutput_StatusTransitionHistoryIsOrdered() { // Arrange var finding = CreateFinding("CVE-2024-0001", "high") with { StatusHistory = new[] { new StatusTransition { Status = "mitigated", Timestamp = DateTimeOffset.Parse("2025-12-24T10:00:00Z") }, new StatusTransition { Status = "open", Timestamp = DateTimeOffset.Parse("2025-12-24T08:00:00Z") }, new StatusTransition { Status = "acknowledged", Timestamp = DateTimeOffset.Parse("2025-12-24T09:00:00Z") } } }; var input = new TriageInput { ScanId = Guid.Parse("44444444-4444-4444-4444-444444444444"), Findings = new[] { finding } }; var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); // Act var output1 = GenerateTriageOutput(input, frozenTime); var output2 = GenerateTriageOutput(input, frozenTime); // Assert var json1 = CanonJson.Serialize(output1); var json2 = CanonJson.Serialize(output2); json1.Should().Be(json2); // Verify history is sorted by timestamp var history = output1.Findings[0].StatusHistory; for (int i = 1; i < history.Count; i++) { history[i - 1].Timestamp.Should().BeBefore(history[i].Timestamp, "Status history should be sorted by timestamp"); } } #endregion #region Inputs Hash Tests [Fact] public void TriageOutput_InputsHashIsStable() { // Arrange var input = CreateSampleTriageInput(); var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); // Act var output1 = GenerateTriageOutput(input, frozenTime); var output2 = GenerateTriageOutput(input, frozenTime); // Assert output1.InputsHash.Should().Be(output2.InputsHash); output1.InputsHash.Should().MatchRegex("^[0-9a-f]{64}$"); } [Fact] public void TriageOutput_DifferentInputs_ProduceDifferentHashes() { // Arrange var input1 = CreateSampleTriageInput(); var input2 = CreateSampleTriageInput() with { ScanId = Guid.Parse("55555555-5555-5555-5555-555555555555") }; var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); // Act var output1 = GenerateTriageOutput(input1, frozenTime); var output2 = GenerateTriageOutput(input2, frozenTime); // Assert output1.InputsHash.Should().NotBe(output2.InputsHash); } #endregion #region Empty/Edge Case Tests [Fact] public void TriageOutput_EmptyFindings_ProducesDeterministicOutput() { // Arrange var input = new TriageInput { ScanId = Guid.Parse("66666666-6666-6666-6666-666666666666"), Findings = Array.Empty() }; var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); // Act var hash1 = ComputeCanonicalHash(GenerateTriageOutput(input, frozenTime)); var hash2 = ComputeCanonicalHash(GenerateTriageOutput(input, frozenTime)); // Assert hash1.Should().Be(hash2); } [Fact] public void TriageOutput_ManyFindings_ProducesDeterministicOutput() { // Arrange - Create 500 findings var findings = Enumerable.Range(0, 500) .Select(i => CreateFinding($"CVE-2024-{i:D4}", i % 4 == 0 ? "critical" : i % 3 == 0 ? "high" : "medium")) .ToArray(); var input = new TriageInput { ScanId = Guid.Parse("77777777-7777-7777-7777-777777777777"), Findings = findings }; var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); // Act var hash1 = ComputeCanonicalHash(GenerateTriageOutput(input, frozenTime)); var hash2 = ComputeCanonicalHash(GenerateTriageOutput(input, frozenTime)); // Assert hash1.Should().Be(hash2); } #endregion #region Helper Methods private static TriageInput CreateSampleTriageInput() { return new TriageInput { ScanId = Guid.Parse("88888888-8888-8888-8888-888888888888"), Findings = new[] { CreateFinding("CVE-2024-1234", "critical"), CreateFinding("CVE-2024-5678", "high"), CreateFinding("CVE-2024-9012", "medium") } }; } private static FindingInput CreateFinding(string cveId, string severity, string? packageUrl = null) { return new FindingInput { CveId = cveId, Severity = severity, PackageUrl = packageUrl ?? $"pkg:npm/test-package@1.0.0", Status = "open", StatusHistory = Array.Empty() }; } private static TriageOutput GenerateTriageOutput(TriageInput input, DateTimeOffset timestamp) { // Sort findings deterministically by CVE ID, then by package URL var sortedFindings = input.Findings .OrderBy(f => f.CveId, StringComparer.Ordinal) .ThenBy(f => f.PackageUrl, StringComparer.Ordinal) .Select(f => new TriageFindingOutput { CveId = f.CveId, Severity = f.Severity, PackageUrl = f.PackageUrl, Status = f.Status, StatusHistory = f.StatusHistory .OrderBy(s => s.Timestamp) .ToList() }) .ToList(); // Compute inputs hash var inputsJson = CanonJson.Serialize(input); var inputsHash = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(inputsJson)); return new TriageOutput { ScanId = input.ScanId, Timestamp = timestamp, Findings = sortedFindings, InputsHash = inputsHash }; } private static string ComputeCanonicalHash(TriageOutput output) { var json = CanonJson.Serialize(output); return CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(json)); } #endregion #region DTOs private sealed record TriageInput { public required Guid ScanId { get; init; } public required FindingInput[] Findings { get; init; } } private sealed record FindingInput { public required string CveId { get; init; } public required string Severity { get; init; } public required string PackageUrl { get; init; } public required string Status { get; init; } public required StatusTransition[] StatusHistory { get; init; } } private sealed record StatusTransition { public required string Status { get; init; } public required DateTimeOffset Timestamp { get; init; } } private sealed record TriageOutput { public required Guid ScanId { get; init; } public required DateTimeOffset Timestamp { get; init; } public required IReadOnlyList Findings { get; init; } public required string InputsHash { get; init; } } private sealed record TriageFindingOutput { public required string CveId { get; init; } public required string Severity { get; init; } public required string PackageUrl { get; init; } public required string Status { get; init; } public required IReadOnlyList StatusHistory { get; init; } } #endregion }