- 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>
179 lines
6.8 KiB
PHP
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();
|
|
});
|