presseportale/tests/Feature/Auth/RegistrationSpamProtectionTest.php
Kevin Adametz 06d2889a4d feat: Spamschutz für Kontakt-Widget und Registrierung mit Cloudflare Turnstile
- Turnstile für Gäste und unbestätigte Konten (TurnstileVerifier,
  <x-turnstile>); ist Cloudflare nicht erreichbar, wird durchgelassen und
  im Verlauf, in der Kontakt-Queue und in der Betriebsüberwachung markiert
- Inhaltsfilter des Kontaktformulars (fremde Links, Floskel mit
  Seitentitel, Zeichensalat, Sperrliste, Absenden unter 3 Sekunden);
  Treffer landen in der Spam-Quarantäne ohne Mail, Löschung nach 30 Tagen
- Admin: Filter „Spam“, Sammelaktion, „Kein Spam“ holt die Mail nach
- Registrierung lehnt Zufallsnamen ab; users:prune-bot-registrations
  (täglicher Lauf erst mit USERS_PRUNE_BOT_REGISTRATIONS=true)
- contact-messages:quarantine-spam für den Altbestand
- CSP erlaubt challenges.cloudflare.com, Log-Kanal spam
- Datenschutzerklärung: Abschnitt 6a Cloudflare Turnstile (Vorschlag)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:49:58 +00:00

132 lines
4.9 KiB
PHP

<?php
use App\Enums\ActivityType;
use App\Models\ActivityEvent;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Notification;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
use Tests\TestCase;
/*
* Spamschutz der Registrierung (Spamschutz B4): 389 Bot-Konten in 30 Tagen
* bis 07.10.2026, jedes mit einer Bestätigungsmail an eine fremde Adresse.
*/
function enableRegistrationTurnstile(): void
{
config()->set('services.turnstile.site_key', '0x4AAAAAAAsitekey');
config()->set('services.turnstile.secret_key', '0x4AAAAAAAsecretkeyforthetests');
config()->set('services.turnstile.fail_open', true);
}
function registrationForm(string $name = 'Paula Presse'): Testable
{
return Livewire::test('auth.register')
->set('name', $name)
->set('email', 'paula@example.com')
->set('password', 'sicheres-passwort-2026')
->set('password_confirmation', 'sicheres-passwort-2026')
->set('terms_accepted', true);
}
test('registration with a confirmed turnstile token creates the account', function () {
/** @var TestCase $this */
Notification::fake();
enableRegistrationTurnstile();
Http::fake(['challenges.cloudflare.com/*' => Http::response(['success' => true, 'action' => 'register'])]);
registrationForm()
->set('turnstileToken', 'token-aus-dem-widget')
->call('register')
->assertHasNoErrors()
->assertRedirect(route('verification.notice', absolute: false));
expect(User::query()->where('email', 'paula@example.com')->exists())->toBeTrue()
->and(ActivityEvent::query()->where('type', ActivityType::BotCheckUnavailable->value)->count())->toBe(0);
});
test('registration without a confirmed token creates no account and sends no mail', function (string $token, array $siteverify) {
/** @var TestCase $this */
Notification::fake();
enableRegistrationTurnstile();
Http::fake(['challenges.cloudflare.com/*' => Http::response($siteverify)]);
registrationForm()
->set('turnstileToken', $token)
->call('register')
->assertHasErrors('turnstile')
->assertDispatched('turnstile-reset')
->assertSet('turnstileToken', '');
expect(User::query()->where('email', 'paula@example.com')->exists())->toBeFalse();
Notification::assertNothingSent();
})->with([
'no token' => ['', ['success' => true, 'action' => 'register']],
'rejected token' => ['token', ['success' => false, 'error-codes' => ['invalid-input-response']]],
'token for the contact form' => ['token', ['success' => true, 'action' => 'contact']],
]);
test('if cloudflare is unreachable the account is created and marked for review', function () {
/** @var TestCase $this */
Notification::fake();
enableRegistrationTurnstile();
Http::fake(['challenges.cloudflare.com/*' => Http::failedConnection()]);
registrationForm()
->set('turnstileToken', 'token-aus-dem-widget')
->call('register')
->assertHasNoErrors();
$user = User::query()->where('email', 'paula@example.com')->sole();
$event = ActivityEvent::query()->where('type', ActivityType::BotCheckUnavailable->value)->sole();
expect($event->user_id)->toBe($user->id)
->and($event->meta)->toMatchArray(['form' => 'register', 'reason' => 'siteverify_unreachable']);
});
test('if the widget could not load in the browser the account is created and marked', function () {
/** @var TestCase $this */
Notification::fake();
enableRegistrationTurnstile();
Http::fake();
registrationForm()
->set('turnstileUnavailable', true)
->call('register')
->assertHasNoErrors();
Http::assertNothingSent();
expect(ActivityEvent::query()->where('type', ActivityType::BotCheckUnavailable->value)->sole()->meta['reason'])
->toBe('widget_not_loaded');
});
test('random bot names are rejected', function (string $name) {
/** @var TestCase $this */
Notification::fake();
registrationForm($name)->call('register')->assertHasErrors('name');
expect(User::query()->count())->toBe(0);
})->with(['ruLrlotwaCoVStmZetSxD', 'VJxlVxkqQzgMfJPaM', 'GeCWsvfRZNHeNXubB']);
test('real names pass the name check', function (string $name) {
/** @var TestCase $this */
Notification::fake();
registrationForm($name)->call('register')->assertHasNoErrors();
})->with(['Paula Presse', 'Anna-Lena Müller-Lüdenscheidt', 'Dr. Jabena B. McNair', 'MacGregor', 'Sky']);
test('the registration page renders the widget only with keys', function () {
/** @var TestCase $this */
// Ohne Keys kein Widget (der Livewire-Snapshot nennt die Properties trotzdem).
$this->get('/register')->assertSuccessful()->assertDontSee('pkTurnstileReady', false);
enableRegistrationTurnstile();
$this->get('/register')
->assertSuccessful()
->assertSee('0x4AAAAAAAsitekey', false)
->assertSee('pkTurnstileReady', false);
});