mivita/tests/Feature/ContactFormSpamTest.php
Kevin Adametz 4c944aa15d Spamschutz Kontakt + Registrierung: Turnstile, Rate-Limit, Honeypot; Passwort min. 8
- Kontaktformular: keine Mail mehr an die eingegebene Adresse (wurde als
  Spam-Versender missbraucht); Anfrage nur an Kontakt-Adresse + Berater (BCC),
  Reply-To = Absender; Eingaben in der Mail escaped
- Kontakt + Registrierung: Cloudflare Turnstile statt reCAPTCHA (Schluessel
  aus dem Code in die .env), Rate-Limits, Honeypot + Mindest-Ausfuellzeit,
  Namensregel (lateinische Schrift, max. 50 Zeichen), Form Requests
- Registrierung: Partner-Nummer nur fuer aktive Berater (vorher ungeprueft)
- CRM: POST /register abgeschaltet (legte ohne Captcha Benutzer an)
- Passwort-Mindestlaenge systemweit 8 Zeichen ueber Password::defaults()
- Log abgefangener Anfragen: storage/logs/form-spam-*.log
- Doku und Protokoll: dev/2026-10-09-spam/README.md

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

240 lines
8.8 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Http\Middleware\SubdomainResolver;
use App\Mail\MailContact;
use App\Models\UserShop;
use App\Services\FormSpamGuard;
use App\Services\TurnstileVerifier;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
config([
'app.contact_mail' => 'kontakt@mivita.test',
'services.turnstile.site_key' => 'site-key',
'services.turnstile.secret_key' => 'secret-key',
]);
Mail::fake();
$this->turnstileResponse = fn () => Http::response(['success' => true]);
Http::fake(['challenges.cloudflare.com/*' => fn () => ($this->turnstileResponse)()]);
});
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
function contactPayload(array $overrides = []): array
{
return array_merge([
'salutation' => 'ms',
'first_name' => 'Anna',
'last_name' => 'Müller',
'email' => 'anna@example.com',
'phone' => '+49 (0) 171 / 123-456',
'message' => 'Ich habe eine Frage zu euren Produkten.',
'sales_partnership' => 'NEIN',
'accepted_data_protection' => '1',
'cf-turnstile-response' => 'token',
FormSpamGuard::TIMESTAMP_FIELD => encrypt(now()->timestamp - 10),
FormSpamGuard::HONEYPOT_FIELD => '',
], $overrides);
}
it('zeigt das Formular mit Turnstile statt reCAPTCHA', function () {
$this->get('/kontakt')
->assertOk()
->assertSee('cf-turnstile', false)
->assertSee('data-sitekey="site-key"', false)
->assertSee(FormSpamGuard::HONEYPOT_FIELD, false)
->assertDontSee('g-recaptcha', false);
});
it('schickt die Anfrage nur an mivita, nie an die eingegebene Adresse', function () {
$this->post('/kontakt', contactPayload())
->assertOk()
->assertSee(__('website.thank_you_for_your_request'));
Mail::assertSent(MailContact::class, 1);
Mail::assertSent(MailContact::class, function (MailContact $mail) {
return $mail->hasTo('kontakt@mivita.test')
&& ! $mail->hasTo('anna@example.com')
&& ! $mail->hasBcc('anna@example.com')
&& $mail->hasReplyTo('anna@example.com', 'Anna Müller');
});
Http::assertSent(fn ($request) => $request['secret'] === 'secret-key' && $request['response'] === 'token');
});
it('schickt die Anfrage aus einem Berater-Shop zusätzlich an den Berater', function () {
$consultant = User::forceCreate([
'email' => 'berater@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
]);
$userShop = UserShop::create([
'user_id' => $consultant->id,
'name' => 'Freshzone',
'slug' => 'freshzone',
'active' => true,
]);
$this->withoutMiddleware(SubdomainResolver::class)
->withSession(['user_shop' => $userShop])
->post('/kontakt', contactPayload())
->assertOk();
Mail::assertSent(MailContact::class, function (MailContact $mail) {
return $mail->hasTo('kontakt@mivita.test')
&& $mail->hasBcc('berater@example.com')
&& ! $mail->hasTo('anna@example.com')
&& str_contains($mail->subject, 'freshzone.mivita.care');
});
});
it('lehnt die Spam-Anfrage aus dem Beispiel ab', function () {
$spam = 'Приветствуем! Напоминаем, что на вашем бонусном счёте находится 926USD. '
."Воспользоваться бонусом: https://share.google/O8hzS8W7vPuroPXdL\nВсего доброго, команда сайта";
$this->from('/kontakt')
->post('/kontakt', contactPayload([
'first_name' => $spam,
'last_name' => $spam,
'email' => 'igor.makhataev@icloud.com',
'phone' => '88538273161',
'message' => $spam,
]))
->assertRedirect('/kontakt')
->assertSessionHasErrors(['first_name', 'last_name']);
Mail::assertNothingSent();
});
it('lehnt Namen mit nicht-lateinischer Schrift, Links oder Ziffern ab', function (string $name) {
$this->post('/kontakt', contactPayload(['first_name' => $name]))
->assertSessionHasErrors('first_name');
Mail::assertNothingSent();
})->with([
'kyrillisch' => 'Игорь',
'link' => 'https://share.google/abc',
'ziffern' => 'Anna123',
'zu lang' => str_repeat('a', 51),
]);
it('akzeptiert Namen mit Umlauten, Akzenten, Bindestrich und Apostroph', function (string $name) {
$this->post('/kontakt', contactPayload(['last_name' => $name]))
->assertSessionHasNoErrors();
Mail::assertSent(MailContact::class);
})->with(['Müller-Lüdenscheidt', 'García Nuñez', "O'Connor", 'Lefèvre', 'Dr. Weiß']);
it('lehnt Nachrichten mit zu vielen Links ab', function () {
$this->post('/kontakt', contactPayload([
'message' => 'Seht mal: https://a.example https://b.example www.c.example',
]))->assertSessionHasErrors('message');
Mail::assertNothingSent();
});
it('lehnt ungültige Telefonnummern ab', function () {
$this->post('/kontakt', contactPayload(['phone' => 'ruf mich an']))
->assertSessionHasErrors('phone');
});
it('lehnt die Anfrage ab, wenn Turnstile das Token nicht bestätigt', function () {
$this->turnstileResponse = fn () => Http::response(['success' => false, 'error-codes' => ['invalid-input-response']]);
$this->post('/kontakt', contactPayload())
->assertSessionHasErrors('cf-turnstile-response');
Mail::assertNothingSent();
});
it('lehnt die Anfrage ohne Turnstile-Token ab', function () {
$this->post('/kontakt', contactPayload(['cf-turnstile-response' => '']))
->assertSessionHasErrors('cf-turnstile-response');
Http::assertNothingSent();
Mail::assertNothingSent();
});
it('verwirft Bots still und zeigt trotzdem die Erfolgsseite', function (array $overrides, string $reason) {
Log::spy();
Log::shouldReceive('channel')->andReturnSelf();
$this->post('/kontakt', contactPayload($overrides))
->assertOk()
->assertSee(__('website.thank_you_for_your_request'));
Mail::assertNothingSent();
Log::shouldHaveReceived('channel')->with('form_spam');
Log::shouldHaveReceived('info')->withArgs(fn ($message, $context = []) => ($context['reason'] ?? null) === $reason);
})->with([
'Honeypot ausgefüllt' => [fn () => [FormSpamGuard::HONEYPOT_FIELD => 'https://spam.example'], 'honeypot'],
'zu schnell abgeschickt' => [fn () => [FormSpamGuard::TIMESTAMP_FIELD => encrypt(now()->timestamp)], 'too_fast'],
'Zeitstempel fehlt' => [fn () => [FormSpamGuard::TIMESTAMP_FIELD => ''], 'invalid_form_token'],
'Zeitstempel manipuliert' => [fn () => [FormSpamGuard::TIMESTAMP_FIELD => '1700000000'], 'invalid_form_token'],
]);
it('begrenzt die Anzahl der Anfragen pro IP', function () {
foreach (range(1, 5) as $i) {
$this->post('/kontakt', contactPayload(['email' => "anna{$i}@example.com"]))->assertOk();
}
$this->from('/kontakt')
->post('/kontakt', contactPayload(['email' => 'anna6@example.com']))
->assertRedirect('/kontakt')
->assertSessionHasErrors(['email' => __('form_protection.too_many_requests')]);
Mail::assertSent(MailContact::class, 5);
});
it('begrenzt die Anzahl der Anfragen pro E-Mail-Adresse', function () {
foreach (range(1, 5) as $i) {
$this->withServerVariables(['REMOTE_ADDR' => "10.0.0.{$i}"])
->post('/kontakt', contactPayload())->assertOk();
}
$this->withServerVariables(['REMOTE_ADDR' => '10.0.0.99'])
->post('/kontakt', contactPayload(['email' => 'ANNA@example.com']))
->assertSessionHasErrors(['email' => __('form_protection.too_many_requests')]);
Mail::assertSent(MailContact::class, 5);
});
it('gibt Eingaben in der Mail escaped aus', function () {
$mail = new MailContact([
'salutation' => 'ms',
'first_name' => 'Anna',
'last_name' => 'Müller',
'email' => 'anna@example.com',
'phone' => null,
'message' => '<a href="https://evil.example">Klick</a>',
]);
$html = $mail->render();
expect($html)
->toContain('&lt;a href=&quot;https://evil.example&quot;&gt;Klick&lt;/a&gt;')
->not->toContain('<a href="https://evil.example">');
});
it('wertet Turnstile als fehlgeschlagen, wenn Cloudflare nicht erreichbar ist', function () {
$this->turnstileResponse = fn () => throw new ConnectionException('timeout');
expect(app(TurnstileVerifier::class)->verify('token', '127.0.0.1'))->toBeFalse();
});
it('wertet Turnstile als fehlgeschlagen, wenn kein Secret konfiguriert ist', function () {
config(['services.turnstile.secret_key' => null]);
expect(app(TurnstileVerifier::class)->verify('token'))->toBeFalse();
Http::assertNothingSent();
});