Add Canonical JSON serialization library with tests and documentation
- Implemented CanonJson class for deterministic JSON serialization and hashing. - Added unit tests for CanonJson functionality, covering various scenarios including key sorting, handling of nested objects, arrays, and special characters. - Created project files for the Canonical JSON library and its tests, including necessary package references. - Added README.md for library usage and API reference. - Introduced RabbitMqIntegrationFactAttribute for conditional RabbitMQ integration tests.
This commit is contained in:
263
src/__Libraries/StellaOps.Canonical.Json.Tests/CanonJsonTests.cs
Normal file
263
src/__Libraries/StellaOps.Canonical.Json.Tests/CanonJsonTests.cs
Normal file
@@ -0,0 +1,263 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Canonical.Json.Tests;
|
||||
|
||||
public class CanonJsonTests
|
||||
{
|
||||
[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));
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Canonicalize_HandlesEmptyObject()
|
||||
{
|
||||
var obj = new { };
|
||||
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
|
||||
|
||||
Assert.Equal("{}", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Canonicalize_HandlesEmptyArray()
|
||||
{
|
||||
var obj = new { items = Array.Empty<int>() };
|
||||
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
|
||||
|
||||
Assert.Equal(@"{""items"":[]}", json);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sha256Hex_ProducesLowercaseHex()
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes("test");
|
||||
var hash = CanonJson.Sha256Hex(bytes);
|
||||
|
||||
Assert.Matches(@"^[0-9a-f]{64}$", hash);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HashPrefixed_CanonicalizesAndHashesWithPrefix()
|
||||
{
|
||||
var obj = new { name = "test" };
|
||||
|
||||
var hash = CanonJson.HashPrefixed(obj);
|
||||
|
||||
Assert.StartsWith("sha256:", hash);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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));
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Canonicalize_UnicodeStrings()
|
||||
{
|
||||
var obj = new { greeting = "Привет мир", emoji = "🚀" };
|
||||
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);
|
||||
}
|
||||
|
||||
[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,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user