mivita/tests/Feature/RegisterFormSpamTest.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

179 lines
6.8 KiB
PHP

<?php
use App\Mail\MailVerifyAccount;
use App\Services\FormSpamGuard;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
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([
'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 registerPayload(array $overrides = []): array
{
return array_merge([
'salutation' => 'ms',
'first_name' => 'Anna',
'last_name' => 'Müller',
'email' => 'anna@example.com',
'email-confirm' => 'anna@example.com',
'password' => 'geheim123',
'password_confirmation' => 'geheim123',
'accepted_data_protection' => '1',
'cf-turnstile-response' => 'token',
FormSpamGuard::TIMESTAMP_FIELD => encrypt(now()->timestamp - 10),
FormSpamGuard::HONEYPOT_FIELD => '',
], $overrides);
}
function makeSponsor(array $attributes = []): User
{
return User::forceCreate(array_merge([
'email' => 'sponsor-'.uniqid().'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'active' => 1,
'blocked' => 0,
'payment_account' => now()->addYear(),
], $attributes));
}
function memberNumber(User $user): string
{
return 'm'.($user->id + (int) config('mivita.add_number_id'));
}
it('zeigt das Registrierungsformular mit Turnstile statt reCAPTCHA', function () {
$this->get('/registrierung')
->assertOk()
->assertSee('cf-turnstile', false)
->assertSee('data-sitekey="site-key"', false)
->assertSee(FormSpamGuard::HONEYPOT_FIELD, false)
->assertDontSee('g-recaptcha', false);
});
it('legt bei gültiger Registrierung den Benutzer an und verschickt die Bestätigungsmail', function () {
$this->post('/registrierung', registerPayload())
->assertRedirect('/registrierung/finish');
$user = User::where('email', 'anna@example.com')->first();
expect($user)->not->toBeNull()
->and($user->account->first_name)->toBe('Anna')
->and($user->m_sponsor)->toBe(1);
Mail::assertSent(MailVerifyAccount::class, fn ($mail) => $mail->hasTo('anna@example.com'));
});
it('übernimmt einen aktiven Sponsor aus der Partner-Nummer', function () {
$sponsor = makeSponsor();
$this->post('/registrierung', registerPayload(['from_member_id' => memberNumber($sponsor)]))
->assertRedirect('/registrierung/finish');
expect(User::where('email', 'anna@example.com')->first()->m_sponsor)->toBe($sponsor->id);
});
it('ignoriert manipulierte Partner-Nummern von inaktiven oder unbekannten Beratern', function (Closure $memberId) {
$this->post('/registrierung', registerPayload(['from_member_id' => $memberId()]))
->assertRedirect('/registrierung/finish');
expect(User::where('email', 'anna@example.com')->first()->m_sponsor)->toBe(1);
})->with([
'gesperrt' => [fn () => memberNumber(makeSponsor(['blocked' => 1]))],
'Account abgelaufen' => [fn () => memberNumber(makeSponsor(['payment_account' => now()->subDay()]))],
'unbekannt' => [fn () => 'm999999'],
]);
it('lehnt Werbetexte, Links und fremde Schrift in Namensfeldern ab', function (string $name) {
$this->post('/registrierung', registerPayload(['first_name' => $name]))
->assertSessionHasErrors('first_name');
expect(User::where('email', 'anna@example.com')->exists())->toBeFalse();
Mail::assertNothingSent();
})->with([
'kyrillisch' => 'Приветствуем! Бонус 926USD',
'link' => 'Bonus https://share.google/abc',
'zu lang' => str_repeat('a', 51),
]);
it('lehnt die Registrierung ab, wenn Turnstile das Token nicht bestätigt', function () {
$this->turnstileResponse = fn () => Http::response(['success' => false]);
$this->post('/registrierung', registerPayload())
->assertSessionHasErrors('cf-turnstile-response');
expect(User::where('email', 'anna@example.com')->exists())->toBeFalse();
});
it('verlangt ein Passwort mit mindestens 8 Zeichen', function () {
$this->post('/registrierung', registerPayload(['password' => 'kurz123', 'password_confirmation' => 'kurz123']))
->assertSessionHasErrors('password');
expect(User::where('email', 'anna@example.com')->exists())->toBeFalse();
$this->post('/registrierung', registerPayload(['password' => 'genau8zk', 'password_confirmation' => 'genau8zk']))
->assertSessionHasNoErrors();
expect(User::where('email', 'anna@example.com')->exists())->toBeTrue();
});
it('gibt das Passwort nach Fehlern nicht ins Formular zurück', function () {
$this->post('/registrierung', registerPayload(['first_name' => 'Игорь']))
->assertSessionHasErrors('first_name')
->assertSessionMissing('_old_input.password');
});
it('verwirft Bots still und legt keinen Benutzer an', function (Closure $overrides, string $reason) {
Log::spy();
Log::shouldReceive('channel')->andReturnSelf();
$this->post('/registrierung', registerPayload($overrides()))
->assertRedirect('/registrierung/finish');
expect(User::where('email', 'anna@example.com')->exists())->toBeFalse();
Mail::assertNothingSent();
Log::shouldHaveReceived('channel')->with('form_spam');
Log::shouldHaveReceived('info')->withArgs(
fn ($message, $context = []) => ($context['form'] ?? null) === 'register' && ($context['reason'] ?? null) === $reason
);
})->with([
'Honeypot ausgefüllt' => [fn () => [FormSpamGuard::HONEYPOT_FIELD => 'x'], 'honeypot'],
'zu schnell abgeschickt' => [fn () => [FormSpamGuard::TIMESTAMP_FIELD => encrypt(now()->timestamp)], 'too_fast'],
'Zeitstempel fehlt' => [fn () => [FormSpamGuard::TIMESTAMP_FIELD => ''], 'invalid_form_token'],
]);
it('begrenzt die Anzahl der Registrierungen pro IP', function () {
foreach (range(1, 5) as $i) {
$this->post('/registrierung', registerPayload([
'email' => "anna{$i}@example.com",
'email-confirm' => "anna{$i}@example.com",
]))->assertRedirect('/registrierung/finish');
}
$this->from('/registrierung')
->post('/registrierung', registerPayload([
'email' => 'anna6@example.com',
'email-confirm' => 'anna6@example.com',
]))
->assertRedirect('/registrierung')
->assertSessionHasErrors(['email' => __('form_protection.too_many_requests')])
->assertSessionMissing('_old_input.password');
expect(User::where('email', 'anna6@example.com')->exists())->toBeFalse();
});