Add tests for SBOM generation determinism across multiple formats
- Created `StellaOps.TestKit.Tests` project for unit tests related to determinism. - Implemented `DeterminismManifestTests` to validate deterministic output for canonical bytes and strings, file read/write operations, and error handling for invalid schema versions. - Added `SbomDeterminismTests` to ensure identical inputs produce consistent SBOMs across SPDX 3.0.1 and CycloneDX 1.6/1.7 formats, including parallel execution tests. - Updated project references in `StellaOps.Integration.Determinism` to include the new determinism testing library.
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.TestKit.Assertions;
|
||||
|
||||
/// <summary>
|
||||
/// Provides assertions for canonical JSON serialization and determinism testing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Canonical JSON ensures:
|
||||
/// - Stable key ordering (alphabetical)
|
||||
/// - Consistent number formatting
|
||||
/// - No whitespace variations
|
||||
/// - UTF-8 encoding
|
||||
/// - Deterministic output (same input → same bytes)
|
||||
/// </remarks>
|
||||
public static class CanonicalJsonAssert
|
||||
{
|
||||
/// <summary>
|
||||
/// Asserts that the canonical JSON serialization of the value produces the expected SHA-256 hash.
|
||||
/// </summary>
|
||||
/// <param name="value">The value to serialize.</param>
|
||||
/// <param name="expectedSha256Hex">The expected SHA-256 hash (lowercase hex string).</param>
|
||||
public static void HasExpectedHash<T>(T value, string expectedSha256Hex)
|
||||
{
|
||||
string actualHash = Canonical.Json.CanonJson.Hash(value);
|
||||
Assert.Equal(expectedSha256Hex.ToLowerInvariant(), actualHash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that two values produce identical canonical JSON.
|
||||
/// </summary>
|
||||
public static void AreCanonicallyEqual<T>(T expected, T actual)
|
||||
{
|
||||
byte[] expectedBytes = Canonical.Json.CanonJson.Canonicalize(expected);
|
||||
byte[] actualBytes = Canonical.Json.CanonJson.Canonicalize(actual);
|
||||
|
||||
Assert.Equal(expectedBytes, actualBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that serializing the value multiple times produces identical bytes (determinism check).
|
||||
/// </summary>
|
||||
public static void IsDeterministic<T>(T value, int iterations = 10)
|
||||
{
|
||||
byte[]? baseline = null;
|
||||
|
||||
for (int i = 0; i < iterations; i++)
|
||||
{
|
||||
byte[] current = Canonical.Json.CanonJson.Canonicalize(value);
|
||||
|
||||
if (baseline == null)
|
||||
{
|
||||
baseline = current;
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Equal(baseline, current);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the SHA-256 hash of the canonical JSON and returns it as a lowercase hex string.
|
||||
/// </summary>
|
||||
public static string ComputeCanonicalHash<T>(T value)
|
||||
{
|
||||
return Canonical.Json.CanonJson.Hash(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that the canonical JSON matches the expected string (useful for debugging).
|
||||
/// </summary>
|
||||
public static void MatchesJson<T>(T value, string expectedJson)
|
||||
{
|
||||
byte[] canonicalBytes = Canonical.Json.CanonJson.Canonicalize(value);
|
||||
string actualJson = System.Text.Encoding.UTF8.GetString(canonicalBytes);
|
||||
Assert.Equal(expectedJson, actualJson);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that the JSON contains the expected key-value pair (deep search).
|
||||
/// </summary>
|
||||
public static void ContainsProperty<T>(T value, string propertyPath, object expectedValue)
|
||||
{
|
||||
byte[] canonicalBytes = Canonical.Json.CanonJson.Canonicalize(value);
|
||||
using var doc = JsonDocument.Parse(canonicalBytes);
|
||||
|
||||
JsonElement? element = FindPropertyByPath(doc.RootElement, propertyPath);
|
||||
|
||||
Assert.NotNull(element);
|
||||
|
||||
// Compare values
|
||||
string expectedJson = JsonSerializer.Serialize(expectedValue);
|
||||
string actualJson = element.Value.GetRawText();
|
||||
|
||||
Assert.Equal(expectedJson, actualJson);
|
||||
}
|
||||
|
||||
private static JsonElement? FindPropertyByPath(JsonElement root, string path)
|
||||
{
|
||||
var parts = path.Split('.');
|
||||
var current = root;
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
if (current.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!current.TryGetProperty(part, out var next))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
current = next;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(byte[] data)
|
||||
{
|
||||
byte[] hash = SHA256.HashData(data);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user