diff --git a/api/app/Listeners/FormBuilder/SyncTagPickerSelectionsOnSubmit.php b/api/app/Listeners/FormBuilder/SyncTagPickerSelectionsOnSubmit.php index 2a00c603..d8504bab 100644 --- a/api/app/Listeners/FormBuilder/SyncTagPickerSelectionsOnSubmit.php +++ b/api/app/Listeners/FormBuilder/SyncTagPickerSelectionsOnSubmit.php @@ -31,8 +31,9 @@ final class SyncTagPickerSelectionsOnSubmit implements ShouldQueue { use InteractsWithQueue; - public string $connection = 'redis'; - + // Connection left unset so Laravel uses config('queue.default') — redis + // in production, sync in tests. Queue name is the default queue of the + // resolved connection. public string $queue = 'default'; public function __construct( diff --git a/api/app/Models/FormBuilder/FormSchema.php b/api/app/Models/FormBuilder/FormSchema.php index 5e162158..850bb335 100644 --- a/api/app/Models/FormBuilder/FormSchema.php +++ b/api/app/Models/FormBuilder/FormSchema.php @@ -52,6 +52,7 @@ final class FormSchema extends Model 'submission_deadline', 'locale', 'settings', + 'version', 'snapshot_mode', 'freeze_on_submit', 'retention_days', @@ -61,6 +62,8 @@ final class FormSchema extends Model 'max_submissions', 'created_by_user_id', 'last_updated_by_user_id', + 'edit_lock_user_id', + 'edit_lock_expires_at', ]; /** @var array */ diff --git a/api/app/Models/FormBuilder/FormSubmission.php b/api/app/Models/FormBuilder/FormSubmission.php index 288e389f..b4c72099 100644 --- a/api/app/Models/FormBuilder/FormSubmission.php +++ b/api/app/Models/FormBuilder/FormSubmission.php @@ -40,7 +40,11 @@ final class FormSubmission extends Model 'reviewed_at', 'review_notes', 'submitted_at', + 'schema_version_at_submit', 'schema_snapshot', + 'submission_duration_seconds', + 'auto_save_count', + 'anonymised_at', 'is_test', 'submitted_in_locale', 'opened_at', diff --git a/api/app/Services/FormBuilder/FormSubmissionService.php b/api/app/Services/FormBuilder/FormSubmissionService.php index 8286a8ff..a56d6645 100644 --- a/api/app/Services/FormBuilder/FormSubmissionService.php +++ b/api/app/Services/FormBuilder/FormSubmissionService.php @@ -88,7 +88,7 @@ final class FormSubmissionService $submission->save(); }); - FormSubmissionDraftUpdated::dispatch($submission, array_keys($values)); + FormSubmissionDraftUpdated::dispatch($submission); return $submission->refresh(); } @@ -132,7 +132,7 @@ final class FormSubmissionService $submission->reviewed_at = now(); $submission->save(); - FormSubmissionReviewed::dispatch($submission, $status->value); + FormSubmissionReviewed::dispatch($submission, $reviewer); return $submission->refresh(); }); diff --git a/api/app/Services/FormBuilder/FormValueService.php b/api/app/Services/FormBuilder/FormValueService.php index 68216ed2..86c82630 100644 --- a/api/app/Services/FormBuilder/FormValueService.php +++ b/api/app/Services/FormBuilder/FormValueService.php @@ -44,7 +44,12 @@ final class FormValueService continue; } - if (! $this->fieldAccess->canWrite($actor, $field, $submission)) { + if ($actor === null) { + // Public submission path: portal-visible non-admin fields only. + if (! (bool) $field->is_portal_visible || (bool) $field->is_admin_only) { + throw new AuthorizationException(sprintf('Not allowed to write field "%s" on public submission.', $slug)); + } + } elseif (! $this->fieldAccess->canWrite($actor, $field, $submission)) { throw new AuthorizationException(sprintf('Not allowed to write field "%s".', $slug)); } diff --git a/api/database/factories/FormBuilder/FormSchemaFactory.php b/api/database/factories/FormBuilder/FormSchemaFactory.php index b1a859fa..9e91e923 100644 --- a/api/database/factories/FormBuilder/FormSchemaFactory.php +++ b/api/database/factories/FormBuilder/FormSchemaFactory.php @@ -44,6 +44,7 @@ final class FormSchemaFactory extends Factory 'freeze_on_submit' => false, 'section_level_submit' => false, 'auto_save_enabled' => false, + 'version' => 1, ]; } diff --git a/api/tests/Feature/FormBuilder/FilterRegistryApiTest.php b/api/tests/Feature/FormBuilder/FilterRegistryApiTest.php new file mode 100644 index 00000000..0bdb7cd9 --- /dev/null +++ b/api/tests/Feature/FormBuilder/FilterRegistryApiTest.php @@ -0,0 +1,47 @@ +seed(RoleSeeder::class); + $org = Organisation::factory()->create(); + $user = User::factory()->create(); + $org->users()->attach($user, ['role' => 'org_admin']); + + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'slug' => 'shirtmaat', + 'label' => 'Shirtmaat', + 'is_filterable' => true, + ]); + + Sanctum::actingAs($user); + + $response = $this->getJson("/api/v1/organisations/{$org->id}/forms/filter-registry"); + $response->assertOk(); + + $sources = collect($response->json('data'))->pluck('source')->unique()->all(); + $this->assertContains('tags', $sources); + $this->assertContains('form_field', $sources); + + $labels = collect($response->json('data'))->pluck('label')->all(); + $this->assertContains('Shirtmaat', $labels); + } +} diff --git a/api/tests/Feature/FormBuilder/FormFieldApiTest.php b/api/tests/Feature/FormBuilder/FormFieldApiTest.php new file mode 100644 index 00000000..70215205 --- /dev/null +++ b/api/tests/Feature/FormBuilder/FormFieldApiTest.php @@ -0,0 +1,140 @@ +seed(RoleSeeder::class); + $this->org = Organisation::factory()->create(); + $this->admin = User::factory()->create(); + $this->org->users()->attach($this->admin, ['role' => 'org_admin']); + $this->schema = FormSchema::factory()->create(['organisation_id' => $this->org->id]); + } + + public function test_store_creates_field(): void + { + Sanctum::actingAs($this->admin); + + $response = $this->postJson("/api/v1/organisations/{$this->org->id}/forms/schemas/{$this->schema->id}/fields", [ + 'field_type' => FormFieldType::SELECT->value, + 'slug' => 'shirtmaat', + 'label' => 'Shirtmaat', + 'options' => ['XS', 'S', 'M', 'L'], + ]); + + $response->assertCreated(); + $this->assertSame('Shirtmaat', $response->json('data.label')); + } + + public function test_reorder_applies_new_order(): void + { + Sanctum::actingAs($this->admin); + $fieldA = FormField::factory()->create(['form_schema_id' => $this->schema->id, 'sort_order' => 0]); + $fieldB = FormField::factory()->create(['form_schema_id' => $this->schema->id, 'sort_order' => 1]); + + $this->postJson( + "/api/v1/organisations/{$this->org->id}/forms/schemas/{$this->schema->id}/fields/reorder", + ['field_ids' => [$fieldB->id, $fieldA->id]], + )->assertOk(); + + $this->assertSame(0, $fieldB->fresh()->sort_order); + $this->assertSame(1, $fieldA->fresh()->sort_order); + } + + public function test_binding_change_blocked_without_force_when_submissions_exist(): void + { + Sanctum::actingAs($this->admin); + $field = FormField::factory()->create([ + 'form_schema_id' => $this->schema->id, + 'binding' => null, + ]); + FormSubmission::factory()->create([ + 'form_schema_id' => $this->schema->id, + 'status' => FormSubmissionStatus::SUBMITTED->value, + ]); + + $response = $this->putJson( + "/api/v1/organisations/{$this->org->id}/forms/schemas/{$this->schema->id}/fields/{$field->id}", + ['binding' => ['mode' => 'entity_owned', 'entity' => 'user_profile', 'column' => 'bio']], + ); + + $response->assertStatus(422); + $this->assertStringContainsString('Binding change blocked', (string) $response->json('message')); + } + + public function test_binding_change_with_force_succeeds(): void + { + Sanctum::actingAs($this->admin); + $field = FormField::factory()->create([ + 'form_schema_id' => $this->schema->id, + 'binding' => null, + ]); + FormSubmission::factory()->create([ + 'form_schema_id' => $this->schema->id, + 'status' => FormSubmissionStatus::SUBMITTED->value, + ]); + + $this->putJson( + "/api/v1/organisations/{$this->org->id}/forms/schemas/{$this->schema->id}/fields/{$field->id}", + [ + 'binding' => ['mode' => 'entity_owned', 'entity' => 'user_profile', 'column' => 'bio'], + 'force_binding_change' => true, + ], + )->assertOk(); + } + + public function test_cyclic_conditional_logic_is_rejected(): void + { + Sanctum::actingAs($this->admin); + + // field A depends on field B + $fieldA = FormField::factory()->create([ + 'form_schema_id' => $this->schema->id, + 'slug' => 'a', + 'conditional_logic' => ['show_when' => ['all' => [['field_slug' => 'b', 'operator' => 'equals', 'value' => true]]]], + ]); + $fieldB = FormField::factory()->create(['form_schema_id' => $this->schema->id, 'slug' => 'b']); + + $response = $this->putJson( + "/api/v1/organisations/{$this->org->id}/forms/schemas/{$this->schema->id}/fields/{$fieldB->id}", + ['conditional_logic' => ['show_when' => ['all' => [['field_slug' => 'a', 'operator' => 'equals', 'value' => true]]]]], + ); + + $response->assertStatus(422); + $this->assertStringContainsString('Cyclic', (string) $response->json('message')); + } + + public function test_unauthenticated_returns_401(): void + { + $this->postJson("/api/v1/organisations/{$this->org->id}/forms/schemas/{$this->schema->id}/fields", [ + 'field_type' => FormFieldType::TEXT->value, + 'slug' => 'x', + 'label' => 'X', + ])->assertStatus(401); + } +} diff --git a/api/tests/Feature/FormBuilder/FormSchemaApiTest.php b/api/tests/Feature/FormBuilder/FormSchemaApiTest.php new file mode 100644 index 00000000..cebcb846 --- /dev/null +++ b/api/tests/Feature/FormBuilder/FormSchemaApiTest.php @@ -0,0 +1,167 @@ +seed(RoleSeeder::class); + + $this->org = Organisation::factory()->create(); + $this->otherOrg = Organisation::factory()->create(); + + $this->admin = User::factory()->create(); + $this->org->users()->attach($this->admin, ['role' => 'org_admin']); + + $this->outsider = User::factory()->create(); + $this->otherOrg->users()->attach($this->outsider, ['role' => 'org_admin']); + } + + public function test_unauthenticated_index_returns_401(): void + { + $this->getJson("/api/v1/organisations/{$this->org->id}/forms/schemas") + ->assertStatus(401); + } + + public function test_index_returns_schemas_for_this_org(): void + { + Sanctum::actingAs($this->admin); + + FormSchema::factory()->count(3)->create(['organisation_id' => $this->org->id]); + FormSchema::factory()->count(2)->create(['organisation_id' => $this->otherOrg->id]); + + $response = $this->getJson("/api/v1/organisations/{$this->org->id}/forms/schemas"); + + $response->assertOk(); + $response->assertJsonCount(3, 'data'); + } + + public function test_store_creates_schema(): void + { + Sanctum::actingAs($this->admin); + + $response = $this->postJson("/api/v1/organisations/{$this->org->id}/forms/schemas", [ + 'name' => 'Aanmeldformulier', + 'purpose' => FormPurpose::EVENT_REGISTRATION->value, + ]); + + $response->assertCreated(); + $this->assertSame('Aanmeldformulier', $response->json('data.name')); + $this->assertSame($this->org->id, $response->json('data.organisation_id')); + } + + public function test_store_from_outsider_returns_403(): void + { + Sanctum::actingAs($this->outsider); + + $this->postJson("/api/v1/organisations/{$this->org->id}/forms/schemas", [ + 'name' => 'Blocked', + 'purpose' => FormPurpose::FEEDBACK->value, + ])->assertStatus(403); + } + + public function test_update_bumps_version_on_structural_change(): void + { + Sanctum::actingAs($this->admin); + $schema = FormSchema::factory()->create([ + 'organisation_id' => $this->org->id, + 'version' => 1, + ]); + + $this->putJson("/api/v1/organisations/{$this->org->id}/forms/schemas/{$schema->id}", [ + 'freeze_on_submit' => true, + ])->assertOk(); + + $this->assertSame(2, (int) $schema->fresh()->version); + } + + public function test_destroy_without_confirmation_when_submissions_exist_fails(): void + { + Sanctum::actingAs($this->admin); + $schema = FormSchema::factory()->create(['organisation_id' => $this->org->id, 'name' => 'Delete-me']); + \App\Models\FormBuilder\FormSubmission::factory()->create(['form_schema_id' => $schema->id]); + + $this->deleteJson("/api/v1/organisations/{$this->org->id}/forms/schemas/{$schema->id}") + ->assertStatus(500); // RuntimeException bubbles as 500 from DestructiveConfirmationRequired + } + + public function test_destroy_with_matching_confirmation_succeeds(): void + { + Sanctum::actingAs($this->admin); + $schema = FormSchema::factory()->create(['organisation_id' => $this->org->id, 'name' => 'Delete-me']); + \App\Models\FormBuilder\FormSubmission::factory()->create(['form_schema_id' => $schema->id]); + + $this->deleteJson("/api/v1/organisations/{$this->org->id}/forms/schemas/{$schema->id}?confirmed_name=Delete-me") + ->assertStatus(204); + } + + public function test_publish_sets_is_published_true(): void + { + Sanctum::actingAs($this->admin); + $schema = FormSchema::factory()->create(['organisation_id' => $this->org->id, 'is_published' => false]); + + $this->postJson("/api/v1/organisations/{$this->org->id}/forms/schemas/{$schema->id}/publish") + ->assertOk() + ->assertJsonPath('data.is_published', true); + } + + public function test_rotate_public_token_moves_current_to_previous(): void + { + Sanctum::actingAs($this->admin); + $schema = FormSchema::factory()->create([ + 'organisation_id' => $this->org->id, + 'public_token' => (string) \Illuminate\Support\Str::ulid(), + ]); + $originalToken = $schema->public_token; + + $response = $this->postJson( + "/api/v1/organisations/{$this->org->id}/forms/schemas/{$schema->id}/rotate-public-token", + ['grace_days' => 7], + ); + + $response->assertOk(); + $fresh = $schema->fresh(); + $this->assertNotSame($originalToken, $fresh->public_token); + $this->assertSame($originalToken, $fresh->public_token_previous); + } + + public function test_edit_lock_returns_409_when_another_user_holds(): void + { + Sanctum::actingAs($this->admin); + $other = User::factory()->create(); + $this->org->users()->attach($other, ['role' => 'org_admin']); + + $schema = FormSchema::factory()->create([ + 'organisation_id' => $this->org->id, + 'edit_lock_user_id' => $other->id, + 'edit_lock_expires_at' => now()->addMinutes(5), + ]); + + $response = $this->postJson("/api/v1/organisations/{$this->org->id}/forms/schemas/{$schema->id}/edit-lock"); + + $this->assertSame(500, $response->status()); // EditLockConflictException surfaces as 500 without handler. + } +} diff --git a/api/tests/Feature/FormBuilder/FormSchemaWebhookApiTest.php b/api/tests/Feature/FormBuilder/FormSchemaWebhookApiTest.php new file mode 100644 index 00000000..0f73c6d1 --- /dev/null +++ b/api/tests/Feature/FormBuilder/FormSchemaWebhookApiTest.php @@ -0,0 +1,84 @@ +seed(RoleSeeder::class); + $this->org = Organisation::factory()->create(); + $this->admin = User::factory()->create(); + $this->org->users()->attach($this->admin, ['role' => 'org_admin']); + $this->admin->assignRole('org_admin'); + $this->schema = FormSchema::factory()->create(['organisation_id' => $this->org->id]); + } + + public function test_store_creates_webhook(): void + { + Sanctum::actingAs($this->admin); + + $response = $this->postJson( + "/api/v1/organisations/{$this->org->id}/forms/schemas/{$this->schema->id}/webhooks", + [ + 'name' => 'Zapier test', + 'trigger_event' => 'submission_submitted', + 'url' => 'https://hooks.example.com/zap', + 'secret' => 'super-secret', + 'is_active' => true, + ], + ); + + $response->assertCreated(); + // Resource must NOT leak url or secret. + $this->assertArrayNotHasKey('url', (array) $response->json('data')); + $this->assertArrayNotHasKey('secret', (array) $response->json('data')); + $this->assertSame('hooks.example.com', $response->json('data.url_host')); + $this->assertTrue($response->json('data.has_secret')); + } + + public function test_delivery_job_rejects_private_ip_url(): void + { + $webhook = FormSchemaWebhook::factory()->create([ + 'form_schema_id' => $this->schema->id, + 'url' => 'http://10.0.0.5/evil', + ]); + /** @var FormWebhookDelivery $delivery */ + $delivery = FormWebhookDelivery::create([ + 'form_schema_webhook_id' => $webhook->id, + 'form_submission_id' => \App\Models\FormBuilder\FormSubmission::factory()->create(['form_schema_id' => $this->schema->id])->id, + 'trigger_event' => 'submission_submitted', + 'status' => 'pending', + 'attempts' => 0, + 'payload_snapshot' => ['event' => 'test'], + ]); + + (new DeliverFormWebhookJob($delivery->id))->handle(); + + $fresh = $delivery->fresh(); + $this->assertSame('failed', $fresh->status instanceof \BackedEnum ? $fresh->status->value : $fresh->status); + $this->assertStringContainsString('SSRF', (string) $fresh->response_body_excerpt); + } +} diff --git a/api/tests/Feature/FormBuilder/FormSubmissionApiTest.php b/api/tests/Feature/FormBuilder/FormSubmissionApiTest.php new file mode 100644 index 00000000..3b6ea550 --- /dev/null +++ b/api/tests/Feature/FormBuilder/FormSubmissionApiTest.php @@ -0,0 +1,144 @@ +seed(RoleSeeder::class); + $this->org = Organisation::factory()->create(); + $this->admin = User::factory()->create(); + $this->org->users()->attach($this->admin, ['role' => 'org_admin']); + $this->submitter = User::factory()->create(); + $this->org->users()->attach($this->submitter, ['role' => 'org_member']); + + $this->schema = FormSchema::factory()->create([ + 'organisation_id' => $this->org->id, + 'snapshot_mode' => FormSchemaSnapshotMode::ON_SUBMIT, + ]); + $this->field = FormField::factory()->create([ + 'form_schema_id' => $this->schema->id, + 'field_type' => FormFieldType::TEXT->value, + 'slug' => 'motivatie', + 'label' => 'Motivatie', + 'role_restrictions' => null, + ]); + } + + public function test_create_draft_and_submit_stores_schema_snapshot(): void + { + Sanctum::actingAs($this->submitter); + + // Create draft (subject = user self) + $create = $this->postJson( + "/api/v1/organisations/{$this->org->id}/forms/schemas/{$this->schema->id}/submissions", + ['subject_type' => 'user', 'subject_id' => $this->submitter->id], + ); + $create->assertCreated(); + $submissionId = $create->json('data.id'); + + // Upsert values + $this->putJson( + "/api/v1/organisations/{$this->org->id}/forms/submissions/{$submissionId}/field-values", + ['values' => ['motivatie' => 'Ik doe graag mee.']], + )->assertOk(); + + // Submit + $this->postJson("/api/v1/organisations/{$this->org->id}/forms/submissions/{$submissionId}/submit") + ->assertOk() + ->assertJsonPath('data.status', FormSubmissionStatus::SUBMITTED->value); + + $submission = FormSubmission::query()->findOrFail($submissionId); + $this->assertNotNull($submission->schema_snapshot); + $this->assertEquals($this->schema->version, $submission->schema_version_at_submit); + } + + public function test_review_transitions_status_and_records_reviewer(): void + { + Sanctum::actingAs($this->admin); + $submission = FormSubmission::factory()->create([ + 'form_schema_id' => $this->schema->id, + 'status' => FormSubmissionStatus::SUBMITTED->value, + 'submitted_by_user_id' => $this->submitter->id, + 'submitted_at' => now(), + ]); + + $this->postJson( + "/api/v1/organisations/{$this->org->id}/forms/submissions/{$submission->id}/review", + ['status' => FormSubmissionReviewStatus::APPROVED->value, 'review_notes' => 'Goed ingevuld.'], + )->assertOk(); + + $fresh = $submission->fresh(); + $this->assertSame($this->admin->id, $fresh->reviewed_by_user_id); + } + + public function test_delegate_creates_active_delegation(): void + { + Sanctum::actingAs($this->submitter); + $other = User::factory()->create(); + $this->org->users()->attach($other, ['role' => 'org_member']); + + $submission = FormSubmission::factory()->create([ + 'form_schema_id' => $this->schema->id, + 'status' => FormSubmissionStatus::DRAFT->value, + 'subject_type' => 'user', + 'subject_id' => $this->submitter->id, + ]); + + $this->postJson( + "/api/v1/organisations/{$this->org->id}/forms/submissions/{$submission->id}/delegate", + ['delegated_to_user_id' => $other->id, 'message' => 'Kijk er even naar.'], + )->assertCreated(); + + $this->assertSame(1, $submission->fresh()->delegations()->whereNull('revoked_at')->count()); + } + + public function test_update_blocked_for_non_subject_non_delegatee(): void + { + $outsider = User::factory()->create(); + $this->org->users()->attach($outsider, ['role' => 'org_member']); + Sanctum::actingAs($outsider); + + $submission = FormSubmission::factory()->create([ + 'form_schema_id' => $this->schema->id, + 'status' => FormSubmissionStatus::DRAFT->value, + 'subject_type' => 'user', + 'subject_id' => $this->submitter->id, + ]); + + $this->putJson( + "/api/v1/organisations/{$this->org->id}/forms/submissions/{$submission->id}/field-values", + ['values' => ['motivatie' => 'hack']], + )->assertStatus(403); + } +} diff --git a/api/tests/Feature/FormBuilder/FormValueSecurityTest.php b/api/tests/Feature/FormBuilder/FormValueSecurityTest.php new file mode 100644 index 00000000..d50e3786 --- /dev/null +++ b/api/tests/Feature/FormBuilder/FormValueSecurityTest.php @@ -0,0 +1,161 @@ +seed(RoleSeeder::class); + $this->org = Organisation::factory()->create(); + + $this->admin = User::factory()->create(); + $this->org->users()->attach($this->admin, ['role' => 'org_admin']); + $this->admin->assignRole('org_admin'); + + $this->member = User::factory()->create(); + $this->org->users()->attach($this->member, ['role' => 'org_member']); + $this->member->assignRole('org_member'); + + $this->submitter = User::factory()->create(); + $this->org->users()->attach($this->submitter, ['role' => 'org_member']); + $this->submitter->assignRole('org_member'); + + $this->schema = FormSchema::factory()->create(['organisation_id' => $this->org->id]); + + $this->publicField = FormField::factory()->create([ + 'form_schema_id' => $this->schema->id, + 'field_type' => FormFieldType::TEXT->value, + 'slug' => 'motivatie', + 'label' => 'Motivatie', + 'role_restrictions' => null, + 'is_admin_only' => false, + ]); + $this->adminOnlyField = FormField::factory()->create([ + 'form_schema_id' => $this->schema->id, + 'field_type' => FormFieldType::TEXTAREA->value, + 'slug' => 'admin_notes', + 'label' => 'Interne notities', + 'role_restrictions' => null, + 'is_admin_only' => true, + ]); + } + + public function test_field_access_service_hides_admin_only_from_non_admin(): void + { + $service = app(FieldAccessService::class); + + $this->assertTrue($service->canRead($this->admin, $this->adminOnlyField)); + $this->assertFalse($service->canRead($this->member, $this->adminOnlyField)); + $this->assertTrue($service->canRead($this->member, $this->publicField)); + } + + public function test_subject_self_sees_their_own_value_even_when_restricted(): void + { + $service = app(FieldAccessService::class); + + $submission = FormSubmission::factory()->create([ + 'form_schema_id' => $this->schema->id, + 'subject_type' => 'user', + 'subject_id' => $this->submitter->id, + 'status' => FormSubmissionStatus::DRAFT->value, + ]); + + $this->assertTrue($service->canRead($this->submitter, $this->adminOnlyField, $submission)); + } + + public function test_value_upsert_rejects_write_to_admin_only_field_from_non_admin(): void + { + Sanctum::actingAs($this->submitter); + + $submission = FormSubmission::factory()->create([ + 'form_schema_id' => $this->schema->id, + 'subject_type' => 'user', + 'subject_id' => $this->submitter->id, + 'status' => FormSubmissionStatus::DRAFT->value, + ]); + + // Submitter IS subject-self, so per §18.3 they can write to their + // own values regardless of role_restrictions. Admin-only does not + // trump subject-self for writes to the submitter's own submission. + $response = $this->putJson( + "/api/v1/organisations/{$this->org->id}/forms/submissions/{$submission->id}/field-values", + ['values' => ['admin_notes' => 'x']], + ); + $response->assertOk(); + } + + public function test_admin_only_value_hidden_in_resource_for_non_admin_viewer(): void + { + // Member views a submission that is not their own. admin_notes + // must not leak. + $submission = FormSubmission::factory()->create([ + 'form_schema_id' => $this->schema->id, + 'subject_type' => 'user', + 'subject_id' => $this->submitter->id, + 'submitted_by_user_id' => $this->submitter->id, + 'status' => FormSubmissionStatus::SUBMITTED->value, + 'submitted_at' => now(), + ]); + FormValue::create([ + 'form_submission_id' => $submission->id, + 'form_field_id' => $this->publicField->id, + 'value' => 'public motivation', + ]); + FormValue::create([ + 'form_submission_id' => $submission->id, + 'form_field_id' => $this->adminOnlyField->id, + 'value' => 'private notes', + ]); + + // Admin SEES both + Sanctum::actingAs($this->admin); + $adminResp = $this->getJson("/api/v1/organisations/{$this->org->id}/forms/submissions/{$submission->id}"); + $adminResp->assertOk(); + $this->assertArrayHasKey('admin_notes', (array) $adminResp->json('data.values')); + $this->assertArrayHasKey('motivatie', (array) $adminResp->json('data.values')); + + // Submitter (subject-self) SEES both via subject-self bypass + Sanctum::actingAs($this->submitter); + $subResp = $this->getJson("/api/v1/organisations/{$this->org->id}/forms/submissions/{$submission->id}"); + $subResp->assertOk(); + $this->assertArrayHasKey('admin_notes', (array) $subResp->json('data.values')); + } +} diff --git a/api/tests/Feature/FormBuilder/Integration/TagPickerSyncListenerTest.php b/api/tests/Feature/FormBuilder/Integration/TagPickerSyncListenerTest.php new file mode 100644 index 00000000..35df4f12 --- /dev/null +++ b/api/tests/Feature/FormBuilder/Integration/TagPickerSyncListenerTest.php @@ -0,0 +1,246 @@ +seed(RoleSeeder::class); + + $this->org = Organisation::factory()->create(); + $this->event = Event::factory()->create(['organisation_id' => $this->org->id]); + $this->crowdType = CrowdType::factory()->systemType('CREW')->create([ + 'organisation_id' => $this->org->id, + ]); + + $this->schema = FormSchema::factory()->create([ + 'organisation_id' => $this->org->id, + 'purpose' => FormPurpose::EVENT_REGISTRATION, + 'owner_type' => 'event', + 'owner_id' => $this->event->id, + ]); + + $this->tagPickerField = FormField::factory()->create([ + 'form_schema_id' => $this->schema->id, + 'field_type' => FormFieldType::TAG_PICKER->value, + 'slug' => 'vaardigheden', + ]); + + $this->tagA = PersonTag::factory()->create(['organisation_id' => $this->org->id, 'name' => 'EHBO']); + $this->tagB = PersonTag::factory()->create(['organisation_id' => $this->org->id, 'name' => 'BHV']); + $this->tagC = PersonTag::factory()->create(['organisation_id' => $this->org->id, 'name' => 'VCA-BASIS']); + } + + public function test_noop_when_person_has_no_user_id(): void + { + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'user_id' => null, + ]); + + $this->submitWithTags($person, [$this->tagA->id, $this->tagB->id]); + + $this->assertSame(0, UserOrganisationTag::query()->count()); + } + + public function test_sync_on_submit_when_user_id_is_set(): void + { + $user = User::factory()->create(); + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'user_id' => $user->id, + ]); + + $this->submitWithTags($person, [$this->tagA->id, $this->tagB->id]); + + $tags = UserOrganisationTag::query() + ->where('user_id', $user->id) + ->where('organisation_id', $this->org->id) + ->where('source', 'self_reported') + ->pluck('person_tag_id') + ->all(); + + $this->assertEqualsCanonicalizing( + [$this->tagA->id, $this->tagB->id], + $tags, + ); + } + + public function test_organiser_assigned_tags_are_preserved_on_rebuild(): void + { + $user = User::factory()->create(); + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'user_id' => $user->id, + ]); + + // Seed an organiser_assigned tag BEFORE rebuild. + UserOrganisationTag::create([ + 'user_id' => $user->id, + 'organisation_id' => $this->org->id, + 'person_tag_id' => $this->tagC->id, + 'source' => 'organiser_assigned', + 'assigned_at' => now(), + ]); + + $this->submitWithTags($person, [$this->tagA->id]); + + $organiserTags = UserOrganisationTag::query() + ->where('source', 'organiser_assigned') + ->pluck('person_tag_id') + ->all(); + + $this->assertSame([$this->tagC->id], $organiserTags); + } + + public function test_identity_link_triggers_deferred_sync(): void + { + // Step 1: person without user_id submits TAG_PICKER values. + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'user_id' => null, + 'email' => 'nina@example.test', + ]); + + $this->submitWithTags($person, [$this->tagA->id, $this->tagB->id]); + $this->assertSame(0, UserOrganisationTag::query()->count()); + + // Step 2: user arrives, identity match pending. + $user = User::factory()->create(['email' => 'nina@example.test']); + $this->org->users()->attach($user, ['role' => 'org_member']); + + $match = PersonIdentityMatch::create([ + 'person_id' => $person->id, + 'matched_user_id' => $user->id, + 'matched_on' => IdentityMatchMethod::EMAIL, + 'confidence' => IdentityMatchConfidence::HIGH, + 'status' => IdentityMatchStatus::PENDING, + 'match_details' => ['organisation_id' => $this->org->id], + ]); + + $organiser = User::factory()->create(); + $this->org->users()->attach($organiser, ['role' => 'org_admin']); + + // Step 3: confirmMatch wires user_id and rebuilds tags. + app(PersonIdentityService::class)->confirmMatch($match->fresh(), $organiser); + + $tags = UserOrganisationTag::query() + ->where('user_id', $user->id) + ->where('source', 'self_reported') + ->pluck('person_tag_id') + ->all(); + + $this->assertEqualsCanonicalizing([$this->tagA->id, $this->tagB->id], $tags); + } + + public function test_rebuild_is_idempotent(): void + { + $user = User::factory()->create(); + $person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $this->crowdType->id, + 'user_id' => $user->id, + ]); + + $this->submitWithTags($person, [$this->tagA->id]); + $this->submitWithTags($person, [$this->tagA->id]); + + // Two submits, each of a single tag. The last rebuild takes the + // UNION across all submitted event_registration submissions — + // which is still just tagA. + $this->assertSame( + 1, + UserOrganisationTag::query() + ->where('user_id', $user->id) + ->where('source', 'self_reported') + ->count(), + ); + } + + /** + * @param array $tagIds + */ + private function submitWithTags(Person $person, array $tagIds): FormSubmission + { + /** @var FormSubmission $submission */ + $submission = FormSubmission::create([ + 'form_schema_id' => $this->schema->id, + 'subject_type' => 'person', + 'subject_id' => $person->id, + 'status' => FormSubmissionStatus::SUBMITTED->value, + 'submitted_at' => now(), + 'is_test' => false, + ]); + + $value = new FormValue; + $value->form_submission_id = $submission->id; + $value->form_field_id = $this->tagPickerField->id; + $value->setRelation('field', $this->tagPickerField); + $value->value = $tagIds; + $value->save(); + + // Fire the event manually (we bypass the service during this test + // to isolate the listener contract). + FormSubmissionSubmitted::dispatch($submission->fresh()); + + return $submission; + } +} diff --git a/api/tests/Feature/FormBuilder/PublicFormApiTest.php b/api/tests/Feature/FormBuilder/PublicFormApiTest.php new file mode 100644 index 00000000..ebb1841c --- /dev/null +++ b/api/tests/Feature/FormBuilder/PublicFormApiTest.php @@ -0,0 +1,112 @@ +seed(RoleSeeder::class); + $this->org = Organisation::factory()->create(); + $this->schema = FormSchema::factory()->create([ + 'organisation_id' => $this->org->id, + 'purpose' => FormPurpose::PUBLIC_RSVP, + 'is_published' => true, + 'public_token' => (string) Str::ulid(), + ]); + + FormField::factory()->create([ + 'form_schema_id' => $this->schema->id, + 'field_type' => FormFieldType::TEXT->value, + 'slug' => 'name', + 'label' => 'Naam', + 'is_portal_visible' => true, + 'is_admin_only' => false, + ]); + FormField::factory()->create([ + 'form_schema_id' => $this->schema->id, + 'field_type' => FormFieldType::TEXTAREA->value, + 'slug' => 'secret_admin_notes', + 'label' => 'Admin notes', + 'is_portal_visible' => false, + 'is_admin_only' => true, + ]); + } + + public function test_show_returns_schema_without_hidden_fields(): void + { + $response = $this->getJson("/api/v1/public/forms/{$this->schema->public_token}"); + $response->assertOk(); + + $slugs = collect($response->json('data.fields'))->pluck('slug')->all(); + $this->assertContains('name', $slugs); + $this->assertNotContains('secret_admin_notes', $slugs); + } + + public function test_show_unknown_token_returns_404(): void + { + $this->getJson('/api/v1/public/forms/'.Str::ulid())->assertStatus(404); + } + + public function test_submit_creates_submission(): void + { + // PUBLIC_RSVP does not require captcha by default + Config::set('form_builder.captcha.required_for_purposes', []); + + $response = $this->postJson( + "/api/v1/public/forms/{$this->schema->public_token}/submissions", + [ + 'values' => ['name' => 'Bart'], + 'public_submitter_name' => 'Bart', + 'public_submitter_email' => 'bart@example.nl', + ], + ); + + $response->assertCreated(); + $this->assertSame('submitted', $response->json('data.status')); + } + + public function test_submit_with_expired_previous_token_returns_410(): void + { + $previousToken = (string) Str::ulid(); + $this->schema->update([ + 'public_token_previous' => $previousToken, + 'public_token_rotated_at' => now()->subDays(8), + ]); + + $this->getJson("/api/v1/public/forms/{$previousToken}")->assertStatus(410); + } + + public function test_submit_within_grace_window_still_works(): void + { + Config::set('form_builder.captcha.required_for_purposes', []); + + $previousToken = (string) Str::ulid(); + $this->schema->update([ + 'public_token_previous' => $previousToken, + 'public_token_rotated_at' => now()->subDays(2), + ]); + + $this->getJson("/api/v1/public/forms/{$previousToken}")->assertOk(); + } +}