using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using Xunit; using StellaOps.TestKit; namespace StellaOps.Canonical.Json.Tests; /// /// Tests for versioned canonicalization and hash computation. /// Verifies version marker embedding, determinism, and backward compatibility. /// public class CanonVersionTests { #region Version Constants [Trait("Category", TestCategories.Unit)] [Fact] public void V1_HasExpectedValue() { Assert.Equal("stella:canon:v1", CanonVersion.V1); } [Trait("Category", TestCategories.Unit)] [Fact] public void VersionFieldName_HasUnderscorePrefix() { Assert.Equal("_canonVersion", CanonVersion.VersionFieldName); Assert.StartsWith("_", CanonVersion.VersionFieldName); } [Trait("Category", TestCategories.Unit)] [Fact] public void Current_EqualsV1() { Assert.Equal(CanonVersion.V1, CanonVersion.Current); } #endregion #region IsVersioned Detection [Trait("Category", TestCategories.Unit)] [Fact] public void IsVersioned_VersionedJson_ReturnsTrue() { var json = """{"_canonVersion":"stella:canon:v1","foo":"bar"}"""u8; Assert.True(CanonVersion.IsVersioned(json)); } [Trait("Category", TestCategories.Unit)] [Fact] public void IsVersioned_LegacyJson_ReturnsFalse() { var json = """{"foo":"bar"}"""u8; Assert.False(CanonVersion.IsVersioned(json)); } [Trait("Category", TestCategories.Unit)] [Fact] public void IsVersioned_EmptyJson_ReturnsFalse() { var json = "{}"u8; Assert.False(CanonVersion.IsVersioned(json)); } [Trait("Category", TestCategories.Unit)] [Fact] public void IsVersioned_TooShort_ReturnsFalse() { var json = """{"_ca":"v"}"""u8; Assert.False(CanonVersion.IsVersioned(json)); } [Trait("Category", TestCategories.Unit)] [Fact] public void IsVersioned_WrongFieldName_ReturnsFalse() { var json = """{"_version":"stella:canon:v1","foo":"bar"}"""u8; Assert.False(CanonVersion.IsVersioned(json)); } #endregion #region ExtractVersion [Trait("Category", TestCategories.Unit)] [Fact] public void ExtractVersion_VersionedJson_ReturnsVersion() { var json = """{"_canonVersion":"stella:canon:v1","foo":"bar"}"""u8; Assert.Equal("stella:canon:v1", CanonVersion.ExtractVersion(json)); } [Trait("Category", TestCategories.Unit)] [Fact] public void ExtractVersion_CustomVersion_ReturnsVersion() { var json = """{"_canonVersion":"custom:v2","foo":"bar"}"""u8; Assert.Equal("custom:v2", CanonVersion.ExtractVersion(json)); } [Trait("Category", TestCategories.Unit)] [Fact] public void ExtractVersion_LegacyJson_ReturnsNull() { var json = """{"foo":"bar"}"""u8; Assert.Null(CanonVersion.ExtractVersion(json)); } [Trait("Category", TestCategories.Unit)] [Fact] public void ExtractVersion_EmptyVersion_ReturnsNull() { var json = """{"_canonVersion":"","foo":"bar"}"""u8; Assert.Null(CanonVersion.ExtractVersion(json)); } #endregion #region CanonicalizeVersioned [Trait("Category", TestCategories.Unit)] [Fact] public void CanonicalizeVersioned_IncludesVersionMarker() { var obj = new { foo = "bar" }; var canonical = CanonJson.CanonicalizeVersioned(obj); var json = Encoding.UTF8.GetString(canonical); Assert.StartsWith("{\"_canonVersion\":\"stella:canon:v1\"", json); Assert.Contains("\"foo\":\"bar\"", json); } [Trait("Category", TestCategories.Unit)] [Fact] public void CanonicalizeVersioned_VersionMarkerIsFirst() { var obj = new { aaa = 1, zzz = 2 }; var canonical = CanonJson.CanonicalizeVersioned(obj); var json = Encoding.UTF8.GetString(canonical); // Version field should be before 'aaa' even though 'aaa' sorts first alphabetically var versionIndex = json.IndexOf("_canonVersion"); var aaaIndex = json.IndexOf("aaa"); Assert.True(versionIndex < aaaIndex); } [Trait("Category", TestCategories.Unit)] [Fact] public void CanonicalizeVersioned_SortsOtherKeys() { var obj = new { z = 3, a = 1, m = 2 }; var canonical = CanonJson.CanonicalizeVersioned(obj); var json = Encoding.UTF8.GetString(canonical); // After version marker, keys should be sorted Assert.Matches(@"\{""_canonVersion"":""[^""]+"",""a"":1,""m"":2,""z"":3\}", json); } [Trait("Category", TestCategories.Unit)] [Fact] public void CanonicalizeVersioned_CustomVersion_UsesProvidedVersion() { var obj = new { foo = "bar" }; var canonical = CanonJson.CanonicalizeVersioned(obj, "custom:v99"); var json = Encoding.UTF8.GetString(canonical); Assert.Contains("\"_canonVersion\":\"custom:v99\"", json); } [Trait("Category", TestCategories.Unit)] [Fact] public void CanonicalizeVersioned_NullVersion_ThrowsArgumentException() { var obj = new { foo = "bar" }; Assert.ThrowsAny(() => CanonJson.CanonicalizeVersioned(obj, null!)); } [Trait("Category", TestCategories.Unit)] [Fact] public void CanonicalizeVersioned_EmptyVersion_ThrowsArgumentException() { var obj = new { foo = "bar" }; Assert.Throws(() => CanonJson.CanonicalizeVersioned(obj, "")); } #endregion #region Hash Difference (Versioned vs Legacy) [Trait("Category", TestCategories.Unit)] [Fact] public void HashVersioned_DiffersFromLegacyHash() { var obj = new { foo = "bar", count = 42 }; var legacyHash = CanonJson.Hash(obj); var versionedHash = CanonJson.HashVersioned(obj); Assert.NotEqual(legacyHash, versionedHash); } [Trait("Category", TestCategories.Unit)] [Fact] public void HashVersionedPrefixed_DiffersFromLegacyHashPrefixed() { var obj = new { foo = "bar", count = 42 }; var legacyHash = CanonJson.HashPrefixed(obj); var versionedHash = CanonJson.HashVersionedPrefixed(obj); Assert.NotEqual(legacyHash, versionedHash); Assert.StartsWith("sha256:", versionedHash); Assert.StartsWith("sha256:", legacyHash); } [Trait("Category", TestCategories.Unit)] [Fact] public void HashVersioned_SameInput_ProducesSameHash() { var obj = new { foo = "bar", count = 42 }; var hash1 = CanonJson.HashVersioned(obj); var hash2 = CanonJson.HashVersioned(obj); Assert.Equal(hash1, hash2); } [Trait("Category", TestCategories.Unit)] [Fact] public void HashVersioned_DifferentVersions_ProduceDifferentHashes() { var obj = new { foo = "bar" }; var hashV1 = CanonJson.HashVersioned(obj, "stella:canon:v1"); var hashV2 = CanonJson.HashVersioned(obj, "stella:canon:v2"); Assert.NotEqual(hashV1, hashV2); } #endregion #region Determinism [Trait("Category", TestCategories.Unit)] [Fact] public void CanonicalizeVersioned_SameInput_ProducesSameBytes() { var obj = new { name = "test", value = 123, nested = new { x = 1, y = 2 } }; var bytes1 = CanonJson.CanonicalizeVersioned(obj); var bytes2 = CanonJson.CanonicalizeVersioned(obj); Assert.Equal(bytes1, bytes2); } [Trait("Category", TestCategories.Unit)] [Fact] public void CanonicalizeVersioned_DifferentPropertyOrder_ProducesSameBytes() { // Create two objects with same properties but defined in different order var json1 = """{"z":3,"a":1,"m":2}"""; var json2 = """{"a":1,"m":2,"z":3}"""; var obj1 = JsonSerializer.Deserialize(json1); var obj2 = JsonSerializer.Deserialize(json2); var bytes1 = CanonJson.CanonicalizeVersioned(obj1); var bytes2 = CanonJson.CanonicalizeVersioned(obj2); Assert.Equal(bytes1, bytes2); } [Trait("Category", TestCategories.Unit)] [Fact] public void CanonicalizeVersioned_StableAcrossMultipleCalls() { var obj = new { id = Guid.Parse("12345678-1234-1234-1234-123456789012"), name = "stable" }; var hashes = Enumerable.Range(0, 100) .Select(_ => CanonJson.HashVersioned(obj)) .Distinct() .ToList(); Assert.Single(hashes); } #endregion #region Golden File / Snapshot Tests [Trait("Category", TestCategories.Unit)] [Fact] public void CanonicalizeVersioned_KnownInput_ProducesKnownOutput() { // Golden test: exact output for known input to detect algorithm changes var obj = new { message = "hello", number = 42 }; var canonical = CanonJson.CanonicalizeVersioned(obj, "stella:canon:v1"); var json = Encoding.UTF8.GetString(canonical); // Exact expected output with version marker first Assert.Equal("""{"_canonVersion":"stella:canon:v1","message":"hello","number":42}""", json); } [Trait("Category", TestCategories.Unit)] [Fact] public void HashVersioned_KnownInput_ProducesKnownHash() { // Golden test: exact hash for known input to detect algorithm changes var obj = new { message = "hello", number = 42 }; var hash = CanonJson.HashVersioned(obj, "stella:canon:v1"); // If this test fails, it indicates the canonicalization algorithm changed // which would invalidate existing content-addressed identifiers // Hash is for: {"_canonVersion":"stella:canon:v1","message":"hello","number":42} Assert.Equal(64, hash.Length); // SHA-256 hex is 64 chars Assert.Matches("^[0-9a-f]{64}$", hash); // Determinism check: same input always produces same hash var hash2 = CanonJson.HashVersioned(obj, "stella:canon:v1"); Assert.Equal(hash, hash2); } [Trait("Category", TestCategories.Unit)] [Fact] public void CanonicalizeVersioned_NestedObject_ProducesCorrectOutput() { var obj = new { outer = new { z = 9, a = 1 }, name = "nested" }; var canonical = CanonJson.CanonicalizeVersioned(obj, "stella:canon:v1"); var json = Encoding.UTF8.GetString(canonical); // Nested objects should also have sorted keys Assert.Equal("""{"_canonVersion":"stella:canon:v1","name":"nested","outer":{"a":1,"z":9}}""", json); } #endregion #region Backward Compatibility [Trait("Category", TestCategories.Unit)] [Fact] public void CanVersion_CanDistinguishLegacyFromVersioned() { var obj = new { foo = "bar" }; var legacy = CanonJson.Canonicalize(obj); var versioned = CanonJson.CanonicalizeVersioned(obj); Assert.False(CanonVersion.IsVersioned(legacy)); Assert.True(CanonVersion.IsVersioned(versioned)); } [Trait("Category", TestCategories.Unit)] [Fact] public void LegacyCanonicalize_StillWorks() { // Ensure we haven't broken the legacy canonicalize method var obj = new { z = 3, a = 1 }; var canonical = CanonJson.Canonicalize(obj); var json = Encoding.UTF8.GetString(canonical); Assert.Equal("""{"a":1,"z":3}""", json); Assert.DoesNotContain("_canonVersion", json); } #endregion #region Edge Cases [Trait("Category", TestCategories.Unit)] [Fact] public void CanonicalizeVersioned_EmptyObject_IncludesOnlyVersion() { var obj = new { }; var canonical = CanonJson.CanonicalizeVersioned(obj); var json = Encoding.UTF8.GetString(canonical); Assert.Equal("""{"_canonVersion":"stella:canon:v1"}""", json); } [Trait("Category", TestCategories.Unit)] [Fact] public void CanonicalizeVersioned_WithSpecialCharacters_HandlesCorrectly() { var obj = new { message = "hello\nworld", special = "quote:\"test\"" }; var canonical = CanonJson.CanonicalizeVersioned(obj); var json = Encoding.UTF8.GetString(canonical); // Should be valid JSON with escaped characters var parsed = JsonSerializer.Deserialize(json); Assert.Equal("hello\nworld", parsed.GetProperty("message").GetString()); Assert.Equal("quote:\"test\"", parsed.GetProperty("special").GetString()); Assert.Equal("stella:canon:v1", parsed.GetProperty("_canonVersion").GetString()); } [Trait("Category", TestCategories.Unit)] [Fact] public void CanonicalizeVersioned_WithUnicodeCharacters_HandlesCorrectly() { var obj = new { greeting = "こんにちは", emoji = "🚀" }; var canonical = CanonJson.CanonicalizeVersioned(obj); var json = Encoding.UTF8.GetString(canonical); var parsed = JsonSerializer.Deserialize(json); Assert.Equal("こんにちは", parsed.GetProperty("greeting").GetString()); Assert.Equal("🚀", parsed.GetProperty("emoji").GetString()); } #endregion }