assertTrue($result->token->isValid(), 'Newly created token should be valid'); $result->token->update(['expires_at' => now()->subMinutes(1)]); $result->token->refresh(); $this->assertFalse($result->token->isValid(), 'Expired token should not be valid'); } public function test_marking_token_as_used_works(): void { $result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); $this->assertNull($result->token->used_at, 'New token should not be marked as used'); $this->assertTrue($result->token->isValid(), 'New token should be valid'); $result->token->markAsUsed(); $result->token->refresh(); $this->assertNotNull($result->token->used_at, 'Token should be marked as used'); $this->assertFalse($result->token->isValid(), 'Used token should not be valid'); } public function test_code_verification_works(): void { $result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); $this->assertTrue( $result->token->verifyCode($result->plainCode), 'Correct code should verify successfully' ); // Derive a code that is guaranteed to differ from the generated one $wrongCode = str_pad((string) (((int) $result->plainCode + 1) % 1000000), 6, '0', STR_PAD_LEFT); $this->assertFalse($result->token->verifyCode($wrongCode), 'Different code should not verify'); // Non-numeric strings can never match a bcrypt hash of a 6-digit code $this->assertFalse($result->token->verifyCode('XXXXXX'), 'Non-numeric string should not verify'); } public function test_code_verification_uses_bcrypt(): void { $result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); $this->assertNotEquals( $result->plainCode, $result->token->code_hash, 'Code hash should not be plain text' ); $this->assertTrue( Hash::check($result->plainCode, $result->token->code_hash), 'Code hash should use bcrypt' ); } public function test_token_hash_uses_sha256(): void { $result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); $this->assertEquals( hash('sha256', $result->plainToken), $result->token->token_hash, 'Token hash should be SHA256 of the plain token' ); $this->assertEquals(64, strlen($result->token->token_hash), 'SHA256 hex is 64 chars'); } public function test_scope_valid_returns_only_valid_tokens(): void { $validResult = MagicLoginToken::generate('valid@example.com', '127.0.0.1', 'TestAgent'); $expiredResult = MagicLoginToken::generate('expired@example.com', '127.0.0.1', 'TestAgent'); $expiredResult->token->update(['expires_at' => now()->subMinutes(1)]); $usedResult = MagicLoginToken::generate('used@example.com', '127.0.0.1', 'TestAgent'); $usedResult->token->markAsUsed(); $validTokens = MagicLoginToken::valid()->get(); $this->assertCount(1, $validTokens, 'Only one token should be valid'); $this->assertEquals($validResult->token->id, $validTokens->first()->id); } public function test_scope_expired_or_used_returns_correct_tokens(): void { $validResult = MagicLoginToken::generate('valid@example.com', '127.0.0.1', 'TestAgent'); $expiredResult = MagicLoginToken::generate('expired@example.com', '127.0.0.1', 'TestAgent'); $expiredResult->token->update(['expires_at' => now()->subMinutes(1)]); $usedResult = MagicLoginToken::generate('used@example.com', '127.0.0.1', 'TestAgent'); $usedResult->token->markAsUsed(); $expiredOrUsedTokens = MagicLoginToken::expiredOrUsed()->get(); $this->assertCount(2, $expiredOrUsedTokens, 'Two tokens should be expired or used'); $ids = $expiredOrUsedTokens->pluck('id')->toArray(); $this->assertContains($expiredResult->token->id, $ids); $this->assertContains($usedResult->token->id, $ids); $this->assertNotContains($validResult->token->id, $ids); } public function test_generate_returns_magic_token_result(): void { $result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); $this->assertInstanceOf(MagicTokenResult::class, $result); $this->assertInstanceOf(MagicLoginToken::class, $result->token); $this->assertNotEmpty($result->plainToken); $this->assertNotEmpty($result->plainCode); } public function test_generated_code_is_6_digits(): void { $result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); $this->assertMatchesRegularExpression( '/^\d{6}$/', $result->plainCode, 'Code should be exactly 6 digits' ); } public function test_generated_code_is_always_6_chars(): void { // Generate enough tokens that low-value codes (which have leading zeros) are statistically likely, // and confirm they are always returned as a 6-character zero-padded string. for ($i = 0; $i < 20; $i++) { $result = MagicLoginToken::generate('test' . $i . '@example.com', '127.0.0.1', 'TestAgent'); $this->assertEquals(6, strlen($result->plainCode)); } } public function test_leading_zero_padding_is_preserved(): void { // Directly verify the padding logic: a code of 42 must be stored as '000042'. // We confirm this by checking str_pad behaviour matches what the model produces. $padded = str_pad('42', 6, '0', STR_PAD_LEFT); $this->assertEquals('000042', $padded); $this->assertMatchesRegularExpression('/^\d{6}$/', $padded); } public function test_token_stores_ip_address_and_user_agent(): void { $ip = '192.168.1.1'; $userAgent = 'Mozilla/5.0 Test Browser'; $result = MagicLoginToken::generate('test@example.com', $ip, $userAgent); $this->assertEquals($ip, $result->token->ip_address); $this->assertEquals($userAgent, $result->token->user_agent); } public function test_token_expiry_is_15_minutes(): void { $beforeCreation = now()->startOfSecond()->addMinutes(15); $result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); $afterCreation = now()->addMinutes(15); $this->assertTrue( $result->token->expires_at->between($beforeCreation, $afterCreation), 'Token should expire in 15 minutes' ); } public function test_email_hash_is_stored_correctly(): void { $email = 'test@example.com'; $expectedHash = User::hashEmail($email); $result = MagicLoginToken::generate($email, '127.0.0.1', 'TestAgent'); $this->assertEquals($expectedHash, $result->token->email_hash); } public function test_plain_values_are_not_persisted(): void { $result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); $fresh = MagicLoginToken::find($result->token->id); $this->assertArrayNotHasKey('plain_token', $fresh->getAttributes()); $this->assertArrayNotHasKey('plain_code', $fresh->getAttributes()); } public function test_token_can_be_looked_up_by_hash(): void { $result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); $found = MagicLoginToken::where('token_hash', hash('sha256', $result->plainToken))->first(); $this->assertNotNull($found); $this->assertEquals($result->token->id, $found->id); } public function test_multiple_tokens_can_exist_for_same_email(): void { $email = 'test@example.com'; $result1 = MagicLoginToken::generate($email, '127.0.0.1', 'TestAgent'); $result2 = MagicLoginToken::generate($email, '127.0.0.1', 'TestAgent'); $this->assertNotEquals($result1->token->id, $result2->token->id); $this->assertNotEquals($result1->plainToken, $result2->plainToken); $this->assertEquals($result1->token->email_hash, $result2->token->email_hash); } public function test_tokens_are_unique(): void { $generatedTokens = []; for ($i = 0; $i < 10; $i++) { $result = MagicLoginToken::generate('test' . $i . '@example.com', '127.0.0.1', 'TestAgent'); $generatedTokens[] = $result->plainToken; } $this->assertCount(10, array_unique($generatedTokens), 'All generated tokens should be unique'); } public function test_token_casts_dates_correctly(): void { $result = MagicLoginToken::generate('test@example.com', '127.0.0.1', 'TestAgent'); $this->assertInstanceOf(\Illuminate\Support\Carbon::class, $result->token->expires_at); $this->assertNull($result->token->used_at); $result->token->markAsUsed(); $result->token->refresh(); $this->assertInstanceOf(\Illuminate\Support\Carbon::class, $result->token->used_at); } public function test_cleanup_deletes_old_expired_and_used_tokens(): void { $service = new MagicLinkAuthService(); // Recent expired — should NOT be deleted (within 7-day retention window) $recentExpired = MagicLoginToken::generate('recent@example.com', null, null); $recentExpired->token->update(['expires_at' => now()->subMinutes(1)]); // Old expired — should be deleted $oldExpired = MagicLoginToken::generate('oldexpired@example.com', null, null); $oldExpired->token->update([ 'expires_at' => now()->subDays(8), 'created_at' => now()->subDays(8), ]); // Old used — should be deleted $oldUsed = MagicLoginToken::generate('oldused@example.com', null, null); $oldUsed->token->update([ 'used_at' => now()->subDays(8), 'created_at' => now()->subDays(8), ]); // Valid, recent — must never be touched $valid = MagicLoginToken::generate('valid@example.com', null, null); $deleted = $service->cleanupExpiredTokens(); $this->assertEquals(2, $deleted); $this->assertDatabaseHas('magic_login_tokens', ['id' => $recentExpired->token->id]); $this->assertDatabaseHas('magic_login_tokens', ['id' => $valid->token->id]); $this->assertDatabaseMissing('magic_login_tokens', ['id' => $oldExpired->token->id]); $this->assertDatabaseMissing('magic_login_tokens', ['id' => $oldUsed->token->id]); } }