diff --git a/api/app/Exceptions/FormBuilder/PublishGuardViolationException.php b/api/app/Exceptions/FormBuilder/PublishGuardViolationException.php new file mode 100644 index 00000000..cbd3f4b1 --- /dev/null +++ b/api/app/Exceptions/FormBuilder/PublishGuardViolationException.php @@ -0,0 +1,50 @@ + $violations + */ + public function __construct( + public readonly string $purposeSlug, + public readonly array $violations, + ) { + $codes = array_map(static fn (PublishGuardResult $v): string => $v->guardCode, $violations); + parent::__construct( + "Schema publish blocked for purpose '{$purposeSlug}': " . implode(', ', $codes), + ); + } + + public function render(Request $request): JsonResponse + { + return response()->json([ + 'error' => 'publish_blocked', + 'message' => 'Schema kan niet gepubliceerd worden — er zijn problemen.', + 'purpose_slug' => $this->purposeSlug, + 'violations' => array_map( + static fn (PublishGuardResult $v): array => [ + 'code' => $v->guardCode, + 'message_key' => $v->messageKey, + 'form_field_id' => $v->offendingFormFieldId, + 'context' => $v->context, + ], + $this->violations, + ), + ], 422); + } +} diff --git a/api/app/Services/FormBuilder/FormSchemaService.php b/api/app/Services/FormBuilder/FormSchemaService.php index 005ccae6..c0cd1f48 100644 --- a/api/app/Services/FormBuilder/FormSchemaService.php +++ b/api/app/Services/FormBuilder/FormSchemaService.php @@ -8,7 +8,9 @@ use App\Enums\FormBuilder\FormPurpose; use App\Enums\FormBuilder\FormSubmissionStatus; use App\Exceptions\FormBuilder\DestructiveConfirmationRequiredException; use App\Exceptions\FormBuilder\EditLockConflictException; +use App\Exceptions\FormBuilder\PublishGuardViolationException; use App\Exceptions\FormBuilder\PurposeRequirementsNotMetException; +use App\FormBuilder\Publishing\PublishGuardResult; use App\FormBuilder\Purposes\PurposeRegistry; use App\Models\FormBuilder\FormField; use App\Models\FormBuilder\FormFieldBinding; @@ -120,6 +122,7 @@ final class FormSchemaService public function publish(FormSchema $schema, User $actor): FormSchema { $this->assertRequiredBindingsPresent($schema); + $this->assertPublishGuardsSatisfied($schema); $schema->is_published = true; $schema->last_updated_by_user_id = $actor->id; @@ -129,6 +132,44 @@ final class FormSchemaService return $schema->refresh(); } + /** + * RFC-WS-6 §3 (Q13) — runs after assertRequiredBindingsPresent(). + * Collects every guard violation (not first-fail) so the builder UI + * can surface all problems in one 422 response. + */ + private function assertPublishGuardsSatisfied(FormSchema $schema): void + { + $purposeValue = $schema->purpose->value; + + if (! $this->purposeRegistry->has($purposeValue)) { + return; + } + + // Eager-load relations needed by guards (avoid N+1). + $schema->loadMissing(['fields.bindings', 'fields.configs', 'sections']); + + $provider = $this->purposeRegistry->guardProviderFor($purposeValue); + + $violations = []; + foreach ($provider->publishGuards() as $guard) { + $result = $guard->evaluate($schema); + if (! $result->passed) { + $violations[] = $result; + } + } + + if ($violations === []) { + return; + } + + usort( + $violations, + static fn (PublishGuardResult $a, PublishGuardResult $b): int => strcmp($a->guardCode, $b->guardCode), + ); + + throw new PublishGuardViolationException($purposeValue, $violations); + } + /** * Verify that every `required_bindings` path declared by the schema's * purpose is bound by at least one field on the schema. diff --git a/api/tests/Feature/FormBuilder/Bindings/PublishChecksRelationalBindingsTest.php b/api/tests/Feature/FormBuilder/Bindings/PublishChecksRelationalBindingsTest.php index 508d62ee..8eae011c 100644 --- a/api/tests/Feature/FormBuilder/Bindings/PublishChecksRelationalBindingsTest.php +++ b/api/tests/Feature/FormBuilder/Bindings/PublishChecksRelationalBindingsTest.php @@ -4,9 +4,11 @@ declare(strict_types=1); namespace Tests\Feature\FormBuilder\Bindings; +use App\Enums\FormBuilder\FormFieldType; use App\Enums\FormBuilder\FormPurpose; use App\Exceptions\FormBuilder\PurposeRequirementsNotMetException; use App\Models\FormBuilder\FormField; +use App\Models\FormBuilder\FormFieldBinding; use App\Models\FormBuilder\FormSchema; use App\Models\Organisation; use App\Models\User; @@ -53,9 +55,22 @@ final class PublishChecksRelationalBindingsTest extends TestCase $this->actor, ); - FormField::factory()->withEntityBinding('person', 'email')->create(['form_schema_id' => $schema->id]); - FormField::factory()->withEntityBinding('person', 'first_name')->create(['form_schema_id' => $schema->id]); - FormField::factory()->withEntityBinding('person', 'last_name')->create(['form_schema_id' => $schema->id]); + // WS-6 publish guards require: EMAIL field type, identity_key flag + // on person.email, unique trust levels per (entity, attribute). + $emailField = FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'field_type' => FormFieldType::EMAIL->value, + ]); + FormFieldBinding::factory()->forField($emailField)->entityOwned('person', 'email') + ->create(['is_identity_key' => true, 'trust_level' => 80]); + + $firstField = FormField::factory()->create(['form_schema_id' => $schema->id]); + FormFieldBinding::factory()->forField($firstField)->entityOwned('person', 'first_name') + ->create(['trust_level' => 70]); + + $lastField = FormField::factory()->create(['form_schema_id' => $schema->id]); + FormFieldBinding::factory()->forField($lastField)->entityOwned('person', 'last_name') + ->create(['trust_level' => 60]); $published = $this->service->publish($schema->fresh(), $this->actor); diff --git a/api/tests/Feature/FormBuilder/FormSchemaServicePublishGuardsTest.php b/api/tests/Feature/FormBuilder/FormSchemaServicePublishGuardsTest.php new file mode 100644 index 00000000..277be486 --- /dev/null +++ b/api/tests/Feature/FormBuilder/FormSchemaServicePublishGuardsTest.php @@ -0,0 +1,151 @@ +buildValidEventRegistrationSchema(); + + $this->service()->publish($schema, $this->actor()); + + $this->assertTrue($schema->refresh()->is_published); + } + + public function test_missing_required_bindings_throws_existing_exception_first(): void + { + $schema = FormSchema::factory()->create([ + 'purpose' => FormPurpose::EVENT_REGISTRATION->value, + ]); + // No bindings → required_bindings (person.email/first_name/last_name) unmet. + + $this->expectException(PurposeRequirementsNotMetException::class); + $this->service()->publish($schema, $this->actor()); + } + + public function test_missing_identity_key_flag_throws_publish_guard_violation(): void + { + $schema = $this->buildValidEventRegistrationSchema(); + FormFieldBinding::query()->withoutGlobalScopes() + ->whereIn('owner_id', $schema->fields->pluck('id')) + ->where('target_attribute', 'email') + ->update(['is_identity_key' => false]); + $schema->load('fields.bindings'); + + try { + $this->service()->publish($schema, $this->actor()); + $this->fail('Expected PublishGuardViolationException'); + } catch (PublishGuardViolationException $e) { + $codes = array_map(static fn (\App\FormBuilder\Publishing\PublishGuardResult $v): string => $v->guardCode, $e->violations); + $this->assertContains('requires_identity_key_binding:person:email', $codes); + } + $this->assertFalse($schema->refresh()->is_published); + } + + public function test_violations_are_sorted_lexicographically(): void + { + $schema = $this->buildValidEventRegistrationSchema(); + + // Trigger TWO violations: drop is_identity_key + create ambiguous trust. + FormFieldBinding::query()->withoutGlobalScopes() + ->whereIn('owner_id', $schema->fields->pluck('id')) + ->where('target_attribute', 'email') + ->update(['is_identity_key' => false, 'trust_level' => 60]); + FormFieldBinding::query()->withoutGlobalScopes() + ->whereIn('owner_id', $schema->fields->pluck('id')) + ->where('target_attribute', 'first_name') + ->update(['trust_level' => 60]); + $schema->load('fields.bindings'); + + try { + $this->service()->publish($schema, $this->actor()); + $this->fail('Expected PublishGuardViolationException'); + } catch (PublishGuardViolationException $e) { + $codes = array_map(static fn (\App\FormBuilder\Publishing\PublishGuardResult $v): string => $v->guardCode, $e->violations); + $sorted = $codes; + sort($sorted); + $this->assertSame($sorted, $codes, 'Violations must be sorted lexicographically by code'); + } + } + + public function test_response_renders_as_422_with_violation_payload(): void + { + $schema = $this->buildValidEventRegistrationSchema(); + FormFieldBinding::query()->withoutGlobalScopes() + ->whereIn('owner_id', $schema->fields->pluck('id')) + ->where('target_attribute', 'email') + ->update(['is_identity_key' => false]); + $schema->load('fields.bindings'); + + try { + $this->service()->publish($schema, $this->actor()); + $this->fail('Expected PublishGuardViolationException'); + } catch (PublishGuardViolationException $e) { + $response = $e->render(request()); + $this->assertSame(422, $response->getStatusCode()); + $body = json_decode((string) $response->getContent(), true); + $this->assertSame('publish_blocked', $body['error']); + $this->assertSame('event_registration', $body['purpose_slug']); + $this->assertNotEmpty($body['violations']); + } + } + + private function service(): FormSchemaService + { + return $this->app->make(FormSchemaService::class); + } + + private function actor(): User + { + return User::factory()->create(); + } + + private function buildValidEventRegistrationSchema(): FormSchema + { + $schema = FormSchema::factory()->create([ + 'purpose' => FormPurpose::EVENT_REGISTRATION->value, + 'section_level_submit' => false, + 'is_published' => false, + ]); + + $emailField = FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'field_type' => FormFieldType::EMAIL->value, + ]); + FormFieldBinding::factory()->forField($emailField)->entityOwned('person', 'email') + ->create(['is_identity_key' => true, 'trust_level' => 80]); + + $firstNameField = FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'field_type' => FormFieldType::TEXT->value, + ]); + FormFieldBinding::factory()->forField($firstNameField)->entityOwned('person', 'first_name') + ->create(['is_identity_key' => false, 'trust_level' => 70]); + + $lastNameField = FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'field_type' => FormFieldType::TEXT->value, + ]); + FormFieldBinding::factory()->forField($lastNameField)->entityOwned('person', 'last_name') + ->create(['is_identity_key' => false, 'trust_level' => 50]); + + return $schema->fresh(['fields.bindings', 'fields.configs', 'sections']); + } +} diff --git a/api/tests/Feature/FormBuilder/Purposes/PurposeSchemaLifecycleTest.php b/api/tests/Feature/FormBuilder/Purposes/PurposeSchemaLifecycleTest.php index 4472ac60..057ad1b7 100644 --- a/api/tests/Feature/FormBuilder/Purposes/PurposeSchemaLifecycleTest.php +++ b/api/tests/Feature/FormBuilder/Purposes/PurposeSchemaLifecycleTest.php @@ -135,26 +135,42 @@ final class PurposeSchemaLifecycleTest extends TestCase { match ($purpose) { FormPurpose::EVENT_REGISTRATION => [ - $this->addBindingField($schema, 'person', 'email', 'email'), - $this->addBindingField($schema, 'person', 'first_name', 'first_name'), - $this->addBindingField($schema, 'person', 'last_name', 'last_name'), + // WS-6 publish guards require: identity_key flag on email, + // EMAIL field type present, unique trust levels per target. + $this->addBindingField($schema, 'person', 'email', 'email', FormFieldType::EMAIL, isIdentityKey: true, trustLevel: 80), + $this->addBindingField($schema, 'person', 'first_name', 'first_name', trustLevel: 70), + $this->addBindingField($schema, 'person', 'last_name', 'last_name', trustLevel: 60), ], FormPurpose::SUPPLIER_INTAKE => [ - $this->addBindingField($schema, 'company', 'name', 'company_name'), + $this->addBindingField($schema, 'company', 'name', 'company_name', isIdentityKey: true, trustLevel: 80), ], default => null, }; } - private function addBindingField(FormSchema $schema, string $entity, string $column, string $slug): FormField - { - return FormField::factory() - ->withEntityBinding($entity, $column) + private function addBindingField( + FormSchema $schema, + string $entity, + string $column, + string $slug, + FormFieldType $fieldType = FormFieldType::TEXT, + bool $isIdentityKey = false, + int $trustLevel = 50, + ): FormField { + $field = FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'field_type' => $fieldType->value, + 'slug' => $slug, + 'label' => ucfirst($slug), + ]); + \App\Models\FormBuilder\FormFieldBinding::factory() + ->forField($field) + ->entityOwned($entity, $column) ->create([ - 'form_schema_id' => $schema->id, - 'field_type' => FormFieldType::TEXT, - 'slug' => $slug, - 'label' => ucfirst($slug), + 'is_identity_key' => $isIdentityKey, + 'trust_level' => $trustLevel, ]); + + return $field; } }