set('services.turnstile.site_key', '0x4AAAAAAAsitekey'); config()->set('services.turnstile.secret_key', '0x4AAAAAAAsecretkeyforthetests'); config()->set('services.turnstile.fail_open', true); } /** * @param array $body */ function fakeSiteverify(array $body): void { Http::fake(['challenges.cloudflare.com/*' => Http::response($body)]); } /** * @param array $overrides * @return array */ function contactPayload(array $overrides = []): array { return [ 'name' => 'Paula Presse', 'email' => 'paula@example.test', 'message' => 'Ich habe eine Frage zu Ihrer Plattform und bitte um Rückruf.', 'cf-turnstile-response' => 'token-aus-dem-widget', ...$overrides, ]; } test('a guest request with a confirmed turnstile token is accepted and mailed', function () { /** @var TestCase $this */ Mail::fake(); enableTurnstile(); fakeSiteverify(['success' => true, 'action' => 'contact', 'hostname' => 'businessportal24.test']); $this->post('https://businessportal24.test/de/kontakt', contactPayload()) ->assertSessionHasNoErrors() ->assertSessionHas('contact-status'); $message = ContactMessage::query()->sole(); expect($message->status)->toBe(ContactMessageStatus::Open) ->and($message->payload)->toBe(['bot_check' => 'passed']); Mail::assertQueued(ContactRequestSubmitted::class); Http::assertSent(fn (HttpRequest $request) => $request->url() === 'https://challenges.cloudflare.com/turnstile/v0/siteverify' && $request['secret'] === '0x4AAAAAAAsecretkeyforthetests' && $request['response'] === 'token-aus-dem-widget' && $request['remoteip'] === '127.0.0.1'); }); test('a guest request without a confirmed token is rejected with a visible error', function (array $overrides, ?array $siteverify) { /** @var TestCase $this */ Mail::fake(); enableTurnstile(); fakeSiteverify($siteverify ?? ['success' => true, 'action' => 'contact']); $this->post('https://businessportal24.test/de/kontakt', contactPayload($overrides)) ->assertSessionHasErrors(['turnstile' => __('web.launcher.bot_check_failed')]); expect(ContactMessage::query()->count())->toBe(0); Mail::assertNothingOutgoing(); })->with([ 'no token' => [['cf-turnstile-response' => ''], null], 'token rejected by cloudflare' => [[], ['success' => false, 'error-codes' => ['invalid-input-response']]], 'token already used' => [[], ['success' => false, 'error-codes' => ['timeout-or-duplicate']]], 'token solved for another form' => [[], ['success' => true, 'action' => 'register']], ]); test('if cloudflare is unreachable the request is accepted and marked for review', function (array $overrides, mixed $fake) { /** @var TestCase $this */ Mail::fake(); enableTurnstile(); Http::fake(['challenges.cloudflare.com/*' => $fake]); $this->post('https://presseecho.test/de/kontakt', contactPayload($overrides)) ->assertSessionHasNoErrors() ->assertSessionHas('contact-status'); $message = ContactMessage::query()->sole(); expect($message->status)->toBe(ContactMessageStatus::Open) ->and($message->skippedBotCheck())->toBeTrue(); Mail::assertQueued(ContactRequestSubmitted::class); $event = ActivityEvent::query()->where('type', ActivityType::BotCheckUnavailable->value)->sole(); expect($event->subject_id)->toBe($message->id) ->and($event->description)->toContain($message->reference()); })->with([ 'connection fails' => [[], fn () => Http::failedConnection()], 'siteverify answers 503' => [[], fn () => Http::response('', 503)], 'siteverify internal error' => [[], fn () => Http::response(['success' => false, 'error-codes' => ['internal-error']])], 'widget could not load in the browser' => [['cf-turnstile-response' => '', 'turnstile_unavailable' => '1'], fn () => Http::response(['success' => false])], ]); test('a rejected secret key is our fault: the request passes, marked, and the error is logged', function () { /** @var TestCase $this */ Mail::fake(); enableTurnstile(); fakeSiteverify(['success' => false, 'error-codes' => ['invalid-input-secret']]); Log::spy(); Log::shouldReceive('channel')->andReturnSelf(); $this->post('https://presseecho.test/de/kontakt', contactPayload())->assertSessionHasNoErrors(); expect(ContactMessage::query()->sole()->skippedBotCheck())->toBeTrue(); Log::shouldHaveReceived('error')->withArgs(fn (string $message) => str_contains($message, 'TURNSTILE_SECRET_KEY')); }); test('with fail_open disabled an unreachable cloudflare blocks the request', function () { /** @var TestCase $this */ Mail::fake(); enableTurnstile(); config()->set('services.turnstile.fail_open', false); Http::fake(['challenges.cloudflare.com/*' => Http::failedConnection()]); $this->post('https://presseecho.test/de/kontakt', contactPayload())->assertSessionHasErrors('turnstile'); expect(ContactMessage::query()->count())->toBe(0); }); test('verified customers skip turnstile and the content filter', function () { /** @var TestCase $this */ Mail::fake(); enableTurnstile(); Http::fake(); $this->actingAs(User::factory()->create()) ->post('https://pressekonto.test/de/kontakt', contactPayload([ 'cf-turnstile-response' => '', 'message' => 'Unsere Website https://beispiel-firma.de zeigt das falsche Logo, bitte prüfen.', ])) ->assertSessionHasNoErrors(); expect(ContactMessage::query()->sole()->status)->toBe(ContactMessageStatus::Open); Http::assertNothingSent(); Mail::assertQueued(ContactRequestSubmitted::class); }); test('unconfirmed accounts are checked like guests', function () { /** @var TestCase $this */ Mail::fake(); enableTurnstile(); Http::fake(); $this->actingAs(User::factory()->unverified()->create()) ->post('https://pressekonto.test/de/kontakt', contactPayload(['cf-turnstile-response' => ''])) ->assertSessionHasErrors('turnstile'); }); test('spam from guests is quarantined silently: stored, no mail, same confirmation', function (array $overrides, string $reason) { /** @var TestCase $this */ Mail::fake(); $this->post('https://businessportal24.test/de/kontakt', contactPayload($overrides)) ->assertSessionHasNoErrors() ->assertSessionHas('contact-status', __('web.launcher.success')); $message = ContactMessage::query()->sole(); expect($message->status)->toBe(ContactMessageStatus::Spam) ->and($message->payload['spam_reason'])->toBe($reason) ->and($message->status->isOpen())->toBeFalse(); Mail::assertNothingOutgoing(); expect(ActivityEvent::query()->where('type', ActivityType::ContactRequest->value)->count())->toBe(0); })->with([ 'link' => [['message' => 'Rolls-Royce Phantom for $15,000 https://telegra.ph/How-to-get-a-new-2026'], 'link'], 'bare domain with path' => [['message' => 'Mehr Besucher für Ihre Seite: indexhelper.live/angebot jetzt buchen'], 'link'], 'html link' => [['message' => 'Президент представляет'], 'link'], 'phrase plus page title' => [['message' => 'Ich wünsche weitere Informationen. Bitte kontaktieren Sie mich per E-Mail — aktuelle pressemitteilungen aus der deutschen wirtschaft.'], 'page_title'], 'random name' => [['name' => 'ruLrlotwaCoVStmZetSxD'], 'gibberish'], 'letter-digit block' => [['name' => 'NAEWTRER2720160NEYRTHYT', 'message' => 'METRYTRE2720160MAWRERGTRH'], 'gibberish'], ]); test('a form submitted faster than a human can type is quarantined', function () { /** @var TestCase $this */ Mail::fake(); $this->post('https://businessportal24.test/de/kontakt', contactPayload(['form_started' => ContactSpamFilter::startedToken()])) ->assertSessionHas('contact-status'); expect(ContactMessage::query()->sole()->payload['spam_reason'])->toBe('too_fast'); Mail::assertNothingOutgoing(); }); test('a link to one of our own pages is not spam, even next to the company website', function () { /** @var TestCase $this */ Mail::fake(); $this->post('https://businessportal24.test/de/kontakt', contactPayload([ 'message' => 'Bitte korrigieren Sie im Newsroom die Website auf https://praxis-webseiten.com, ' .'betrifft https://businessportal24.test/de/pm/meine-meldung-123 — danke!', ]))->assertSessionHasNoErrors(); expect(ContactMessage::query()->sole()->status)->toBe(ContactMessageStatus::Open); Mail::assertQueued(ContactRequestSubmitted::class); }); test('a form filled in at human speed passes the time check', function () { /** @var TestCase $this */ Mail::fake(); $started = ContactSpamFilter::startedToken(); $this->travel(20)->seconds(); $this->post('https://businessportal24.test/de/kontakt', contactPayload(['form_started' => $started])) ->assertSessionHasNoErrors(); expect(ContactMessage::query()->sole()->status)->toBe(ContactMessageStatus::Open); }); test('a forged time stamp is ignored rather than treated as spam', function () { /** @var TestCase $this */ Mail::fake(); $this->post('https://businessportal24.test/de/kontakt', contactPayload(['form_started' => 'manipuliert'])) ->assertSessionHasNoErrors(); expect(ContactMessage::query()->sole()->status)->toBe(ContactMessageStatus::Open); }); test('the launcher renders the turnstile widget and the time stamp for guests only', function () { /** @var TestCase $this */ enableTurnstile(); $this->get('https://businessportal24.test/de/faq') ->assertSuccessful() ->assertSee('0x4AAAAAAAsitekey', false) ->assertSee('name="turnstile_unavailable"', false) ->assertSee('name="form_started"', false); $this->actingAs(User::factory()->create()) ->get('https://businessportal24.test/de/faq') ->assertSuccessful() ->assertDontSee('name="turnstile_unavailable"', false) ->assertDontSee('name="form_started"', false); }); test('without keys no widget is rendered', function () { /** @var TestCase $this */ $this->get('https://businessportal24.test/de/faq') ->assertSuccessful() ->assertDontSee('name="turnstile_unavailable"', false); }); test('quarantined spam is pruned after 30 days, everything else stays', function () { /** @var TestCase $this */ $oldSpam = ContactMessage::factory()->create(['status' => ContactMessageStatus::Spam, 'created_at' => now()->subDays(31)]); $freshSpam = ContactMessage::factory()->create(['status' => ContactMessageStatus::Spam, 'created_at' => now()->subDays(29)]); $oldOpen = ContactMessage::factory()->create(['created_at' => now()->subDays(90)]); $this->artisan('model:prune', ['--model' => [ContactMessage::class]])->assertSuccessful(); expect(ContactMessage::query()->pluck('id')->all())->toEqualCanonicalizing([$freshSpam->id, $oldOpen->id]) ->and(ContactMessage::query()->find($oldSpam->id))->toBeNull(); }); test('the csp allows the turnstile script and challenge frame', function () { /** @var TestCase $this */ config()->set('security.csp.report_only', true); $policy = (string) $this->get('https://presseecho.test/de')->headers->get('Content-Security-Policy-Report-Only'); expect(Str::between($policy, 'script-src', ';'))->toContain('https://challenges.cloudflare.com') ->and(Str::between($policy, 'frame-src', ';'))->toContain('https://challenges.cloudflare.com'); });