feat(KAN-21): rebuild login verification and two-factor flows
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 6s

This commit is contained in:
2026-09-11 14:18:23 +02:00
parent 5e52e4fe78
commit 802294d6a2
17 changed files with 1294 additions and 250 deletions
+18 -1
View File
@@ -4,6 +4,7 @@ namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Inertia\Testing\AssertableInertia as Assert;
use Laravel\Fortify\Features;
@@ -85,7 +86,7 @@ class AuthenticationTest extends TestCase
$this->post(route('login.store'), [
'email' => $user->email,
'password' => 'wrong-password',
]);
])->assertSessionHasErrors('email');
$this->assertGuest();
}
@@ -100,6 +101,22 @@ class AuthenticationTest extends TestCase
$response->assertRedirect(route('home'));
}
public function test_login_preserves_the_intended_destination_and_remember_choice(): void
{
$user = User::factory()->create();
$this->withSession(['url.intended' => route('dashboard')])
->post(route('login.store'), [
'email' => $user->email,
'password' => 'password',
'remember' => true,
])
->assertRedirect(route('dashboard'))
->assertCookie(Auth::guard('web')->getRecallerName());
$this->assertAuthenticatedAs($user);
}
public function test_users_are_rate_limited()
{
$user = User::factory()->create();
+30 -2
View File
@@ -61,7 +61,7 @@ class EmailVerificationTest extends TestCase
['id' => $user->id, 'hash' => sha1('wrong-email')],
);
$this->actingAs($user)->get($verificationUrl);
$this->actingAs($user)->get($verificationUrl)->assertForbidden();
Event::assertNotDispatched(Verified::class);
$this->assertFalse($user->fresh()->hasVerifiedEmail());
@@ -79,7 +79,7 @@ class EmailVerificationTest extends TestCase
['id' => 123, 'hash' => sha1($user->email)],
);
$this->actingAs($user)->get($verificationUrl);
$this->actingAs($user)->get($verificationUrl)->assertForbidden();
Event::assertNotDispatched(Verified::class);
$this->assertFalse($user->fresh()->hasVerifiedEmail());
@@ -97,6 +97,34 @@ class EmailVerificationTest extends TestCase
$response->assertRedirect(route('dashboard', absolute: false));
}
public function test_the_ui_rework_preserves_the_existing_optional_verification_policy(): void
{
$user = User::factory()->unverified()->create();
$this->actingAs($user)->get(route('dashboard'))->assertOk();
$this->assertFalse($user->fresh()->hasVerifiedEmail());
}
public function test_guests_cannot_view_the_verification_prompt(): void
{
$this->get(route('verification.notice'))->assertRedirect(route('login'));
}
public function test_expired_verification_links_cannot_verify_an_email(): void
{
$user = User::factory()->unverified()->create();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->subMinute(),
['id' => $user->id, 'hash' => sha1($user->email)],
);
$this->actingAs($user)->get($verificationUrl)->assertForbidden();
$this->assertFalse($user->fresh()->hasVerifiedEmail());
}
public function test_already_verified_user_visiting_verification_link_is_redirected_without_firing_event_again(): void
{
$user = User::factory()->create();
@@ -4,7 +4,9 @@ namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
use Inertia\Testing\AssertableInertia as Assert;
use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider;
use Laravel\Fortify\Features;
use Tests\TestCase;
@@ -52,4 +54,77 @@ class TwoFactorChallengeTest extends TestCase
->component('auth/TwoFactorChallenge'),
);
}
public function test_valid_authenticator_code_completes_the_pending_login(): void
{
$user = User::factory()->withTwoFactor()->create();
$this->mock(TwoFactorAuthenticationProvider::class)
->shouldReceive('verify')->once()->with('secret', '012345')->andReturnTrue();
$this->withSession(['url.intended' => route('dashboard')])
->post(route('login.store'), [
'email' => $user->email,
'password' => 'password',
'remember' => true,
])->assertRedirect(route('two-factor.login'));
$this->assertGuest();
$this->post(route('two-factor.login.store'), ['code' => '012345'])
->assertRedirect(route('dashboard'))
->assertSessionMissing('login.id')
->assertSessionMissing('login.remember')
->assertCookie(Auth::guard('web')->getRecallerName());
$this->assertAuthenticatedAs($user);
}
public function test_invalid_authenticator_code_keeps_the_pending_login_for_retry(): void
{
$user = User::factory()->withTwoFactor()->create();
$this->mock(TwoFactorAuthenticationProvider::class)
->shouldReceive('verify')->once()->with('secret', '000000')->andReturnFalse();
$this->withSession(['login.id' => $user->id])
->from(route('two-factor.login'))
->post(route('two-factor.login.store'), ['code' => '000000'])
->assertRedirect(route('two-factor.login'))
->assertSessionHasErrors('code')
->assertSessionHas('login.id', $user->id);
$this->assertGuest();
}
public function test_recovery_code_signs_in_once_and_is_replaced(): void
{
$user = User::factory()->withTwoFactor()->create();
$this->withSession(['login.id' => $user->id, 'login.remember' => true])
->post(route('two-factor.login.store'), ['recovery_code' => 'recovery-code-1'])
->assertRedirect(route('dashboard', absolute: false))
->assertSessionMissing('login.id')
->assertCookie(Auth::guard('web')->getRecallerName());
$this->assertAuthenticatedAs($user);
$this->assertNotContains('recovery-code-1', $user->fresh()->recoveryCodes());
$this->assertCount(1, $user->fresh()->recoveryCodes());
$this->post(route('logout'));
$this->withSession(['login.id' => $user->id])
->post(route('two-factor.login.store'), ['recovery_code' => 'recovery-code-1'])
->assertSessionHasErrors('recovery_code')
->assertSessionHas('login.id', $user->id);
$this->assertGuest();
}
public function test_challenge_submission_cannot_sign_in_without_a_pending_login(): void
{
$this->from(route('two-factor.login'))
->post(route('two-factor.login.store'), ['code' => '012345'])
->assertRedirect(route('two-factor.login'))
->assertSessionHasErrors('code');
$this->assertGuest();
$this->get(route('two-factor.login'))->assertRedirect(route('login'));
}
}
@@ -28,7 +28,8 @@ class VerificationNotificationTest extends TestCase
$this->actingAs($user)
->post(route('verification.send'))
->assertRedirect(route('home'));
->assertRedirect(route('home'))
->assertSessionHas('status', 'verification-link-sent');
Notification::assertSentTo($user, VerifyEmail::class);
}
@@ -45,4 +46,27 @@ class VerificationNotificationTest extends TestCase
Notification::assertNothingSent();
}
public function test_guests_cannot_request_a_verification_email(): void
{
Notification::fake();
$this->post(route('verification.send'))->assertRedirect(route('login'));
Notification::assertNothingSent();
}
public function test_resending_verification_emails_is_throttled(): void
{
Notification::fake();
$user = User::factory()->unverified()->create();
$this->actingAs($user);
for ($attempt = 0; $attempt < 6; $attempt++) {
$this->post(route('verification.send'))->assertRedirect();
}
$this->post(route('verification.send'))->assertTooManyRequests();
Notification::assertSentToTimes($user, VerifyEmail::class, 6);
}
}