From 08a442e6617e05b26c890d9b47400f2e2767e984 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 11 Sep 2026 00:11:36 +0930 Subject: [PATCH] Rewrite 2FA tests for the pending-login refactor The 2FA flow moved from a middleware-gated i/auth/checkpoint model to a pending-login model (auth.pending session, POST /login/2fa, /login?step=2fa challenge). The old tests referenced the removed route and dead session keys (2fa.session.active, 2fa.attempts) and failed with 404s. Rewritten against the new code as source of truth: - Checkpoint test: throttle assertion retargeted to the login/2fa route; failed-verification audit log now driven through a pending 2FA session. - Logout-session test: asserts auth.pending is cleared and the user stays a guest after MAX_2FA_ATTEMPTS failures (replacing the old flag cleanup). - TwoFactorTest: challenge-redirect and challenge-page cases rewritten around the login flow; setup/recovery password-confirmation cases unchanged. - MiddlewarePipelineTest: 2FA is enforced at login, not per-request, so an authenticated 2FA user browses normally. Full suite: 715 passed. --- .../Feature/Auth/TwoFactorCheckpointTest.php | 30 +++++++++++-- .../Auth/TwoFactorLogoutSessionTest.php | 44 ++++++++++++------- tests/Feature/Auth/TwoFactorTest.php | 38 +++++++++++++--- .../Framework/MiddlewarePipelineTest.php | 9 ++-- 4 files changed, 91 insertions(+), 30 deletions(-) diff --git a/tests/Feature/Auth/TwoFactorCheckpointTest.php b/tests/Feature/Auth/TwoFactorCheckpointTest.php index b2df42a99..089778c36 100644 --- a/tests/Feature/Auth/TwoFactorCheckpointTest.php +++ b/tests/Feature/Auth/TwoFactorCheckpointTest.php @@ -2,6 +2,7 @@ use App\Models\AccountLog; use App\Models\User; +use App\Services\PendingLoginService; use Illuminate\Foundation\Testing\LazilyRefreshDatabase; use Illuminate\Support\Facades\Route; use PragmaRX\Google2FA\Google2FA; @@ -16,11 +17,33 @@ uses(LazilyRefreshDatabase::class); | The 2FA verify endpoint must be server-side rate limited (so re-login cannot | reset an unlimited guess budget) and failed attempts must be audit-logged. | +| The 2FA flow is a pending-login model: after valid credentials the user is +| NOT logged in but a pending session (auth.pending, step=2fa) is created, and +| the code is submitted to POST /login/2fa. +| */ +/** + * Seed a pending 2FA login session for the given user, mirroring what + * PendingLoginService::start writes after credentials are verified. + */ +function pending2faSession(User $user, int $attempts = 0): array +{ + return [ + PendingLoginService::SESSION_KEY => [ + 'user_id' => $user->id, + 'email' => $user->email, + 'remember' => false, + 'step' => PendingLoginService::STEP_2FA, + 'attempts' => $attempts, + 'expires_at' => now()->addSeconds(PendingLoginService::TTL_SECONDS)->getTimestamp(), + ], + ]; +} + it('applies throttle middleware to the 2FA verify route', function () { $route = collect(Route::getRoutes())->first(function ($r) { - return $r->uri() === 'i/auth/checkpoint' && in_array('POST', $r->methods()); + return $r->uri() === 'login/2fa' && in_array('POST', $r->methods()); }); expect($route)->not->toBeNull(); @@ -38,8 +61,9 @@ it('audit-logs a failed 2FA verification', function () { $user = User::factory()->create(['2fa_secret' => $secret, '2fa_enabled' => true]); $user->refresh(); - $this->actingAs($user) - ->post('/i/auth/checkpoint', ['code' => '000000']); + // A wrong 6-digit code against an active pending 2FA login. + $this->withSession(pending2faSession($user)) + ->post('/login/2fa', ['code' => '000000']); expect( AccountLog::where('user_id', $user->id) diff --git a/tests/Feature/Auth/TwoFactorLogoutSessionTest.php b/tests/Feature/Auth/TwoFactorLogoutSessionTest.php index 18e62b0dc..55aefb2f9 100644 --- a/tests/Feature/Auth/TwoFactorLogoutSessionTest.php +++ b/tests/Feature/Auth/TwoFactorLogoutSessionTest.php @@ -1,6 +1,7 @@ withoutMiddleware(ThrottleRequests::class); }); -it('clears the 2fa.session.active flag when forced logout occurs', function () { +it('clears the pending login state after the final failed attempt', function () { $google2fa = new Google2FA; $secret = $google2fa->generateSecretKey(); $user = User::factory()->create(['2fa_secret' => $secret, '2fa_enabled' => true]); $user->refresh(); - $this->actingAs($user) - ->withSession([ - '2fa.attempts' => 3, - '2fa.session.active' => [true], - ]) - ->post('/i/auth/checkpoint', ['code' => '000000']) - ->assertRedirect('/'); - - // The forced logout must have cleared the 2FA session flag. - expect(session()->has('2fa.session.active'))->toBeFalse(); - expect(session()->has('2fa.attempts'))->toBeFalse(); + // Already at MAX_2FA_ATTEMPTS - 1 failures; the next wrong code trips the limit. + $attempts = PendingLoginService::MAX_2FA_ATTEMPTS - 1; + + $this->withSession([ + PendingLoginService::SESSION_KEY => [ + 'user_id' => $user->id, + 'email' => $user->email, + 'remember' => false, + 'step' => PendingLoginService::STEP_2FA, + 'attempts' => $attempts, + 'expires_at' => now()->addSeconds(PendingLoginService::TTL_SECONDS)->getTimestamp(), + ], + ]) + ->post('/login/2fa', ['code' => '000000']) + ->assertRedirect(route('login')); + + // The pending login must have been discarded, forcing a fresh sign-in. + expect(session()->has(PendingLoginService::SESSION_KEY))->toBeFalse(); + + // And the user is not authenticated. + $this->assertGuest(); }); diff --git a/tests/Feature/Auth/TwoFactorTest.php b/tests/Feature/Auth/TwoFactorTest.php index 700ec4f06..6f0eaba69 100644 --- a/tests/Feature/Auth/TwoFactorTest.php +++ b/tests/Feature/Auth/TwoFactorTest.php @@ -1,6 +1,7 @@ create([ '2fa_enabled' => true, '2fa_secret' => 'TESTSECRETKEY123', ]); $user->refresh(); - $this->actingAs($user) - ->get('/settings/home') - ->assertRedirect('/i/auth/checkpoint'); + $this->post('/login', [ + 'email' => $user->email, + 'password' => 'password', + ])->assertRedirect(route('login', ['step' => PendingLoginService::STEP_2FA])); + + // Credentials verified but the session is NOT yet authenticated. + $this->assertGuest(); + + // A pending 2FA login was recorded. + expect(session(PendingLoginService::SESSION_KEY.'.step')) + ->toBe(PendingLoginService::STEP_2FA); }); -it('renders the 2fa checkpoint page for authenticated user', function () { +it('renders the 2fa challenge page for a pending login', function () { $user = User::factory()->create([ '2fa_enabled' => true, '2fa_secret' => 'TESTSECRETKEY123', ]); $user->refresh(); - $this->actingAs($user) - ->get('/i/auth/checkpoint') + $this->withSession([ + PendingLoginService::SESSION_KEY => [ + 'user_id' => $user->id, + 'email' => $user->email, + 'remember' => false, + 'step' => PendingLoginService::STEP_2FA, + 'attempts' => 0, + 'expires_at' => now()->addSeconds(PendingLoginService::TTL_SECONDS)->getTimestamp(), + ], + ]) + ->get(route('login', ['step' => PendingLoginService::STEP_2FA])) ->assertOk(); }); diff --git a/tests/Feature/Framework/MiddlewarePipelineTest.php b/tests/Feature/Framework/MiddlewarePipelineTest.php index 861f5fbf2..d091624bf 100644 --- a/tests/Feature/Framework/MiddlewarePipelineTest.php +++ b/tests/Feature/Framework/MiddlewarePipelineTest.php @@ -90,7 +90,10 @@ it('adds X-Frame-Options header via FrameGuard', function () { ->assertHeader('X-Frame-Options', 'SAMEORIGIN'); }); -it('handles TwoFactorAuth middleware for users with 2fa', function () { +it('does not gate an already-authenticated 2fa user per-request', function () { + // 2FA is enforced at login time (pending-login model), not by a + // per-request middleware. An already-authenticated 2FA user browses + // normally -- no checkpoint redirect. $user = User::factory()->create([ '2fa_enabled' => true, '2fa_secret' => 'TESTSECRET123456', @@ -99,10 +102,10 @@ it('handles TwoFactorAuth middleware for users with 2fa', function () { $this->actingAs($user) ->get('/settings/home') - ->assertRedirect('/i/auth/checkpoint'); + ->assertOk(); }); -it('passes TwoFactorAuth middleware for users without 2fa', function () { +it('allows an authenticated user without 2fa to browse', function () { $user = User::factory()->create([ '2fa_enabled' => false, ]);