create([ 'email' => $email, 'two_factor_secret' => (new Google2FA)->generateSecretKey(), 'two_factor_confirmed_at' => now(), ]); } private function attemptReset(string $email, string $code = '123456'): Testable { return Livewire::test(ForgotPassword::class) ->set('email', $email)->set('code', $code) ->set('password', 'NewPassw0rd123')->set('password_confirmation', 'NewPassw0rd123') ->call('resetPassword'); } // ── (a) the reset-link mail is queued ──────────────────────────────── public function test_reset_link_notification_is_queued(): void { config(['mail.default' => 'smtp']); Notification::fake(); $user = User::factory()->create(['email' => 'admin@clusev.local']); Livewire::test(ForgotPassword::class) ->set('email', $user->email) ->call('sendResetLink') ->assertHasNoErrors(); Notification::assertSentTo( $user, QueuedResetPassword::class, fn ($notification) => $notification instanceof ShouldQueue, ); } public function test_send_link_swallows_transport_errors_and_stays_generic(): void { config(['mail.default' => 'smtp']); // No user exists → Password::sendResetLink hits the INVALID_USER path; the screen must // still report the generic message with no error, exactly like the existing-user case. Livewire::test(ForgotPassword::class) ->set('email', 'ghost@clusev.local') ->call('sendResetLink') ->assertHasNoErrors() ->assertDispatched('notify', message: __('auth.reset_link_sent')); } // ── (b) every invalid branch issues an identical query fingerprint ─── public function test_invalid_branches_have_identical_query_fingerprints(): void { // A real 2FA account with a wrong code runs a uniform webauthn-existence probe + verifyTotp // (HMAC) + useRecoveryCode (a locked SELECT in a transaction). The no-account and no-2FA // branches must produce the SAME query fingerprint (users + webauthn_credentials counts) — // proving none of them returns early (faster), does heavier work like a cost-12 bcrypt // (slower), nor skips the webauthn lookup (an extra account-enumeration tell). $this->twoFactorUser('totp@clusev.local'); User::factory()->create(['email' => 'plain@clusev.local']); // registered, no 2FA // registered with a security key only (no TOTP) $keyUser = User::factory()->create(['email' => 'key@clusev.local']); WebauthnCredential::create(['user_id' => $keyUser->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]); $totp = $this->queryFingerprint(fn () => $this->attemptReset('totp@clusev.local', '000000')->assertHasErrors('code')); $plain = $this->queryFingerprint(fn () => $this->attemptReset('plain@clusev.local')->assertHasErrors('code')); $keyOnly = $this->queryFingerprint(fn () => $this->attemptReset('key@clusev.local')->assertHasErrors('code')); $unknown = $this->queryFingerprint(fn () => $this->attemptReset('ghost@clusev.local')->assertHasErrors('code')); $this->assertGreaterThan(1, $unknown['users'], 'no-account branch must do dummy verification work, not return early'); $this->assertSame(1, $unknown['webauthn'], 'no-account branch must still issue the webauthn existence probe'); $this->assertSame($totp, $unknown, 'unknown email must look identical to a real TOTP wrong-code attempt'); $this->assertSame($totp, $plain, 'a no-2FA account must look identical to a real TOTP wrong-code attempt'); $this->assertSame($totp, $keyOnly, 'a webauthn-only account must look identical to a real TOTP wrong-code attempt'); } /** Count queries per table issued while running $action (one DB round-trip = one timing tell). */ private function queryFingerprint(callable $action): array { DB::flushQueryLog(); DB::enableQueryLog(); $action(); $queries = collect(DB::getQueryLog())->map(fn ($entry) => strtolower((string) $entry['query'])); DB::disableQueryLog(); return [ 'users' => $queries->filter(fn ($q) => str_contains($q, '"users"'))->count(), 'webauthn' => $queries->filter(fn ($q) => str_contains($q, '"webauthn_credentials"'))->count(), ]; } // ── structural equivalence of the invalid branches ────────────────── public function test_missing_and_no_2fa_branches_are_structurally_identical(): void { User::factory()->create(['email' => 'real@clusev.local']); $missing = $this->attemptReset('ghost@clusev.local'); $no2fa = $this->attemptReset('real@clusev.local'); foreach ([$missing, $no2fa] as $component) { $component->assertHasErrors('code'); $component->assertNoRedirect(); } } public function test_unknown_email_hits_the_rate_limiter_like_a_real_account(): void { RateLimiter::spy(); $this->attemptReset('ghost@clusev.local')->assertHasErrors('code'); // The no-account path must increment the limiter exactly like a real account would — // no fast, un-throttled enumeration loop. RateLimiter::shouldHaveReceived('hit')->atLeast()->once(); } }