From 15387d0a3f2c3574582f1ed5b0d5c00a5a5e85dc Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Tue, 7 Jul 2026 10:53:24 +0000 Subject: [PATCH] =?UTF-8?q?QA-Befunde=20A4:=202FA-Best=C3=A4tigungsschritt?= =?UTF-8?q?=20+=20sicherer=20E-Mail-Wechsel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 2FA: Einrichtung verlangt jetzt die Bestätigung mit einem App-Code (Fortify confirm-Semantik); vorher blieb two_factor_confirmed_at leer und der Login fragte nie nach der Challenge. - Recovery-Codes nur noch einmalig sichtbar (nach Bestätigung bzw. Neu-Erzeugung), nicht mehr bei jedem Seitenaufruf. - E-Mail-Änderung als Pending-Flow: Bestätigungslink geht an die NEUE Adresse, die alte bleibt bis zum Klick aktiv — kein Aussperren durch Tippfehler und keine Registrierungs-Notice mehr nach dem Ändern. Co-Authored-By: Claude Fable 5 --- .../Auth/ConfirmEmailChangeController.php | 45 ++++ app/Mail/EmailChangeConfirmation.php | 41 ++++ ...24816_add_pending_email_to_users_table.php | 30 +++ .../auth/email-change-confirmation.blade.php | 13 ++ .../livewire/customer/security.blade.php | 213 ++++++++++++++---- routes/auth.php | 7 + tests/Feature/Auth/TwoFactorLoginTest.php | 23 ++ tests/Feature/CustomerProfileSecurityTest.php | 178 +++++++++++++++ 8 files changed, 505 insertions(+), 45 deletions(-) create mode 100644 app/Http/Controllers/Auth/ConfirmEmailChangeController.php create mode 100644 app/Mail/EmailChangeConfirmation.php create mode 100644 database/migrations/2026_07_07_124816_add_pending_email_to_users_table.php create mode 100644 resources/views/emails/auth/email-change-confirmation.blade.php diff --git a/app/Http/Controllers/Auth/ConfirmEmailChangeController.php b/app/Http/Controllers/Auth/ConfirmEmailChangeController.php new file mode 100644 index 00000000..1a4ca0ec --- /dev/null +++ b/app/Http/Controllers/Auth/ConfirmEmailChangeController.php @@ -0,0 +1,45 @@ +user()->is($user), 403); + + if (! $user->pending_email) { + return redirect()->route('me.security') + ->with('security-status', __('Es liegt keine ausstehende E-Mail-Änderung vor. Möglicherweise wurde der Link bereits verwendet.')); + } + + abort_unless(hash_equals(sha1($user->pending_email), $hash), 403); + + // Zwischenzeitlich vergeben? Dann Änderung verwerfen statt Konflikt anlegen. + if (User::query()->where('email', $user->pending_email)->whereKeyNot($user->id)->exists()) { + $user->forceFill(['pending_email' => null])->save(); + + return redirect()->route('me.security') + ->with('security-error', __('Die neue E-Mail-Adresse wird inzwischen von einem anderen Konto verwendet. Die Änderung wurde verworfen.')); + } + + $user->forceFill([ + 'email' => $user->pending_email, + 'email_verified_at' => now(), + 'pending_email' => null, + ])->save(); + + return redirect()->route('me.security') + ->with('security-status', __('Ihre E-Mail-Adresse wurde erfolgreich geändert.')); + } +} diff --git a/app/Mail/EmailChangeConfirmation.php b/app/Mail/EmailChangeConfirmation.php new file mode 100644 index 00000000..b7e63f56 --- /dev/null +++ b/app/Mail/EmailChangeConfirmation.php @@ -0,0 +1,41 @@ +string('pending_email', 190)->nullable()->after('email'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('pending_email'); + }); + } +}; diff --git a/resources/views/emails/auth/email-change-confirmation.blade.php b/resources/views/emails/auth/email-change-confirmation.blade.php new file mode 100644 index 00000000..fa91bad0 --- /dev/null +++ b/resources/views/emails/auth/email-change-confirmation.blade.php @@ -0,0 +1,13 @@ + +

Hallo {{ $user->name }},

+ +

für Ihr Kundenkonto wurde eine Änderung der E-Mail-Adresse auf {{ $newEmail }} angefordert. Die Änderung wird erst wirksam, wenn Sie sie über den folgenden Button bestätigen.

+ + Neue E-Mail-Adresse bestätigen + +

Der Link ist gültig bis {{ $expiresAt }} Uhr. Bis zur Bestätigung bleibt Ihre bisherige Adresse aktiv.

+ +
+

Falls Sie diese Änderung nicht angefordert haben, ignorieren Sie diese E-Mail bitte – Ihr Konto bleibt unverändert.

+
+
diff --git a/resources/views/livewire/customer/security.blade.php b/resources/views/livewire/customer/security.blade.php index abc4e51a..5d8d0cfe 100644 --- a/resources/views/livewire/customer/security.blade.php +++ b/resources/views/livewire/customer/security.blade.php @@ -1,13 +1,17 @@ user(); - $this->email = (string) $user->email; - $this->confirmedTwoFactor = ! is_null($user->two_factor_confirmed_at ?? null); + $this->email = (string) auth()->user()->email; } public function updatePassword(): void @@ -57,6 +65,12 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends session()->flash('security-status', __('Passwort aktualisiert.')); } + /** + * Startet eine E-Mail-Änderung: Die neue Adresse wird nur als + * `pending_email` vorgemerkt und erhält einen signierten Bestätigungslink. + * Die alte Adresse bleibt bis zur Bestätigung aktiv — so sperrt sich + * niemand mit einer vertippten Adresse aus dem Konto aus. + */ public function updateEmail(): void { $validated = $this->validate([ @@ -71,29 +85,73 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends /** @var User $user */ $user = auth()->user(); - $user->forceFill([ - 'email' => $validated['email'], - 'email_verified_at' => null, - ])->save(); - - if (Features::enabled(Features::emailVerification())) { - $user->sendEmailVerificationNotification(); + if (strcasecmp($validated['email'], $user->email) === 0) { + throw ValidationException::withMessages([ + 'email' => __('Das ist bereits Ihre aktuelle E-Mail-Adresse.'), + ]); } - session()->flash('security-status', __('E-Mail-Adresse aktualisiert. Bitte erneut bestätigen, falls eine Verifizierung verschickt wurde.')); + $user->forceFill(['pending_email' => $validated['email']])->save(); + + $expiresAt = now()->addHour(); + + $confirmUrl = URL::temporarySignedRoute('email-change.confirm', $expiresAt, [ + 'user' => $user->id, + 'hash' => sha1($validated['email']), + ]); + + Mail::to($validated['email'])->send(new EmailChangeConfirmation( + $user, + $validated['email'], + $confirmUrl, + $expiresAt->format('H:i') + )); + + session()->flash('security-status', __('Bestätigungslink an :email gesendet. Die Änderung wird erst nach Klick auf den Link wirksam — bis dahin bleibt Ihre bisherige Adresse aktiv.', ['email' => $validated['email']])); + } + + public function cancelPendingEmailChange(): void + { + auth()->user()->forceFill(['pending_email' => null])->save(); + + session()->flash('security-status', __('Die ausstehende E-Mail-Änderung wurde verworfen.')); } public function enableTwoFactorAuthentication(EnableTwoFactorAuthentication $enable): void { $enable(auth()->user()); + $this->showRecoveryCodes = false; - session()->flash('security-status', __('Zwei-Faktor-Authentifizierung aktiviert. Scannen Sie den QR-Code mit Ihrer Authenticator-App.')); + session()->flash('security-status', __('Scannen Sie den QR-Code mit Ihrer Authenticator-App und bestätigen Sie die Einrichtung mit einem aktuellen Code.')); + } + + /** + * Bestätigt die 2FA-Einrichtung mit einem TOTP-Code. Erst danach ist + * `two_factor_confirmed_at` gesetzt und der Login fordert die Challenge an. + */ + public function confirmTwoFactorAuthentication(ConfirmTwoFactorAuthentication $confirm): void + { + $this->validate(['twoFactorCode' => ['required', 'string']]); + + try { + $confirm(auth()->user(), $this->twoFactorCode); + } catch (ValidationException) { + throw ValidationException::withMessages([ + 'twoFactorCode' => __('Der eingegebene Code ist ungültig. Bitte versuchen Sie es mit einem aktuellen Code erneut.'), + ]); + } + + $this->reset('twoFactorCode'); + $this->showRecoveryCodes = true; + + session()->flash('security-status', __('Zwei-Faktor-Authentifizierung ist jetzt aktiv. Speichern Sie Ihre Wiederherstellungs-Codes — sie werden nur einmal angezeigt.')); } public function disableTwoFactorAuthentication(DisableTwoFactorAuthentication $disable): void { $disable(auth()->user()); - $this->confirmedTwoFactor = false; + $this->reset('twoFactorCode'); + $this->showRecoveryCodes = false; session()->flash('security-status', __('Zwei-Faktor-Authentifizierung deaktiviert.')); } @@ -101,8 +159,9 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends public function regenerateRecoveryCodes(GenerateNewRecoveryCodes $generate): void { $generate(auth()->user()); + $this->showRecoveryCodes = true; - session()->flash('security-status', __('Neue Wiederherstellungs-Codes erzeugt.')); + session()->flash('security-status', __('Neue Wiederherstellungs-Codes erzeugt. Die alten Codes sind ab sofort ungültig.')); } public function with(): array @@ -111,13 +170,20 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends $user = auth()->user(); $user->refresh(); + $twoFactorPending = ! is_null($user->two_factor_secret ?? null) && is_null($user->two_factor_confirmed_at ?? null); + $twoFactorConfirmed = ! is_null($user->two_factor_confirmed_at ?? null); + $qrUrl = null; $recoveryCodes = []; if (! is_null($user->two_factor_secret ?? null) && Features::enabled(Features::twoFactorAuthentication())) { try { - $qrUrl = $user->twoFactorQrCodeSvg(); - $recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true) ?: []; + // QR-Code nur während der Einrichtung; Recovery-Codes nur + // direkt nach Bestätigung/Neu-Erzeugung (einmalige Anzeige). + $qrUrl = $twoFactorPending ? $user->twoFactorQrCodeSvg() : null; + $recoveryCodes = ($twoFactorConfirmed && $this->showRecoveryCodes) + ? (json_decode(decrypt($user->two_factor_recovery_codes), true) ?: []) + : []; } catch (\Throwable) { $qrUrl = null; $recoveryCodes = []; @@ -126,7 +192,8 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends return [ 'user' => $user, - 'twoFactorEnabled' => ! is_null($user->two_factor_secret ?? null), + 'twoFactorPending' => $twoFactorPending, + 'twoFactorConfirmed' => $twoFactorConfirmed, 'twoFactorQrSvg' => $qrUrl, 'recoveryCodes' => $recoveryCodes, 'sessions' => DB::table('sessions') @@ -163,6 +230,14 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends @endif + @if (session('security-error')) +
+ + {{ session('security-error') }} +
+ @endif + {{-- ============== KPI-Reihe ============== --}}
@@ -184,11 +259,19 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends {{ __('Zwei-Faktor') }}
- {{ $twoFactorEnabled ? __('Aktiv') : __('Nicht aktiv') }} + @if ($twoFactorConfirmed) + {{ __('Aktiv') }} + @elseif ($twoFactorPending) + {{ __('Einrichtung ausstehend') }} + @else + {{ __('Nicht aktiv') }} + @endif
- @if ($twoFactorEnabled) + @if ($twoFactorConfirmed) {{ __('Zusatzschutz aktiv') }} + @elseif ($twoFactorPending) + {{ __('Bestätigung erforderlich') }} @else {{ __('Empfohlen') }} @endif @@ -238,12 +321,25 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends {{ __('E-Mail-Adresse ändern') }}
+ @if ($user->pending_email) +
+
+ + {{ __('Ausstehende Änderung zu:') }} {{ $user->pending_email }} +
+

{{ __('Der Bestätigungslink wurde an die neue Adresse gesendet. Bis zur Bestätigung bleibt die bisherige Adresse aktiv.') }}

+ + {{ __('Änderung verwerfen') }} + +
+ @endif

- {{ __('Nach der Änderung kann eine erneute Bestätigung der E-Mail-Adresse erforderlich sein.') }} + {{ __('Sie erhalten einen Bestätigungslink an die neue Adresse. Erst nach dem Klick wird die Änderung wirksam — Ihre bisherige Adresse bleibt bis dahin aktiv.') }}

- {{ __('E-Mail speichern') }} + {{ __('Bestätigungslink senden') }}
@@ -252,41 +348,68 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
{{ __('Zwei-Faktor-Authentifizierung') }} - @if ($twoFactorEnabled) + @if ($twoFactorConfirmed) {{ __('Aktiv') }} + @elseif ($twoFactorPending) + {{ __('Bestätigung erforderlich') }} @endif
- @if (! $twoFactorEnabled) + @if (! $twoFactorPending && ! $twoFactorConfirmed) + {{-- Zustand 1: nicht eingerichtet --}}

{{ __('Schützen Sie Ihren Account zusätzlich mit einer Authenticator-App (TOTP).') }}

{{ __('Zwei-Faktor-Authentifizierung aktivieren') }} + @elseif ($twoFactorPending) + {{-- Zustand 2: Secret erzeugt, Bestätigung mit App-Code steht aus. + Erst die Bestätigung aktiviert die Login-Abfrage. --}} +
+ {{-- QR-Code: bg-white ist BEWUSST konstant in beiden Modi — + QR-Codes brauchen schwarz-auf-weiß für zuverlässiges Scannen. --}} +
+ {!! $twoFactorQrSvg !!} +
+
+

+ {{ __('Scannen Sie den QR-Code mit Ihrer Authenticator-App (z. B. 1Password, Google Authenticator) und geben Sie anschließend den angezeigten 6-stelligen Code ein. Erst danach ist die Zwei-Faktor-Authentifizierung aktiv.') }} +

+
+ +
+ + {{ __('Einrichtung bestätigen') }} + + + {{ __('Abbrechen') }} + +
+ +
+
@else - @if ($twoFactorQrSvg) -
- {{-- QR-Code: bg-white ist BEWUSST konstant in beiden Modi — - QR-Codes brauchen schwarz-auf-weiß für zuverlässiges Scannen. --}} -
- {!! $twoFactorQrSvg !!} -
-
-

- {{ __('Scannen Sie den QR-Code mit Ihrer Authenticator-App (z. B. 1Password, Google Authenticator).') }} -

- @if (! empty($recoveryCodes)) -
- {{ __('Wiederherstellungs-Codes') }} -
-
    - @foreach ($recoveryCodes as $code) -
  • {{ $code }}
  • - @endforeach -
- @endif + {{-- Zustand 3: aktiv. Recovery-Codes nur einmalig direkt nach + Bestätigung bzw. Neu-Erzeugung sichtbar. --}} +

+ {{ __('Die Zwei-Faktor-Authentifizierung ist aktiv. Beim Login wird zusätzlich ein Code aus Ihrer Authenticator-App abgefragt.') }} +

+ + @if (! empty($recoveryCodes)) +
+
+ {{ __('Wiederherstellungs-Codes') }}
+

+ {{ __('Speichern Sie diese Codes jetzt an einem sicheren Ort — sie werden nur dieses eine Mal angezeigt. Jeder Code ersetzt einmalig Ihre Authenticator-App, falls Sie den Zugriff darauf verlieren.') }} +

+
    + @foreach ($recoveryCodes as $code) +
  • {{ $code }}
  • + @endforeach +
@endif diff --git a/routes/auth.php b/routes/auth.php index c3132f90..1df55cc3 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -1,5 +1,6 @@ config('fortify.middleware', ['web'])], function ( })->middleware(['auth:'.config('fortify.guard'), 'throttle:6,1']) ->name('verification.send'); + // Signierter Bestätigungslink für eine E-Mail-Änderung (geht an die NEUE + // Adresse; erst der Klick übernimmt sie ins Konto). + Route::get('/email-aendern/{user}/{hash}', ConfirmEmailChangeController::class) + ->middleware(['auth:'.config('fortify.guard'), 'signed', 'throttle:6,1']) + ->name('email-change.confirm'); + // 2FA-Challenge-Seite (Volt-Frontend für Fortifys two-factor-challenge); // der eigentliche POST geht an Fortifys Controller. Volt::route('/two-factor-challenge', 'auth.two-factor-challenge') diff --git a/tests/Feature/Auth/TwoFactorLoginTest.php b/tests/Feature/Auth/TwoFactorLoginTest.php index 0f043e4e..9beb3036 100644 --- a/tests/Feature/Auth/TwoFactorLoginTest.php +++ b/tests/Feature/Auth/TwoFactorLoginTest.php @@ -34,6 +34,29 @@ test('a user with two-factor enabled is handed to the challenge, not logged in', expect(session('login.id'))->toBe($user->id); }); +test('an unconfirmed two-factor setup does not trigger the challenge', function () { + /** @var TestCase $this */ + $this->seed(RolesAndPermissionsSeeder::class); + $user = User::factory()->create(['is_active' => true]); + $user->assignRole('customer'); + + // Secret erzeugt, aber Einrichtung nie mit App-Code bestätigt + // (two_factor_confirmed_at bleibt null → Fortify-'confirm'-Semantik). + $user->forceFill([ + 'two_factor_secret' => encrypt(app(Google2FA::class)->generateSecretKey()), + 'two_factor_confirmed_at' => null, + ])->save(); + + Volt::test('auth.login') + ->set('email', $user->email) + ->set('password', 'password') + ->call('login') + ->assertHasNoErrors() + ->assertRedirect(route('me.dashboard', absolute: false)); + + $this->assertAuthenticatedAs($user); +}); + test('the challenge page redirects to login without a pending challenge', function () { /** @var TestCase $this */ $this->get(route('two-factor.challenge'))->assertRedirect(route('login')); diff --git a/tests/Feature/CustomerProfileSecurityTest.php b/tests/Feature/CustomerProfileSecurityTest.php index a60e4035..ac6b906f 100644 --- a/tests/Feature/CustomerProfileSecurityTest.php +++ b/tests/Feature/CustomerProfileSecurityTest.php @@ -2,6 +2,7 @@ use App\Enums\Portal; use App\Enums\PressReleaseStatus; +use App\Mail\EmailChangeConfirmation; use App\Models\Category; use App\Models\Company; use App\Models\Contact; @@ -11,7 +12,10 @@ use App\Models\PressReleaseStatusLog; use App\Models\User; use Database\Seeders\RolesAndPermissionsSeeder; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\URL; use Livewire\Volt\Volt as LivewireVolt; +use PragmaRX\Google2FA\Google2FA; use Tests\TestCase; test('customer can update own profile fields', function () { @@ -185,6 +189,180 @@ test('customer can change own password through security page', function () { expect(Hash::check('new-strong-password', $customer->password))->toBeTrue(); }); +test('two factor setup only becomes active after confirming an app code', function () { + /** @var TestCase $this */ + $customer = User::factory()->create(['is_active' => true]); + + $this->actingAs($customer); + + $component = LivewireVolt::test('customer.security') + ->call('enableTwoFactorAuthentication'); + + $customer->refresh(); + + expect($customer->two_factor_secret)->not->toBeNull(); + expect($customer->two_factor_confirmed_at)->toBeNull(); + expect($customer->hasEnabledTwoFactorAuthentication())->toBeFalse(); + + $otp = app(Google2FA::class)->getCurrentOtp(decrypt($customer->two_factor_secret)); + + $component + ->set('twoFactorCode', $otp) + ->call('confirmTwoFactorAuthentication') + ->assertHasNoErrors() + ->assertSee('Wiederherstellungs-Codes'); + + $customer->refresh(); + + expect($customer->two_factor_confirmed_at)->not->toBeNull(); + expect($customer->hasEnabledTwoFactorAuthentication())->toBeTrue(); +}); + +test('an invalid two factor confirmation code is rejected', function () { + /** @var TestCase $this */ + $customer = User::factory()->create(['is_active' => true]); + + $this->actingAs($customer); + + LivewireVolt::test('customer.security') + ->call('enableTwoFactorAuthentication') + ->set('twoFactorCode', '000000') + ->call('confirmTwoFactorAuthentication') + ->assertHasErrors(['twoFactorCode']); + + expect($customer->refresh()->two_factor_confirmed_at)->toBeNull(); +}); + +test('recovery codes stay hidden on a later visit to the security page', function () { + /** @var TestCase $this */ + $customer = User::factory()->create(['is_active' => true]); + $customer->forceFill([ + 'two_factor_secret' => encrypt(app(Google2FA::class)->generateSecretKey()), + 'two_factor_recovery_codes' => encrypt(json_encode(['geheim-code-eins', 'geheim-code-zwei'])), + 'two_factor_confirmed_at' => now(), + ])->save(); + + $this->actingAs($customer); + + LivewireVolt::test('customer.security') + ->assertDontSee('geheim-code-eins') + ->call('regenerateRecoveryCodes') + ->assertSee('Wiederherstellungs-Codes'); +}); + +test('changing the email keeps the old address active and mails a confirm link to the new one', function () { + /** @var TestCase $this */ + Mail::fake(); + + $customer = User::factory()->create([ + 'is_active' => true, + 'email' => 'alt@example.com', + 'email_verified_at' => now(), + ]); + + $this->actingAs($customer); + + LivewireVolt::test('customer.security') + ->set('email', 'neu@example.com') + ->call('updateEmail') + ->assertHasNoErrors(); + + $customer->refresh(); + + expect($customer->email)->toBe('alt@example.com'); + expect($customer->pending_email)->toBe('neu@example.com'); + expect($customer->email_verified_at)->not->toBeNull(); + + Mail::assertSent(EmailChangeConfirmation::class, function ($mail) { + return $mail->hasTo('neu@example.com') && str_contains($mail->confirmUrl, 'signature='); + }); +}); + +test('the signed link applies the pending email change', function () { + /** @var TestCase $this */ + $customer = User::factory()->create([ + 'is_active' => true, + 'email' => 'alt@example.com', + 'pending_email' => 'neu@example.com', + ]); + + $url = URL::temporarySignedRoute('email-change.confirm', now()->addHour(), [ + 'user' => $customer->id, + 'hash' => sha1('neu@example.com'), + ]); + + $this->actingAs($customer) + ->get($url) + ->assertRedirect(route('me.security')); + + $customer->refresh(); + + expect($customer->email)->toBe('neu@example.com'); + expect($customer->pending_email)->toBeNull(); + expect($customer->email_verified_at)->not->toBeNull(); +}); + +test('the email change link of another user is rejected', function () { + /** @var TestCase $this */ + $owner = User::factory()->create(['is_active' => true, 'pending_email' => 'neu@example.com']); + $intruder = User::factory()->create(['is_active' => true]); + + $url = URL::temporarySignedRoute('email-change.confirm', now()->addHour(), [ + 'user' => $owner->id, + 'hash' => sha1('neu@example.com'), + ]); + + $this->actingAs($intruder)->get($url)->assertForbidden(); + + expect($owner->refresh()->email)->not->toBe('neu@example.com'); +}); + +test('an unsigned email change link is rejected', function () { + /** @var TestCase $this */ + $customer = User::factory()->create(['is_active' => true, 'pending_email' => 'neu@example.com']); + + $this->actingAs($customer) + ->get(route('email-change.confirm', ['user' => $customer->id, 'hash' => sha1('neu@example.com')])) + ->assertForbidden(); +}); + +test('a pending email change can be discarded', function () { + /** @var TestCase $this */ + $customer = User::factory()->create(['is_active' => true, 'pending_email' => 'neu@example.com']); + + $this->actingAs($customer); + + LivewireVolt::test('customer.security') + ->call('cancelPendingEmailChange'); + + expect($customer->refresh()->pending_email)->toBeNull(); +}); + +test('a pending email that got taken by another account meanwhile is discarded on confirm', function () { + /** @var TestCase $this */ + $customer = User::factory()->create([ + 'is_active' => true, + 'email' => 'alt@example.com', + 'pending_email' => 'neu@example.com', + ]); + User::factory()->create(['email' => 'neu@example.com']); + + $url = URL::temporarySignedRoute('email-change.confirm', now()->addHour(), [ + 'user' => $customer->id, + 'hash' => sha1('neu@example.com'), + ]); + + $this->actingAs($customer) + ->get($url) + ->assertRedirect(route('me.security')) + ->assertSessionHas('security-error'); + + $customer->refresh(); + + expect($customer->email)->toBe('alt@example.com'); + expect($customer->pending_email)->toBeNull(); +}); + test('press release policy denies access to other users releases', function () { /** @var TestCase $this */ $owner = User::factory()->create(['is_active' => true]);