From ea8d0174455e5bc58eb0e61c79e7aac9f4e5f474 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Tue, 14 Jul 2026 13:49:21 +0000 Subject: [PATCH] =?UTF-8?q?auth/mail:=20Magic-Link-2FA=20(M1)=20+=20Mailve?= =?UTF-8?q?rsand=20=C3=BCber=20die=20Queue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Magic-Link-Login übergibt bei aktiviertem 2FA an die 2FA-Challenge, statt sie zu umgehen (M1). - Bestätigungs-/Benachrichtigungs-Mails (Kontakt, Recht, Kündigung, Widerruf, Magic-Link) laufen als ShouldQueue+afterCommit über die Queue, damit die Response nicht am SMTP-Roundtrip hängt. Hinweis: Der Queue-Worker muss im Betrieb laufen und überwacht werden, da fristrelevante Bestätigungen darüber zugestellt werden (siehe Betriebsüberwachung). Co-Authored-By: Claude Fable 5 --- .../Auth/MagicLinkConsumeController.php | 23 +++++++++-- .../Controllers/LegalRequestController.php | 8 +++- .../Controllers/Web/ReportFormController.php | 15 +++++-- .../Controllers/Web/TerminationController.php | 8 +++- .../Controllers/Web/WithdrawalController.php | 8 +++- app/Mail/ContactRequestSubmitted.php | 3 +- app/Mail/LegalRequestReceived.php | 3 +- app/Mail/LegalRequestSubmitted.php | 3 +- app/Mail/MagicLoginLink.php | 3 +- app/Mail/TerminationRequestReceived.php | 3 +- app/Mail/TerminationRequestSubmitted.php | 3 +- app/Mail/WithdrawalRequestReceived.php | 3 +- app/Mail/WithdrawalRequestSubmitted.php | 3 +- app/Services/Auth/ContactAccessService.php | 4 +- tests/Feature/Auth/ContactAccessTest.php | 22 +++++----- tests/Feature/Auth/MagicLinkLoginTest.php | 41 ++++++++++++++++--- tests/Feature/LegalRequestTest.php | 8 ++-- tests/Feature/Web/ReportFormTest.php | 10 ++--- tests/Feature/Web/TerminationRequestTest.php | 12 +++--- tests/Feature/Web/WithdrawalRequestTest.php | 12 +++--- 20 files changed, 135 insertions(+), 60 deletions(-) diff --git a/app/Http/Controllers/Auth/MagicLinkConsumeController.php b/app/Http/Controllers/Auth/MagicLinkConsumeController.php index fefd2035..ef659760 100644 --- a/app/Http/Controllers/Auth/MagicLinkConsumeController.php +++ b/app/Http/Controllers/Auth/MagicLinkConsumeController.php @@ -45,18 +45,35 @@ class MagicLinkConsumeController extends Controller return redirect()->route('login')->with('status', __('The magic login link has expired or was already used.')); } - $magicLink->user->update([ + $user = $magicLink->user; + + // 2FA aktiv: nicht direkt einloggen, sondern – wie der Passwort-Login – + // in Fortifys Challenge übergeben. Der Magic-Link (Besitz des Postfachs) + // ersetzt nur das Passwort, nicht den zweiten Faktor. Der Link ist bereits + // atomar verbraucht; bricht der Nutzer die Challenge ab, fordert er einen + // neuen an. + if ($user->hasEnabledTwoFactorAuthentication()) { + $request->session()->put([ + 'login.id' => $user->getKey(), + 'login.remember' => false, + ]); + + $request->session()->regenerate(); + + return redirect()->route('two-factor.challenge'); + } + + $user->update([ 'last_login_at' => now(), 'last_login_ip' => $request->ip(), ]); - Auth::guard('web')->login($magicLink->user); + Auth::guard('web')->login($user); $request->session()->regenerate(); // Rollensicherer Redirect ohne intended(): eine als Gast besuchte // Admin-URL (z. B. /dashboard) darf einen Customer nach dem // Magic-Link-Login nicht in den 403-Admin-Bereich schicken. - $user = $magicLink->user; $home = $user->canAccessAdmin() ? route('dashboard', absolute: false) diff --git a/app/Http/Controllers/LegalRequestController.php b/app/Http/Controllers/LegalRequestController.php index 807a9081..3e96084e 100644 --- a/app/Http/Controllers/LegalRequestController.php +++ b/app/Http/Controllers/LegalRequestController.php @@ -95,9 +95,13 @@ class LegalRequestController extends Controller // Vorgangsnummer (sofern eine E-Mail-Adresse angegeben wurde) + // interner Hinweis ans Melde-Postfach. if ($legalRequest->requester_email) { - Mail::to($legalRequest->requester_email)->send(new LegalRequestReceived($legalRequest)); + Mail::to($legalRequest->requester_email)->queue( + (new LegalRequestReceived($legalRequest))->afterCommit() + ); } - Mail::to($this->operatorRecipient())->send(new LegalRequestSubmitted($legalRequest)); + Mail::to($this->operatorRecipient())->queue( + (new LegalRequestSubmitted($legalRequest))->afterCommit() + ); return redirect() ->route('release.detail', ['slug' => $release->slug]) diff --git a/app/Http/Controllers/Web/ReportFormController.php b/app/Http/Controllers/Web/ReportFormController.php index 0607684a..e49956b7 100644 --- a/app/Http/Controllers/Web/ReportFormController.php +++ b/app/Http/Controllers/Web/ReportFormController.php @@ -77,10 +77,17 @@ class ReportFormController extends Controller 'requester_ip' => $request->ip(), ]); - // Unverzügliche Eingangsbestätigung (DSA Art. 16 Abs. 4) + interner - // Hinweis ans Melde-Postfach, damit Fristen nicht an der Queue hängen. - Mail::to($legalRequest->requester_email)->send(new LegalRequestReceived($legalRequest)); - Mail::to($this->operatorRecipient($request))->send(new LegalRequestSubmitted($legalRequest)); + // Eingangsbestätigung (DSA Art. 16 Abs. 4) + interner Hinweis ans + // Melde-Postfach. Versand über die Queue (afterCommit), damit die + // Response nicht am SMTP-Roundtrip hängt — setzt einen laufenden + // Queue-Worker voraus (Ops-Monitoring erforderlich, da die Zustellung + // fristrelevant ist). + Mail::to($legalRequest->requester_email)->queue( + (new LegalRequestReceived($legalRequest))->afterCommit() + ); + Mail::to($this->operatorRecipient($request))->queue( + (new LegalRequestSubmitted($legalRequest))->afterCommit() + ); return back()->with('report-status', __('static.report_form.flash_received', [ 'reference' => $legalRequest->reference(), diff --git a/app/Http/Controllers/Web/TerminationController.php b/app/Http/Controllers/Web/TerminationController.php index f59f4f22..f17280e8 100644 --- a/app/Http/Controllers/Web/TerminationController.php +++ b/app/Http/Controllers/Web/TerminationController.php @@ -71,8 +71,12 @@ class TerminationController extends Controller // § 312k Abs. 4 BGB: Zugang der Kündigungserklärung sofort auf // elektronischem Wege bestätigen (Inhalt, Datum, Uhrzeit). - Mail::to($terminationRequest->email)->send(new TerminationRequestReceived($terminationRequest)); - Mail::to($this->operatorRecipient())->send(new TerminationRequestSubmitted($terminationRequest)); + Mail::to($terminationRequest->email)->queue( + (new TerminationRequestReceived($terminationRequest))->afterCommit() + ); + Mail::to($this->operatorRecipient())->queue( + (new TerminationRequestSubmitted($terminationRequest))->afterCommit() + ); return $this->done(); } diff --git a/app/Http/Controllers/Web/WithdrawalController.php b/app/Http/Controllers/Web/WithdrawalController.php index 7147ab89..cbc830c7 100644 --- a/app/Http/Controllers/Web/WithdrawalController.php +++ b/app/Http/Controllers/Web/WithdrawalController.php @@ -49,8 +49,12 @@ class WithdrawalController extends Controller ]); // Gesetzlich vorgeschrieben: sofortige automatische Eingangsbestätigung. - Mail::to($withdrawalRequest->email)->send(new WithdrawalRequestReceived($withdrawalRequest)); - Mail::to($this->operatorRecipient())->send(new WithdrawalRequestSubmitted($withdrawalRequest)); + Mail::to($withdrawalRequest->email)->queue( + (new WithdrawalRequestReceived($withdrawalRequest))->afterCommit() + ); + Mail::to($this->operatorRecipient())->queue( + (new WithdrawalRequestSubmitted($withdrawalRequest))->afterCommit() + ); return $this->done(); } diff --git a/app/Mail/ContactRequestSubmitted.php b/app/Mail/ContactRequestSubmitted.php index 9e69f2b5..d90e01ca 100644 --- a/app/Mail/ContactRequestSubmitted.php +++ b/app/Mail/ContactRequestSubmitted.php @@ -3,13 +3,14 @@ namespace App\Mail; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Address; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; use Illuminate\Queue\SerializesModels; -class ContactRequestSubmitted extends Mailable +class ContactRequestSubmitted extends Mailable implements ShouldQueue { use Queueable, SerializesModels; diff --git a/app/Mail/LegalRequestReceived.php b/app/Mail/LegalRequestReceived.php index 74846497..25586637 100644 --- a/app/Mail/LegalRequestReceived.php +++ b/app/Mail/LegalRequestReceived.php @@ -4,6 +4,7 @@ namespace App\Mail; use App\Models\LegalRequest; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; @@ -14,7 +15,7 @@ use Illuminate\Queue\SerializesModels; * meldende Person (T10, DSA Art. 16 Abs. 4) — mit Vorgangsnummer sowie Datum * und Uhrzeit des Eingangs. */ -class LegalRequestReceived extends Mailable +class LegalRequestReceived extends Mailable implements ShouldQueue { use Queueable, SerializesModels; diff --git a/app/Mail/LegalRequestSubmitted.php b/app/Mail/LegalRequestSubmitted.php index 458905da..1c6f602a 100644 --- a/app/Mail/LegalRequestSubmitted.php +++ b/app/Mail/LegalRequestSubmitted.php @@ -4,6 +4,7 @@ namespace App\Mail; use App\Models\LegalRequest; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; @@ -14,7 +15,7 @@ use Illuminate\Queue\SerializesModels; * der Admin-Queue (DSA-Fristen — Meldungen sollen nicht erst beim nächsten * Blick in den Admin auffallen). */ -class LegalRequestSubmitted extends Mailable +class LegalRequestSubmitted extends Mailable implements ShouldQueue { use Queueable, SerializesModels; diff --git a/app/Mail/MagicLoginLink.php b/app/Mail/MagicLoginLink.php index 2565d736..1404f1d5 100644 --- a/app/Mail/MagicLoginLink.php +++ b/app/Mail/MagicLoginLink.php @@ -4,12 +4,13 @@ namespace App\Mail; use App\Models\User; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; use Illuminate\Queue\SerializesModels; -class MagicLoginLink extends Mailable +class MagicLoginLink extends Mailable implements ShouldQueue { use Queueable, SerializesModels; diff --git a/app/Mail/TerminationRequestReceived.php b/app/Mail/TerminationRequestReceived.php index ab5829be..3c540d4d 100644 --- a/app/Mail/TerminationRequestReceived.php +++ b/app/Mail/TerminationRequestReceived.php @@ -4,6 +4,7 @@ namespace App\Mail; use App\Models\TerminationRequest; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; @@ -14,7 +15,7 @@ use Illuminate\Queue\SerializesModels; * vorgeschrieben beim Kündigungsbutton (§ 312k Abs. 4 BGB): Inhalt sowie * Datum und Uhrzeit des Zugangs der Kündigungserklärung. */ -class TerminationRequestReceived extends Mailable +class TerminationRequestReceived extends Mailable implements ShouldQueue { use Queueable, SerializesModels; diff --git a/app/Mail/TerminationRequestSubmitted.php b/app/Mail/TerminationRequestSubmitted.php index f18ccf74..43b8628d 100644 --- a/app/Mail/TerminationRequestSubmitted.php +++ b/app/Mail/TerminationRequestSubmitted.php @@ -4,6 +4,7 @@ namespace App\Mail; use App\Models\TerminationRequest; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; @@ -13,7 +14,7 @@ use Illuminate\Queue\SerializesModels; * Interne Benachrichtigung an den Betreiber über eine neue Online-Kündigung * (Kündigungsbutton § 312k BGB) — Bearbeitung über die Admin-Queue. */ -class TerminationRequestSubmitted extends Mailable +class TerminationRequestSubmitted extends Mailable implements ShouldQueue { use Queueable, SerializesModels; diff --git a/app/Mail/WithdrawalRequestReceived.php b/app/Mail/WithdrawalRequestReceived.php index 580abbe4..7bf56479 100644 --- a/app/Mail/WithdrawalRequestReceived.php +++ b/app/Mail/WithdrawalRequestReceived.php @@ -4,6 +4,7 @@ namespace App\Mail; use App\Models\WithdrawalRequest; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; @@ -13,7 +14,7 @@ use Illuminate\Queue\SerializesModels; * Sofortige automatische Eingangsbestätigung an den Verbraucher — gesetzlich * vorgeschrieben beim Online-Widerruf (Widerrufsbutton). */ -class WithdrawalRequestReceived extends Mailable +class WithdrawalRequestReceived extends Mailable implements ShouldQueue { use Queueable, SerializesModels; diff --git a/app/Mail/WithdrawalRequestSubmitted.php b/app/Mail/WithdrawalRequestSubmitted.php index 7864fe18..41909085 100644 --- a/app/Mail/WithdrawalRequestSubmitted.php +++ b/app/Mail/WithdrawalRequestSubmitted.php @@ -4,6 +4,7 @@ namespace App\Mail; use App\Models\WithdrawalRequest; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; @@ -12,7 +13,7 @@ use Illuminate\Queue\SerializesModels; /** * Interne Benachrichtigung an den Betreiber über einen neuen Online-Widerruf. */ -class WithdrawalRequestSubmitted extends Mailable +class WithdrawalRequestSubmitted extends Mailable implements ShouldQueue { use Queueable, SerializesModels; diff --git a/app/Services/Auth/ContactAccessService.php b/app/Services/Auth/ContactAccessService.php index 37ecbce0..afb2cb2e 100644 --- a/app/Services/Auth/ContactAccessService.php +++ b/app/Services/Auth/ContactAccessService.php @@ -203,11 +203,11 @@ class ContactAccessService { $generated = $this->magicLinks->createLoginLink($user, $ip); - Mail::to($user->email)->send(new MagicLoginLink( + Mail::to($user->email)->queue((new MagicLoginLink( user: $user, loginUrl: route('magic-links.consume', ['token' => $generated['plain_token']]), expiresAt: $generated['expires_at']->format('d.m.Y H:i'), - )); + ))->afterCommit()); } /** diff --git a/tests/Feature/Auth/ContactAccessTest.php b/tests/Feature/Auth/ContactAccessTest.php index a9bfb535..eefa66a8 100644 --- a/tests/Feature/Auth/ContactAccessTest.php +++ b/tests/Feature/Auth/ContactAccessTest.php @@ -22,7 +22,7 @@ test('requesting access for an unknown email creates nothing and sends no mail', expect($sent)->toBeFalse(); expect(User::query()->where('email', 'nobody@example.test')->exists())->toBeFalse(); - Mail::assertNothingSent(); + Mail::assertNothingOutgoing(); }); test('requesting access for a known contact lazily creates a scoped account and sends a link', function () { @@ -53,7 +53,7 @@ test('requesting access for a known contact lazily creates a scoped account and expect($user->accessibleCompanyIds())->toContain($company->id); expect($user->gdpr_consent_at)->not->toBeNull(); - Mail::assertSent(MagicLoginLink::class, fn (MagicLoginLink $mail) => $mail->user->is($user)); + Mail::assertQueued(MagicLoginLink::class, fn (MagicLoginLink $mail) => $mail->user->is($user)); }); test('requesting access reuses an existing user without creating a duplicate', function () { @@ -73,7 +73,7 @@ test('requesting access reuses an existing user without creating a duplicate', f expect(User::query()->where('email', 'existing@example.test')->count())->toBe(1); expect($existing->fresh()->accessibleCompanyIds())->toContain($company->id); - Mail::assertSent(MagicLoginLink::class); + Mail::assertQueued(MagicLoginLink::class); }); test('the magic link form responds neutrally and triggers the service for a contact', function () { @@ -92,7 +92,7 @@ test('the magic link form responds neutrally and triggers the service for a cont ->assertHasNoErrors() ->assertSet('email', ''); - Mail::assertSent(MagicLoginLink::class); + Mail::assertQueued(MagicLoginLink::class); expect(User::query()->where('email', 'form@example.test')->exists())->toBeTrue(); }); @@ -109,7 +109,7 @@ test('the magic link form sends a link to an existing active user without a cont ->assertHasNoErrors() ->assertSet('email', ''); - Mail::assertSent(MagicLoginLink::class, fn (MagicLoginLink $mail) => $mail->user->is($user)); + Mail::assertQueued(MagicLoginLink::class, fn (MagicLoginLink $mail) => $mail->user->is($user)); }); test('requestMagicAccess does nothing for an unknown email', function () { @@ -120,7 +120,7 @@ test('requestMagicAccess does nothing for an unknown email', function () { expect($sent)->toBeFalse(); expect(User::query()->where('email', 'nobody@example.test')->exists())->toBeFalse(); - Mail::assertNothingSent(); + Mail::assertNothingOutgoing(); }); test('requestMagicAccess does not send to an inactive existing user', function () { @@ -132,7 +132,7 @@ test('requestMagicAccess does not send to an inactive existing user', function ( $sent = app(ContactAccessService::class)->requestMagicAccess('off@example.test'); expect($sent)->toBeFalse(); - Mail::assertNothingSent(); + Mail::assertNothingOutgoing(); }); test('a later-added company with the same email is linked on the next password login', function () { @@ -213,7 +213,7 @@ test('a company email lazily creates an account scoped to that company', functio expect($user->name)->toBe('Firma AG'); expect($user->accessibleCompanyIds())->toContain($company->id); - Mail::assertSent(MagicLoginLink::class, fn (MagicLoginLink $mail) => $mail->user->is($user)); + Mail::assertQueued(MagicLoginLink::class, fn (MagicLoginLink $mail) => $mail->user->is($user)); }); test('one account is created for an email stored across multiple companies and contacts', function () { @@ -247,7 +247,7 @@ test('one account is created for an email stored across multiple companies and c expect($ids)->toContain($companyB->id); expect($ids)->toContain($companyC->id); - Mail::assertSent(MagicLoginLink::class, 1); + Mail::assertQueued(MagicLoginLink::class, 1); }); test('requesting access does not mutate an existing inactive account', function () { @@ -268,7 +268,7 @@ test('requesting access does not mutate an existing inactive account', function expect($sent)->toBeFalse(); // Keine Firmenzuordnung an einem deaktivierten Bestands-Account. expect($existing->fresh()->accessibleCompanyIds())->not->toContain($company->id); - Mail::assertNothingSent(); + Mail::assertNothingOutgoing(); }); test('the honeypot blocks bots without creating an account', function () { @@ -287,6 +287,6 @@ test('the honeypot blocks bots without creating an account', function () { ->call('requestLink') ->assertHasNoErrors(); - Mail::assertNothingSent(); + Mail::assertNothingOutgoing(); expect(User::query()->where('email', 'bot-target@example.test')->exists())->toBeFalse(); }); diff --git a/tests/Feature/Auth/MagicLinkLoginTest.php b/tests/Feature/Auth/MagicLinkLoginTest.php index f2c74100..ed6f406c 100644 --- a/tests/Feature/Auth/MagicLinkLoginTest.php +++ b/tests/Feature/Auth/MagicLinkLoginTest.php @@ -6,6 +6,7 @@ use App\Models\User; use Database\Seeders\RolesAndPermissionsSeeder; use Illuminate\Support\Facades\Mail; use Livewire\Volt\Volt as LivewireVolt; +use PragmaRX\Google2FA\Google2FA; use Tests\TestCase; test('user can request a magic login link from the magic-link page', function () { @@ -17,7 +18,7 @@ test('user can request a magic login link from the magic-link page', function () ->call('requestLink') ->assertHasNoErrors(); - Mail::assertSent(MagicLoginLink::class, function (MagicLoginLink $mail) use ($user) { + Mail::assertQueued(MagicLoginLink::class, function (MagicLoginLink $mail) use ($user) { return $mail->user->is($user) && str_contains($mail->loginUrl, '/magic-login/'); }); @@ -37,7 +38,7 @@ test('the magic link form validates its email field and clears on success', func ->call('requestLink') ->assertHasErrors(['email']); - Mail::assertNothingSent(); + Mail::assertNothingOutgoing(); // Gültige Eingabe → Feld wird geleert. LivewireVolt::test('auth.magic-link') @@ -64,7 +65,7 @@ test('magic link requests are rate limited per email', function () { ->call('requestLink') ->assertHasErrors(['email']); - Mail::assertSent(MagicLoginLink::class, 3); + Mail::assertQueued(MagicLoginLink::class, 3); }); test('admin can login with a valid magic link and lands on admin dashboard', function () { @@ -80,7 +81,7 @@ test('admin can login with a valid magic link and lands on admin dashboard', fun $sentMail = null; - Mail::assertSent(MagicLoginLink::class, function (MagicLoginLink $mail) use (&$sentMail) { + Mail::assertQueued(MagicLoginLink::class, function (MagicLoginLink $mail) use (&$sentMail) { $sentMail = $mail; return true; @@ -111,7 +112,7 @@ test('customer is redirected to me dashboard after magic link login', function ( ->call('requestLink'); $sentMail = null; - Mail::assertSent(MagicLoginLink::class, function (MagicLoginLink $mail) use (&$sentMail) { + Mail::assertQueued(MagicLoginLink::class, function (MagicLoginLink $mail) use (&$sentMail) { $sentMail = $mail; return true; @@ -147,6 +148,36 @@ test('a stale intended admin url does not send a customer into the 403 admin are $this->assertAuthenticatedAs($customer); }); +test('magic link login with two-factor enabled is handed to the challenge', function () { + /** + * Regression zu Befund M1 (Audit 14.07.2026): Magic-Link darf 2FA nicht + * umgehen — bei aktivem 2FA wird wie beim Passwort-Login in die Challenge + * übergeben, statt direkt einzuloggen. + */ + /** @var TestCase $this */ + $user = User::factory()->create(['is_active' => true]); + $user->forceFill([ + 'two_factor_secret' => encrypt(app(Google2FA::class)->generateSecretKey()), + 'two_factor_confirmed_at' => now(), + ])->save(); + + $plainToken = 'twofactor-token'; + MagicLink::query()->create([ + 'user_id' => $user->id, + 'token_hash' => hash('sha256', $plainToken), + 'purpose' => 'login', + 'expires_at' => now()->addMinutes(5), + ]); + + $this->get(route('magic-links.consume', ['token' => $plainToken])) + ->assertRedirect(route('two-factor.challenge')); + + $this->assertGuest(); + expect(session('login.id'))->toBe($user->id); + // Einmal-Nutzung bleibt erhalten: Link ist verbraucht. + expect(MagicLink::query()->firstOrFail()->consumed_at)->not->toBeNull(); +}); + test('expired magic link can not be used', function () { /** @var TestCase $this */ $user = User::factory()->create(['is_active' => true]); diff --git a/tests/Feature/LegalRequestTest.php b/tests/Feature/LegalRequestTest.php index 700696c2..b943093c 100644 --- a/tests/Feature/LegalRequestTest.php +++ b/tests/Feature/LegalRequestTest.php @@ -79,8 +79,8 @@ test('a dsgvo request is stored in the queue', function () { // T10: Vorgangsnummer im Hinweis + Eingangsbestätigung/Queue-Hinweis. expect(session('legal-status'))->toContain($request->reference()); - Mail::assertSent(LegalRequestReceived::class, fn ($mail) => $mail->hasTo('betroffen@example.test')); - Mail::assertSent(LegalRequestSubmitted::class); + Mail::assertQueued(LegalRequestReceived::class, fn ($mail) => $mail->hasTo('betroffen@example.test')); + Mail::assertQueued(LegalRequestSubmitted::class); }); test('a personal rights request requires an email', function () { @@ -111,8 +111,8 @@ test('a report can be submitted without an email and feeds the same queue', func expect($request->requester_email)->toBeNull(); // Ohne E-Mail keine Eingangsbestätigung — der interne Hinweis geht trotzdem raus. - Mail::assertNotSent(LegalRequestReceived::class); - Mail::assertSent(LegalRequestSubmitted::class); + Mail::assertNotQueued(LegalRequestReceived::class); + Mail::assertQueued(LegalRequestSubmitted::class); }); test('a filled honeypot field blocks bots without creating a request', function (string $field) { diff --git a/tests/Feature/Web/ReportFormTest.php b/tests/Feature/Web/ReportFormTest.php index cca7bfd7..fcc8e7e0 100644 --- a/tests/Feature/Web/ReportFormTest.php +++ b/tests/Feature/Web/ReportFormTest.php @@ -48,8 +48,8 @@ test('an illegal content report creates a request and confirms receipt with a re expect(session('report-status'))->toContain($request->reference()); // DSA Art. 16 Abs. 4: unverzügliche Eingangsbestätigung + interner Hinweis. - Mail::assertSent(LegalRequestReceived::class, fn ($mail) => $mail->hasTo('erika@example.com')); - Mail::assertSent(LegalRequestSubmitted::class); + Mail::assertQueued(LegalRequestReceived::class, fn ($mail) => $mail->hasTo('erika@example.com')); + Mail::assertQueued(LegalRequestSubmitted::class); }); test('a reported url matching a published release is linked to it', function () { @@ -86,7 +86,7 @@ test('the illegal content report requires the truthfulness affirmation and a val ->assertSessionHasErrors(['reported_url', 'truth_affirmed']); expect(LegalRequest::query()->count())->toBe(0); - Mail::assertNothingSent(); + Mail::assertNothingOutgoing(); }); test('the contact data removal form creates a dsgvo request without affirmation checkbox', function () { @@ -103,7 +103,7 @@ test('the contact data removal form creates a dsgvo request without affirmation $request = LegalRequest::query()->sole(); expect($request->type)->toBe(LegalRequestType::Dsgvo); expect($request->portal)->toBe('presseecho'); - Mail::assertSent(LegalRequestReceived::class); + Mail::assertQueued(LegalRequestReceived::class); }); test('a filled honeypot is silently discarded', function () { @@ -120,7 +120,7 @@ test('a filled honeypot is silently discarded', function () { ])->assertSessionHas('report-status'); expect(LegalRequest::query()->count())->toBe(0); - Mail::assertNothingSent(); + Mail::assertNothingOutgoing(); }); test('an unknown form way responds 404', function () { diff --git a/tests/Feature/Web/TerminationRequestTest.php b/tests/Feature/Web/TerminationRequestTest.php index 0dcddafd..81a9755d 100644 --- a/tests/Feature/Web/TerminationRequestTest.php +++ b/tests/Feature/Web/TerminationRequestTest.php @@ -34,7 +34,7 @@ test('the termination confirmation page renders the § 312k form with the hub sh ->toContain('Publisher · Hub'); }); -test('an ordinary termination is persisted and both mails are sent immediately', function () { +test('an ordinary termination is persisted and both mails are queued after persistence', function () { /** @var TestCase $this */ Mail::fake(); config()->set('domains.domains.pressekonto.brand.contact_email', 'kontakt@pressekonto.de'); @@ -62,8 +62,8 @@ test('an ordinary termination is persisted and both mails are sent immediately', expect($request->status)->toBe('open'); // § 312k Abs. 4 BGB: sofortige automatische Zugangsbestätigung. - Mail::assertSent(TerminationRequestReceived::class, fn (TerminationRequestReceived $mail) => $mail->hasTo('paula@example.test')); - Mail::assertSent(TerminationRequestSubmitted::class, fn (TerminationRequestSubmitted $mail) => $mail->hasTo('kontakt@pressekonto.de')); + Mail::assertQueued(TerminationRequestReceived::class, fn (TerminationRequestReceived $mail) => $mail->hasTo('paula@example.test')); + Mail::assertQueued(TerminationRequestSubmitted::class, fn (TerminationRequestSubmitted $mail) => $mail->hasTo('kontakt@pressekonto.de')); }); test('an extraordinary termination requires a reason', function () { @@ -78,7 +78,7 @@ test('an extraordinary termination requires a reason', function () { ])->assertSessionHasErrorsIn('termination', ['reason']); expect(TerminationRequest::query()->count())->toBe(0); - Mail::assertNothingSent(); + Mail::assertNothingOutgoing(); $this->post('https://pressekonto.test/de/kuendigung', [ 'kind' => 'ausserordentlich', @@ -123,7 +123,7 @@ test('termination validation errors land in the termination error bag', function ])->assertSessionHasErrorsIn('termination', ['kind', 'name', 'email', 'terminate_mode']); expect(TerminationRequest::query()->count())->toBe(0); - Mail::assertNothingSent(); + Mail::assertNothingOutgoing(); }); test('honeypot submissions are silently accepted without persisting or mailing', function () { @@ -139,5 +139,5 @@ test('honeypot submissions are silently accepted without persisting or mailing', ])->assertSessionHas('termination-status'); expect(TerminationRequest::query()->count())->toBe(0); - Mail::assertNothingSent(); + Mail::assertNothingOutgoing(); }); diff --git a/tests/Feature/Web/WithdrawalRequestTest.php b/tests/Feature/Web/WithdrawalRequestTest.php index 8966f349..2c322956 100644 --- a/tests/Feature/Web/WithdrawalRequestTest.php +++ b/tests/Feature/Web/WithdrawalRequestTest.php @@ -19,7 +19,7 @@ test('the withdrawal ui is retired on the hub (B2B-only, decision E2)', function ->not->toContain('open-withdrawal-modal'); }); -test('a withdrawal is persisted and both mails are sent immediately', function () { +test('a withdrawal is persisted and both mails are queued after persistence', function () { /** @var TestCase $this */ Mail::fake(); config()->set('domains.domains.pressekonto.brand.contact_email', 'kontakt@pressekonto.de'); @@ -42,8 +42,8 @@ test('a withdrawal is persisted and both mails are sent immediately', function ( expect($request->status)->toBe('open'); // Gesetzlich vorgeschrieben: sofortige automatische Eingangsbestätigung. - Mail::assertSent(WithdrawalRequestReceived::class, fn (WithdrawalRequestReceived $mail) => $mail->hasTo('paula@example.test')); - Mail::assertSent(WithdrawalRequestSubmitted::class, fn (WithdrawalRequestSubmitted $mail) => $mail->hasTo('kontakt@pressekonto.de')); + Mail::assertQueued(WithdrawalRequestReceived::class, fn (WithdrawalRequestReceived $mail) => $mail->hasTo('paula@example.test')); + Mail::assertQueued(WithdrawalRequestSubmitted::class, fn (WithdrawalRequestSubmitted $mail) => $mail->hasTo('kontakt@pressekonto.de')); }); test('the order reference is optional', function () { @@ -68,7 +68,7 @@ test('withdrawal validation errors land in the withdrawal error bag', function ( ])->assertSessionHasErrorsIn('withdrawal', ['name', 'email']); expect(WithdrawalRequest::query()->count())->toBe(0); - Mail::assertNothingSent(); + Mail::assertNothingOutgoing(); }); test('honeypot submissions are silently accepted without persisting or mailing', function () { @@ -82,7 +82,7 @@ test('honeypot submissions are silently accepted without persisting or mailing', ])->assertSessionHas('withdrawal-status'); expect(WithdrawalRequest::query()->count())->toBe(0); - Mail::assertNothingSent(); + Mail::assertNothingOutgoing(); }); test('a withdrawal from a spoke domain goes to that brand address', function () { @@ -95,5 +95,5 @@ test('a withdrawal from a spoke domain goes to that brand address', function () 'email' => 'paula@example.test', ])->assertSessionHas('withdrawal-status'); - Mail::assertSent(WithdrawalRequestSubmitted::class, fn (WithdrawalRequestSubmitted $mail) => $mail->hasTo('redaktion@presseecho.de')); + Mail::assertQueued(WithdrawalRequestSubmitted::class, fn (WithdrawalRequestSubmitted $mail) => $mail->hasTo('redaktion@presseecho.de')); });