// -----------------------------------------------------------------------------
// 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
}