372 lines
10 KiB
PHP
Executable file
372 lines
10 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\Hash;
|
|
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:' . hash('sha256', strtolower('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();
|
|
$response->assertSessionHas('status', 'Check your email for a login link and 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_4_word_token_logs_user_in(): void
|
|
{
|
|
$user = User::create([
|
|
'email_hash' => User::hashEmail('test@example.com'),
|
|
]);
|
|
|
|
$token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
|
|
|
$signedUrl = URL::temporarySignedRoute(
|
|
'magic-link.verify',
|
|
now()->addMinutes(15),
|
|
['token' => $token->plain_token]
|
|
);
|
|
|
|
$this->assertGuest();
|
|
|
|
$response = $this->get($signedUrl);
|
|
|
|
$response->assertRedirect(route('dashboard'));
|
|
$this->assertAuthenticated();
|
|
$this->assertEquals($user->id, Auth::id());
|
|
|
|
$token->refresh();
|
|
$this->assertNotNull($token->used_at);
|
|
}
|
|
|
|
public function test_valid_6_digit_code_logs_user_in(): void
|
|
{
|
|
$user = User::create([
|
|
'email_hash' => User::hashEmail('test@example.com'),
|
|
]);
|
|
|
|
$token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
|
$code = $token->plain_code;
|
|
|
|
$this->assertGuest();
|
|
|
|
$response = $this->post('/verify-code', [
|
|
'email' => 'test@example.com',
|
|
'code' => $code,
|
|
]);
|
|
|
|
$response->assertRedirect(route('dashboard'));
|
|
$this->assertAuthenticated();
|
|
$this->assertEquals($user->id, Auth::id());
|
|
|
|
$token->refresh();
|
|
$this->assertNotNull($token->used_at);
|
|
}
|
|
|
|
public function test_invalid_code_rejected_with_error(): void
|
|
{
|
|
User::create([
|
|
'email_hash' => User::hashEmail('test@example.com'),
|
|
]);
|
|
|
|
MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
|
|
|
$response = $this->post('/verify-code', [
|
|
'email' => 'test@example.com',
|
|
'code' => '999999',
|
|
]);
|
|
|
|
$response->assertRedirect();
|
|
$response->assertSessionHasErrors('code');
|
|
$this->assertGuest();
|
|
}
|
|
|
|
public function test_expired_token_rejected(): void
|
|
{
|
|
$user = User::create([
|
|
'email_hash' => User::hashEmail('test@example.com'),
|
|
]);
|
|
|
|
$token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
|
|
|
$token->update(['expires_at' => now()->subMinutes(1)]);
|
|
|
|
$signedUrl = URL::temporarySignedRoute(
|
|
'magic-link.verify',
|
|
now()->addMinutes(15),
|
|
['token' => $token->plain_token]
|
|
);
|
|
|
|
$this->assertGuest();
|
|
|
|
$response = $this->get($signedUrl);
|
|
|
|
$response->assertRedirect(route('login'));
|
|
$response->assertSessionHas('error', 'Invalid or expired magic link.');
|
|
$this->assertGuest();
|
|
}
|
|
|
|
public function test_4_word_token_format_validation(): void
|
|
{
|
|
$token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
|
|
|
$plainToken = $token->plain_token;
|
|
|
|
$this->assertMatchesRegularExpression(
|
|
'/^[a-z]+-[a-z]+-[a-z]+-[a-z]+$/',
|
|
$plainToken,
|
|
'Token should be 4 words separated by hyphens'
|
|
);
|
|
|
|
$words = explode('-', $plainToken);
|
|
$this->assertCount(4, $words, 'Token should contain exactly 4 words');
|
|
|
|
foreach ($words as $word) {
|
|
$this->assertNotEmpty($word, 'Each word should not be empty');
|
|
$this->assertMatchesRegularExpression('/^[a-z]+$/', $word, 'Each word should contain only lowercase letters');
|
|
}
|
|
}
|
|
|
|
public function test_remember_token_always_set(): void
|
|
{
|
|
$user = User::create([
|
|
'email_hash' => User::hashEmail('test@example.com'),
|
|
]);
|
|
|
|
$token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
|
|
|
$signedUrl = URL::temporarySignedRoute(
|
|
'magic-link.verify',
|
|
now()->addMinutes(15),
|
|
['token' => $token->plain_token]
|
|
);
|
|
|
|
$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:' . hash('sha256', strtolower($email)));
|
|
}
|
|
|
|
public function test_rate_limiting_on_code_attempts(): void
|
|
{
|
|
$user = User::create([
|
|
'email_hash' => User::hashEmail('test@example.com'),
|
|
]);
|
|
|
|
MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
|
|
|
for ($i = 0; $i < 5; $i++) {
|
|
$this->post('/verify-code', [
|
|
'email' => 'test@example.com',
|
|
'code' => '000000',
|
|
]);
|
|
}
|
|
|
|
$token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
|
$validCode = $token->plain_code;
|
|
|
|
$response = $this->post('/verify-code', [
|
|
'email' => 'test@example.com',
|
|
'code' => $validCode,
|
|
]);
|
|
|
|
$this->assertGuest();
|
|
|
|
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 = User::create([
|
|
'email_hash' => User::hashEmail('test@example.com'),
|
|
]);
|
|
|
|
$token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
|
|
|
$invalidUrl = route('magic-link.verify', ['token' => $token->plain_token]);
|
|
|
|
$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 = User::create([
|
|
'email_hash' => User::hashEmail('test@example.com'),
|
|
]);
|
|
|
|
$token = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent');
|
|
|
|
$signedUrl = URL::temporarySignedRoute(
|
|
'magic-link.verify',
|
|
now()->addMinutes(15),
|
|
['token' => $token->plain_token]
|
|
);
|
|
|
|
$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');
|
|
}
|
|
}
|