diff --git a/src/EntitlementPushContract.php b/src/EntitlementPushContract.php new file mode 100644 index 0000000..4863f82 --- /dev/null +++ b/src/EntitlementPushContract.php @@ -0,0 +1,84 @@ + $payload */ + public static function canonicalJson(array $payload): string + { + return json_encode( + self::canonicalPayload($payload), + JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES, + ); + } + + /** + * @param array $payload + * @return array + */ + public static function canonicalPayload(array $payload): array + { + self::assertValid($payload); + $limits = (array) $payload['limits']; + + return [ + 'tenant_id' => (string) $payload['tenant_id'], + 'tenant_ref' => [ + 'issuer' => (string) $payload['tenant_ref']['issuer'], + 'id' => (string) $payload['tenant_ref']['id'], + ], + 'product_code' => (string) $payload['product_code'], + 'sku_code' => (string) $payload['sku_code'], + 'version' => (int) $payload['version'], + 'modules' => $payload['modules'], + 'capabilities' => $payload['capabilities'], + 'limits' => $limits === [] ? (object) [] : $limits, + 'addons' => $payload['addons'], + ]; + } + + /** @param array $payload */ + public static function assertValid(array $payload): void + { + foreach ( + [ + 'tenant_id', 'tenant_ref', 'product_code', 'sku_code', 'version', + 'modules', 'capabilities', 'limits', 'addons', + ] as $field + ) { + if (! array_key_exists($field, $payload)) { + throw new InvalidArgumentException("Central entitlement push is missing {$field}."); + } + } + + foreach (['tenant_id', 'product_code', 'sku_code'] as $field) { + if (! is_string($payload[$field]) || trim($payload[$field]) === '') { + throw new InvalidArgumentException("Central entitlement push {$field} is invalid."); + } + } + + if ( + ! is_array($payload['tenant_ref']) + || ($payload['tenant_ref']['issuer'] ?? null) !== 'central' + || ($payload['tenant_ref']['id'] ?? null) !== $payload['tenant_id'] + ) { + throw new InvalidArgumentException('Central entitlement push tenant reference is invalid.'); + } + + if (! is_int($payload['version']) || $payload['version'] < 1) { + throw new InvalidArgumentException('Central entitlement push version is invalid.'); + } + + foreach (['modules', 'capabilities', 'limits', 'addons'] as $field) { + if (! is_array($payload[$field]) && ! is_object($payload[$field])) { + throw new InvalidArgumentException("Central entitlement push {$field} is invalid."); + } + } + } +} diff --git a/tests/EntitlementPushContractTest.php b/tests/EntitlementPushContractTest.php new file mode 100644 index 0000000..b136c95 --- /dev/null +++ b/tests/EntitlementPushContractTest.php @@ -0,0 +1,69 @@ +payload(); + $wirePayload['limits'] = (object) []; + $decodedPayload = json_decode( + json_encode($wirePayload, JSON_THROW_ON_ERROR), + true, + 512, + JSON_THROW_ON_ERROR, + ); + + $canonical = EntitlementPushContract::canonicalJson($wirePayload); + + self::assertSame($canonical, EntitlementPushContract::canonicalJson($decodedPayload)); + self::assertStringContainsString('"limits":{}', $canonical); + } + + public function test_tenant_reference_is_signed_and_must_match_the_subject(): void + { + $payload = $this->payload(); + $payload['tenant_ref']['id'] = 'another-tenant'; + + $this->expectException(InvalidArgumentException::class); + EntitlementPushContract::canonicalJson($payload); + } + + public function test_canonical_field_order_is_stable_and_excludes_transport_metadata(): void + { + $payload = $this->payload(); + $payload['snapshot_hash'] = str_repeat('a', 64); + $payload['generated_at'] = '2026-08-22T00:00:00Z'; + + self::assertSame( + [ + 'tenant_id', 'tenant_ref', 'product_code', 'sku_code', 'version', + 'modules', 'capabilities', 'limits', 'addons', + ], + array_keys(EntitlementPushContract::canonicalPayload($payload)), + ); + } + + /** @return array */ + private function payload(): array + { + return [ + 'tenant_id' => 'tenant-1', + 'tenant_ref' => ['issuer' => 'central', 'id' => 'tenant-1'], + 'product_code' => 'filasign', + 'sku_code' => 'none', + 'version' => 20260822000101, + 'modules' => [], + 'capabilities' => [], + 'limits' => ['envelopes_per_month' => 25], + 'addons' => [], + ]; + } +}