get('/login'); $response->assertStatus(200); $response->assertViewIs('auth.login'); $response->assertSee('email'); } public function test_magic_link_request_sends_email(): void { Mail::fake(); $response = $this->post('/magic-link', [ 'email' => 'test@example.com', ]); $response->assertRedirect(route('verify-code')); $response->assertSessionHas('status', 'Check your email for your login code!'); Mail::assertQueued(MagicLoginLink::class, function ($mail) { return $mail->hasTo('test@example.com'); }); Mail::assertQueued(MagicLoginLink::class, 1); $this->assertDatabaseHas('magic_login_tokens', [ 'email_hash' => User::hashEmail('test@example.com'), ]); } public function test_user_created_on_first_login_request(): void { Mail::fake(); $this->assertDatabaseMissing('users', [ 'email_hash' => User::hashEmail('newuser@example.com'), ]); $this->post('/magic-link', [ 'email' => 'newuser@example.com', ]); $this->assertDatabaseHas('users', [ 'email_hash' => User::hashEmail('newuser@example.com'), ]); } public function test_valid_token_logs_user_in(): void { $user = User::create([ 'email_hash' => User::hashEmail('test@example.com'), ]); $result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); $signedUrl = URL::temporarySignedRoute( 'magic-link.verify', now()->addMinutes(15), ['token' => $result->plainToken] ); $this->assertGuest(); $response = $this->get($signedUrl); $response->assertRedirect(route('dashboard')); $this->assertAuthenticated(); $this->assertEquals($user->id, Auth::id()); $result->token->refresh(); $this->assertNotNull($result->token->used_at); } public function test_valid_6_digit_code_logs_user_in(): void { $user = User::create([ 'email_hash' => User::hashEmail('test@example.com'), ]); $result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); $this->assertGuest(); $response = $this->post('/verify-code', [ 'email' => 'test@example.com', 'code' => $result->plainCode, ]); $response->assertRedirect(route('dashboard')); $this->assertAuthenticated(); $this->assertEquals($user->id, Auth::id()); $result->token->refresh(); $this->assertNotNull($result->token->used_at); } public function test_invalid_code_rejected_with_error(): void { User::create([ 'email_hash' => User::hashEmail('test@example.com'), ]); $result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); // Derive a code guaranteed to differ from the real one $wrongCode = str_pad((string) (((int) $result->plainCode + 1) % 1000000), 6, '0', STR_PAD_LEFT); $response = $this->post('/verify-code', [ 'email' => 'test@example.com', 'code' => $wrongCode, ]); $response->assertRedirect(); $response->assertSessionHasErrors('code'); $this->assertGuest(); } public function test_expired_token_rejected(): void { User::create([ 'email_hash' => User::hashEmail('test@example.com'), ]); $result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); $result->token->update(['expires_at' => now()->subMinutes(1)]); $signedUrl = URL::temporarySignedRoute( 'magic-link.verify', now()->addMinutes(15), ['token' => $result->plainToken] ); $this->assertGuest(); $response = $this->get($signedUrl); $response->assertRedirect(route('login')); $response->assertSessionHas('error', 'Invalid or expired magic link.'); $this->assertGuest(); } public function test_remember_token_always_set(): void { $user = User::create([ 'email_hash' => User::hashEmail('test@example.com'), ]); $result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); $signedUrl = URL::temporarySignedRoute( 'magic-link.verify', now()->addMinutes(15), ['token' => $result->plainToken] ); $this->get($signedUrl); $user->refresh(); $this->assertNotNull($user->remember_token, 'Remember token should be set for 30-day sessions'); } public function test_logout_works_and_invalidates_session(): void { $user = User::create([ 'email_hash' => User::hashEmail('test@example.com'), ]); Auth::login($user); $this->assertAuthenticated(); $response = $this->post('/logout'); $response->assertRedirect('/'); $this->assertGuest(); } public function test_rate_limiting_on_email_requests(): void { Mail::fake(); $email = 'ratelimit@example.com'; for ($i = 0; $i < 5; $i++) { $response = $this->post('/magic-link', ['email' => $email]); $response->assertRedirect(); } $response = $this->post('/magic-link', ['email' => $email]); $response->assertRedirect(); $response->assertSessionHasErrors('email'); $errors = session('errors'); $this->assertStringContainsString( 'Too many magic link requests', $errors->first('email') ); RateLimiter::clear('magic-link:' . User::hashEmail($email)); } public function test_rate_limiting_on_code_attempts(): void { User::create([ 'email_hash' => User::hashEmail('test@example.com'), ]); $first = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); // Derive a code guaranteed to be wrong so the loop never accidentally succeeds $wrongCode = str_pad((string) (((int) $first->plainCode + 1) % 1000000), 6, '0', STR_PAD_LEFT); for ($i = 0; $i < 5; $i++) { $this->post('/verify-code', [ 'email' => 'test@example.com', 'code' => $wrongCode, ]); } // Even a fresh valid token is blocked once the limit is reached $result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); $response = $this->post('/verify-code', [ 'email' => 'test@example.com', 'code' => $result->plainCode, ]); $this->assertGuest(); $response->assertSessionHasErrors('code'); $errors = session('errors'); $this->assertStringContainsString('Too many attempts', $errors->first('code')); RateLimiter::clear('magic-code:' . User::hashEmail('test@example.com')); } public function test_verify_code_form_renders(): void { $response = $this->get('/verify-code'); $response->assertStatus(200); $response->assertViewIs('auth.verify-code'); } public function test_invalid_signature_rejected(): void { User::create([ 'email_hash' => User::hashEmail('test@example.com'), ]); $result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); $invalidUrl = route('magic-link.verify', ['token' => $result->plainToken]); $response = $this->get($invalidUrl); $response->assertRedirect(route('login')); $response->assertSessionHas('error', 'Invalid or expired magic link.'); $this->assertGuest(); } public function test_used_token_cannot_be_reused(): void { User::create([ 'email_hash' => User::hashEmail('test@example.com'), ]); $result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); $signedUrl = URL::temporarySignedRoute( 'magic-link.verify', now()->addMinutes(15), ['token' => $result->plainToken] ); $this->get($signedUrl); $this->assertAuthenticated(); Auth::logout(); $response = $this->get($signedUrl); $response->assertRedirect(route('login')); $response->assertSessionHas('error', 'Invalid or expired magic link.'); $this->assertGuest(); } public function test_email_validation_required(): void { $response = $this->post('/magic-link', [ 'email' => '', ]); $response->assertSessionHasErrors('email'); } public function test_email_validation_must_be_valid(): void { $response = $this->post('/magic-link', [ 'email' => 'not-an-email', ]); $response->assertSessionHasErrors('email'); } public function test_code_validation_required(): void { $response = $this->post('/verify-code', [ 'email' => 'test@example.com', 'code' => '', ]); $response->assertSessionHasErrors('code'); } public function test_code_validation_must_be_6_digits(): void { $response = $this->post('/verify-code', [ 'email' => 'test@example.com', 'code' => '12345', ]); $response->assertSessionHasErrors('code'); $response = $this->post('/verify-code', [ 'email' => 'test@example.com', 'code' => 'abcdef', ]); $response->assertSessionHasErrors('code'); } public function test_plain_values_not_stored_after_magic_link_request(): void { Mail::fake(); $this->post('/magic-link', ['email' => 'test@example.com']); $token = MagicLoginToken::where('email_hash', User::hashEmail('test@example.com'))->firstOrFail(); $this->assertArrayNotHasKey('plain_token', $token->getAttributes()); $this->assertArrayNotHasKey('plain_code', $token->getAttributes()); // Confirm the hash is the correct length for SHA256 $this->assertEquals(64, strlen($token->token_hash)); } public function test_email_verified_on_first_login_via_link(): void { $user = User::create([ 'email_hash' => User::hashEmail('test@example.com'), ]); $this->assertNull($user->email_verified_at); $result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); $signedUrl = URL::temporarySignedRoute( 'magic-link.verify', now()->addMinutes(15), ['token' => $result->plainToken] ); $this->get($signedUrl); $user->refresh(); $this->assertNotNull($user->email_verified_at); } public function test_email_verified_on_first_login_via_code(): void { $user = User::create([ 'email_hash' => User::hashEmail('test@example.com'), ]); $this->assertNull($user->email_verified_at); $result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); $this->post('/verify-code', [ 'email' => 'test@example.com', 'code' => $result->plainCode, ]); $user->refresh(); $this->assertNotNull($user->email_verified_at); } public function test_already_verified_email_not_overwritten(): void { $verifiedAt = now()->subDays(30); $user = User::create([ 'email_hash' => User::hashEmail('test@example.com'), 'email_verified_at' => $verifiedAt, ]); $result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); $signedUrl = URL::temporarySignedRoute( 'magic-link.verify', now()->addMinutes(15), ['token' => $result->plainToken] ); $this->get($signedUrl); $user->refresh(); $this->assertEquals( $verifiedAt->timestamp, $user->email_verified_at->timestamp, 'Existing email_verified_at should not be overwritten on subsequent logins' ); } public function test_code_attempt_with_no_existing_token_is_rejected(): void { User::create([ 'email_hash' => User::hashEmail('test@example.com'), ]); // No token generated — no valid token exists for this email $response = $this->post('/verify-code', [ 'email' => 'test@example.com', 'code' => '123456', ]); $response->assertSessionHasErrors('code'); $this->assertGuest(); } }