scan.fyi/tests/Feature/Auth/MagicLinkAuthTest.php
ritual 1b241aeddb First round of refinements on the login system...
There's a lot more to do on the to-do list
2026-02-22 20:02:09 +00:00

458 lines
13 KiB
PHP
Executable file

<?php
namespace Tests\Feature\Auth;
use App\Mail\MagicLoginLink;
use App\Models\MagicLoginToken;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\URL;
use Tests\TestCase;
class MagicLinkAuthTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
RateLimiter::clear('magic-link:' . User::hashEmail('test@example.com'));
RateLimiter::clear('magic-code:' . User::hashEmail('test@example.com'));
}
public function test_login_screen_renders(): void
{
$response = $this->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();
}
}