value)->toBe('email_verification'); }); it('validates action format', function () { new TokenAction('invalid-action'); // Hyphens not allowed })->throws(InvalidArgumentException::class, 'must contain only lowercase letters and underscores'); it('rejects empty action', function () { new TokenAction(''); })->throws(InvalidArgumentException::class, 'cannot be empty'); it('compares actions correctly', function () { $action1 = new TokenAction('email_verification'); $action2 = new TokenAction('email_verification'); $action3 = new TokenAction('password_reset'); expect($action1->equals($action2))->toBeTrue(); expect($action1->equals($action3))->toBeFalse(); }); }); describe('MagicLinkToken', function () { it('creates valid token', function () { $token = new MagicLinkToken('abcdef0123456789'); // 16 chars expect($token->value)->toBe('abcdef0123456789'); }); it('rejects short tokens', function () { new MagicLinkToken('short'); // Less than 16 chars })->throws(InvalidArgumentException::class, 'must be at least 16 characters'); it('generates cryptographically secure tokens', function () { $token1 = MagicLinkToken::generate(); $token2 = MagicLinkToken::generate(); expect($token1->value)->not->toBe($token2->value); expect(strlen($token1->value))->toBe(64); // 32 bytes * 2 (hex) }); it('compares tokens with constant time', function () { $token1 = new MagicLinkToken('abcdef0123456789'); $token2 = new MagicLinkToken('abcdef0123456789'); $token3 = new MagicLinkToken('different0123456'); expect($token1->equals($token2))->toBeTrue(); expect($token1->equals($token3))->toBeFalse(); }); it('generates tokens with custom length', function () { $token = MagicLinkToken::generate(16); // 16 bytes expect(strlen($token->value))->toBe(32); // 16 bytes * 2 (hex) }); }); describe('TokenConfig', function () { it('creates default config', function () { $config = new TokenConfig(); expect($config->ttlSeconds)->toBe(3600); expect($config->oneTimeUse)->toBeFalse(); expect($config->maxUses)->toBeNull(); expect($config->ipRestriction)->toBeFalse(); }); it('creates custom config', function () { $config = new TokenConfig( ttlSeconds: 7200, oneTimeUse: true, maxUses: null, ipRestriction: true ); expect($config->ttlSeconds)->toBe(7200); expect($config->oneTimeUse)->toBeTrue(); expect($config->ipRestriction)->toBeTrue(); }); it('validates positive ttl', function () { new TokenConfig(ttlSeconds: 0); })->throws(InvalidArgumentException::class, 'TTL must be positive'); it('validates positive max uses', function () { new TokenConfig(maxUses: 0); })->throws(InvalidArgumentException::class, 'Max uses must be positive'); it('validates one-time-use consistency', function () { new TokenConfig(oneTimeUse: true, maxUses: 5); })->throws(InvalidArgumentException::class, 'One-time-use tokens cannot have maxUses'); it('creates email verification config', function () { $config = TokenConfig::forEmailVerification(); expect($config->ttlSeconds)->toBe(86400); // 24 hours expect($config->oneTimeUse)->toBeTrue(); }); it('creates password reset config', function () { $config = TokenConfig::forPasswordReset(); expect($config->ttlSeconds)->toBe(3600); // 1 hour expect($config->oneTimeUse)->toBeTrue(); expect($config->ipRestriction)->toBeTrue(); }); it('creates document access config', function () { $config = TokenConfig::forDocumentAccess(3); expect($config->ttlSeconds)->toBe(3600); expect($config->maxUses)->toBe(3); }); }); describe('ActionResult', function () { it('creates successful result', function () { $result = new ActionResult( success: true, message: 'Email verified', data: ['user_id' => 123] ); expect($result->isSuccess())->toBeTrue(); expect($result->message)->toBe('Email verified'); expect($result->data)->toBe(['user_id' => 123]); }); it('creates failure result', function () { $result = new ActionResult( success: false, message: 'Verification failed', errors: ['Token expired'] ); expect($result->isSuccess())->toBeFalse(); expect($result->hasErrors())->toBeTrue(); expect($result->errors)->toBe(['Token expired']); }); it('detects redirect presence', function () { $result1 = new ActionResult( success: true, message: 'Success', redirectUrl: '/dashboard' ); $result2 = new ActionResult( success: true, message: 'Success' ); expect($result1->hasRedirect())->toBeTrue(); expect($result2->hasRedirect())->toBeFalse(); }); it('creates success via factory method', function () { $result = ActionResult::success( message: 'Operation completed', data: ['id' => 456], redirectUrl: '/success' ); expect($result->isSuccess())->toBeTrue(); expect($result->message)->toBe('Operation completed'); expect($result->data)->toBe(['id' => 456]); expect($result->redirectUrl)->toBe('/success'); }); it('creates failure via factory method', function () { $result = ActionResult::failure( message: 'Operation failed', errors: ['Invalid input', 'Permission denied'] ); expect($result->isSuccess())->toBeFalse(); expect($result->errors)->toHaveCount(2); }); }); describe('MagicLinkData', function () { it('creates valid magic link data', function () { $now = new DateTimeImmutable(); $expiresAt = $now->modify('+1 hour'); $data = new MagicLinkData( id: 'test-123', action: new TokenAction('email_verification'), payload: ['user_id' => 1, 'email' => 'test@example.com'], expiresAt: $expiresAt, createdAt: $now ); expect($data->id)->toBe('test-123'); expect($data->action->value)->toBe('email_verification'); expect($data->payload)->toBe(['user_id' => 1, 'email' => 'test@example.com']); }); it('detects expired tokens', function () { $now = new DateTimeImmutable(); $past = $now->modify('-1 hour'); $data = new MagicLinkData( id: 'test-123', action: new TokenAction('test'), payload: [], expiresAt: $past, createdAt: $now->modify('-2 hours') ); expect($data->isExpired())->toBeTrue(); expect($data->isValid())->toBeFalse(); }); it('validates non-expired tokens', function () { $now = new DateTimeImmutable(); $future = $now->modify('+1 hour'); $data = new MagicLinkData( id: 'test-123', action: new TokenAction('test'), payload: [], expiresAt: $future, createdAt: $now ); expect($data->isExpired())->toBeFalse(); expect($data->isValid())->toBeTrue(); }); it('invalidates one-time-use tokens after use', function () { $now = new DateTimeImmutable(); $future = $now->modify('+1 hour'); $data = new MagicLinkData( id: 'test-123', action: new TokenAction('test'), payload: [], expiresAt: $future, createdAt: $now, oneTimeUse: true, isUsed: true ); expect($data->isValid())->toBeFalse(); }); it('tracks use count correctly', function () { $now = new DateTimeImmutable(); $future = $now->modify('+1 hour'); $data = new MagicLinkData( id: 'test-123', action: new TokenAction('test'), payload: [], expiresAt: $future, createdAt: $now, useCount: 2, maxUses: 5 ); expect($data->hasRemainingUses())->toBeTrue(); expect($data->isValid())->toBeTrue(); }); it('invalidates tokens exceeding max uses', function () { $now = new DateTimeImmutable(); $future = $now->modify('+1 hour'); $data = new MagicLinkData( id: 'test-123', action: new TokenAction('test'), payload: [], expiresAt: $future, createdAt: $now, useCount: 5, maxUses: 5 ); expect($data->hasRemainingUses())->toBeFalse(); expect($data->isValid())->toBeFalse(); }); it('calculates remaining time correctly', function () { $now = new DateTimeImmutable(); $future = $now->modify('+3600 seconds'); $data = new MagicLinkData( id: 'test-123', action: new TokenAction('test'), payload: [], expiresAt: $future, createdAt: $now ); $remaining = $data->getSecondsUntilExpiry(); expect($remaining)->toBeGreaterThan(3590); expect($remaining)->toBeLessThanOrEqual(3600); }); it('returns zero for expired token remaining time', function () { $now = new DateTimeImmutable(); $past = $now->modify('-1 hour'); $data = new MagicLinkData( id: 'test-123', action: new TokenAction('test'), payload: [], expiresAt: $past, createdAt: $now->modify('-2 hours') ); expect($data->getSecondsUntilExpiry())->toBe(0); }); it('marks token as used immutably', function () { $now = new DateTimeImmutable(); $future = $now->modify('+1 hour'); $data = new MagicLinkData( id: 'test-123', action: new TokenAction('test'), payload: [], expiresAt: $future, createdAt: $now, oneTimeUse: true ); $usedData = $data->withUsed(new DateTimeImmutable()); expect($data->isUsed)->toBeFalse(); // Original unchanged expect($usedData->isUsed)->toBeTrue(); expect($usedData->useCount)->toBe(1); }); it('increments use count immutably', function () { $now = new DateTimeImmutable(); $future = $now->modify('+1 hour'); $data = new MagicLinkData( id: 'test-123', action: new TokenAction('test'), payload: [], expiresAt: $future, createdAt: $now, useCount: 2 ); $incremented = $data->withIncrementedUseCount(); expect($data->useCount)->toBe(2); // Original unchanged expect($incremented->useCount)->toBe(3); }); it('serializes to array correctly', function () { $now = new DateTimeImmutable(); $future = $now->modify('+1 hour'); $data = new MagicLinkData( id: 'test-123', action: new TokenAction('email_verification'), payload: ['user_id' => 1], expiresAt: $future, createdAt: $now, oneTimeUse: true, createdByIp: '127.0.0.1', userAgent: 'Mozilla/5.0' ); $array = $data->toArray(); expect($array)->toHaveKey('id'); expect($array)->toHaveKey('action'); expect($array)->toHaveKey('payload'); expect($array)->toHaveKey('is_valid'); expect($array['action'])->toBe('email_verification'); expect($array['payload'])->toBe(['user_id' => 1]); }); it('deserializes from array correctly', function () { $array = [ 'id' => 'test-123', 'action' => 'password_reset', 'payload' => ['user_id' => 456], 'expires_at' => '2025-12-31 23:59:59', 'created_at' => '2025-12-31 12:00:00', 'one_time_use' => true, 'created_by_ip' => '192.168.1.1', 'user_agent' => 'Test Agent', 'is_used' => false, 'use_count' => 0 ]; $data = MagicLinkData::fromArray($array); expect($data->id)->toBe('test-123'); expect($data->action->value)->toBe('password_reset'); expect($data->payload)->toBe(['user_id' => 456]); expect($data->oneTimeUse)->toBeTrue(); expect($data->createdByIp)->toBe('192.168.1.1'); }); });