diff --git a/CHANGELOG.md b/CHANGELOG.md index 2965dff..1679232 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [0.2.0] - 2026-08-01 +- Optional human-verification challenge on public submissions, off unless configured +- Cloudflare Turnstile ships as the first challenge provider; others plug in behind a challenge contract +- The challenge is set per form as well as by default, so forms submitted by non-browser clients can opt out +- A form carries its challenge as a driver name plus an optional settings bag, so a provider needing per-form settings does not need a schema change +- Turnstile can be pinned to a hostname per form, refusing a token minted for another site +- A challenge provider that cannot be reached answers 503 rather than rejecting the submitter +- The challenge token is verified and discarded instead of being stored with the submission +- The origin allowlist is settable for every form through config, not only per form row + ## [0.1.0] - 2026-07-23 - Polymorphic form collection: a Form definition and its submissions, each scoped to any owning model via an owner morph - Public, throttled submission endpoint that resolves a form by key and records the submission diff --git a/README.md b/README.md index 5603cef..785d9d4 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,10 @@ submission is stored, then delivered to any number of configured destinations (mail, webhook, Mautic, SmartPings, ...). Add a destination once and every form gains it. -## Install (in this monorepo) - -The package is developed under `api/packages/eloquent-forms` and wired as a path -repository in the app's `composer.json`. From `api/`: +## Install ```bash -composer install # symlinks the package and discovers the provider +composer require whilesmart/eloquent-forms php artisan migrate # creates forms + form_submissions php artisan vendor:publish --tag=eloquent-forms-config # optional ``` @@ -30,8 +27,66 @@ columns, everything else is retained in `payload`. Include two protection fields the frontend should send: -- `_gotcha` — honeypot, must be empty (configurable name) -- `_started_at` — unix-ms timestamp of when the form rendered (time-trap) +- `_gotcha`: honeypot, must be empty (configurable name) +- `_started_at`: unix-ms timestamp of when the form rendered (time-trap) + +## Challenges + +The honeypot and the time trap cost a bot nothing to defeat once someone +bothers. Set `FORMS_CHALLENGE` to require a solved human-verification challenge +as well: + +```env +FORMS_CHALLENGE=turnstile +TURNSTILE_SECRET_KEY=your-turnstile-secret-here +``` + +The frontend renders the widget with its own public site key and posts the +resulting token as `cf_turnstile_response`. The token is verified server-side, +then dropped rather than stored with the submission. + +A rejected or missing token answers 422. A provider that cannot be reached +answers 503 instead, so an outage at the provider reads as a retry rather than +as an accusation. + +Leaving `FORMS_CHALLENGE` unset runs no challenge and makes no outbound call. + +### Forms a browser does not submit + +Only a browser can render the widget and produce a token, so a challenge set in +config would otherwise lock out mobile and server-to-server clients posting to +the same API. The `challenge` column decides this per form: + +| Stored value | Effect | +| :-- | :----- | +| `null` | inherit the configured default | +| `['driver' => null]` | run no challenge on this form | +| `['driver' => 'turnstile']` | name a driver | +| `['driver' => 'turnstile', 'options' => [...]]` | and pass it settings | + +```php +Form::create(['key' => 'mobile-intake', 'challenge' => ['driver' => null]]); +``` + +A present `driver` entry always wins, including a null one. Anything else falls +through to the default, so the column follows the same rule as `destinations` +and `allowed_origins`. + +`options` belongs to the driver and nothing else interprets it. Turnstile reads +`hostname`, checking it against the host Cloudflare reports for the token so one +minted on another site cannot be replayed: + +```php +'challenge' => ['driver' => 'turnstile', 'options' => ['hostname' => 'whilesmart.com']], +``` + +Forms are created on first use, so a key that has never been submitted inherits +the config default and is challenged. Create the row ahead of the first call for +anything an API client submits. + +Add another provider by implementing +`Whilesmart\Forms\Contracts\ChallengeVerifier` and registering it in +`config('eloquent-forms.challenge_drivers')`. ## Destinations diff --git a/composer.json b/composer.json index 49eaafa..5fe0e30 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "description": "Polymorphic form collection with pluggable, agnostic delivery destinations.", "type": "library", "license": "MIT", - "version": "0.1.0", + "version": "0.2.0", "authors": [ { "name": "WhileSmart", diff --git a/config/eloquent-forms.php b/config/eloquent-forms.php index a534bc0..1fb7ad5 100644 --- a/config/eloquent-forms.php +++ b/config/eloquent-forms.php @@ -9,6 +9,15 @@ 'register_routes' => true, 'route_prefix' => env('FORMS_ROUTE_PREFIX', 'api'), + /* + | Hosts permitted to submit, comma-separated. Empty allows any origin. A + | Form row may narrow this further via its `allowed_origins` column. + */ + 'allowed_origins' => array_values(array_filter(array_map( + 'trim', + explode(',', (string) env('FORMS_ALLOWED_ORIGINS', '')) + ))), + /* | Response envelope used by the package controllers. Swap for your own | implementation of ResponseFormatterInterface to change the shape. @@ -34,6 +43,26 @@ 'rate_limit_per_minute' => env('FORMS_RATE_LIMIT', 10), // Max characters accepted for any single freeform value. 'max_value_length' => 5000, + // Human-verification challenge, resolved through `challenge_drivers`. + // Null runs no challenge; the honeypot and time trap still apply. + 'challenge' => env('FORMS_CHALLENGE'), + ], + + /* + | Challenge driver map. Each key resolves to a class implementing + | Whilesmart\Forms\Contracts\ChallengeVerifier. Add your own provider here + | and every form gains it. + */ + 'challenge_drivers' => [ + 'turnstile' => \Whilesmart\Forms\Challenges\TurnstileVerifier::class, + ], + + 'turnstile' => [ + // Only the secret belongs here. The site key is public and is baked + // into the frontend bundle that renders the widget. + 'secret' => env('TURNSTILE_SECRET_KEY'), + 'token_field' => env('TURNSTILE_TOKEN_FIELD', 'cf_turnstile_response'), + 'timeout' => env('TURNSTILE_TIMEOUT', 5), ], /* diff --git a/database/migrations/2026_01_01_000001_create_forms_table.php b/database/migrations/2026_01_01_000001_create_forms_table.php index 9f2b679..64a28c9 100644 --- a/database/migrations/2026_01_01_000001_create_forms_table.php +++ b/database/migrations/2026_01_01_000001_create_forms_table.php @@ -14,6 +14,7 @@ public function up(): void $table->string('recipient_email')->nullable(); $table->json('destinations')->nullable(); $table->json('allowed_origins')->nullable(); + $table->json('challenge')->nullable(); $table->boolean('is_active')->default(true); $table->json('meta')->nullable(); $table->nullableMorphs('owner'); diff --git a/database/migrations/2026_08_01_000001_add_challenge_to_forms_table.php b/database/migrations/2026_08_01_000001_add_challenge_to_forms_table.php new file mode 100644 index 0000000..ce52e1a --- /dev/null +++ b/database/migrations/2026_08_01_000001_add_challenge_to_forms_table.php @@ -0,0 +1,67 @@ +json('challenge')->nullable()->after('allowed_origins'); + }); + + return; + } + + if (! in_array(Schema::getColumnType('forms', 'challenge'), self::NARROW_TYPES, true)) { + return; + } + + // One row per form definition, so holding them while the column is + // swapped costs nothing and avoids a driver-specific rename. + $existing = DB::table('forms') + ->whereNotNull('challenge') + ->pluck('challenge', 'id'); + + Schema::table('forms', function (Blueprint $table) { + $table->dropColumn('challenge'); + }); + + Schema::table('forms', function (Blueprint $table) { + $table->json('challenge')->nullable()->after('allowed_origins'); + }); + + foreach ($existing as $id => $driver) { + DB::table('forms')->where('id', $id)->update([ + 'challenge' => json_encode([ + 'driver' => $driver === 'none' ? null : $driver, + ]), + ]); + } + } + + public function down(): void + { + if (Schema::hasColumn('forms', 'challenge')) { + Schema::table('forms', function (Blueprint $table) { + $table->dropColumn('challenge'); + }); + } + } +}; diff --git a/src/Challenges/ChallengeManager.php b/src/Challenges/ChallengeManager.php new file mode 100644 index 0000000..25be097 --- /dev/null +++ b/src/Challenges/ChallengeManager.php @@ -0,0 +1,39 @@ +container->make($map[$key]); + + if (! $instance instanceof ChallengeVerifier) { + throw new InvalidArgumentException("Form challenge [{$key}] must implement ChallengeVerifier."); + } + + return $instance; + } +} diff --git a/src/Challenges/TurnstileVerifier.php b/src/Challenges/TurnstileVerifier.php new file mode 100644 index 0000000..016ee28 --- /dev/null +++ b/src/Challenges/TurnstileVerifier.php @@ -0,0 +1,82 @@ + $options + */ + public function verify(string $token, ?string $ipAddress = null, array $options = []): bool + { + $secret = config('eloquent-forms.turnstile.secret'); + + if (blank($secret)) { + throw new ChallengeUnavailableException('Turnstile secret is not configured.'); + } + + try { + $response = Http::asForm() + ->timeout((int) config('eloquent-forms.turnstile.timeout', 5)) + ->post(self::VERIFY_URL, array_filter([ + 'secret' => $secret, + 'response' => $token, + 'remoteip' => $ipAddress, + ])); + } catch (Throwable $e) { + throw new ChallengeUnavailableException('Turnstile could not be reached.', 0, $e); + } + + if ($response->failed()) { + throw new ChallengeUnavailableException( + sprintf('Turnstile answered %d.', $response->status()) + ); + } + + if ($response->json('success') !== true) { + Log::info('Turnstile rejected a submission.', [ + 'errors' => $response->json('error-codes', []), + 'ip' => $ipAddress, + ]); + + return false; + } + + $expectedHostname = $options['hostname'] ?? null; + + if (filled($expectedHostname) && $response->json('hostname') !== $expectedHostname) { + Log::info('Turnstile token was solved for a different hostname.', [ + 'expected' => $expectedHostname, + 'actual' => $response->json('hostname'), + 'ip' => $ipAddress, + ]); + + return false; + } + + return true; + } +} diff --git a/src/Contracts/ChallengeVerifier.php b/src/Contracts/ChallengeVerifier.php new file mode 100644 index 0000000..f9bb912 --- /dev/null +++ b/src/Contracts/ChallengeVerifier.php @@ -0,0 +1,21 @@ + $options Driver-defined settings for this form. + */ + public function verify(string $token, ?string $ipAddress = null, array $options = []): bool; +} diff --git a/src/Exceptions/ChallengeUnavailableException.php b/src/Exceptions/ChallengeUnavailableException.php new file mode 100644 index 0000000..a3f76a1 --- /dev/null +++ b/src/Exceptions/ChallengeUnavailableException.php @@ -0,0 +1,28 @@ +expectsJson()) { + return null; + } + + return app(ResponseFormatterInterface::class)->failure( + 'Verification is unavailable. Please try again shortly.', + 503, + ); + } +} diff --git a/src/FormsServiceProvider.php b/src/FormsServiceProvider.php index 872885c..206b2b5 100644 --- a/src/FormsServiceProvider.php +++ b/src/FormsServiceProvider.php @@ -7,6 +7,7 @@ use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; +use Whilesmart\Forms\Challenges\ChallengeManager; use Whilesmart\Forms\Destinations\DestinationManager; use Whilesmart\Forms\Interfaces\ResponseFormatterInterface; @@ -29,6 +30,11 @@ public function register(): void DestinationManager::class, fn ($app) => new DestinationManager($app) ); + + $this->app->singleton( + ChallengeManager::class, + fn ($app) => new ChallengeManager($app) + ); } public function boot(): void diff --git a/src/Http/Controllers/FormSubmissionController.php b/src/Http/Controllers/FormSubmissionController.php index 19e388d..67f0ce7 100644 --- a/src/Http/Controllers/FormSubmissionController.php +++ b/src/Http/Controllers/FormSubmissionController.php @@ -2,8 +2,11 @@ namespace Whilesmart\Forms\Http\Controllers; +use Illuminate\Http\JsonResponse; use Illuminate\Routing\Controller; use Illuminate\Support\Str; +use Whilesmart\Forms\Challenges\ChallengeManager; +use Whilesmart\Forms\Contracts\ChallengeVerifier; use Whilesmart\Forms\Events\FormSubmittedEvent; use Whilesmart\Forms\Http\Requests\SubmitFormRequest; use Whilesmart\Forms\Jobs\ProcessFormSubmission; @@ -15,7 +18,7 @@ class FormSubmissionController extends Controller { use ApiResponse; - public function store(SubmitFormRequest $request, string $key) + public function store(SubmitFormRequest $request, ChallengeManager $challenges, string $key) { $form = Form::firstOrCreate( ['key' => $key], @@ -30,7 +33,20 @@ public function store(SubmitFormRequest $request, string $key) return $this->failure('Origin not allowed.', 403); } - $payload = $request->except(['_started_at']); + $verifier = $challenges->verifier($form->challengeKey()); + + if ($verifier !== null) { + $failure = $this->challengeFailure($request, $verifier, $form->challengeOptions()); + + if ($failure !== null) { + return $failure; + } + } + + $payload = $request->except(array_filter([ + '_started_at', + $verifier?->tokenField(), + ])); $submission = new FormSubmission([ 'form_id' => $form->id, @@ -58,6 +74,31 @@ public function store(SubmitFormRequest $request, string $key) ); } + /** + * Null when the challenge was solved. A provider that cannot be reached + * throws instead, answering 503 rather than blaming the submitter. + */ + /** + * @param array $options + */ + private function challengeFailure( + SubmitFormRequest $request, + ChallengeVerifier $verifier, + array $options, + ): ?JsonResponse { + $token = (string) $request->input($verifier->tokenField(), ''); + + if ($token === '') { + return $this->failure('Please complete the verification challenge.', 422); + } + + if (! $verifier->verify($token, $request->ip(), $options)) { + return $this->failure('Verification failed. Please try again.', 422); + } + + return null; + } + private function originAllowed(SubmitFormRequest $request, Form $form): bool { $allowed = $form->allowed_origins diff --git a/src/Models/Form.php b/src/Models/Form.php index 0ec9b5c..f7e0777 100644 --- a/src/Models/Form.php +++ b/src/Models/Form.php @@ -9,14 +9,16 @@ /** * A named form definition. Optional: submissions can arrive for a bare key and * a Form row is resolved or created on the fly. When present, a Form row lets - * you scope recipients, allowed origins and destinations per form, and attach - * the form to any owning model (page, product, workspace) polymorphically. + * you scope recipients, allowed origins, destinations and the human-verification + * challenge per form, and attach the form to any owning model (page, product, + * workspace) polymorphically. * * @property string $key * @property string|null $name * @property string|null $recipient_email * @property array|null $destinations * @property array|null $allowed_origins + * @property array|null $challenge * @property bool $is_active * @property array|null $meta */ @@ -28,6 +30,7 @@ class Form extends Model 'recipient_email', 'destinations', 'allowed_origins', + 'challenge', 'is_active', 'meta', ]; @@ -37,6 +40,7 @@ protected function casts(): array return [ 'destinations' => 'array', 'allowed_origins' => 'array', + 'challenge' => 'array', 'is_active' => 'boolean', 'meta' => 'array', ]; @@ -52,6 +56,50 @@ public function submissions(): HasMany return $this->hasMany(FormSubmission::class); } + /** + * Effective challenge driver for this form, or null to run none. + * + * The stored document names a driver and, optionally, settings for it: + * + * null inherit the configured default + * ['driver' => null] run no challenge on this form + * ['driver' => 'turnstile'] name a driver + * ['driver' => 'turnstile', 'options' => []] and pass it settings + * + * A present `driver` entry always wins, including a null one, which is how + * a form submitted by a non-browser client opts out of an install-wide + * default. Anything else falls through to that default. + */ + public function challengeKey(): ?string + { + $config = $this->challenge; + + if (is_array($config) && array_key_exists('driver', $config)) { + return blank($config['driver']) ? null : (string) $config['driver']; + } + + $default = config('eloquent-forms.protection.challenge'); + + return blank($default) ? null : (string) $default; + } + + /** + * Settings handed to this form's challenge driver. Their meaning belongs to + * the driver, so nothing here interprets them. + * + * @return array + */ + public function challengeOptions(): array + { + $config = $this->challenge; + + if (! is_array($config) || ! isset($config['options']) || ! is_array($config['options'])) { + return []; + } + + return $config['options']; + } + /** * Effective destination keys for this form: the form's own list when set, * otherwise the package default from config. diff --git a/tests/Feature/ChallengeColumnMigrationTest.php b/tests/Feature/ChallengeColumnMigrationTest.php new file mode 100644 index 0000000..c400b17 --- /dev/null +++ b/tests/Feature/ChallengeColumnMigrationTest.php @@ -0,0 +1,70 @@ +up(); + } + + #[Test] + public function it_is_a_no_op_when_the_column_is_already_present(): void + { + $form = Form::create(['key' => 'contact', 'challenge' => ['driver' => 'turnstile']]); + + $this->runMigration(); + + $this->assertTrue(Schema::hasColumn('forms', 'challenge')); + $this->assertSame(['driver' => 'turnstile'], $form->fresh()->challenge); + } + + #[Test] + public function it_adds_the_column_when_an_older_table_lacks_it(): void + { + Schema::table('forms', function (Blueprint $table) { + $table->dropColumn('challenge'); + }); + $this->assertFalse(Schema::hasColumn('forms', 'challenge')); + + $this->runMigration(); + + $this->assertTrue(Schema::hasColumn('forms', 'challenge')); + + Form::create(['key' => 'contact', 'challenge' => ['driver' => null]]); + $this->assertSame(['driver' => null], Form::first()->challenge); + } + + #[Test] + public function it_widens_a_single_driver_column_and_carries_its_values(): void + { + Schema::table('forms', function (Blueprint $table) { + $table->dropColumn('challenge'); + }); + Schema::table('forms', function (Blueprint $table) { + $table->string('challenge')->nullable(); + }); + + DB::table('forms')->insert([ + ['key' => 'contact', 'challenge' => 'turnstile', 'is_active' => true], + ['key' => 'mobile', 'challenge' => 'none', 'is_active' => true], + ['key' => 'inherits', 'challenge' => null, 'is_active' => true], + ]); + + $this->runMigration(); + + $this->assertSame(['driver' => 'turnstile'], Form::where('key', 'contact')->first()->challenge); + $this->assertSame(['driver' => null], Form::where('key', 'mobile')->first()->challenge); + $this->assertNull(Form::where('key', 'inherits')->first()->challenge); + } +} diff --git a/tests/Feature/ChallengeVerificationTest.php b/tests/Feature/ChallengeVerificationTest.php new file mode 100644 index 0000000..5127b22 --- /dev/null +++ b/tests/Feature/ChallengeVerificationTest.php @@ -0,0 +1,213 @@ + '2026-08-01T15:00:29.021Z', + 'error-codes' => [], + 'hostname' => 'example.com', + 'metadata' => ['result_with_testing_key' => true], + 'success' => true, + ]; + + /** + * Captured from a live siteverify call against the always-failing secret. + */ + private const REJECTED = [ + 'error-codes' => ['invalid-input-response'], + 'success' => false, + 'messages' => [], + 'metadata' => ['result_with_testing_key' => true], + ]; + + protected function defineEnvironment($app) + { + parent::defineEnvironment($app); + + $app['config']->set('eloquent-forms.protection.challenge', 'turnstile'); + $app['config']->set('eloquent-forms.turnstile.secret', 'test-secret'); + } + + #[Test] + public function a_solved_challenge_is_accepted(): void + { + Http::fake([self::VERIFY_URL => Http::response(self::ACCEPTED)]); + + $this->postJson('/api/forms/contact/submissions', [ + 'name' => 'Ada Lovelace', + 'email' => 'ada@example.com', + 'message' => 'I would like a demo.', + 'cf_turnstile_response' => 'a-token-from-the-widget', + ])->assertCreated(); + + $this->assertSame(1, FormSubmission::count()); + } + + #[Test] + public function the_challenge_token_is_not_kept_in_the_payload(): void + { + Http::fake([self::VERIFY_URL => Http::response(self::ACCEPTED)]); + + $this->postJson('/api/forms/contact/submissions', [ + 'message' => 'I would like a demo.', + 'cf_turnstile_response' => 'a-token-from-the-widget', + ])->assertCreated(); + + $this->assertArrayNotHasKey('cf_turnstile_response', FormSubmission::first()->payload); + } + + #[Test] + public function a_rejected_token_is_refused(): void + { + Http::fake([self::VERIFY_URL => Http::response(self::REJECTED)]); + + $this->postJson('/api/forms/contact/submissions', [ + 'message' => 'buy now', + 'cf_turnstile_response' => 'a-stale-token', + ])->assertStatus(422); + + $this->assertSame(0, FormSubmission::count()); + } + + #[Test] + public function a_missing_token_is_refused(): void + { + Http::fake([self::VERIFY_URL => Http::response(self::ACCEPTED)]); + + $this->postJson('/api/forms/contact/submissions', [ + 'message' => 'no challenge solved', + ])->assertStatus(422); + + $this->assertSame(0, FormSubmission::count()); + } + + #[Test] + public function an_unreachable_provider_answers_service_unavailable(): void + { + Http::fake([self::VERIFY_URL => Http::response('gateway down', 502)]); + + $this->postJson('/api/forms/contact/submissions', [ + 'message' => 'I would like a demo.', + 'cf_turnstile_response' => 'a-token-from-the-widget', + ])->assertStatus(503); + + $this->assertSame(0, FormSubmission::count()); + } + + #[Test] + public function a_missing_secret_answers_service_unavailable(): void + { + config()->set('eloquent-forms.turnstile.secret', null); + + $this->postJson('/api/forms/contact/submissions', [ + 'message' => 'I would like a demo.', + 'cf_turnstile_response' => 'a-token-from-the-widget', + ])->assertStatus(503); + + $this->assertSame(0, FormSubmission::count()); + } + + #[Test] + public function no_configured_challenge_leaves_submissions_untouched(): void + { + config()->set('eloquent-forms.protection.challenge', null); + Http::fake(); + + $this->postJson('/api/forms/contact/submissions', [ + 'message' => 'I would like a demo.', + ])->assertCreated(); + + Http::assertNothingSent(); + } + + #[Test] + public function a_form_can_opt_out_of_the_configured_challenge(): void + { + Form::create(['key' => 'mobile-intake', 'challenge' => ['driver' => null]]); + Http::fake(); + + $this->postJson('/api/forms/mobile-intake/submissions', [ + 'message' => 'posted by a client that cannot render a widget', + ])->assertCreated(); + + Http::assertNothingSent(); + } + + #[Test] + public function opting_one_form_out_leaves_the_others_challenged(): void + { + Form::create(['key' => 'mobile-intake', 'challenge' => ['driver' => null]]); + Http::fake([self::VERIFY_URL => Http::response(self::ACCEPTED)]); + + $this->postJson('/api/forms/mobile-intake/submissions', ['message' => 'no token']) + ->assertCreated(); + + $this->postJson('/api/forms/contact/submissions', ['message' => 'no token']) + ->assertStatus(422); + } + + #[Test] + public function a_form_can_require_a_challenge_the_default_does_not(): void + { + config()->set('eloquent-forms.protection.challenge', null); + Form::create(['key' => 'guarded', 'challenge' => ['driver' => 'turnstile']]); + Http::fake([self::VERIFY_URL => Http::response(self::ACCEPTED)]); + + $this->postJson('/api/forms/open/submissions', ['message' => 'no token needed']) + ->assertCreated(); + + $this->postJson('/api/forms/guarded/submissions', ['message' => 'no token']) + ->assertStatus(422); + + $this->postJson('/api/forms/guarded/submissions', [ + 'message' => 'token supplied', + 'cf_turnstile_response' => 'a-token-from-the-widget', + ])->assertCreated(); + } + + #[Test] + public function driver_options_are_carried_from_the_form(): void + { + Form::create([ + 'key' => 'pinned', + 'challenge' => ['driver' => 'turnstile', 'options' => ['hostname' => 'example.com']], + ]); + Http::fake([self::VERIFY_URL => Http::response(self::ACCEPTED)]); + + $this->postJson('/api/forms/pinned/submissions', [ + 'message' => 'solved on the expected host', + 'cf_turnstile_response' => 'a-token-from-the-widget', + ])->assertCreated(); + } + + #[Test] + public function a_token_solved_for_another_hostname_is_refused(): void + { + Form::create([ + 'key' => 'pinned', + 'challenge' => ['driver' => 'turnstile', 'options' => ['hostname' => 'whilesmart.com']], + ]); + Http::fake([self::VERIFY_URL => Http::response(self::ACCEPTED)]); + + $this->postJson('/api/forms/pinned/submissions', [ + 'message' => 'token minted elsewhere', + 'cf_turnstile_response' => 'a-token-from-the-widget', + ])->assertStatus(422); + + $this->assertSame(0, FormSubmission::count()); + } +}