fix(form-builder): canonicalize JSON for byte-stable storage (WS-6)

MySQL 8.0 JSON columns may reorder associative-array keys on
round-trip. For audit-immutable values (schema snapshots, webhook
payloads, activity log diffs), this is corrupting: re-emits produce
different byte sequences for the same logical content.

Introduced JsonCanonicalizer (recursive ksort on associative arrays;
numeric-indexed lists preserve order) and applied at every writer
site that produces byte-stable JSON:

- FormSubmissionService: canonicalize the schema_snapshot array
  before storage (audit-immutable per ARCH §4.3, RFC-WS-6 v1.1).
- FormField::logFieldChange / FormSchema::logSchemaChange: canonicalize
  activity-log properties before withProperties() so old/new diffs
  read back byte-stable.
- BindingActivityLogger: canonicalize both the pass-level and
  per-binding activity properties.
- FormWebhookDispatcher: canonicalize payload_snapshot before
  storage (delivery-time HMAC re-encodes the same canonical bytes).
- DeliverFormWebhookJob: switched json_encode to
  JsonCanonicalizer::encode for the HMAC-signed body, so the
  signature is byte-stable across re-deliveries and reproducible by
  receivers from the same logical payload.

Sites NOT canonicalized (deliberate):
- form_schemas.settings — opaque UI config; key order has no
  semantic meaning, no byte-stability requirement.
- form_schemas.translations / form_fields.translations — read by
  display layer; key order doesn't matter.
- form_templates.schema_snapshot — user-supplied input via store/
  update; user is the source of truth, not audit-immutable in the
  same way as form_submissions.schema_snapshot.

Reverted the 7 assertEquals workarounds from session 2.6:
- ConditionalLogicActivityLogPayloadTest
- ConditionalLogicBackfillTest::test_rollback_reconstructs_canonical_json
- FormFieldBindingMigrationTest::test_rollback_reconstructs_json_and_drops_table
- FormFieldOptionServiceAndScopeTest::test_replace_options_emits_activity_log_on_field_only
- FormFieldOptionsActivityLogTest::test_field_updated_payload_contains_options_diff_when_options_change
- FormFieldOptionsBackfillTest::test_forward_migration_backfills_rows_strips_translations_and_rewrites_snapshot
- FormFieldOptionsSnapshotAndStrictRequestTest::test_submission_snapshot_embeds_rich_shape_options

Each now uses assertSame on JsonCanonicalizer::encode of both sides —
byte-stable comparison meaningful regardless of MySQL JSON storage
behavior.

New regression test SchemaSnapshotByteStableAcrossReemitsTest
exercises the contract end-to-end: complex schema with bindings,
validation rules, options, conditional logic, submitted; reads
schema_snapshot via three roads (Eloquent cast, fresh model, raw
bytes) and asserts the canonical encode is identical.

ARCH-FORM-BUILDER.md §4.6.1 gets a "Byte-stability" sub-section
explaining what's canonicalized and why.

Test count: 1388 → 1400 (+11 JsonCanonicalizer unit, +1 snapshot
regression). Larastan clean. Rector dry-run unchanged at 355.

Refs: WS-6 session 2.6 deviation #4 cleanup, RFC-WS-6 v1.1

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 13:51:38 +02:00
parent 571777b5df
commit 060d6f36ca
17 changed files with 488 additions and 82 deletions

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Support\Json;
use App\Support\Json\JsonCanonicalizer;
use PHPUnit\Framework\TestCase;
final class JsonCanonicalizerTest extends TestCase
{
public function test_scalar_passthrough_string(): void
{
$this->assertSame('hello', JsonCanonicalizer::canonicalize('hello'));
}
/**
* @return iterable<string, array{0: int|float|bool|null}>
*/
public static function scalarProvider(): iterable
{
yield 'int' => [42];
yield 'float' => [3.14];
yield 'bool true' => [true];
yield 'bool false' => [false];
yield 'null' => [null];
}
/**
* @dataProvider scalarProvider
*/
public function test_scalar_passthrough(int|float|bool|null $value): void
{
// assertSame across the provider keeps PHPStan from narrowing
// both sides to a single literal type per call.
$this->assertSame($value, JsonCanonicalizer::canonicalize($value));
}
public function test_empty_array(): void
{
$this->assertSame([], JsonCanonicalizer::canonicalize([]));
}
public function test_numeric_list_preserves_order(): void
{
$list = ['c', 'a', 'b'];
$this->assertSame(['c', 'a', 'b'], JsonCanonicalizer::canonicalize($list));
}
public function test_associative_array_keys_sorted(): void
{
$assoc = ['c' => 1, 'a' => 2, 'b' => 3];
$result = JsonCanonicalizer::canonicalize($assoc);
$this->assertSame(['a' => 2, 'b' => 3, 'c' => 1], $result);
$this->assertSame(['a', 'b', 'c'], array_keys($result));
}
public function test_nested_associative_sorted_recursively(): void
{
$input = [
'z' => ['c' => 1, 'a' => 2],
'a' => 'first',
];
$result = JsonCanonicalizer::canonicalize($input);
$this->assertSame(['a', 'z'], array_keys($result));
$this->assertSame(['a', 'c'], array_keys($result['z']));
}
public function test_list_of_dicts_each_dict_sorted_list_order_preserved(): void
{
$input = [
['c' => 1, 'a' => 2],
['z' => 9, 'a' => 8],
];
$result = JsonCanonicalizer::canonicalize($input);
// List preserved
$this->assertCount(2, $result);
$this->assertSame(2, $result[0]['a']);
$this->assertSame(8, $result[1]['a']);
// Each dict sorted
$this->assertSame(['a', 'c'], array_keys($result[0]));
$this->assertSame(['a', 'z'], array_keys($result[1]));
}
public function test_encode_byte_identical_for_inputs_differing_only_in_key_order(): void
{
$a = ['name' => 'Alice', 'age' => 30, 'role' => 'admin'];
$b = ['role' => 'admin', 'age' => 30, 'name' => 'Alice'];
$this->assertSame(
JsonCanonicalizer::encode($a),
JsonCanonicalizer::encode($b),
);
}
public function test_encode_uses_unescaped_unicode_and_slashes(): void
{
$value = ['url' => 'https://example.com/path', 'name' => 'café'];
$encoded = JsonCanonicalizer::encode($value);
$this->assertStringContainsString('café', $encoded, 'unicode must be unescaped');
$this->assertStringNotContainsString('\\/', $encoded, 'slashes must be unescaped');
}
public function test_mixed_key_array_treated_as_associative(): void
{
// Mixed keys: keys 0, 1 plus 'foo'. Not a list per array_is_list();
// ksort applies, putting numeric-string keys before alphabetic.
$input = [0 => 'first', 'foo' => 'bar', 1 => 'second'];
$result = JsonCanonicalizer::canonicalize($input);
$this->assertSame([0, 1, 'foo'], array_keys($result));
}
public function test_empty_dict_inside_canonical_structure(): void
{
$input = ['z' => [], 'a' => 'x'];
$result = JsonCanonicalizer::canonicalize($input);
$this->assertSame(['a', 'z'], array_keys($result));
$this->assertSame([], $result['z']);
}
}