using System.Security.Cryptography; using System.Text; using System.Text.Json; using Xunit; namespace StellaOps.TestKit.Assertions; /// /// Provides assertions for canonical JSON serialization and determinism testing. /// /// /// Canonical JSON ensures: /// - Stable key ordering (alphabetical) /// - Consistent number formatting /// - No whitespace variations /// - UTF-8 encoding /// - Deterministic output (same input → same bytes) /// public static class CanonicalJsonAssert { /// /// Asserts that the canonical JSON serialization of the value produces the expected SHA-256 hash. /// /// The value to serialize. /// The expected SHA-256 hash (lowercase hex string). public static void HasExpectedHash(T value, string expectedSha256Hex) { string actualHash = Canonical.Json.CanonJson.Hash(value); Assert.Equal(expectedSha256Hex.ToLowerInvariant(), actualHash); } /// /// Asserts that two values produce identical canonical JSON. /// public static void AreCanonicallyEqual(T expected, T actual) { byte[] expectedBytes = Canonical.Json.CanonJson.Canonicalize(expected); byte[] actualBytes = Canonical.Json.CanonJson.Canonicalize(actual); Assert.Equal(expectedBytes, actualBytes); } /// /// Asserts that serializing the value multiple times produces identical bytes (determinism check). /// public static void IsDeterministic(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); } } } /// /// Computes the SHA-256 hash of the canonical JSON and returns it as a lowercase hex string. /// public static string ComputeCanonicalHash(T value) { return Canonical.Json.CanonJson.Hash(value); } /// /// Asserts that the canonical JSON matches the expected string (useful for debugging). /// public static void MatchesJson(T value, string expectedJson) { byte[] canonicalBytes = Canonical.Json.CanonJson.Canonicalize(value); string actualJson = System.Text.Encoding.UTF8.GetString(canonicalBytes); Assert.Equal(expectedJson, actualJson); } /// /// Asserts that the JSON contains the expected key-value pair (deep search). /// public static void ContainsProperty(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(); } }