create([ 'two_factor_secret' => (new Google2FA)->generateSecretKey(), 'two_factor_confirmed_at' => now(), ]); } private function keyOnlyUser(): User { $u = User::factory()->create(); WebauthnCredential::create(['user_id' => $u->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]); return $u->fresh(); } public function test_backup_code_logs_in_and_is_consumed(): void { $user = $this->enrolledUser(); $code = $user->replaceRecoveryCodes()[0]; session()->put('2fa.user', $user->id); Livewire::test(TwoFactorBackup::class) ->set('code', $code) ->call('verify') ->assertRedirect(route('dashboard')); $this->assertAuthenticatedAs($user); $this->assertFalse($user->fresh()->useRecoveryCode($code)); // consumed } public function test_invalid_backup_code_fails(): void { $user = $this->enrolledUser(); $user->replaceRecoveryCodes(); session()->put('2fa.user', $user->id); Livewire::test(TwoFactorBackup::class) ->set('code', 'nope-nope') ->call('verify') ->assertHasErrors('code'); $this->assertGuest(); } public function test_empty_code_is_rejected(): void { $user = $this->enrolledUser(); $user->replaceRecoveryCodes(); session()->put('2fa.user', $user->id); Livewire::test(TwoFactorBackup::class) ->set('code', '') ->call('verify') ->assertHasErrors('code'); $this->assertGuest(); } public function test_a_totp_code_is_not_accepted_on_the_backup_view(): void { $user = $this->enrolledUser(); $user->replaceRecoveryCodes(); $totp = (new Google2FA)->getCurrentOtp($user->two_factor_secret); session()->put('2fa.user', $user->id); Livewire::test(TwoFactorBackup::class) ->set('code', $totp) ->call('verify') ->assertHasErrors('code'); $this->assertGuest(); } public function test_requires_a_pending_2fa_session(): void { Livewire::test(TwoFactorBackup::class) ->assertRedirect(route('login')); } public function test_field_and_login_link_render(): void { $user = $this->enrolledUser(); $user->replaceRecoveryCodes(); session()->put('2fa.user', $user->id); Livewire::test(TwoFactorBackup::class) ->assertSee(__('auth.backup_heading')) ->assertSee(__('auth.challenge_backup_placeholder')) // the backup field is present ->assertSee(__('auth.back_to_login')); } public function test_back_to_options_shown_for_a_totp_user(): void { $user = $this->enrolledUser(); $user->replaceRecoveryCodes(); session()->put('2fa.user', $user->id); Livewire::test(TwoFactorBackup::class) ->assertSee(__('auth.back_to_options')); } public function test_back_to_options_hidden_for_key_only_without_secure_context(): void { // No domain configured → webauthnAvailable() false, pendingHasTotp false: backup is the // only path, so the "back to options" link would loop straight back here and is hidden. $user = $this->keyOnlyUser(); $user->replaceRecoveryCodes(); session()->put('2fa.user', $user->id); Livewire::test(TwoFactorBackup::class) ->assertDontSee(__('auth.back_to_options')) ->assertSee(__('auth.back_to_login')); } public function test_failed_backup_attempts_share_the_rate_limit_with_the_main_challenge(): void { $user = $this->enrolledUser(); $codes = $user->replaceRecoveryCodes(); session()->put('2fa.user', $user->id); // 5 wrong backup attempts trip the per-(user+IP) bucket (5/60s). for ($i = 0; $i < 5; $i++) { Livewire::test(TwoFactorBackup::class)->set('code', 'wrong-'.$i)->call('verify'); } // A genuinely valid, unused backup code is now refused on the MAIN challenge by the // SAME shared bucket — proving one counter spans both components. Livewire::test(TwoFactorChallenge::class) ->set('code', $codes[0]) ->call('verify') ->assertHasErrors('code'); $this->assertGuest(); } }