clusev/tests/Feature/ForgotPasswordTimingTest.php

140 lines
5.3 KiB
PHP

<?php
namespace Tests\Feature;
use App\Livewire\Auth\ForgotPassword;
use App\Models\User;
use App\Notifications\QueuedResetPassword;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
use PragmaRX\Google2FAQRCode\Google2FA;
use Tests\TestCase;
/**
* Hardening of the two low-severity account-enumeration *timing* side channels on the
* forgot-password screen (the message-level leak is already closed via generic copy):
* (a) sendResetLink() must enqueue the reset mail (never send synchronously), so SMTP
* latency/exceptions can't distinguish a known address;
* (b) resetPassword() must spend comparable crypto time whether the account exists / has
* 2FA or not, so the verification branch isn't a timing oracle.
* These tests assert the *structural* equivalence of the branches (and that mail is queued),
* not wall-clock timing (which would be flaky).
*/
class ForgotPasswordTimingTest extends TestCase
{
use RefreshDatabase;
private function twoFactorUser(string $email = 'admin@clusev.local'): User
{
return User::factory()->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) the verification branch does constant dummy work ─────────────
public function test_unknown_email_invalid_branch_burns_verification_time(): void
{
Hash::spy();
$this->attemptReset('ghost@clusev.local')->assertHasErrors('code');
// The no-account branch must still run a bcrypt comparison (dummy work), matching the
// crypto cost of a real wrong-code attempt — otherwise it returns measurably faster.
Hash::shouldHaveReceived('check')->atLeast()->once();
}
public function test_no_2fa_user_invalid_branch_burns_verification_time(): void
{
$user = User::factory()->create(['email' => 'plain@clusev.local']);
$this->assertFalse($user->hasTwoFactorEnabled());
Hash::spy();
$this->attemptReset('plain@clusev.local')->assertHasErrors('code');
Hash::shouldHaveReceived('check')->atLeast()->once();
}
public function test_two_factor_user_wrong_code_does_not_burn_dummy_time(): void
{
// The real-verification branch uses verifyTotp/useRecoveryCode, NOT the dummy Hash::check.
$this->twoFactorUser();
Hash::spy();
$this->attemptReset('admin@clusev.local', '000000')->assertHasErrors('code');
Hash::shouldNotHaveReceived('check');
}
// ── 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();
}
}