296 lines
9.8 KiB
PHP
296 lines
9.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Framework\DateTime\SystemClock;
|
|
use App\Framework\Id\Ulid\UlidGenerator;
|
|
use App\Framework\MagicLinks\MagicLinkToken;
|
|
use App\Framework\MagicLinks\Services\InMemoryMagicLinkService;
|
|
use App\Framework\MagicLinks\TokenAction;
|
|
use App\Framework\MagicLinks\TokenConfig;
|
|
|
|
beforeEach(function () {
|
|
$this->clock = new SystemClock();
|
|
$this->ulidGenerator = new UlidGenerator();
|
|
$this->service = new InMemoryMagicLinkService($this->ulidGenerator, $this->clock);
|
|
});
|
|
|
|
describe('InMemoryMagicLinkService - Token Generation', function () {
|
|
it('generates valid token', function () {
|
|
$action = new TokenAction('email_verification');
|
|
$payload = ['user_id' => 123, 'email' => 'test@example.com'];
|
|
|
|
$token = $this->service->generate($action, $payload);
|
|
|
|
expect($token)->toBeInstanceOf(MagicLinkToken::class);
|
|
expect(strlen($token->value))->toBeGreaterThanOrEqual(16);
|
|
});
|
|
|
|
it('generates unique tokens for each call', function () {
|
|
$action = new TokenAction('test_action');
|
|
$payload = ['data' => 'test'];
|
|
|
|
$token1 = $this->service->generate($action, $payload);
|
|
$token2 = $this->service->generate($action, $payload);
|
|
|
|
expect($token1->equals($token2))->toBeFalse();
|
|
});
|
|
|
|
it('stores token with default configuration', function () {
|
|
$action = new TokenAction('test_action');
|
|
$payload = ['test' => 'data'];
|
|
|
|
$token = $this->service->generate($action, $payload);
|
|
|
|
expect($this->service->exists($token))->toBeTrue();
|
|
});
|
|
|
|
it('stores token with custom configuration', function () {
|
|
$action = new TokenAction('test_action');
|
|
$payload = ['test' => 'data'];
|
|
$config = new TokenConfig(expiryHours: 48, oneTimeUse: true);
|
|
|
|
$token = $this->service->generate($action, $payload, $config);
|
|
|
|
expect($this->service->exists($token))->toBeTrue();
|
|
});
|
|
|
|
it('stores IP address and user agent', function () {
|
|
$action = new TokenAction('test_action');
|
|
$payload = ['test' => 'data'];
|
|
|
|
$token = $this->service->generate(
|
|
$action,
|
|
$payload,
|
|
createdByIp: '192.168.1.1',
|
|
userAgent: 'Test Browser'
|
|
);
|
|
|
|
$data = $this->service->validate($token);
|
|
|
|
expect($data->createdByIp)->toBe('192.168.1.1');
|
|
expect($data->userAgent)->toBe('Test Browser');
|
|
});
|
|
});
|
|
|
|
describe('InMemoryMagicLinkService - Token Validation', function () {
|
|
it('validates existing token', function () {
|
|
$action = new TokenAction('test_action');
|
|
$payload = ['user_id' => 123];
|
|
|
|
$token = $this->service->generate($action, $payload);
|
|
$data = $this->service->validate($token);
|
|
|
|
expect($data)->toBeInstanceOf(\App\Framework\MagicLinks\MagicLinkData::class);
|
|
expect($data->action->name)->toBe('test_action');
|
|
expect($data->payload->get('user_id'))->toBe(123);
|
|
});
|
|
|
|
it('returns null for non-existent token', function () {
|
|
$nonExistentToken = new MagicLinkToken('01234567890123456789012345678901');
|
|
|
|
$data = $this->service->validate($nonExistentToken);
|
|
|
|
expect($data)->toBeNull();
|
|
});
|
|
|
|
it('returns null for used one-time token', function () {
|
|
$action = new TokenAction('test_action');
|
|
$payload = ['test' => 'data'];
|
|
$config = new TokenConfig(oneTimeUse: true);
|
|
|
|
$token = $this->service->generate($action, $payload, $config);
|
|
$this->service->markAsUsed($token);
|
|
|
|
$data = $this->service->validate($token);
|
|
|
|
expect($data)->toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('InMemoryMagicLinkService - Token Usage', function () {
|
|
it('marks token as used', function () {
|
|
$action = new TokenAction('test_action');
|
|
$payload = ['test' => 'data'];
|
|
$config = new TokenConfig(oneTimeUse: true);
|
|
|
|
$token = $this->service->generate($action, $payload, $config);
|
|
|
|
// Valid before use
|
|
expect($this->service->validate($token))->toBeInstanceOf(\App\Framework\MagicLinks\MagicLinkData::class);
|
|
|
|
$this->service->markAsUsed($token);
|
|
|
|
// Invalid after use (one-time use)
|
|
expect($this->service->validate($token))->toBeNull();
|
|
});
|
|
|
|
it('allows marking non-one-time tokens as used', function () {
|
|
$action = new TokenAction('test_action');
|
|
$payload = ['test' => 'data'];
|
|
$config = new TokenConfig(oneTimeUse: false);
|
|
|
|
$token = $this->service->generate($action, $payload, $config);
|
|
$this->service->markAsUsed($token);
|
|
|
|
// Still valid (not one-time use)
|
|
expect($this->service->validate($token))->toBeInstanceOf(\App\Framework\MagicLinks\MagicLinkData::class);
|
|
});
|
|
|
|
it('handles marking non-existent token gracefully', function () {
|
|
$nonExistentToken = new MagicLinkToken('01234567890123456789012345678901');
|
|
|
|
// Should not throw exception
|
|
$this->service->markAsUsed($nonExistentToken);
|
|
|
|
expect(true)->toBeTrue();
|
|
});
|
|
});
|
|
|
|
describe('InMemoryMagicLinkService - Token Revocation', function () {
|
|
it('revokes token', function () {
|
|
$action = new TokenAction('test_action');
|
|
$payload = ['test' => 'data'];
|
|
|
|
$token = $this->service->generate($action, $payload);
|
|
|
|
// Exists before revocation
|
|
expect($this->service->exists($token))->toBeTrue();
|
|
|
|
$this->service->revoke($token);
|
|
|
|
// Does not exist after revocation
|
|
expect($this->service->exists($token))->toBeFalse();
|
|
expect($this->service->validate($token))->toBeNull();
|
|
});
|
|
|
|
it('handles revoking non-existent token gracefully', function () {
|
|
$nonExistentToken = new MagicLinkToken('01234567890123456789012345678901');
|
|
|
|
// Should not throw exception
|
|
$this->service->revoke($nonExistentToken);
|
|
|
|
expect(true)->toBeTrue();
|
|
});
|
|
});
|
|
|
|
describe('InMemoryMagicLinkService - Token Existence', function () {
|
|
it('checks token existence correctly', function () {
|
|
$action = new TokenAction('test_action');
|
|
$payload = ['test' => 'data'];
|
|
|
|
$token = $this->service->generate($action, $payload);
|
|
|
|
expect($this->service->exists($token))->toBeTrue();
|
|
});
|
|
|
|
it('returns false for non-existent token', function () {
|
|
$nonExistentToken = new MagicLinkToken('01234567890123456789012345678901');
|
|
|
|
expect($this->service->exists($nonExistentToken))->toBeFalse();
|
|
});
|
|
|
|
it('returns false for revoked token', function () {
|
|
$action = new TokenAction('test_action');
|
|
$payload = ['test' => 'data'];
|
|
|
|
$token = $this->service->generate($action, $payload);
|
|
$this->service->revoke($token);
|
|
|
|
expect($this->service->exists($token))->toBeFalse();
|
|
});
|
|
});
|
|
|
|
describe('InMemoryMagicLinkService - Active Tokens', function () {
|
|
it('retrieves active tokens', function () {
|
|
$action = new TokenAction('test_action');
|
|
|
|
// Generate multiple tokens
|
|
$this->service->generate($action, ['id' => 1]);
|
|
$this->service->generate($action, ['id' => 2]);
|
|
$this->service->generate($action, ['id' => 3]);
|
|
|
|
$activeTokens = $this->service->getActiveTokens();
|
|
|
|
expect($activeTokens)->toHaveCount(3);
|
|
});
|
|
|
|
it('respects limit parameter', function () {
|
|
$action = new TokenAction('test_action');
|
|
|
|
// Generate 5 tokens
|
|
for ($i = 1; $i <= 5; $i++) {
|
|
$this->service->generate($action, ['id' => $i]);
|
|
}
|
|
|
|
$activeTokens = $this->service->getActiveTokens(limit: 3);
|
|
|
|
expect($activeTokens)->toHaveCount(3);
|
|
});
|
|
|
|
it('excludes used one-time tokens from active list', function () {
|
|
$action = new TokenAction('test_action');
|
|
$config = new TokenConfig(oneTimeUse: true);
|
|
|
|
$token1 = $this->service->generate($action, ['id' => 1], $config);
|
|
$token2 = $this->service->generate($action, ['id' => 2], $config);
|
|
|
|
$this->service->markAsUsed($token1);
|
|
|
|
$activeTokens = $this->service->getActiveTokens();
|
|
|
|
expect($activeTokens)->toHaveCount(1);
|
|
});
|
|
|
|
it('returns empty array when no active tokens', function () {
|
|
$activeTokens = $this->service->getActiveTokens();
|
|
|
|
expect($activeTokens)->toBeArray();
|
|
expect($activeTokens)->toBeEmpty();
|
|
});
|
|
});
|
|
|
|
describe('InMemoryMagicLinkService - Cleanup', function () {
|
|
it('removes expired tokens', function () {
|
|
$action = new TokenAction('test_action');
|
|
|
|
// Create token, then manually expire it by waiting
|
|
// We'll test cleanup by generating tokens and checking count
|
|
// Since we can't create expired tokens directly (validation prevents it),
|
|
// we'll just verify cleanup works with no expired tokens
|
|
$config = new TokenConfig(expiryHours: 1);
|
|
|
|
$token1 = $this->service->generate($action, ['id' => 1], $config);
|
|
$token2 = $this->service->generate($action, ['id' => 2], $config);
|
|
|
|
// Initially should have 2 tokens
|
|
expect($this->service->getActiveTokens())->toHaveCount(2);
|
|
|
|
// Cleanup with no expired tokens should return 0
|
|
$removedCount = $this->service->cleanupExpired();
|
|
expect($removedCount)->toBe(0);
|
|
|
|
// Both tokens should still exist
|
|
expect($this->service->exists($token1))->toBeTrue();
|
|
expect($this->service->exists($token2))->toBeTrue();
|
|
});
|
|
|
|
it('returns zero when no expired tokens', function () {
|
|
$action = new TokenAction('test_action');
|
|
$config = new TokenConfig(expiryHours: 24);
|
|
|
|
$this->service->generate($action, ['id' => 1], $config);
|
|
$this->service->generate($action, ['id' => 2], $config);
|
|
|
|
$removedCount = $this->service->cleanupExpired();
|
|
|
|
expect($removedCount)->toBe(0);
|
|
});
|
|
|
|
it('handles empty token list', function () {
|
|
$removedCount = $this->service->cleanupExpired();
|
|
|
|
expect($removedCount)->toBe(0);
|
|
});
|
|
});
|