140 lines
5.3 KiB
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();
|
|
}
|
|
}
|