stabilizaiton work - projects rework for maintenanceability and ui livening
This commit is contained in:
@@ -9,8 +9,8 @@ Own test coverage for canonical JSON serialization and hashing. Keep tests deter
|
||||
- Surface open work on `TASKS.md`; update statuses (TODO/DOING/DONE/BLOCKED/REVIEW).
|
||||
|
||||
## Key Paths
|
||||
- `CanonJsonTests.cs`
|
||||
- `CanonVersionTests.cs`
|
||||
- `CanonJsonTests.*.cs`
|
||||
- `CanonVersionTests.*.cs`
|
||||
|
||||
## Coordination
|
||||
- Canonical Json library owners.
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Text;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Canonical.Json.Tests;
|
||||
|
||||
public partial class CanonJsonTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_SameInput_ProducesSameHash()
|
||||
{
|
||||
var obj = new { foo = "bar", baz = 42, nested = new { x = 1, y = 2 } };
|
||||
var bytes1 = CanonJson.Canonicalize(obj);
|
||||
var bytes2 = CanonJson.Canonicalize(obj);
|
||||
|
||||
Assert.Equal(bytes1, bytes2);
|
||||
Assert.Equal(CanonJson.Sha256Hex(bytes1), CanonJson.Sha256Hex(bytes2));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_SortsKeysAlphabetically()
|
||||
{
|
||||
var obj = new { z = 3, a = 1, m = 2 };
|
||||
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
|
||||
|
||||
Assert.Matches(@"\{""a"":1,""m"":2,""z"":3\}", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_HandlesNestedObjects()
|
||||
{
|
||||
var obj = new { outer = new { z = 9, a = 1 }, inner = new { b = 2 } };
|
||||
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
|
||||
|
||||
Assert.Contains(@"""inner"":{""b"":2}", json);
|
||||
Assert.Contains(@"""outer"":{""a"":1,""z"":9}", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_HandlesArrays()
|
||||
{
|
||||
var obj = new { items = new[] { 3, 1, 2 } };
|
||||
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
|
||||
|
||||
Assert.Contains(@"""items"":[3,1,2]", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_HandlesNullValues()
|
||||
{
|
||||
var obj = new { name = "test", value = (string?)null };
|
||||
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
|
||||
|
||||
Assert.Contains(@"""value"":null", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_HandlesBooleans()
|
||||
{
|
||||
var obj = new { enabled = true, disabled = false };
|
||||
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
|
||||
|
||||
Assert.Contains(@"""disabled"":false", json);
|
||||
Assert.Contains(@"""enabled"":true", json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Canonical.Json.Tests;
|
||||
|
||||
public partial class CanonJsonTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_HandlesDecimals()
|
||||
{
|
||||
var obj = new { value = 3.14159, integer = 42 };
|
||||
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
|
||||
|
||||
Assert.Contains(@"""integer"":42", json);
|
||||
Assert.Contains(@"""value"":3.14159", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_HandlesEmptyObject()
|
||||
{
|
||||
var obj = new { };
|
||||
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
|
||||
|
||||
Assert.Equal("{}", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_HandlesEmptyArray()
|
||||
{
|
||||
var obj = new { items = Array.Empty<int>() };
|
||||
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
|
||||
|
||||
Assert.Equal(@"{""items"":[]}", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_WithCustomOptions_UsesOptions()
|
||||
{
|
||||
var obj = new { MyProperty = "test" };
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
};
|
||||
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj, options));
|
||||
|
||||
Assert.Contains(@"""my_property"":""test""", json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Text;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Canonical.Json.Tests;
|
||||
|
||||
public partial class CanonJsonTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Sha256Hex_ProducesLowercaseHex()
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes("test");
|
||||
var hash = CanonJson.Sha256Hex(bytes);
|
||||
|
||||
Assert.Matches(@"^[0-9a-f]{64}$", hash);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Sha256Hex_ProducesConsistentHash()
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes("deterministic input");
|
||||
|
||||
var hash1 = CanonJson.Sha256Hex(bytes);
|
||||
var hash2 = CanonJson.Sha256Hex(bytes);
|
||||
|
||||
Assert.Equal(hash1, hash2);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Sha256Prefixed_IncludesPrefix()
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes("test");
|
||||
var hash = CanonJson.Sha256Prefixed(bytes);
|
||||
|
||||
Assert.StartsWith("sha256:", hash);
|
||||
Assert.Equal(71, hash.Length);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Hash_CanonicalizesAndHashes()
|
||||
{
|
||||
var obj = new { z = 3, a = 1 };
|
||||
|
||||
var hash1 = CanonJson.Hash(obj);
|
||||
var hash2 = CanonJson.Hash(obj);
|
||||
|
||||
Assert.Equal(hash1, hash2);
|
||||
Assert.Matches(@"^[0-9a-f]{64}$", hash1);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void HashPrefixed_CanonicalizesAndHashesWithPrefix()
|
||||
{
|
||||
var obj = new { name = "test" };
|
||||
|
||||
var hash = CanonJson.HashPrefixed(obj);
|
||||
|
||||
Assert.StartsWith("sha256:", hash);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DifferentObjects_ProduceDifferentHashes()
|
||||
{
|
||||
var obj1 = new { value = 1 };
|
||||
var obj2 = new { value = 2 };
|
||||
|
||||
var hash1 = CanonJson.Hash(obj1);
|
||||
var hash2 = CanonJson.Hash(obj2);
|
||||
|
||||
Assert.NotEqual(hash1, hash2);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void KeyOrderDoesNotAffectHash()
|
||||
{
|
||||
var json1 = Encoding.UTF8.GetBytes(@"{""a"":1,""b"":2}");
|
||||
var json2 = Encoding.UTF8.GetBytes(@"{""b"":2,""a"":1}");
|
||||
|
||||
var canonical1 = CanonJson.CanonicalizeParsedJson(json1);
|
||||
var canonical2 = CanonJson.CanonicalizeParsedJson(json2);
|
||||
|
||||
Assert.Equal(
|
||||
CanonJson.Sha256Hex(canonical1),
|
||||
CanonJson.Sha256Hex(canonical2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Canonical.Json.Tests;
|
||||
|
||||
public partial class CanonJsonTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_RawJsonBytes_SortsKeys()
|
||||
{
|
||||
var rawJson = Encoding.UTF8.GetBytes(@"{""z"":3,""a"":1}");
|
||||
var canonical = CanonJson.CanonicalizeParsedJson(rawJson);
|
||||
var json = Encoding.UTF8.GetString(canonical);
|
||||
|
||||
Assert.Equal(@"{""a"":1,""z"":3}", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_RawJsonBytes_InvalidJson_Throws()
|
||||
{
|
||||
var rawJson = Encoding.UTF8.GetBytes(@"{""a"":");
|
||||
|
||||
Assert.ThrowsAny<JsonException>(() => CanonJson.CanonicalizeParsedJson(rawJson));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_RawJsonBytes_CustomEncoder_EscapesUnsafeChars()
|
||||
{
|
||||
var rawJson = Encoding.UTF8.GetBytes(@"{""value"":""<tag>""}");
|
||||
var canonical = CanonJson.CanonicalizeParsedJson(rawJson, JavaScriptEncoder.Default);
|
||||
var json = Encoding.UTF8.GetString(canonical);
|
||||
|
||||
Assert.DoesNotContain("<", json);
|
||||
Assert.DoesNotContain(">", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_RawJsonBytes_HandlesScientificNotation()
|
||||
{
|
||||
var rawJson = Encoding.UTF8.GetBytes(@"{""value"":1e2,""small"":1e-6}");
|
||||
var canonical = CanonJson.CanonicalizeParsedJson(rawJson);
|
||||
|
||||
using var doc = JsonDocument.Parse(canonical);
|
||||
Assert.Equal(100d, doc.RootElement.GetProperty("value").GetDouble());
|
||||
Assert.Equal(0.000001d, doc.RootElement.GetProperty("small").GetDouble());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_ArrayOfObjects_SortsObjectKeys()
|
||||
{
|
||||
var rawJson = Encoding.UTF8.GetBytes(@"{""items"":[{""z"":3,""a"":1},{""b"":2,""a"":1}]}");
|
||||
var canonical = CanonJson.CanonicalizeParsedJson(rawJson);
|
||||
var json = Encoding.UTF8.GetString(canonical);
|
||||
|
||||
Assert.Contains(@"{""a"":1,""z"":3}", json);
|
||||
Assert.Contains(@"{""a"":1,""b"":2}", json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Text;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Canonical.Json.Tests;
|
||||
|
||||
public partial class CanonJsonTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_DeeplyNestedStructure()
|
||||
{
|
||||
var obj = new
|
||||
{
|
||||
level1 = new
|
||||
{
|
||||
z = "last",
|
||||
a = new
|
||||
{
|
||||
nested = new { b = 2, a = 1 }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
|
||||
|
||||
Assert.Contains(@"""a"":{""nested"":{""a"":1,""b"":2}}", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_UnicodeStrings()
|
||||
{
|
||||
var obj = new { greeting = "\u041f\u0440\u0438\u0432\u0435\u0442 \u043c\u0438\u0440", emoji = "\U0001F680" };
|
||||
var bytes = CanonJson.Canonicalize(obj);
|
||||
|
||||
var hash1 = CanonJson.Sha256Hex(bytes);
|
||||
var hash2 = CanonJson.Sha256Hex(CanonJson.Canonicalize(obj));
|
||||
Assert.Equal(hash1, hash2);
|
||||
|
||||
var json = Encoding.UTF8.GetString(bytes);
|
||||
Assert.Contains("greeting", json);
|
||||
Assert.Contains("emoji", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_SpecialCharactersInStrings()
|
||||
{
|
||||
var obj = new { path = "C:\\Users\\test", quote = "He said \"hello\"" };
|
||||
var bytes = CanonJson.Canonicalize(obj);
|
||||
|
||||
var hash1 = CanonJson.Sha256Hex(bytes);
|
||||
var hash2 = CanonJson.Sha256Hex(CanonJson.Canonicalize(obj));
|
||||
Assert.Equal(hash1, hash2);
|
||||
}
|
||||
}
|
||||
@@ -1,320 +0,0 @@
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Canonical.Json.Tests;
|
||||
|
||||
public class CanonJsonTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_SameInput_ProducesSameHash()
|
||||
{
|
||||
var obj = new { foo = "bar", baz = 42, nested = new { x = 1, y = 2 } };
|
||||
|
||||
var bytes1 = CanonJson.Canonicalize(obj);
|
||||
var bytes2 = CanonJson.Canonicalize(obj);
|
||||
|
||||
Assert.Equal(bytes1, bytes2);
|
||||
Assert.Equal(CanonJson.Sha256Hex(bytes1), CanonJson.Sha256Hex(bytes2));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_SortsKeysAlphabetically()
|
||||
{
|
||||
var obj = new { z = 3, a = 1, m = 2 };
|
||||
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
|
||||
|
||||
// Keys should be ordered: a, m, z
|
||||
Assert.Matches(@"\{""a"":1,""m"":2,""z"":3\}", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_HandlesNestedObjects()
|
||||
{
|
||||
var obj = new { outer = new { z = 9, a = 1 }, inner = new { b = 2 } };
|
||||
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
|
||||
|
||||
// Nested keys also sorted
|
||||
Assert.Contains(@"""inner"":{""b"":2}", json);
|
||||
Assert.Contains(@"""outer"":{""a"":1,""z"":9}", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_HandlesArrays()
|
||||
{
|
||||
var obj = new { items = new[] { 3, 1, 2 } };
|
||||
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
|
||||
|
||||
// Array order preserved (not sorted)
|
||||
Assert.Contains(@"""items"":[3,1,2]", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_HandlesNullValues()
|
||||
{
|
||||
var obj = new { name = "test", value = (string?)null };
|
||||
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
|
||||
|
||||
Assert.Contains(@"""value"":null", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_HandlesBooleans()
|
||||
{
|
||||
var obj = new { enabled = true, disabled = false };
|
||||
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
|
||||
|
||||
Assert.Contains(@"""disabled"":false", json);
|
||||
Assert.Contains(@"""enabled"":true", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_HandlesDecimals()
|
||||
{
|
||||
var obj = new { value = 3.14159, integer = 42 };
|
||||
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
|
||||
|
||||
Assert.Contains(@"""integer"":42", json);
|
||||
Assert.Contains(@"""value"":3.14159", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_HandlesEmptyObject()
|
||||
{
|
||||
var obj = new { };
|
||||
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
|
||||
|
||||
Assert.Equal("{}", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_HandlesEmptyArray()
|
||||
{
|
||||
var obj = new { items = Array.Empty<int>() };
|
||||
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
|
||||
|
||||
Assert.Equal(@"{""items"":[]}", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_WithCustomOptions_UsesOptions()
|
||||
{
|
||||
var obj = new { MyProperty = "test" };
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
};
|
||||
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj, options));
|
||||
|
||||
Assert.Contains(@"""my_property"":""test""", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_RawJsonBytes_SortsKeys()
|
||||
{
|
||||
var rawJson = Encoding.UTF8.GetBytes(@"{""z"":3,""a"":1}");
|
||||
var canonical = CanonJson.CanonicalizeParsedJson(rawJson);
|
||||
var json = Encoding.UTF8.GetString(canonical);
|
||||
|
||||
Assert.Equal(@"{""a"":1,""z"":3}", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_RawJsonBytes_InvalidJson_Throws()
|
||||
{
|
||||
var rawJson = Encoding.UTF8.GetBytes(@"{""a"":");
|
||||
|
||||
Assert.ThrowsAny<JsonException>(() => CanonJson.CanonicalizeParsedJson(rawJson));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_RawJsonBytes_CustomEncoder_EscapesUnsafeChars()
|
||||
{
|
||||
var rawJson = Encoding.UTF8.GetBytes(@"{""value"":""<tag>""}");
|
||||
var canonical = CanonJson.CanonicalizeParsedJson(rawJson, JavaScriptEncoder.Default);
|
||||
var json = Encoding.UTF8.GetString(canonical);
|
||||
|
||||
Assert.DoesNotContain("<", json);
|
||||
Assert.DoesNotContain(">", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_RawJsonBytes_HandlesScientificNotation()
|
||||
{
|
||||
var rawJson = Encoding.UTF8.GetBytes(@"{""value"":1e2,""small"":1e-6}");
|
||||
var canonical = CanonJson.CanonicalizeParsedJson(rawJson);
|
||||
|
||||
using var doc = JsonDocument.Parse(canonical);
|
||||
Assert.Equal(100d, doc.RootElement.GetProperty("value").GetDouble());
|
||||
Assert.Equal(0.000001d, doc.RootElement.GetProperty("small").GetDouble());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Sha256Hex_ProducesLowercaseHex()
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes("test");
|
||||
var hash = CanonJson.Sha256Hex(bytes);
|
||||
|
||||
Assert.Matches(@"^[0-9a-f]{64}$", hash);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Sha256Hex_ProducesConsistentHash()
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes("deterministic input");
|
||||
|
||||
var hash1 = CanonJson.Sha256Hex(bytes);
|
||||
var hash2 = CanonJson.Sha256Hex(bytes);
|
||||
|
||||
Assert.Equal(hash1, hash2);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Sha256Prefixed_IncludesPrefix()
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes("test");
|
||||
var hash = CanonJson.Sha256Prefixed(bytes);
|
||||
|
||||
Assert.StartsWith("sha256:", hash);
|
||||
Assert.Equal(71, hash.Length); // "sha256:" (7) + 64 hex chars
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Hash_CanonicalizesAndHashes()
|
||||
{
|
||||
var obj = new { z = 3, a = 1 };
|
||||
|
||||
var hash1 = CanonJson.Hash(obj);
|
||||
var hash2 = CanonJson.Hash(obj);
|
||||
|
||||
Assert.Equal(hash1, hash2);
|
||||
Assert.Matches(@"^[0-9a-f]{64}$", hash1);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void HashPrefixed_CanonicalizesAndHashesWithPrefix()
|
||||
{
|
||||
var obj = new { name = "test" };
|
||||
|
||||
var hash = CanonJson.HashPrefixed(obj);
|
||||
|
||||
Assert.StartsWith("sha256:", hash);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DifferentObjects_ProduceDifferentHashes()
|
||||
{
|
||||
var obj1 = new { value = 1 };
|
||||
var obj2 = new { value = 2 };
|
||||
|
||||
var hash1 = CanonJson.Hash(obj1);
|
||||
var hash2 = CanonJson.Hash(obj2);
|
||||
|
||||
Assert.NotEqual(hash1, hash2);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void KeyOrderDoesNotAffectHash()
|
||||
{
|
||||
// These should produce the same hash because keys are sorted
|
||||
var json1 = Encoding.UTF8.GetBytes(@"{""a"":1,""b"":2}");
|
||||
var json2 = Encoding.UTF8.GetBytes(@"{""b"":2,""a"":1}");
|
||||
|
||||
var canonical1 = CanonJson.CanonicalizeParsedJson(json1);
|
||||
var canonical2 = CanonJson.CanonicalizeParsedJson(json2);
|
||||
|
||||
Assert.Equal(
|
||||
CanonJson.Sha256Hex(canonical1),
|
||||
CanonJson.Sha256Hex(canonical2));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_DeeplyNestedStructure()
|
||||
{
|
||||
var obj = new
|
||||
{
|
||||
level1 = new
|
||||
{
|
||||
z = "last",
|
||||
a = new
|
||||
{
|
||||
nested = new { b = 2, a = 1 }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
|
||||
|
||||
// Verify deep nesting is sorted
|
||||
Assert.Contains(@"""a"":{""nested"":{""a"":1,""b"":2}}", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_ArrayOfObjects_SortsObjectKeys()
|
||||
{
|
||||
// Use raw JSON to test mixed object shapes in array
|
||||
var rawJson = Encoding.UTF8.GetBytes(@"{""items"":[{""z"":3,""a"":1},{""b"":2,""a"":1}]}");
|
||||
var canonical = CanonJson.CanonicalizeParsedJson(rawJson);
|
||||
var json = Encoding.UTF8.GetString(canonical);
|
||||
|
||||
// Objects in array have sorted keys
|
||||
Assert.Contains(@"{""a"":1,""z"":3}", json);
|
||||
Assert.Contains(@"{""a"":1,""b"":2}", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_UnicodeStrings()
|
||||
{
|
||||
var obj = new { greeting = "\u041f\u0440\u0438\u0432\u0435\u0442 \u043c\u0438\u0440", emoji = "\U0001F680" };
|
||||
var bytes = CanonJson.Canonicalize(obj);
|
||||
|
||||
// Verify deterministic hashing regardless of Unicode escaping
|
||||
var hash1 = CanonJson.Sha256Hex(bytes);
|
||||
var hash2 = CanonJson.Sha256Hex(CanonJson.Canonicalize(obj));
|
||||
Assert.Equal(hash1, hash2);
|
||||
|
||||
// Unicode may be escaped in JSON output - this is valid canonical JSON
|
||||
var json = Encoding.UTF8.GetString(bytes);
|
||||
Assert.Contains("greeting", json);
|
||||
Assert.Contains("emoji", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Canonicalize_SpecialCharactersInStrings()
|
||||
{
|
||||
var obj = new { path = "C:\\Users\\test", quote = "He said \"hello\"" };
|
||||
var bytes = CanonJson.Canonicalize(obj);
|
||||
|
||||
// Should not throw and should produce consistent output
|
||||
var hash1 = CanonJson.Sha256Hex(bytes);
|
||||
var hash2 = CanonJson.Sha256Hex(CanonJson.Canonicalize(obj));
|
||||
Assert.Equal(hash1, hash2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Text;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Canonical.Json.Tests;
|
||||
|
||||
public partial class CanonVersionTests
|
||||
{
|
||||
[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);
|
||||
|
||||
var versionIndex = json.IndexOf("_canonVersion", StringComparison.Ordinal);
|
||||
var aaaIndex = json.IndexOf("aaa", StringComparison.Ordinal);
|
||||
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);
|
||||
|
||||
Assert.Matches(@"\{""_canonVersion"":""[^""]+"",""a"":1,""m"":2,""z"":3\}", json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Canonical.Json.Tests;
|
||||
|
||||
public partial class CanonVersionTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CanonicalizeVersioned_NullVersion_ThrowsArgumentException()
|
||||
{
|
||||
var obj = new { foo = "bar" };
|
||||
Assert.ThrowsAny<ArgumentException>(() => CanonJson.CanonicalizeVersioned(obj, null!));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CanonicalizeVersioned_EmptyVersion_ThrowsArgumentException()
|
||||
{
|
||||
var obj = new { foo = "bar" };
|
||||
Assert.Throws<ArgumentException>(() => CanonJson.CanonicalizeVersioned(obj, ""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Canonical.Json.Tests;
|
||||
|
||||
public partial class CanonVersionTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CanonicalizeVersioned_WithCustomOptions_UsesOptions()
|
||||
{
|
||||
var obj = new { MyProperty = "test" };
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
};
|
||||
|
||||
var canonical = CanonJson.CanonicalizeVersioned(obj, options);
|
||||
var json = Encoding.UTF8.GetString(canonical);
|
||||
|
||||
Assert.Contains(@"""my_property"":""test""", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CanonicalizeVersioned_SkipsExistingVersionField()
|
||||
{
|
||||
var jsonInput = """{"_canonVersion":"legacy","foo":"bar"}""";
|
||||
var element = JsonSerializer.Deserialize<JsonElement>(jsonInput);
|
||||
|
||||
var canonical = CanonJson.CanonicalizeVersioned(element, "stella:canon:v1");
|
||||
var json = Encoding.UTF8.GetString(canonical);
|
||||
|
||||
var matches = Regex.Matches(json, "_canonVersion");
|
||||
Assert.Single(matches);
|
||||
Assert.Contains(@"""_canonVersion"":""stella:canon:v1""", 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Text;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Canonical.Json.Tests;
|
||||
|
||||
public partial class CanonVersionTests
|
||||
{
|
||||
[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()
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Canonical.Json.Tests;
|
||||
|
||||
public partial class CanonVersionTests
|
||||
{
|
||||
[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()
|
||||
{
|
||||
var json1 = """{"z":3,"a":1,"m":2}""";
|
||||
var json2 = """{"a":1,"m":2,"z":3}""";
|
||||
|
||||
var obj1 = JsonSerializer.Deserialize<JsonElement>(json1);
|
||||
var obj2 = JsonSerializer.Deserialize<JsonElement>(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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Canonical.Json.Tests;
|
||||
|
||||
public partial class CanonVersionTests
|
||||
{
|
||||
[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);
|
||||
|
||||
var parsed = JsonSerializer.Deserialize<JsonElement>(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 = "\u3053\u3093\u306b\u3061\u306f", emoji = "\U0001F680" };
|
||||
var canonical = CanonJson.CanonicalizeVersioned(obj);
|
||||
var json = Encoding.UTF8.GetString(canonical);
|
||||
|
||||
var parsed = JsonSerializer.Deserialize<JsonElement>(json);
|
||||
Assert.Equal("\u3053\u3093\u306b\u3061\u306f", parsed.GetProperty("greeting").GetString());
|
||||
Assert.Equal("\U0001F680", parsed.GetProperty("emoji").GetString());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CanonicalizeVersioned_NonObjectRoot_WrapsValue()
|
||||
{
|
||||
var canonical = CanonJson.CanonicalizeVersioned(42);
|
||||
var json = Encoding.UTF8.GetString(canonical);
|
||||
|
||||
Assert.Equal("""{"_canonVersion":"stella:canon:v1","_value":42}""", json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Canonical.Json.Tests;
|
||||
|
||||
public partial class CanonVersionTests
|
||||
{
|
||||
[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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Text;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Canonical.Json.Tests;
|
||||
|
||||
public partial class CanonVersionTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CanonicalizeVersioned_KnownInput_ProducesKnownOutput()
|
||||
{
|
||||
var obj = new { message = "hello", number = 42 };
|
||||
var canonical = CanonJson.CanonicalizeVersioned(obj, "stella:canon:v1");
|
||||
var json = Encoding.UTF8.GetString(canonical);
|
||||
|
||||
Assert.Equal("""{"_canonVersion":"stella:canon:v1","message":"hello","number":42}""", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void HashVersioned_KnownInput_ProducesKnownHash()
|
||||
{
|
||||
var obj = new { message = "hello", number = 42 };
|
||||
var hash = CanonJson.HashVersioned(obj, "stella:canon:v1");
|
||||
|
||||
Assert.Equal(64, hash.Length);
|
||||
Assert.Matches("^[0-9a-f]{64}$", 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);
|
||||
|
||||
Assert.Equal("""{"_canonVersion":"stella:canon:v1","name":"nested","outer":{"a":1,"z":9}}""", json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Canonical.Json.Tests;
|
||||
|
||||
public partial class CanonVersionTests
|
||||
{
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Canonical.Json.Tests;
|
||||
|
||||
public partial class CanonVersionTests
|
||||
{
|
||||
[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));
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,14 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Canonical.Json.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for versioned canonicalization and hash computation.
|
||||
/// Verifies version marker embedding, determinism, and backward compatibility.
|
||||
/// </summary>
|
||||
public class CanonVersionTests
|
||||
public partial class CanonVersionTests
|
||||
{
|
||||
#region Version Constants
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void V1_HasExpectedValue()
|
||||
@@ -35,411 +30,4 @@ public class CanonVersionTests
|
||||
{
|
||||
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_WithCustomOptions_UsesOptions()
|
||||
{
|
||||
var obj = new { MyProperty = "test" };
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
};
|
||||
|
||||
var canonical = CanonJson.CanonicalizeVersioned(obj, options);
|
||||
var json = Encoding.UTF8.GetString(canonical);
|
||||
|
||||
Assert.Contains(@"""my_property"":""test""", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CanonicalizeVersioned_SkipsExistingVersionField()
|
||||
{
|
||||
var jsonInput = """{"_canonVersion":"legacy","foo":"bar"}""";
|
||||
var element = JsonSerializer.Deserialize<JsonElement>(jsonInput);
|
||||
|
||||
var canonical = CanonJson.CanonicalizeVersioned(element, "stella:canon:v1");
|
||||
var json = Encoding.UTF8.GetString(canonical);
|
||||
|
||||
var matches = Regex.Matches(json, "_canonVersion");
|
||||
Assert.Single(matches);
|
||||
Assert.Contains(@"""_canonVersion"":""stella:canon:v1""", 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<ArgumentException>(() => CanonJson.CanonicalizeVersioned(obj, null!));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CanonicalizeVersioned_EmptyVersion_ThrowsArgumentException()
|
||||
{
|
||||
var obj = new { foo = "bar" };
|
||||
Assert.Throws<ArgumentException>(() => 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<JsonElement>(json1);
|
||||
var obj2 = JsonSerializer.Deserialize<JsonElement>(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<JsonElement>(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 = "\u3053\u3093\u306b\u3061\u306f", emoji = "\U0001F680" };
|
||||
var canonical = CanonJson.CanonicalizeVersioned(obj);
|
||||
var json = Encoding.UTF8.GetString(canonical);
|
||||
|
||||
var parsed = JsonSerializer.Deserialize<JsonElement>(json);
|
||||
Assert.Equal("\u3053\u3093\u306b\u3061\u306f", parsed.GetProperty("greeting").GetString());
|
||||
Assert.Equal("\U0001F680", parsed.GetProperty("emoji").GetString());
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Canonical Json Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0046-T | DONE | Revalidated 2026-01-08; open findings tracked in audit report. |
|
||||
| AUDIT-0046-A | DONE | Waived (test project; revalidated 2026-01-08). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| REMED-2026-02-03 | DONE | Split CanonJsonTests/CanonVersionTests into partials <=100 lines; using directives sorted; added non-object root versioned test; `dotnet test src/__Libraries/StellaOps.Canonical.Json.Tests/StellaOps.Canonical.Json.Tests.csproj` passed (61 tests). |
|
||||
|
||||
Reference in New Issue
Block a user