Enable Discovery debug logging for production troubleshooting
- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
130
tests/Unit/Framework/Cache/CacheTest.php
Normal file
130
tests/Unit/Framework/Cache/CacheTest.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Framework\Cache;
|
||||
|
||||
use App\Framework\Cache\CacheItem;
|
||||
use App\Framework\Cache\CacheKey;
|
||||
use App\Framework\Cache\Driver\InMemoryCache;
|
||||
use App\Framework\Cache\GeneralCache;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Serializer\Php\PhpSerializer;
|
||||
|
||||
test('cache can store and retrieve values', function () {
|
||||
$cache = new GeneralCache(new InMemoryCache(), new PhpSerializer());
|
||||
$key = CacheKey::fromString('test-key');
|
||||
|
||||
$result = $cache->set($key, 'test-value');
|
||||
expect($result)->toBeTrue();
|
||||
|
||||
$item = $cache->get($key);
|
||||
expect($item)->toBeInstanceOf(CacheItem::class);
|
||||
expect($item->isHit)->toBeTrue();
|
||||
expect($item->value)->toBe('test-value');
|
||||
});
|
||||
|
||||
test('cache returns miss for non-existent key', function () {
|
||||
$cache = new GeneralCache(new InMemoryCache(), new PhpSerializer());
|
||||
$key = CacheKey::fromString('non-existent');
|
||||
|
||||
$item = $cache->get($key);
|
||||
expect($item->isHit)->toBeFalse();
|
||||
expect($item->value)->toBeNull();
|
||||
});
|
||||
|
||||
test('cache can check if key exists', function () {
|
||||
$cache = new GeneralCache(new InMemoryCache(), new PhpSerializer());
|
||||
$key = CacheKey::fromString('test-key');
|
||||
|
||||
expect($cache->has($key))->toBeFalse();
|
||||
|
||||
$cache->set($key, 'value');
|
||||
expect($cache->has($key))->toBeTrue();
|
||||
});
|
||||
|
||||
test('cache can forget keys', function () {
|
||||
$cache = new GeneralCache(new InMemoryCache(), new PhpSerializer());
|
||||
$key = CacheKey::fromString('test-key');
|
||||
|
||||
$cache->set($key, 'value');
|
||||
expect($cache->has($key))->toBeTrue();
|
||||
|
||||
$result = $cache->forget($key);
|
||||
expect($result)->toBeTrue();
|
||||
expect($cache->has($key))->toBeFalse();
|
||||
});
|
||||
|
||||
test('cache can clear all entries', function () {
|
||||
$cache = new GeneralCache(new InMemoryCache(), new PhpSerializer());
|
||||
$key1 = CacheKey::fromString('key1');
|
||||
$key2 = CacheKey::fromString('key2');
|
||||
|
||||
$cache->set($key1, 'value1');
|
||||
$cache->set($key2, 'value2');
|
||||
|
||||
$result = $cache->clear();
|
||||
expect($result)->toBeTrue();
|
||||
|
||||
expect($cache->has($key1))->toBeFalse();
|
||||
expect($cache->has($key2))->toBeFalse();
|
||||
});
|
||||
|
||||
test('cache remember pattern works', function () {
|
||||
$cache = new GeneralCache(new InMemoryCache(), new PhpSerializer());
|
||||
$key = CacheKey::fromString('test-key');
|
||||
$callCount = 0;
|
||||
|
||||
$callback = function () use (&$callCount) {
|
||||
$callCount++;
|
||||
|
||||
return 'computed-value';
|
||||
};
|
||||
|
||||
// First call should execute callback
|
||||
$item1 = $cache->remember($key, $callback);
|
||||
expect($item1->value)->toBe('computed-value');
|
||||
expect($callCount)->toBe(1);
|
||||
|
||||
// Second call should return cached value
|
||||
$item2 = $cache->remember($key, $callback);
|
||||
expect($item2->value)->toBe('computed-value');
|
||||
expect($callCount)->toBe(1); // Callback not called again
|
||||
});
|
||||
|
||||
test('cache respects TTL', function () {
|
||||
$cache = new GeneralCache(new InMemoryCache(), new PhpSerializer());
|
||||
$key = CacheKey::fromString('test-key');
|
||||
$ttl = Duration::fromSeconds(60);
|
||||
|
||||
// This test would need a mock or a way to advance time
|
||||
// For now, just test that TTL parameter is accepted
|
||||
$result = $cache->set($key, 'value', $ttl);
|
||||
expect($result)->toBeTrue();
|
||||
});
|
||||
|
||||
test('cache can store different data types', function () {
|
||||
$cache = new GeneralCache(new InMemoryCache(), new PhpSerializer());
|
||||
|
||||
// String
|
||||
$stringKey = CacheKey::fromString('string');
|
||||
$cache->set($stringKey, 'test');
|
||||
expect($cache->get($stringKey)->value)->toBe('test');
|
||||
|
||||
// Integer
|
||||
$intKey = CacheKey::fromString('int');
|
||||
$cache->set($intKey, 42);
|
||||
expect($cache->get($intKey)->value)->toBe(42);
|
||||
|
||||
// Array
|
||||
$arrayKey = CacheKey::fromString('array');
|
||||
$cache->set($arrayKey, ['a' => 1, 'b' => 2]);
|
||||
expect($cache->get($arrayKey)->value)->toBe(['a' => 1, 'b' => 2]);
|
||||
|
||||
// Object
|
||||
$objectKey = CacheKey::fromString('object');
|
||||
$obj = new \stdClass();
|
||||
$obj->test = 'value';
|
||||
$cache->set($objectKey, $obj);
|
||||
expect($cache->get($objectKey)->value)->toEqual($obj);
|
||||
});
|
||||
267
tests/Unit/Framework/Cryptography/CryptographicUtilitiesTest.php
Normal file
267
tests/Unit/Framework/Cryptography/CryptographicUtilitiesTest.php
Normal file
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Framework\Cryptography\CryptographicUtilities;
|
||||
use App\Framework\Random\SecureRandomGenerator;
|
||||
|
||||
it('performs timing-safe string comparison', function () {
|
||||
$utils = new CryptographicUtilities(new SecureRandomGenerator());
|
||||
|
||||
$string1 = 'secret-value';
|
||||
$string2 = 'secret-value';
|
||||
$string3 = 'different-value';
|
||||
|
||||
expect($utils->timingSafeEquals($string1, $string2))->toBeTrue();
|
||||
expect($utils->timingSafeEquals($string1, $string3))->toBeFalse();
|
||||
});
|
||||
|
||||
it('performs timing-safe array comparison', function () {
|
||||
$utils = new CryptographicUtilities(new SecureRandomGenerator());
|
||||
|
||||
$array1 = ['key1' => 'value1', 'key2' => 'value2'];
|
||||
$array2 = ['key2' => 'value2', 'key1' => 'value1']; // Different order
|
||||
$array3 = ['key1' => 'value1', 'key2' => 'different'];
|
||||
|
||||
expect($utils->timingSafeArrayEquals($array1, $array2))->toBeTrue();
|
||||
expect($utils->timingSafeArrayEquals($array1, $array3))->toBeFalse();
|
||||
});
|
||||
|
||||
it('generates cryptographically secure nonce', function () {
|
||||
$utils = new CryptographicUtilities(new SecureRandomGenerator());
|
||||
|
||||
$nonce1 = $utils->generateNonce(32);
|
||||
$nonce2 = $utils->generateNonce(32);
|
||||
|
||||
expect(strlen($nonce1))->toBe(32);
|
||||
expect(strlen($nonce2))->toBe(32);
|
||||
expect($nonce1)->not->toBe($nonce2); // Should be different
|
||||
});
|
||||
|
||||
it('generates initialization vector', function () {
|
||||
$utils = new CryptographicUtilities(new SecureRandomGenerator());
|
||||
|
||||
$iv1 = $utils->generateIv(16);
|
||||
$iv2 = $utils->generateIv(16);
|
||||
|
||||
expect(strlen($iv1))->toBe(16);
|
||||
expect(strlen($iv2))->toBe(16);
|
||||
expect($iv1)->not->toBe($iv2); // Should be different
|
||||
});
|
||||
|
||||
it('validates entropy correctly', function () {
|
||||
$utils = new CryptographicUtilities(new SecureRandomGenerator());
|
||||
|
||||
// Use known high-entropy data (256 unique bytes)
|
||||
$highEntropyData = '';
|
||||
for ($i = 0; $i < 256; $i++) {
|
||||
$highEntropyData .= chr($i);
|
||||
}
|
||||
$lowEntropyData = str_repeat('a', 32); // Low entropy
|
||||
$emptyData = '';
|
||||
|
||||
expect($utils->validateEntropy($highEntropyData, 6.0))->toBeTrue(); // Lower threshold for realistic testing
|
||||
expect($utils->validateEntropy($lowEntropyData))->toBeFalse();
|
||||
expect($utils->validateEntropy($emptyData))->toBeFalse();
|
||||
});
|
||||
|
||||
it('calculates Shannon entropy', function () {
|
||||
$utils = new CryptographicUtilities(new SecureRandomGenerator());
|
||||
|
||||
$randomData = $utils->generateNonce(32);
|
||||
$uniformData = str_repeat('a', 32);
|
||||
|
||||
$randomEntropy = $utils->calculateShannonEntropy($randomData);
|
||||
$uniformEntropy = $utils->calculateShannonEntropy($uniformData);
|
||||
|
||||
expect($randomEntropy)->toBeGreaterThan($uniformEntropy);
|
||||
expect($uniformEntropy)->toBe(0.0); // Uniform data has zero entropy
|
||||
});
|
||||
|
||||
it('performs constant-time modular exponentiation if GMP available', function () {
|
||||
$utils = new CryptographicUtilities(new SecureRandomGenerator());
|
||||
|
||||
if (! extension_loaded('gmp')) {
|
||||
expect(fn () => $utils->constantTimeModPow('5', '3', '13'))
|
||||
->toThrow(InvalidArgumentException::class, 'GMP extension required');
|
||||
} else {
|
||||
$result = $utils->constantTimeModPow('5', '3', '13'); // 5^3 mod 13 = 125 mod 13 = 8
|
||||
expect($result)->toBe('8');
|
||||
}
|
||||
});
|
||||
|
||||
it('generates cryptographically secure UUID v4', function () {
|
||||
$utils = new CryptographicUtilities(new SecureRandomGenerator());
|
||||
|
||||
$uuid1 = $utils->generateUuid4();
|
||||
$uuid2 = $utils->generateUuid4();
|
||||
|
||||
// Check UUID format
|
||||
$pattern = '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i';
|
||||
expect($uuid1)->toMatch($pattern);
|
||||
expect($uuid2)->toMatch($pattern);
|
||||
expect($uuid1)->not->toBe($uuid2); // Should be unique
|
||||
});
|
||||
|
||||
it('stretches keys correctly', function () {
|
||||
$utils = new CryptographicUtilities(new SecureRandomGenerator());
|
||||
|
||||
$key = 'password';
|
||||
$salt = 'salt1234';
|
||||
|
||||
$stretched1 = $utils->stretchKey($key, $salt, 10000, 32);
|
||||
$stretched2 = $utils->stretchKey($key, $salt, 10000, 32); // Same parameters
|
||||
$stretched3 = $utils->stretchKey($key, 'differentsalt1234', 10000, 32); // Different salt
|
||||
|
||||
expect(strlen($stretched1))->toBe(32);
|
||||
expect($stretched1)->toBe($stretched2); // Same inputs = same output
|
||||
expect($stretched1)->not->toBe($stretched3); // Different salt = different output
|
||||
});
|
||||
|
||||
it('XORs strings correctly', function () {
|
||||
$utils = new CryptographicUtilities(new SecureRandomGenerator());
|
||||
|
||||
$str1 = "\x01\x02\x03\x04";
|
||||
$str2 = "\xFF\xFE\xFD\xFC";
|
||||
|
||||
$result = $utils->xorStrings($str1, $str2);
|
||||
|
||||
expect($result)->toBe("\xFE\xFC\xFE\xF8"); // XOR result
|
||||
expect(strlen($result))->toBe(4);
|
||||
});
|
||||
|
||||
it('generates and removes PKCS#7 padding', function () {
|
||||
$utils = new CryptographicUtilities(new SecureRandomGenerator());
|
||||
|
||||
$data = 'Hello World!'; // 12 bytes
|
||||
$blockSize = 16;
|
||||
|
||||
$padding = $utils->generatePadding($blockSize, strlen($data));
|
||||
$paddedData = $data . $padding;
|
||||
|
||||
expect(strlen($paddedData) % $blockSize)->toBe(0); // Should be block-aligned
|
||||
|
||||
$unpaddedData = $utils->removePadding($paddedData);
|
||||
expect($unpaddedData)->toBe($data);
|
||||
});
|
||||
|
||||
it('generates bit string correctly', function () {
|
||||
$utils = new CryptographicUtilities(new SecureRandomGenerator());
|
||||
|
||||
$bitString = $utils->generateBitString(16); // 2 bytes = 16 bits
|
||||
|
||||
expect(strlen($bitString))->toBe(16);
|
||||
expect($bitString)->toMatch('/^[01]+$/'); // Only 0s and 1s
|
||||
});
|
||||
|
||||
it('converts bit string to bytes', function () {
|
||||
$utils = new CryptographicUtilities(new SecureRandomGenerator());
|
||||
|
||||
$bitString = '0110100001101001'; // "hi" in binary
|
||||
$bytes = $utils->bitStringToBytes($bitString);
|
||||
|
||||
expect($bytes)->toBe('hi');
|
||||
});
|
||||
|
||||
it('validates key strength', function () {
|
||||
$utils = new CryptographicUtilities(new SecureRandomGenerator());
|
||||
|
||||
// Use known high-entropy data (64 different bytes)
|
||||
$strongKey = '';
|
||||
for ($i = 0; $i < 64; $i++) {
|
||||
$strongKey .= chr($i);
|
||||
}
|
||||
$weakKey = str_repeat('a', 32); // No entropy
|
||||
$shortKey = 'short'; // Too short
|
||||
|
||||
expect($utils->validateKeyStrength($strongKey, 128))->toBeTrue();
|
||||
expect($utils->validateKeyStrength($weakKey, 128))->toBeFalse();
|
||||
expect($utils->validateKeyStrength($shortKey, 128))->toBeFalse();
|
||||
});
|
||||
|
||||
it('generates deterministic UUID v5', function () {
|
||||
$utils = new CryptographicUtilities(new SecureRandomGenerator());
|
||||
|
||||
$namespace = str_repeat("\x00", 16); // Null namespace
|
||||
$name1 = 'test-name';
|
||||
$name2 = 'test-name'; // Same name
|
||||
$name3 = 'different-name';
|
||||
|
||||
$uuid1 = $utils->generateUuid5($namespace, $name1);
|
||||
$uuid2 = $utils->generateUuid5($namespace, $name2);
|
||||
$uuid3 = $utils->generateUuid5($namespace, $name3);
|
||||
|
||||
// Check UUID v5 format (version 5)
|
||||
$pattern = '/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i';
|
||||
expect($uuid1)->toMatch($pattern);
|
||||
|
||||
expect($uuid1)->toBe($uuid2); // Same inputs = same UUID
|
||||
expect($uuid1)->not->toBe($uuid3); // Different name = different UUID
|
||||
});
|
||||
|
||||
it('performs constant-time array search', function () {
|
||||
$utils = new CryptographicUtilities(new SecureRandomGenerator());
|
||||
|
||||
$haystack = ['value1', 'value2', 'secret-value', 'value3'];
|
||||
|
||||
expect($utils->constantTimeArraySearch($haystack, 'secret-value'))->toBeTrue();
|
||||
expect($utils->constantTimeArraySearch($haystack, 'not-found'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('securely wipes memory', function () {
|
||||
$utils = new CryptographicUtilities(new SecureRandomGenerator());
|
||||
|
||||
$sensitiveData = 'very-secret-password';
|
||||
$utils->secureWipe($sensitiveData);
|
||||
|
||||
expect($sensitiveData)->toBe(''); // Should be empty after wipe
|
||||
});
|
||||
|
||||
it('throws exception for too short nonce', function () {
|
||||
$utils = new CryptographicUtilities(new SecureRandomGenerator());
|
||||
|
||||
expect(fn () => $utils->generateNonce(4))
|
||||
->toThrow(InvalidArgumentException::class, 'Nonce length must be at least 8 bytes');
|
||||
});
|
||||
|
||||
it('throws exception for too short IV', function () {
|
||||
$utils = new CryptographicUtilities(new SecureRandomGenerator());
|
||||
|
||||
expect(fn () => $utils->generateIv(4))
|
||||
->toThrow(InvalidArgumentException::class, 'IV length must be at least 8 bytes');
|
||||
});
|
||||
|
||||
it('throws exception for too few key stretching iterations', function () {
|
||||
$utils = new CryptographicUtilities(new SecureRandomGenerator());
|
||||
|
||||
expect(fn () => $utils->stretchKey('key', 'salt', 500))
|
||||
->toThrow(InvalidArgumentException::class, 'Iterations must be at least 1000');
|
||||
});
|
||||
|
||||
it('throws exception for invalid bit string length', function () {
|
||||
$utils = new CryptographicUtilities(new SecureRandomGenerator());
|
||||
|
||||
expect(fn () => $utils->generateBitString(12)) // Not divisible by 8
|
||||
->toThrow(InvalidArgumentException::class, 'Bit length must be divisible by 8');
|
||||
});
|
||||
|
||||
it('throws exception for invalid bit string characters', function () {
|
||||
$utils = new CryptographicUtilities(new SecureRandomGenerator());
|
||||
|
||||
expect(fn () => $utils->bitStringToBytes('01012345')) // Contains invalid characters
|
||||
->toThrow(InvalidArgumentException::class, 'Bit string contains invalid characters');
|
||||
});
|
||||
|
||||
it('throws exception for invalid UUID v5 namespace', function () {
|
||||
$utils = new CryptographicUtilities(new SecureRandomGenerator());
|
||||
|
||||
expect(fn () => $utils->generateUuid5('short', 'name')) // Namespace too short
|
||||
->toThrow(InvalidArgumentException::class, 'Namespace must be 16 bytes');
|
||||
});
|
||||
|
||||
it('creates instance using factory method', function () {
|
||||
$randomGen = new SecureRandomGenerator();
|
||||
$utils = CryptographicUtilities::create($randomGen);
|
||||
|
||||
expect($utils)->toBeInstanceOf(CryptographicUtilities::class);
|
||||
});
|
||||
269
tests/Unit/Framework/Cryptography/DerivedKeyTest.php
Normal file
269
tests/Unit/Framework/Cryptography/DerivedKeyTest.php
Normal file
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Framework\Cryptography\DerivedKey;
|
||||
|
||||
it('creates derived key with valid parameters', function () {
|
||||
$key = str_repeat('a', 32);
|
||||
$salt = str_repeat('b', 32);
|
||||
|
||||
$derivedKey = new DerivedKey(
|
||||
key: $key,
|
||||
salt: $salt,
|
||||
algorithm: 'pbkdf2-sha256',
|
||||
iterations: 100000,
|
||||
keyLength: 32
|
||||
);
|
||||
|
||||
expect($derivedKey->getKey())->toBe($key);
|
||||
expect($derivedKey->getSalt())->toBe($salt);
|
||||
expect($derivedKey->getAlgorithm())->toBe('pbkdf2-sha256');
|
||||
expect($derivedKey->getIterations())->toBe(100000);
|
||||
expect($derivedKey->getKeyLength())->toBe(32);
|
||||
});
|
||||
|
||||
it('throws exception for empty key', function () {
|
||||
expect(fn () => new DerivedKey(
|
||||
key: '',
|
||||
salt: str_repeat('b', 32),
|
||||
algorithm: 'pbkdf2-sha256',
|
||||
iterations: 100000,
|
||||
keyLength: 32
|
||||
))->toThrow(InvalidArgumentException::class, 'Key cannot be empty');
|
||||
});
|
||||
|
||||
it('throws exception for empty salt', function () {
|
||||
expect(fn () => new DerivedKey(
|
||||
key: str_repeat('a', 32),
|
||||
salt: '',
|
||||
algorithm: 'pbkdf2-sha256',
|
||||
iterations: 100000,
|
||||
keyLength: 32
|
||||
))->toThrow(InvalidArgumentException::class, 'Salt cannot be empty');
|
||||
});
|
||||
|
||||
it('throws exception for key length mismatch', function () {
|
||||
expect(fn () => new DerivedKey(
|
||||
key: str_repeat('a', 16), // 16 bytes
|
||||
salt: str_repeat('b', 32),
|
||||
algorithm: 'pbkdf2-sha256',
|
||||
iterations: 100000,
|
||||
keyLength: 32 // Claims 32 bytes
|
||||
))->toThrow(InvalidArgumentException::class, 'Key length does not match specified length');
|
||||
});
|
||||
|
||||
it('provides hex representation', function () {
|
||||
$key = "\x01\x02\x03\x04";
|
||||
$salt = "\x05\x06\x07\x08";
|
||||
|
||||
$derivedKey = new DerivedKey(
|
||||
key: $key,
|
||||
salt: $salt,
|
||||
algorithm: 'pbkdf2-sha256',
|
||||
iterations: 100000,
|
||||
keyLength: 4
|
||||
);
|
||||
|
||||
expect($derivedKey->getKeyHex())->toBe('01020304');
|
||||
expect($derivedKey->getSaltHex())->toBe('05060708');
|
||||
});
|
||||
|
||||
it('provides base64 representation', function () {
|
||||
$key = 'test-key-data';
|
||||
$salt = 'test-salt-data';
|
||||
|
||||
$derivedKey = new DerivedKey(
|
||||
key: $key,
|
||||
salt: $salt,
|
||||
algorithm: 'pbkdf2-sha256',
|
||||
iterations: 100000,
|
||||
keyLength: strlen($key)
|
||||
);
|
||||
|
||||
expect($derivedKey->getKeyBase64())->toBe(base64_encode($key));
|
||||
expect($derivedKey->getSaltBase64())->toBe(base64_encode($salt));
|
||||
});
|
||||
|
||||
it('checks equality correctly', function () {
|
||||
$key = str_repeat('a', 32);
|
||||
$salt = str_repeat('b', 32);
|
||||
|
||||
$derivedKey1 = new DerivedKey(
|
||||
key: $key,
|
||||
salt: $salt,
|
||||
algorithm: 'pbkdf2-sha256',
|
||||
iterations: 100000,
|
||||
keyLength: 32
|
||||
);
|
||||
|
||||
$derivedKey2 = new DerivedKey(
|
||||
key: $key,
|
||||
salt: $salt,
|
||||
algorithm: 'pbkdf2-sha256',
|
||||
iterations: 100000,
|
||||
keyLength: 32
|
||||
);
|
||||
|
||||
$derivedKey3 = new DerivedKey(
|
||||
key: str_repeat('c', 32), // Different key
|
||||
salt: $salt,
|
||||
algorithm: 'pbkdf2-sha256',
|
||||
iterations: 100000,
|
||||
keyLength: 32
|
||||
);
|
||||
|
||||
expect($derivedKey1->equals($derivedKey2))->toBeTrue();
|
||||
expect($derivedKey1->equals($derivedKey3))->toBeFalse();
|
||||
});
|
||||
|
||||
it('exports to array correctly', function () {
|
||||
$derivedKey = new DerivedKey(
|
||||
key: str_repeat('a', 32),
|
||||
salt: str_repeat('b', 32),
|
||||
algorithm: 'argon2id',
|
||||
iterations: 4,
|
||||
keyLength: 32,
|
||||
memoryCost: 65536,
|
||||
threads: 3
|
||||
);
|
||||
|
||||
$array = $derivedKey->toArray();
|
||||
|
||||
expect($array)->toHaveKey('key');
|
||||
expect($array)->toHaveKey('salt');
|
||||
expect($array)->toHaveKey('algorithm');
|
||||
expect($array)->toHaveKey('iterations');
|
||||
expect($array)->toHaveKey('key_length');
|
||||
expect($array)->toHaveKey('memory_cost');
|
||||
expect($array)->toHaveKey('threads');
|
||||
|
||||
expect($array['algorithm'])->toBe('argon2id');
|
||||
expect($array['memory_cost'])->toBe(65536);
|
||||
expect($array['threads'])->toBe(3);
|
||||
});
|
||||
|
||||
it('creates from array correctly', function () {
|
||||
$originalKey = new DerivedKey(
|
||||
key: str_repeat('a', 32),
|
||||
salt: str_repeat('b', 32),
|
||||
algorithm: 'pbkdf2-sha256',
|
||||
iterations: 100000,
|
||||
keyLength: 32
|
||||
);
|
||||
|
||||
$array = $originalKey->toArray();
|
||||
$restoredKey = DerivedKey::fromArray($array);
|
||||
|
||||
expect($restoredKey->equals($originalKey))->toBeTrue();
|
||||
});
|
||||
|
||||
it('creates from hex correctly', function () {
|
||||
$keyHex = '0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20';
|
||||
$saltHex = 'abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdef';
|
||||
|
||||
$derivedKey = DerivedKey::fromHex(
|
||||
keyHex: $keyHex,
|
||||
saltHex: $saltHex,
|
||||
algorithm: 'pbkdf2-sha256',
|
||||
iterations: 100000,
|
||||
keyLength: 32
|
||||
);
|
||||
|
||||
expect($derivedKey->getKeyHex())->toBe($keyHex);
|
||||
expect($derivedKey->getSaltHex())->toBe($saltHex);
|
||||
});
|
||||
|
||||
it('provides summary information', function () {
|
||||
$derivedKey = new DerivedKey(
|
||||
key: str_repeat('a', 32),
|
||||
salt: str_repeat('b', 16),
|
||||
algorithm: 'pbkdf2-sha256',
|
||||
iterations: 100000,
|
||||
keyLength: 32
|
||||
);
|
||||
|
||||
$summary = $derivedKey->getSummary();
|
||||
|
||||
expect($summary)->toHaveKey('algorithm');
|
||||
expect($summary)->toHaveKey('iterations');
|
||||
expect($summary)->toHaveKey('key_length');
|
||||
expect($summary)->toHaveKey('salt_length');
|
||||
expect($summary['salt_length'])->toBe(16);
|
||||
});
|
||||
|
||||
it('identifies algorithm types correctly', function () {
|
||||
$pbkdf2Key = new DerivedKey(
|
||||
key: str_repeat('a', 32),
|
||||
salt: str_repeat('b', 32),
|
||||
algorithm: 'pbkdf2-sha256',
|
||||
iterations: 100000,
|
||||
keyLength: 32
|
||||
);
|
||||
|
||||
$argon2Key = new DerivedKey(
|
||||
key: str_repeat('a', 32),
|
||||
salt: str_repeat('b', 32),
|
||||
algorithm: 'argon2id',
|
||||
iterations: 4,
|
||||
keyLength: 32
|
||||
);
|
||||
|
||||
$scryptKey = new DerivedKey(
|
||||
key: str_repeat('a', 32),
|
||||
salt: str_repeat('b', 32),
|
||||
algorithm: 'scrypt',
|
||||
iterations: 16384,
|
||||
keyLength: 32
|
||||
);
|
||||
|
||||
expect($pbkdf2Key->isPbkdf2())->toBeTrue();
|
||||
expect($pbkdf2Key->isArgon2())->toBeFalse();
|
||||
expect($pbkdf2Key->isScrypt())->toBeFalse();
|
||||
|
||||
expect($argon2Key->isArgon2())->toBeTrue();
|
||||
expect($argon2Key->isPbkdf2())->toBeFalse();
|
||||
|
||||
expect($scryptKey->isScrypt())->toBeTrue();
|
||||
expect($scryptKey->isPbkdf2())->toBeFalse();
|
||||
});
|
||||
|
||||
it('handles scrypt parameters correctly', function () {
|
||||
$derivedKey = new DerivedKey(
|
||||
key: str_repeat('a', 32),
|
||||
salt: str_repeat('b', 32),
|
||||
algorithm: 'scrypt',
|
||||
iterations: 16384,
|
||||
keyLength: 32,
|
||||
blockSize: 8,
|
||||
parallelization: 1
|
||||
);
|
||||
|
||||
expect($derivedKey->getBlockSize())->toBe(8);
|
||||
expect($derivedKey->getParallelization())->toBe(1);
|
||||
});
|
||||
|
||||
it('throws exception for missing required fields in fromArray', function () {
|
||||
$incompleteData = [
|
||||
'key' => base64_encode(str_repeat('a', 32)),
|
||||
'salt' => base64_encode(str_repeat('b', 32)),
|
||||
'algorithm' => 'pbkdf2-sha256',
|
||||
// Missing iterations and key_length
|
||||
];
|
||||
|
||||
expect(fn () => DerivedKey::fromArray($incompleteData))
|
||||
->toThrow(InvalidArgumentException::class, 'Missing required field');
|
||||
});
|
||||
|
||||
it('throws exception for invalid base64 in fromArray', function () {
|
||||
$invalidData = [
|
||||
'key' => 'invalid-base64!@#',
|
||||
'salt' => base64_encode(str_repeat('b', 32)),
|
||||
'algorithm' => 'pbkdf2-sha256',
|
||||
'iterations' => 100000,
|
||||
'key_length' => 32,
|
||||
];
|
||||
|
||||
expect(fn () => DerivedKey::fromArray($invalidData))
|
||||
->toThrow(InvalidArgumentException::class, 'Failed to decode Base64 data');
|
||||
});
|
||||
178
tests/Unit/Framework/Cryptography/KeyDerivationFunctionTest.php
Normal file
178
tests/Unit/Framework/Cryptography/KeyDerivationFunctionTest.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Framework\Cryptography\DerivedKey;
|
||||
use App\Framework\Cryptography\KeyDerivationFunction;
|
||||
use App\Framework\Random\SecureRandomGenerator;
|
||||
|
||||
it('generates salt with correct length', function () {
|
||||
$kdf = new KeyDerivationFunction(new SecureRandomGenerator());
|
||||
$salt = $kdf->generateSalt(32);
|
||||
|
||||
expect(strlen($salt))->toBe(32);
|
||||
});
|
||||
|
||||
it('throws exception for too short salt', function () {
|
||||
$kdf = new KeyDerivationFunction(new SecureRandomGenerator());
|
||||
|
||||
expect(fn () => $kdf->generateSalt(8))
|
||||
->toThrow(InvalidArgumentException::class, 'Salt length must be at least 16 bytes');
|
||||
});
|
||||
|
||||
it('derives key using PBKDF2', function () {
|
||||
$kdf = new KeyDerivationFunction(new SecureRandomGenerator());
|
||||
$password = 'test-password';
|
||||
$salt = $kdf->generateSalt();
|
||||
|
||||
$derivedKey = $kdf->pbkdf2($password, $salt, 10000, 32);
|
||||
|
||||
expect($derivedKey)->toBeInstanceOf(DerivedKey::class);
|
||||
expect($derivedKey->getAlgorithm())->toBe('pbkdf2-sha256');
|
||||
expect($derivedKey->getKeyLength())->toBe(32);
|
||||
expect($derivedKey->getIterations())->toBe(10000);
|
||||
expect(strlen($derivedKey->getKey()))->toBe(32);
|
||||
});
|
||||
|
||||
it('derives key using Argon2ID if available', function () {
|
||||
if (! function_exists('sodium_crypto_pwhash') || ! defined('SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID')) {
|
||||
$this->markTestSkipped('Sodium extension or Argon2ID constant not available');
|
||||
}
|
||||
|
||||
$kdf = new KeyDerivationFunction(new SecureRandomGenerator());
|
||||
$password = 'test-password';
|
||||
$salt = str_repeat("\x00", 32); // Argon2ID needs exactly 32 bytes
|
||||
|
||||
$derivedKey = $kdf->argon2id($password, $salt, 65536, 4, 3, 32);
|
||||
|
||||
expect($derivedKey)->toBeInstanceOf(DerivedKey::class);
|
||||
expect($derivedKey->getAlgorithm())->toBe('argon2id');
|
||||
expect($derivedKey->getKeyLength())->toBe(32);
|
||||
expect(strlen($derivedKey->getKey()))->toBe(32);
|
||||
});
|
||||
|
||||
it('derives key using scrypt if available', function () {
|
||||
if (! function_exists('scrypt')) {
|
||||
$this->markTestSkipped('scrypt function not available');
|
||||
}
|
||||
|
||||
$kdf = new KeyDerivationFunction(new SecureRandomGenerator());
|
||||
$password = 'test-password';
|
||||
$salt = $kdf->generateSalt();
|
||||
|
||||
$derivedKey = $kdf->scrypt($password, $salt, 16384, 8, 1, 32);
|
||||
|
||||
expect($derivedKey)->toBeInstanceOf(DerivedKey::class);
|
||||
expect($derivedKey->getAlgorithm())->toBe('scrypt');
|
||||
expect($derivedKey->getKeyLength())->toBe(32);
|
||||
expect(strlen($derivedKey->getKey()))->toBe(32);
|
||||
});
|
||||
|
||||
it('verifies password correctly', function () {
|
||||
$kdf = new KeyDerivationFunction(new SecureRandomGenerator());
|
||||
$password = 'correct-password';
|
||||
$wrongPassword = 'wrong-password';
|
||||
$salt = $kdf->generateSalt();
|
||||
|
||||
$derivedKey = $kdf->pbkdf2($password, $salt, 10000, 32);
|
||||
|
||||
expect($kdf->verify($password, $derivedKey))->toBeTrue();
|
||||
expect($kdf->verify($wrongPassword, $derivedKey))->toBeFalse();
|
||||
});
|
||||
|
||||
it('hashes password with automatic salt generation', function () {
|
||||
$kdf = new KeyDerivationFunction(new SecureRandomGenerator());
|
||||
$password = 'test-password';
|
||||
|
||||
$derivedKey = $kdf->hashPassword($password, 'pbkdf2-sha256');
|
||||
|
||||
expect($derivedKey)->toBeInstanceOf(DerivedKey::class);
|
||||
expect($derivedKey->getAlgorithm())->toBe('pbkdf2-sha256');
|
||||
expect(strlen($derivedKey->getSalt()))->toBe(32);
|
||||
expect($kdf->verify($password, $derivedKey))->toBeTrue();
|
||||
});
|
||||
|
||||
it('throws exception for empty password', function () {
|
||||
$kdf = new KeyDerivationFunction(new SecureRandomGenerator());
|
||||
$salt = $kdf->generateSalt();
|
||||
|
||||
expect(fn () => $kdf->pbkdf2('', $salt))
|
||||
->toThrow(InvalidArgumentException::class, 'Password cannot be empty');
|
||||
});
|
||||
|
||||
it('throws exception for short salt', function () {
|
||||
$kdf = new KeyDerivationFunction(new SecureRandomGenerator());
|
||||
|
||||
expect(fn () => $kdf->pbkdf2('password', 'short'))
|
||||
->toThrow(InvalidArgumentException::class, 'Salt must be at least 16 bytes');
|
||||
});
|
||||
|
||||
it('throws exception for too few iterations', function () {
|
||||
$kdf = new KeyDerivationFunction(new SecureRandomGenerator());
|
||||
$salt = $kdf->generateSalt();
|
||||
|
||||
expect(fn () => $kdf->pbkdf2('password', $salt, 5000))
|
||||
->toThrow(InvalidArgumentException::class, 'Iterations must be at least 10,000');
|
||||
});
|
||||
|
||||
it('supports different hash algorithms', function () {
|
||||
$kdf = new KeyDerivationFunction(new SecureRandomGenerator());
|
||||
$password = 'test-password';
|
||||
$salt = $kdf->generateSalt();
|
||||
|
||||
$sha256Key = $kdf->pbkdf2($password, $salt, 10000, 32, 'sha256');
|
||||
$sha512Key = $kdf->pbkdf2($password, $salt, 10000, 32, 'sha512');
|
||||
|
||||
expect($sha256Key->getAlgorithm())->toBe('pbkdf2-sha256');
|
||||
expect($sha512Key->getAlgorithm())->toBe('pbkdf2-sha512');
|
||||
expect($sha256Key->getKey())->not->toBe($sha512Key->getKey());
|
||||
});
|
||||
|
||||
it('provides recommended parameters', function () {
|
||||
$kdf = new KeyDerivationFunction(new SecureRandomGenerator());
|
||||
|
||||
$params = $kdf->getRecommendedParameters('pbkdf2-sha256', 'standard');
|
||||
|
||||
expect($params)->toHaveKey('iterations');
|
||||
expect($params)->toHaveKey('key_length');
|
||||
expect($params['iterations'])->toBe(100000);
|
||||
expect($params['key_length'])->toBe(32);
|
||||
});
|
||||
|
||||
it('throws exception for unsupported algorithm in recommendations', function () {
|
||||
$kdf = new KeyDerivationFunction(new SecureRandomGenerator());
|
||||
|
||||
expect(fn () => $kdf->getRecommendedParameters('unsupported', 'standard'))
|
||||
->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
|
||||
it('creates instance using factory method', function () {
|
||||
$randomGen = new SecureRandomGenerator();
|
||||
$kdf = KeyDerivationFunction::create($randomGen);
|
||||
|
||||
expect($kdf)->toBeInstanceOf(KeyDerivationFunction::class);
|
||||
});
|
||||
|
||||
it('generates different keys for same password with different salts', function () {
|
||||
$kdf = new KeyDerivationFunction(new SecureRandomGenerator());
|
||||
$password = 'same-password';
|
||||
|
||||
$salt1 = $kdf->generateSalt();
|
||||
$salt2 = $kdf->generateSalt();
|
||||
|
||||
$key1 = $kdf->pbkdf2($password, $salt1);
|
||||
$key2 = $kdf->pbkdf2($password, $salt2);
|
||||
|
||||
expect($key1->getKey())->not->toBe($key2->getKey());
|
||||
});
|
||||
|
||||
it('generates consistent keys for same inputs', function () {
|
||||
$kdf = new KeyDerivationFunction(new SecureRandomGenerator());
|
||||
$password = 'test-password';
|
||||
$salt = str_repeat('a', 16);
|
||||
|
||||
$key1 = $kdf->pbkdf2($password, $salt, 10000, 32);
|
||||
$key2 = $kdf->pbkdf2($password, $salt, 10000, 32);
|
||||
|
||||
expect($key1->getKey())->toBe($key2->getKey());
|
||||
});
|
||||
244
tests/Unit/Framework/Cryptography/SecureTokenGeneratorTest.php
Normal file
244
tests/Unit/Framework/Cryptography/SecureTokenGeneratorTest.php
Normal file
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Framework\Cryptography\SecureToken;
|
||||
use App\Framework\Cryptography\SecureTokenGenerator;
|
||||
use App\Framework\Random\SecureRandomGenerator;
|
||||
|
||||
it('generates secure token with default parameters', function () {
|
||||
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
|
||||
$token = $generator->generate('api_key');
|
||||
|
||||
expect($token)->toBeInstanceOf(SecureToken::class);
|
||||
expect($token->getType())->toBe('api_key');
|
||||
expect($token->getFormat())->toBe(SecureTokenGenerator::FORMAT_BASE64_URL);
|
||||
expect($token->getLength())->toBe(32);
|
||||
});
|
||||
|
||||
it('generates API key with prefix', function () {
|
||||
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
|
||||
$token = $generator->generateApiKey('myapp');
|
||||
|
||||
expect($token->getType())->toBe(SecureTokenGenerator::TYPE_API_KEY);
|
||||
expect($token->getPrefix())->toBe('myapp');
|
||||
expect($token->getValue())->toStartWith('myapp_');
|
||||
});
|
||||
|
||||
it('generates session token', function () {
|
||||
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
|
||||
$token = $generator->generateSessionToken();
|
||||
|
||||
expect($token->getType())->toBe(SecureTokenGenerator::TYPE_SESSION);
|
||||
expect($token->getMetadataValue('purpose'))->toBe('session_management');
|
||||
expect($token->getMetadataValue('secure'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('generates CSRF token', function () {
|
||||
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
|
||||
$token = $generator->generateCsrfToken();
|
||||
|
||||
expect($token->getType())->toBe(SecureTokenGenerator::TYPE_CSRF);
|
||||
expect($token->getMetadataValue('purpose'))->toBe('csrf_protection');
|
||||
});
|
||||
|
||||
it('generates verification token', function () {
|
||||
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
|
||||
$token = $generator->generateVerificationToken('email_verification');
|
||||
|
||||
expect($token->getType())->toBe(SecureTokenGenerator::TYPE_VERIFICATION);
|
||||
expect($token->getMetadataValue('purpose'))->toBe('email_verification');
|
||||
expect($token->getMetadataValue('single_use'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('generates OTP token with correct digits', function () {
|
||||
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
|
||||
$token = $generator->generateOtpToken(6);
|
||||
|
||||
expect($token->getType())->toBe(SecureTokenGenerator::TYPE_OTP);
|
||||
expect($token->getValue())->toMatch('/^\d{6}$/');
|
||||
expect($token->getLength())->toBe(6);
|
||||
expect($token->getMetadataValue('digits'))->toBe(6);
|
||||
});
|
||||
|
||||
it('generates webhook token', function () {
|
||||
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
|
||||
$token = $generator->generateWebhookToken();
|
||||
|
||||
expect($token->getType())->toBe(SecureTokenGenerator::TYPE_WEBHOOK);
|
||||
expect($token->getPrefix())->toBe('whsec');
|
||||
expect($token->getValue())->toStartWith('whsec_');
|
||||
});
|
||||
|
||||
it('generates bearer token', function () {
|
||||
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
|
||||
$token = $generator->generateBearerToken();
|
||||
|
||||
expect($token->getType())->toBe(SecureTokenGenerator::TYPE_BEARER);
|
||||
expect($token->getMetadataValue('purpose'))->toBe('api_authorization');
|
||||
});
|
||||
|
||||
it('generates refresh token with longer length', function () {
|
||||
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
|
||||
$token = $generator->generateRefreshToken();
|
||||
|
||||
expect($token->getType())->toBe(SecureTokenGenerator::TYPE_REFRESH);
|
||||
expect($token->getLength())->toBe(64);
|
||||
expect($token->getMetadataValue('long_lived'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('generates tokens in different formats', function () {
|
||||
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
|
||||
|
||||
$base64Token = $generator->generate(SecureTokenGenerator::TYPE_API_KEY, 32, SecureTokenGenerator::FORMAT_BASE64);
|
||||
$hexToken = $generator->generate(SecureTokenGenerator::TYPE_API_KEY, 32, SecureTokenGenerator::FORMAT_HEX);
|
||||
$base32Token = $generator->generate(SecureTokenGenerator::TYPE_API_KEY, 32, SecureTokenGenerator::FORMAT_BASE32);
|
||||
|
||||
expect($base64Token->getFormat())->toBe(SecureTokenGenerator::FORMAT_BASE64);
|
||||
expect($hexToken->getFormat())->toBe(SecureTokenGenerator::FORMAT_HEX);
|
||||
expect($base32Token->getFormat())->toBe(SecureTokenGenerator::FORMAT_BASE32);
|
||||
|
||||
// Check format patterns
|
||||
expect($hexToken->getValue())->toMatch('/^[0-9a-f]+$/');
|
||||
expect($base32Token->getValue())->toMatch('/^[A-Z2-7]+$/');
|
||||
});
|
||||
|
||||
it('generates custom tokens with alphabet', function () {
|
||||
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
|
||||
$alphabet = '0123456789ABCDEF';
|
||||
$token = $generator->generateCustom($alphabet, 16);
|
||||
|
||||
expect($token->getValue())->toMatch('/^[0-9A-F]{16}$/');
|
||||
expect($token->getMetadataValue('alphabet'))->toBe($alphabet);
|
||||
expect($token->getMetadataValue('alphabet_size'))->toBe(16);
|
||||
});
|
||||
|
||||
it('generates batch of tokens', function () {
|
||||
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
|
||||
$tokens = $generator->generateBatch('session', 5, 32);
|
||||
|
||||
expect($tokens)->toHaveCount(5);
|
||||
expect($tokens[0])->toBeInstanceOf(SecureToken::class);
|
||||
|
||||
// All tokens should be unique
|
||||
$values = array_map(fn ($token) => $token->getValue(), $tokens);
|
||||
$uniqueValues = array_unique($values);
|
||||
expect($uniqueValues)->toHaveCount(5);
|
||||
});
|
||||
|
||||
it('validates token formats correctly', function () {
|
||||
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
|
||||
|
||||
expect($generator->isValidFormat('dGVzdA', SecureTokenGenerator::FORMAT_BASE64))->toBeTrue();
|
||||
expect($generator->isValidFormat('dGVzdA-_', SecureTokenGenerator::FORMAT_BASE64_URL))->toBeTrue();
|
||||
expect($generator->isValidFormat('deadbeef', SecureTokenGenerator::FORMAT_HEX))->toBeTrue();
|
||||
expect($generator->isValidFormat('MFRGG', SecureTokenGenerator::FORMAT_BASE32))->toBeTrue();
|
||||
expect($generator->isValidFormat('Test123', SecureTokenGenerator::FORMAT_ALPHANUMERIC))->toBeTrue();
|
||||
|
||||
// Invalid formats
|
||||
expect($generator->isValidFormat('invalid!@#', SecureTokenGenerator::FORMAT_BASE64_URL))->toBeFalse();
|
||||
expect($generator->isValidFormat('invalidhex', SecureTokenGenerator::FORMAT_HEX))->toBeFalse();
|
||||
});
|
||||
|
||||
it('calculates token entropy correctly', function () {
|
||||
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
|
||||
|
||||
$base64Token = $generator->generate(SecureTokenGenerator::TYPE_API_KEY, 32, SecureTokenGenerator::FORMAT_BASE64_URL);
|
||||
$hexToken = $generator->generate(SecureTokenGenerator::TYPE_API_KEY, 32, SecureTokenGenerator::FORMAT_HEX);
|
||||
|
||||
$base64Entropy = $generator->getEntropy($base64Token);
|
||||
$hexEntropy = $generator->getEntropy($hexToken);
|
||||
|
||||
expect($base64Entropy)->toBeGreaterThan($hexEntropy); // Base64 has higher entropy per character
|
||||
expect($base64Entropy)->toBeGreaterThan(100); // Should have significant entropy
|
||||
});
|
||||
|
||||
it('throws exception for too short token length', function () {
|
||||
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
|
||||
|
||||
expect(fn () => $generator->generate('test', 8))
|
||||
->toThrow(InvalidArgumentException::class, 'Token length must be at least 16 bytes');
|
||||
});
|
||||
|
||||
it('throws exception for too long token length', function () {
|
||||
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
|
||||
|
||||
expect(fn () => $generator->generate('test', 512))
|
||||
->toThrow(InvalidArgumentException::class, 'Token length cannot exceed 256 bytes');
|
||||
});
|
||||
|
||||
it('throws exception for unsupported token type', function () {
|
||||
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
|
||||
|
||||
expect(fn () => $generator->generate('unsupported_type'))
|
||||
->toThrow(InvalidArgumentException::class, 'Unsupported token type');
|
||||
});
|
||||
|
||||
it('throws exception for unsupported format', function () {
|
||||
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
|
||||
|
||||
expect(fn () => $generator->generate('api_key', 32, 'unsupported_format'))
|
||||
->toThrow(InvalidArgumentException::class, 'Unsupported format');
|
||||
});
|
||||
|
||||
it('throws exception for invalid OTP digits', function () {
|
||||
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
|
||||
|
||||
expect(fn () => $generator->generateOtpToken(2))
|
||||
->toThrow(InvalidArgumentException::class, 'OTP digits must be between 4 and 12');
|
||||
|
||||
expect(fn () => $generator->generateOtpToken(15))
|
||||
->toThrow(InvalidArgumentException::class, 'OTP digits must be between 4 and 12');
|
||||
});
|
||||
|
||||
it('throws exception for too small custom alphabet', function () {
|
||||
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
|
||||
|
||||
expect(fn () => $generator->generateCustom('ABC', 16))
|
||||
->toThrow(InvalidArgumentException::class, 'Alphabet must contain at least 16 characters');
|
||||
});
|
||||
|
||||
it('throws exception for too short custom token', function () {
|
||||
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
|
||||
|
||||
expect(fn () => $generator->generateCustom('0123456789ABCDEF', 4))
|
||||
->toThrow(InvalidArgumentException::class, 'Token length must be at least 8 characters');
|
||||
});
|
||||
|
||||
it('throws exception for invalid batch count', function () {
|
||||
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
|
||||
|
||||
expect(fn () => $generator->generateBatch('session', 0))
|
||||
->toThrow(InvalidArgumentException::class, 'Count must be positive');
|
||||
|
||||
expect(fn () => $generator->generateBatch('session', 2000))
|
||||
->toThrow(InvalidArgumentException::class, 'Batch size cannot exceed 1000');
|
||||
});
|
||||
|
||||
it('creates instance using factory method', function () {
|
||||
$randomGen = new SecureRandomGenerator();
|
||||
$generator = SecureTokenGenerator::create($randomGen);
|
||||
|
||||
expect($generator)->toBeInstanceOf(SecureTokenGenerator::class);
|
||||
});
|
||||
|
||||
it('removes duplicate characters from custom alphabet', function () {
|
||||
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
|
||||
$alphabetWithDuplicates = '0123456789ABCDEFABCDEF'; // Has duplicate ABCDEF
|
||||
$token = $generator->generateCustom($alphabetWithDuplicates, 8);
|
||||
|
||||
$metadata = $token->getMetadata();
|
||||
$actualAlphabet = $metadata['alphabet'];
|
||||
expect($actualAlphabet)->toBe('0123456789ABCDEF'); // Duplicates removed
|
||||
expect($metadata['alphabet_size'])->toBe(16);
|
||||
});
|
||||
|
||||
it('generates unique tokens in batch', function () {
|
||||
$generator = new SecureTokenGenerator(new SecureRandomGenerator());
|
||||
$tokens = $generator->generateBatch('api_key', 10, 32);
|
||||
|
||||
$values = array_map(fn ($token) => $token->getValue(), $tokens);
|
||||
$uniqueValues = array_unique($values);
|
||||
|
||||
expect($uniqueValues)->toHaveCount(10); // All should be unique
|
||||
});
|
||||
360
tests/Unit/Framework/Cryptography/SecureTokenTest.php
Normal file
360
tests/Unit/Framework/Cryptography/SecureTokenTest.php
Normal file
@@ -0,0 +1,360 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Framework\Cryptography\SecureToken;
|
||||
use App\Framework\Cryptography\SecureTokenGenerator;
|
||||
|
||||
it('creates secure token with valid parameters', function () {
|
||||
$token = new SecureToken(
|
||||
value: 'test-token-value',
|
||||
type: SecureTokenGenerator::TYPE_API_KEY,
|
||||
format: SecureTokenGenerator::FORMAT_BASE64_URL,
|
||||
length: 32,
|
||||
prefix: 'ak',
|
||||
rawBytes: 'raw-bytes-data',
|
||||
metadata: ['purpose' => 'testing']
|
||||
);
|
||||
|
||||
expect($token->getValue())->toBe('test-token-value');
|
||||
expect($token->getType())->toBe(SecureTokenGenerator::TYPE_API_KEY);
|
||||
expect($token->getFormat())->toBe(SecureTokenGenerator::FORMAT_BASE64_URL);
|
||||
expect($token->getLength())->toBe(32);
|
||||
expect($token->getPrefix())->toBe('ak');
|
||||
expect($token->getRawBytes())->toBe('raw-bytes-data');
|
||||
expect($token->getMetadata())->toBe(['purpose' => 'testing']);
|
||||
});
|
||||
|
||||
it('throws exception for empty token value', function () {
|
||||
expect(fn () => new SecureToken(
|
||||
value: '',
|
||||
type: SecureTokenGenerator::TYPE_API_KEY,
|
||||
format: SecureTokenGenerator::FORMAT_BASE64_URL,
|
||||
length: 32,
|
||||
prefix: null,
|
||||
rawBytes: 'raw-bytes',
|
||||
metadata: []
|
||||
))->toThrow(InvalidArgumentException::class, 'Token value cannot be empty');
|
||||
});
|
||||
|
||||
it('provides string representation', function () {
|
||||
$tokenValue = 'test-token-value';
|
||||
$token = new SecureToken(
|
||||
value: $tokenValue,
|
||||
type: SecureTokenGenerator::TYPE_SESSION,
|
||||
format: SecureTokenGenerator::FORMAT_BASE64_URL,
|
||||
length: 32,
|
||||
prefix: null,
|
||||
rawBytes: 'raw-bytes',
|
||||
metadata: []
|
||||
);
|
||||
|
||||
expect($token->toString())->toBe($tokenValue);
|
||||
expect((string)$token)->toBe($tokenValue);
|
||||
});
|
||||
|
||||
it('extracts value without prefix', function () {
|
||||
$token = new SecureToken(
|
||||
value: 'ak_abcd1234efgh5678',
|
||||
type: SecureTokenGenerator::TYPE_API_KEY,
|
||||
format: SecureTokenGenerator::FORMAT_BASE64_URL,
|
||||
length: 32,
|
||||
prefix: 'ak',
|
||||
rawBytes: 'raw-bytes',
|
||||
metadata: []
|
||||
);
|
||||
|
||||
expect($token->getValueWithoutPrefix())->toBe('abcd1234efgh5678');
|
||||
expect($token->hasPrefix())->toBeTrue();
|
||||
});
|
||||
|
||||
it('handles token without prefix', function () {
|
||||
$token = new SecureToken(
|
||||
value: 'abcd1234efgh5678',
|
||||
type: SecureTokenGenerator::TYPE_SESSION,
|
||||
format: SecureTokenGenerator::FORMAT_BASE64_URL,
|
||||
length: 32,
|
||||
prefix: null,
|
||||
rawBytes: 'raw-bytes',
|
||||
metadata: []
|
||||
);
|
||||
|
||||
expect($token->getValueWithoutPrefix())->toBe('abcd1234efgh5678');
|
||||
expect($token->hasPrefix())->toBeFalse();
|
||||
});
|
||||
|
||||
it('provides raw bytes in different formats', function () {
|
||||
$rawBytes = "\x01\x02\x03\x04";
|
||||
$token = new SecureToken(
|
||||
value: 'test-token',
|
||||
type: SecureTokenGenerator::TYPE_API_KEY,
|
||||
format: SecureTokenGenerator::FORMAT_BASE64_URL,
|
||||
length: 32,
|
||||
prefix: null,
|
||||
rawBytes: $rawBytes,
|
||||
metadata: []
|
||||
);
|
||||
|
||||
expect($token->getRawBytesHex())->toBe('01020304');
|
||||
expect($token->getRawBytesBase64())->toBe(base64_encode($rawBytes));
|
||||
});
|
||||
|
||||
it('checks equality correctly with timing-safe comparison', function () {
|
||||
$tokenValue = 'same-token-value';
|
||||
|
||||
$token1 = new SecureToken(
|
||||
value: $tokenValue,
|
||||
type: SecureTokenGenerator::TYPE_API_KEY,
|
||||
format: SecureTokenGenerator::FORMAT_BASE64_URL,
|
||||
length: 32,
|
||||
prefix: null,
|
||||
rawBytes: 'raw-bytes',
|
||||
metadata: []
|
||||
);
|
||||
|
||||
$token2 = new SecureToken(
|
||||
value: $tokenValue,
|
||||
type: SecureTokenGenerator::TYPE_SESSION,
|
||||
format: SecureTokenGenerator::FORMAT_HEX,
|
||||
length: 64,
|
||||
prefix: 'test',
|
||||
rawBytes: 'different-raw',
|
||||
metadata: ['different' => 'metadata']
|
||||
);
|
||||
|
||||
$token3 = new SecureToken(
|
||||
value: 'different-token-value',
|
||||
type: SecureTokenGenerator::TYPE_API_KEY,
|
||||
format: SecureTokenGenerator::FORMAT_BASE64_URL,
|
||||
length: 32,
|
||||
prefix: null,
|
||||
rawBytes: 'raw-bytes',
|
||||
metadata: []
|
||||
);
|
||||
|
||||
expect($token1->equals($token2))->toBeTrue(); // Same value
|
||||
expect($token1->equals($token3))->toBeFalse(); // Different value
|
||||
});
|
||||
|
||||
it('verifies token value with timing-safe comparison', function () {
|
||||
$tokenValue = 'secure-token-value';
|
||||
$token = new SecureToken(
|
||||
value: $tokenValue,
|
||||
type: SecureTokenGenerator::TYPE_API_KEY,
|
||||
format: SecureTokenGenerator::FORMAT_BASE64_URL,
|
||||
length: 32,
|
||||
prefix: null,
|
||||
rawBytes: 'raw-bytes',
|
||||
metadata: []
|
||||
);
|
||||
|
||||
expect($token->verify($tokenValue))->toBeTrue();
|
||||
expect($token->verify('wrong-token-value'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('identifies token types correctly', function () {
|
||||
$apiKeyToken = new SecureToken(
|
||||
value: 'test-token',
|
||||
type: SecureTokenGenerator::TYPE_API_KEY,
|
||||
format: SecureTokenGenerator::FORMAT_BASE64_URL,
|
||||
length: 32,
|
||||
prefix: null,
|
||||
rawBytes: 'raw-bytes',
|
||||
metadata: []
|
||||
);
|
||||
|
||||
$sessionToken = new SecureToken(
|
||||
value: 'test-token',
|
||||
type: SecureTokenGenerator::TYPE_SESSION,
|
||||
format: SecureTokenGenerator::FORMAT_BASE64_URL,
|
||||
length: 32,
|
||||
prefix: null,
|
||||
rawBytes: 'raw-bytes',
|
||||
metadata: []
|
||||
);
|
||||
|
||||
expect($apiKeyToken->isApiKey())->toBeTrue();
|
||||
expect($apiKeyToken->isSessionToken())->toBeFalse();
|
||||
expect($sessionToken->isSessionToken())->toBeTrue();
|
||||
expect($sessionToken->isApiKey())->toBeFalse();
|
||||
});
|
||||
|
||||
it('checks token properties from metadata', function () {
|
||||
$singleUseToken = new SecureToken(
|
||||
value: 'test-token',
|
||||
type: SecureTokenGenerator::TYPE_VERIFICATION,
|
||||
format: SecureTokenGenerator::FORMAT_BASE64_URL,
|
||||
length: 32,
|
||||
prefix: null,
|
||||
rawBytes: 'raw-bytes',
|
||||
metadata: ['single_use' => true, 'long_lived' => false]
|
||||
);
|
||||
|
||||
$longLivedToken = new SecureToken(
|
||||
value: 'test-token',
|
||||
type: SecureTokenGenerator::TYPE_REFRESH,
|
||||
format: SecureTokenGenerator::FORMAT_BASE64_URL,
|
||||
length: 64,
|
||||
prefix: null,
|
||||
rawBytes: 'raw-bytes',
|
||||
metadata: ['single_use' => false, 'long_lived' => true]
|
||||
);
|
||||
|
||||
expect($singleUseToken->isSingleUse())->toBeTrue();
|
||||
expect($singleUseToken->isLongLived())->toBeFalse();
|
||||
expect($longLivedToken->isLongLived())->toBeTrue();
|
||||
expect($longLivedToken->isSingleUse())->toBeFalse();
|
||||
});
|
||||
|
||||
it('gets specific metadata values', function () {
|
||||
$token = new SecureToken(
|
||||
value: 'test-token',
|
||||
type: SecureTokenGenerator::TYPE_API_KEY,
|
||||
format: SecureTokenGenerator::FORMAT_BASE64_URL,
|
||||
length: 32,
|
||||
prefix: null,
|
||||
rawBytes: 'raw-bytes',
|
||||
metadata: ['purpose' => 'api_auth', 'expires' => 3600]
|
||||
);
|
||||
|
||||
expect($token->getMetadataValue('purpose'))->toBe('api_auth');
|
||||
expect($token->getMetadataValue('expires'))->toBe(3600);
|
||||
expect($token->getMetadataValue('nonexistent', 'default'))->toBe('default');
|
||||
});
|
||||
|
||||
it('calculates token age', function () {
|
||||
$token = new SecureToken(
|
||||
value: 'test-token',
|
||||
type: SecureTokenGenerator::TYPE_SESSION,
|
||||
format: SecureTokenGenerator::FORMAT_BASE64_URL,
|
||||
length: 32,
|
||||
prefix: null,
|
||||
rawBytes: 'raw-bytes',
|
||||
metadata: []
|
||||
);
|
||||
|
||||
sleep(1);
|
||||
$age = $token->getAgeInSeconds();
|
||||
expect($age)->toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('exports to and imports from array', function () {
|
||||
$originalToken = new SecureToken(
|
||||
value: 'test-token-value',
|
||||
type: SecureTokenGenerator::TYPE_API_KEY,
|
||||
format: SecureTokenGenerator::FORMAT_BASE64_URL,
|
||||
length: 32,
|
||||
prefix: 'ak',
|
||||
rawBytes: 'raw-bytes-data',
|
||||
metadata: ['purpose' => 'testing', 'scope' => 'read']
|
||||
);
|
||||
|
||||
$array = $originalToken->toArray();
|
||||
$restoredToken = SecureToken::fromArray($array);
|
||||
|
||||
expect($restoredToken->getValue())->toBe($originalToken->getValue());
|
||||
expect($restoredToken->getType())->toBe($originalToken->getType());
|
||||
expect($restoredToken->getFormat())->toBe($originalToken->getFormat());
|
||||
expect($restoredToken->getLength())->toBe($originalToken->getLength());
|
||||
expect($restoredToken->getPrefix())->toBe($originalToken->getPrefix());
|
||||
expect($restoredToken->getRawBytes())->toBe($originalToken->getRawBytes());
|
||||
expect($restoredToken->getMetadata())->toBe($originalToken->getMetadata());
|
||||
});
|
||||
|
||||
it('provides safe summary without sensitive data', function () {
|
||||
$token = new SecureToken(
|
||||
value: 'very-secret-token-value',
|
||||
type: SecureTokenGenerator::TYPE_API_KEY,
|
||||
format: SecureTokenGenerator::FORMAT_BASE64_URL,
|
||||
length: 32,
|
||||
prefix: 'ak',
|
||||
rawBytes: 'raw-bytes',
|
||||
metadata: ['purpose' => 'api_auth', 'scope' => 'admin']
|
||||
);
|
||||
|
||||
$summary = $token->getSafeSummary();
|
||||
|
||||
expect($summary)->toHaveKey('type');
|
||||
expect($summary)->toHaveKey('format');
|
||||
expect($summary)->toHaveKey('length');
|
||||
expect($summary)->toHaveKey('prefix');
|
||||
expect($summary)->toHaveKey('has_prefix');
|
||||
expect($summary)->toHaveKey('metadata_keys');
|
||||
expect($summary)->not->toHaveKey('value'); // Should not contain sensitive value
|
||||
expect($summary)->not->toHaveKey('raw_bytes'); // Should not contain raw bytes
|
||||
|
||||
expect($summary['metadata_keys'])->toBe(['purpose', 'scope']);
|
||||
});
|
||||
|
||||
it('generates fingerprint for identification', function () {
|
||||
$token = new SecureToken(
|
||||
value: 'test-token-value',
|
||||
type: SecureTokenGenerator::TYPE_API_KEY,
|
||||
format: SecureTokenGenerator::FORMAT_BASE64_URL,
|
||||
length: 32,
|
||||
prefix: null,
|
||||
rawBytes: 'raw-bytes',
|
||||
metadata: []
|
||||
);
|
||||
|
||||
$fingerprint = $token->getFingerprint();
|
||||
$shortFingerprint = $token->getShortFingerprint();
|
||||
|
||||
expect($fingerprint)->toHaveLength(64); // SHA-256 hex
|
||||
expect($shortFingerprint)->toHaveLength(16);
|
||||
expect($fingerprint)->toStartWith($shortFingerprint);
|
||||
});
|
||||
|
||||
it('masks token value for safe logging', function () {
|
||||
$longToken = new SecureToken(
|
||||
value: 'this-is-a-very-long-token-value-for-testing',
|
||||
type: SecureTokenGenerator::TYPE_API_KEY,
|
||||
format: SecureTokenGenerator::FORMAT_BASE64_URL,
|
||||
length: 32,
|
||||
prefix: null,
|
||||
rawBytes: 'raw-bytes',
|
||||
metadata: []
|
||||
);
|
||||
|
||||
$shortToken = new SecureToken(
|
||||
value: 'short',
|
||||
type: SecureTokenGenerator::TYPE_API_KEY,
|
||||
format: SecureTokenGenerator::FORMAT_BASE64_URL,
|
||||
length: 32,
|
||||
prefix: null,
|
||||
rawBytes: 'raw-bytes',
|
||||
metadata: []
|
||||
);
|
||||
|
||||
$longMasked = $longToken->getMaskedValue();
|
||||
$shortMasked = $shortToken->getMaskedValue();
|
||||
|
||||
expect($longMasked)->toStartWith('this');
|
||||
expect($longMasked)->toEndWith('ting');
|
||||
expect($longMasked)->toContain('*');
|
||||
expect($shortMasked)->toBe('***'); // Short tokens fully masked
|
||||
});
|
||||
|
||||
it('throws exception for missing required fields in fromArray', function () {
|
||||
$incompleteData = [
|
||||
'value' => 'test-token',
|
||||
'type' => SecureTokenGenerator::TYPE_API_KEY,
|
||||
// Missing format, length, raw_bytes
|
||||
];
|
||||
|
||||
expect(fn () => SecureToken::fromArray($incompleteData))
|
||||
->toThrow(InvalidArgumentException::class, 'Missing required field');
|
||||
});
|
||||
|
||||
it('throws exception for invalid base64 raw bytes in fromArray', function () {
|
||||
$invalidData = [
|
||||
'value' => 'test-token',
|
||||
'type' => SecureTokenGenerator::TYPE_API_KEY,
|
||||
'format' => SecureTokenGenerator::FORMAT_BASE64_URL,
|
||||
'length' => 32,
|
||||
'raw_bytes' => 'invalid-base64!@#',
|
||||
];
|
||||
|
||||
expect(fn () => SecureToken::fromArray($invalidData))
|
||||
->toThrow(InvalidArgumentException::class, 'Invalid Base64 raw bytes');
|
||||
});
|
||||
276
tests/Unit/Framework/Cuid/CuidGeneratorTest.php
Normal file
276
tests/Unit/Framework/Cuid/CuidGeneratorTest.php
Normal file
@@ -0,0 +1,276 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Framework\Cuid\Cuid;
|
||||
use App\Framework\Cuid\CuidGenerator;
|
||||
use App\Framework\Random\SecureRandomGenerator;
|
||||
|
||||
it('generates Cuid with current timestamp', function () {
|
||||
$generator = new CuidGenerator(new SecureRandomGenerator());
|
||||
$cuid = $generator->generate();
|
||||
|
||||
expect($cuid)->toBeInstanceOf(Cuid::class);
|
||||
expect($cuid->toString())->toStartWith('c');
|
||||
expect($cuid->toString())->toHaveLength(Cuid::LENGTH);
|
||||
|
||||
$currentTimeMs = intval(microtime(true) * 1000);
|
||||
expect($cuid->getTimestamp())->toBeGreaterThanOrEqual($currentTimeMs - 100);
|
||||
expect($cuid->getTimestamp())->toBeLessThanOrEqual($currentTimeMs + 100);
|
||||
});
|
||||
|
||||
it('generates Cuid at specific timestamp', function () {
|
||||
$generator = new CuidGenerator(new SecureRandomGenerator());
|
||||
$timestamp = 1609459200000; // 2021-01-01 00:00:00.000 UTC
|
||||
|
||||
$cuid = $generator->generateAt($timestamp);
|
||||
|
||||
expect($cuid->getTimestamp())->toBe($timestamp);
|
||||
});
|
||||
|
||||
it('generates Cuid in the past', function () {
|
||||
$generator = new CuidGenerator(new SecureRandomGenerator());
|
||||
$millisecondsAgo = 5000; // 5 seconds ago
|
||||
|
||||
$cuid = $generator->generateInPast($millisecondsAgo);
|
||||
|
||||
$expectedTimestamp = intval(microtime(true) * 1000) - $millisecondsAgo;
|
||||
expect($cuid->getTimestamp())->toBeGreaterThanOrEqual($expectedTimestamp - 100);
|
||||
expect($cuid->getTimestamp())->toBeLessThanOrEqual($expectedTimestamp + 100);
|
||||
});
|
||||
|
||||
it('generates batch of Cuids with incrementing counters', function () {
|
||||
$generator = CuidGenerator::createWithFingerprint(new SecureRandomGenerator(), 'test');
|
||||
$generator->resetCounter(); // Start from 0
|
||||
|
||||
$cuids = $generator->generateBatch(5);
|
||||
|
||||
expect($cuids)->toHaveCount(5);
|
||||
expect($cuids[0])->toBeInstanceOf(Cuid::class);
|
||||
|
||||
// All should have the same timestamp and fingerprint
|
||||
$firstTimestamp = $cuids[0]->getTimestamp();
|
||||
foreach ($cuids as $i => $cuid) {
|
||||
expect($cuid->getTimestamp())->toBe($firstTimestamp);
|
||||
expect($cuid->getFingerprint())->toBe('test');
|
||||
expect($cuid->getCounter())->toBe($i); // Incrementing counter
|
||||
}
|
||||
|
||||
// All should be unique
|
||||
$values = array_map(fn ($cuid) => $cuid->toString(), $cuids);
|
||||
$uniqueValues = array_unique($values);
|
||||
expect($uniqueValues)->toHaveCount(5);
|
||||
});
|
||||
|
||||
it('generates sequence of Cuids with incrementing timestamps', function () {
|
||||
$generator = new CuidGenerator(new SecureRandomGenerator());
|
||||
$count = 3;
|
||||
$interval = 1000; // 1 second intervals
|
||||
|
||||
$cuids = $generator->generateSequence($count, $interval);
|
||||
|
||||
expect($cuids)->toHaveCount($count);
|
||||
|
||||
// Check timestamps are incrementing
|
||||
for ($i = 1; $i < $count; $i++) {
|
||||
$timeDiff = $cuids[$i]->getTimestamp() - $cuids[$i - 1]->getTimestamp();
|
||||
expect($timeDiff)->toBe($interval);
|
||||
}
|
||||
});
|
||||
|
||||
it('generates Cuid with specific counter', function () {
|
||||
$generator = new CuidGenerator(new SecureRandomGenerator());
|
||||
$counter = 1234;
|
||||
|
||||
$cuid = $generator->generateWithCounter($counter);
|
||||
|
||||
expect($cuid->getCounter())->toBe($counter);
|
||||
});
|
||||
|
||||
it('increments counter correctly', function () {
|
||||
$generator = CuidGenerator::createWithFingerprint(new SecureRandomGenerator(), 'test');
|
||||
$generator->resetCounter();
|
||||
|
||||
$cuid1 = $generator->generate();
|
||||
$cuid2 = $generator->generate();
|
||||
$cuid3 = $generator->generate();
|
||||
|
||||
expect($cuid1->getCounter())->toBe(0);
|
||||
expect($cuid2->getCounter())->toBe(1);
|
||||
expect($cuid3->getCounter())->toBe(2);
|
||||
});
|
||||
|
||||
it('counter increments and resets correctly', function () {
|
||||
$generator = CuidGenerator::createWithFingerprint(new SecureRandomGenerator(), 'test');
|
||||
$generator->resetCounter();
|
||||
|
||||
// Generate a few Cuids to test counter increment
|
||||
$cuid1 = $generator->generate();
|
||||
$cuid2 = $generator->generate();
|
||||
$cuid3 = $generator->generate();
|
||||
|
||||
expect($cuid1->getCounter())->toBe(0);
|
||||
expect($cuid2->getCounter())->toBe(1);
|
||||
expect($cuid3->getCounter())->toBe(2);
|
||||
expect($generator->getCurrentCounter())->toBe(3);
|
||||
|
||||
// Test reset
|
||||
$generator->resetCounter();
|
||||
expect($generator->getCurrentCounter())->toBe(0);
|
||||
});
|
||||
|
||||
it('validates Cuid strings', function () {
|
||||
$generator = new CuidGenerator(new SecureRandomGenerator());
|
||||
|
||||
$validCuid = $generator->generate();
|
||||
expect($generator->isValid($validCuid->toString()))->toBeTrue();
|
||||
|
||||
expect($generator->isValid('invalid'))->toBeFalse();
|
||||
expect($generator->isValid(''))->toBeFalse();
|
||||
expect($generator->isValid('x' . str_repeat('a', 24)))->toBeFalse(); // Wrong prefix
|
||||
expect($generator->isValid('c' . str_repeat('!', 24)))->toBeFalse(); // Invalid characters
|
||||
});
|
||||
|
||||
it('parses Cuid strings', function () {
|
||||
$generator = new CuidGenerator(new SecureRandomGenerator());
|
||||
$original = $generator->generate();
|
||||
|
||||
$parsed = $generator->parse($original->toString());
|
||||
|
||||
expect($parsed->equals($original))->toBeTrue();
|
||||
});
|
||||
|
||||
it('generates consistent fingerprints', function () {
|
||||
$generator = new CuidGenerator(new SecureRandomGenerator());
|
||||
|
||||
$cuid1 = $generator->generate();
|
||||
$cuid2 = $generator->generate();
|
||||
|
||||
// Same generator should produce same fingerprint
|
||||
expect($cuid1->getFingerprint())->toBe($cuid2->getFingerprint());
|
||||
expect($generator->isSameGenerator($cuid1))->toBeTrue();
|
||||
expect($generator->isSameGenerator($cuid2))->toBeTrue();
|
||||
});
|
||||
|
||||
it('uses custom fingerprint', function () {
|
||||
$customFingerprint = 'abcd';
|
||||
$generator = CuidGenerator::createWithFingerprint(new SecureRandomGenerator(), $customFingerprint);
|
||||
|
||||
expect($generator->getFingerprint())->toBe($customFingerprint);
|
||||
|
||||
$cuid = $generator->generate();
|
||||
expect($cuid->getFingerprint())->toBe($customFingerprint);
|
||||
});
|
||||
|
||||
it('generates different fingerprints for different generators', function () {
|
||||
$generator1 = CuidGenerator::createWithFingerprint(new SecureRandomGenerator(), 'aaaa');
|
||||
$generator2 = CuidGenerator::createWithFingerprint(new SecureRandomGenerator(), 'bbbb');
|
||||
|
||||
expect($generator1->getFingerprint())->not->toBe($generator2->getFingerprint());
|
||||
|
||||
$cuid1 = $generator1->generate();
|
||||
$cuid2 = $generator2->generate();
|
||||
|
||||
expect($generator1->isSameGenerator($cuid2))->toBeFalse();
|
||||
expect($generator2->isSameGenerator($cuid1))->toBeFalse();
|
||||
});
|
||||
|
||||
it('resets counter correctly', function () {
|
||||
$generator = new CuidGenerator(new SecureRandomGenerator());
|
||||
|
||||
// Generate a few to increment counter
|
||||
$generator->generate();
|
||||
$generator->generate();
|
||||
expect($generator->getCurrentCounter())->toBeGreaterThan(0);
|
||||
|
||||
// Reset and check
|
||||
$generator->resetCounter();
|
||||
expect($generator->getCurrentCounter())->toBe(0);
|
||||
});
|
||||
|
||||
it('throws exception for invalid batch count', function () {
|
||||
$generator = new CuidGenerator(new SecureRandomGenerator());
|
||||
|
||||
expect(fn () => $generator->generateBatch(0))
|
||||
->toThrow(InvalidArgumentException::class, 'Count must be positive');
|
||||
|
||||
expect(fn () => $generator->generateBatch(10001))
|
||||
->toThrow(InvalidArgumentException::class, 'Batch size cannot exceed 10000');
|
||||
});
|
||||
|
||||
it('throws exception for invalid sequence count', function () {
|
||||
$generator = new CuidGenerator(new SecureRandomGenerator());
|
||||
|
||||
expect(fn () => $generator->generateSequence(0))
|
||||
->toThrow(InvalidArgumentException::class, 'Count must be positive');
|
||||
|
||||
expect(fn () => $generator->generateSequence(1001))
|
||||
->toThrow(InvalidArgumentException::class, 'Sequence size cannot exceed 1000');
|
||||
});
|
||||
|
||||
it('throws exception for negative interval', function () {
|
||||
$generator = new CuidGenerator(new SecureRandomGenerator());
|
||||
|
||||
expect(fn () => $generator->generateSequence(3, -1))
|
||||
->toThrow(InvalidArgumentException::class, 'Interval must be non-negative');
|
||||
});
|
||||
|
||||
it('throws exception for invalid counter value', function () {
|
||||
$generator = new CuidGenerator(new SecureRandomGenerator());
|
||||
$maxCounter = 36 ** Cuid::COUNTER_LENGTH;
|
||||
|
||||
expect(fn () => $generator->generateWithCounter(-1))
|
||||
->toThrow(InvalidArgumentException::class, 'Counter must be between 0 and');
|
||||
|
||||
expect(fn () => $generator->generateWithCounter($maxCounter))
|
||||
->toThrow(InvalidArgumentException::class, 'Counter must be between 0 and');
|
||||
});
|
||||
|
||||
it('throws exception for invalid fingerprint length', function () {
|
||||
expect(fn () => CuidGenerator::createWithFingerprint(new SecureRandomGenerator(), 'abc'))
|
||||
->toThrow(InvalidArgumentException::class, 'Fingerprint must be exactly 4 characters');
|
||||
|
||||
expect(fn () => CuidGenerator::createWithFingerprint(new SecureRandomGenerator(), 'abcde'))
|
||||
->toThrow(InvalidArgumentException::class, 'Fingerprint must be exactly 4 characters');
|
||||
});
|
||||
|
||||
it('creates generator using factory method', function () {
|
||||
$randomGen = new SecureRandomGenerator();
|
||||
$generator = CuidGenerator::create($randomGen);
|
||||
|
||||
expect($generator)->toBeInstanceOf(CuidGenerator::class);
|
||||
expect($generator->generate())->toBeInstanceOf(Cuid::class);
|
||||
});
|
||||
|
||||
it('generates sortable Cuids by timestamp', function () {
|
||||
$generator = new CuidGenerator(new SecureRandomGenerator());
|
||||
|
||||
$older = $generator->generateInPast(5000);
|
||||
$newer = $generator->generate();
|
||||
|
||||
// Lexicographic comparison should match timestamp order
|
||||
expect($older->compare($newer))->toBeLessThan(0);
|
||||
expect($older->toString() < $newer->toString())->toBeTrue();
|
||||
});
|
||||
|
||||
it('generates unique Cuids across multiple calls', function () {
|
||||
$generator = new CuidGenerator(new SecureRandomGenerator());
|
||||
$cuids = [];
|
||||
$count = 1000;
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$cuids[] = $generator->generate()->toString();
|
||||
}
|
||||
|
||||
$uniqueCuids = array_unique($cuids);
|
||||
expect($uniqueCuids)->toHaveCount($count);
|
||||
});
|
||||
|
||||
it('generates only lowercase characters', function () {
|
||||
$generator = new CuidGenerator(new SecureRandomGenerator());
|
||||
$cuid = $generator->generate();
|
||||
|
||||
$cuidString = $cuid->toString();
|
||||
expect($cuidString)->toBe(strtolower($cuidString)); // Should already be lowercase
|
||||
expect($cuidString)->toMatch('/^[c0-9a-z]+$/'); // Only valid Base36 chars
|
||||
});
|
||||
217
tests/Unit/Framework/Cuid/CuidTest.php
Normal file
217
tests/Unit/Framework/Cuid/CuidTest.php
Normal file
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Framework\Cuid\Cuid;
|
||||
|
||||
it('creates Cuid from string', function () {
|
||||
$value = 'cjld2cjxh0000qzrmn831i7rn';
|
||||
$cuid = Cuid::fromString($value);
|
||||
|
||||
expect($cuid->toString())->toBe($value);
|
||||
expect($cuid->getValue())->toBe($value);
|
||||
});
|
||||
|
||||
it('creates Cuid from components', function () {
|
||||
$timestamp = 1609459200000; // 2021-01-01 00:00:00.000 UTC
|
||||
$counter = 1234;
|
||||
$fingerprint = 'abcd';
|
||||
$random = '12345678';
|
||||
|
||||
$cuid = Cuid::fromComponents($timestamp, $counter, $fingerprint, $random);
|
||||
|
||||
expect($cuid->getTimestamp())->toBe($timestamp);
|
||||
expect($cuid->getCounter())->toBe($counter);
|
||||
expect($cuid->getFingerprint())->toBe($fingerprint);
|
||||
expect($cuid->getRandom())->toBe($random);
|
||||
expect($cuid->toString())->toStartWith('c');
|
||||
});
|
||||
|
||||
it('validates Cuid length', function () {
|
||||
expect(fn () => Cuid::fromString('c' . str_repeat('a', 30)))
|
||||
->toThrow(InvalidArgumentException::class, 'Cuid must be exactly 25 characters long');
|
||||
|
||||
expect(fn () => Cuid::fromString('ctooshort'))
|
||||
->toThrow(InvalidArgumentException::class, 'Cuid must be exactly 25 characters long');
|
||||
});
|
||||
|
||||
it('validates Cuid prefix', function () {
|
||||
expect(fn () => Cuid::fromString('x' . str_repeat('a', 24)))
|
||||
->toThrow(InvalidArgumentException::class, 'Cuid must start with "c"');
|
||||
});
|
||||
|
||||
it('validates Cuid characters', function () {
|
||||
expect(fn () => Cuid::fromString('c' . str_repeat('!', 24)))
|
||||
->toThrow(InvalidArgumentException::class, 'Cuid contains invalid characters');
|
||||
});
|
||||
|
||||
it('validates component lengths', function () {
|
||||
$timestamp = 1609459200000;
|
||||
$counter = 1234;
|
||||
|
||||
expect(fn () => Cuid::fromComponents($timestamp, $counter, 'abc', '12345678'))
|
||||
->toThrow(InvalidArgumentException::class, 'Fingerprint must be exactly 4 characters');
|
||||
|
||||
expect(fn () => Cuid::fromComponents($timestamp, $counter, 'abcd', '1234567'))
|
||||
->toThrow(InvalidArgumentException::class, 'Random part must be exactly 8 characters');
|
||||
});
|
||||
|
||||
it('parses timestamp correctly', function () {
|
||||
$expectedTimestamp = 1609459200000; // 2021-01-01 00:00:00.000 UTC
|
||||
$cuid = Cuid::fromComponents($expectedTimestamp, 0, 'abcd', '12345678');
|
||||
|
||||
expect($cuid->getTimestamp())->toBe($expectedTimestamp);
|
||||
});
|
||||
|
||||
it('gets DateTime from timestamp', function () {
|
||||
$timestamp = 1609459200500; // 2021-01-01 00:00:00.500 UTC
|
||||
$cuid = Cuid::fromComponents($timestamp, 0, 'abcd', '12345678');
|
||||
|
||||
$dateTime = $cuid->getDateTime();
|
||||
|
||||
expect($dateTime->getTimestamp())->toBe(1609459200); // Seconds part
|
||||
expect($dateTime->format('u'))->toBe('500000'); // Microseconds part
|
||||
});
|
||||
|
||||
it('checks equality between Cuids', function () {
|
||||
$value = 'cjld2cjxh0000qzrmn831i7rn';
|
||||
$cuid1 = Cuid::fromString($value);
|
||||
$cuid2 = Cuid::fromString($value);
|
||||
$cuid3 = Cuid::fromString('cjld2cjxh0000qzrmn831i7ro');
|
||||
|
||||
expect($cuid1->equals($cuid2))->toBeTrue();
|
||||
expect($cuid1->equals($cuid3))->toBeFalse();
|
||||
});
|
||||
|
||||
it('compares Cuids for sorting', function () {
|
||||
$value1 = 'cjld2cjxh0000qzrmn831i7rn';
|
||||
$value2 = 'cjld2cjxh0000qzrmn831i7ro'; // Lexicographically greater
|
||||
|
||||
$cuid1 = Cuid::fromString($value1);
|
||||
$cuid2 = Cuid::fromString($value2);
|
||||
|
||||
expect($cuid1->compare($cuid2))->toBeLessThan(0);
|
||||
expect($cuid2->compare($cuid1))->toBeGreaterThan(0);
|
||||
expect($cuid1->compare($cuid1))->toBe(0);
|
||||
});
|
||||
|
||||
it('checks age comparisons', function () {
|
||||
$olderTimestamp = 1609459200000; // 2021-01-01 00:00:00.000 UTC
|
||||
$newerTimestamp = 1609459201000; // 1 second later
|
||||
|
||||
$olderCuid = Cuid::fromComponents($olderTimestamp, 0, 'abcd', '12345678');
|
||||
$newerCuid = Cuid::fromComponents($newerTimestamp, 0, 'abcd', '87654321');
|
||||
|
||||
expect($olderCuid->isOlderThan($newerCuid))->toBeTrue();
|
||||
expect($newerCuid->isNewerThan($olderCuid))->toBeTrue();
|
||||
expect($olderCuid->isNewerThan($newerCuid))->toBeFalse();
|
||||
expect($newerCuid->isOlderThan($olderCuid))->toBeFalse();
|
||||
});
|
||||
|
||||
it('calculates age in milliseconds', function () {
|
||||
$timestamp = intval(microtime(true) * 1000) - 5000; // 5 seconds ago
|
||||
$cuid = Cuid::fromComponents($timestamp, 0, 'abcd', '12345678');
|
||||
|
||||
$ageMs = $cuid->getAgeInMilliseconds();
|
||||
|
||||
expect($ageMs)->toBeGreaterThanOrEqual(4900); // Allow some tolerance
|
||||
expect($ageMs)->toBeLessThanOrEqual(5100);
|
||||
});
|
||||
|
||||
it('calculates age in seconds', function () {
|
||||
$timestamp = intval(microtime(true) * 1000) - 3000; // 3 seconds ago
|
||||
$cuid = Cuid::fromComponents($timestamp, 0, 'abcd', '12345678');
|
||||
|
||||
$ageSeconds = $cuid->getAgeInSeconds();
|
||||
|
||||
expect($ageSeconds)->toBeGreaterThanOrEqual(2.9);
|
||||
expect($ageSeconds)->toBeLessThanOrEqual(3.1);
|
||||
});
|
||||
|
||||
it('checks same process', function () {
|
||||
$cuid1 = Cuid::fromComponents(1609459200000, 0, 'abcd', '12345678');
|
||||
$cuid2 = Cuid::fromComponents(1609459201000, 1, 'abcd', '87654321'); // Same fingerprint
|
||||
$cuid3 = Cuid::fromComponents(1609459202000, 2, 'efgh', '11223344'); // Different fingerprint
|
||||
|
||||
expect($cuid1->isSameProcess($cuid2))->toBeTrue();
|
||||
expect($cuid1->isSameProcess($cuid3))->toBeFalse();
|
||||
});
|
||||
|
||||
it('converts to string using magic method', function () {
|
||||
$value = 'cjld2cjxh0000qzrmn831i7rn';
|
||||
$cuid = Cuid::fromString($value);
|
||||
|
||||
expect((string)$cuid)->toBe($value);
|
||||
});
|
||||
|
||||
it('parses all components correctly', function () {
|
||||
// Create a known Cuid and verify all components are parsed correctly
|
||||
$timestamp = 1609459200000;
|
||||
$counter = 1234;
|
||||
$fingerprint = 'test';
|
||||
$random = 'abcd1234';
|
||||
|
||||
$cuid = Cuid::fromComponents($timestamp, $counter, $fingerprint, $random);
|
||||
|
||||
// Parse it back
|
||||
$parsed = Cuid::fromString($cuid->toString());
|
||||
|
||||
expect($parsed->getTimestamp())->toBe($timestamp);
|
||||
expect($parsed->getCounter())->toBe($counter);
|
||||
expect($parsed->getFingerprint())->toBe($fingerprint);
|
||||
expect($parsed->getRandom())->toBe($random);
|
||||
});
|
||||
|
||||
it('handles Base36 conversion correctly', function () {
|
||||
// Test with various numbers to ensure Base36 conversion works
|
||||
$testCases = [
|
||||
['timestamp' => 1609459200000, 'counter' => 0],
|
||||
['timestamp' => 1609459200000, 'counter' => 35], // Max single digit in Base36
|
||||
['timestamp' => 1609459200000, 'counter' => 36], // Two digits in Base36
|
||||
['timestamp' => 1609459200000, 'counter' => 1295], // Multiple digits
|
||||
];
|
||||
|
||||
foreach ($testCases as $case) {
|
||||
$cuid = Cuid::fromComponents(
|
||||
$case['timestamp'],
|
||||
$case['counter'],
|
||||
'test',
|
||||
'abcd1234'
|
||||
);
|
||||
|
||||
$parsed = Cuid::fromString($cuid->toString());
|
||||
|
||||
expect($parsed->getTimestamp())->toBe($case['timestamp']);
|
||||
expect($parsed->getCounter())->toBe($case['counter']);
|
||||
}
|
||||
});
|
||||
|
||||
it('throws exception for empty Cuid', function () {
|
||||
expect(fn () => Cuid::fromString(''))
|
||||
->toThrow(InvalidArgumentException::class, 'Cuid cannot be empty');
|
||||
});
|
||||
|
||||
it('maintains lexicographic ordering by timestamp', function () {
|
||||
$timestamp1 = 1609459200000;
|
||||
$timestamp2 = 1609459201000; // 1 second later
|
||||
|
||||
$cuid1 = Cuid::fromComponents($timestamp1, 0, 'abcd', '12345678');
|
||||
$cuid2 = Cuid::fromComponents($timestamp2, 0, 'abcd', '12345678');
|
||||
|
||||
// String comparison should match timestamp order
|
||||
expect($cuid1->toString() < $cuid2->toString())->toBeTrue();
|
||||
});
|
||||
|
||||
it('handles maximum timestamp values', function () {
|
||||
$maxTimestamp = (36 ** 8) - 1; // Max value for 8 Base36 digits
|
||||
$cuid = Cuid::fromComponents($maxTimestamp, 0, 'abcd', '12345678');
|
||||
|
||||
expect($cuid->getTimestamp())->toBe($maxTimestamp);
|
||||
});
|
||||
|
||||
it('handles maximum counter values', function () {
|
||||
$maxCounter = (36 ** 4) - 1; // Max value for 4 Base36 digits
|
||||
$cuid = Cuid::fromComponents(1609459200000, $maxCounter, 'abcd', '12345678');
|
||||
|
||||
expect($cuid->getCounter())->toBe($maxCounter);
|
||||
});
|
||||
162
tests/Unit/Framework/DI/ContainerTest.php
Normal file
162
tests/Unit/Framework/DI/ContainerTest.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Framework\DI;
|
||||
|
||||
use App\Framework\DI\DefaultContainer;
|
||||
|
||||
class TestService
|
||||
{
|
||||
public function __construct(public string $message = 'Hello World')
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
class DependentService
|
||||
{
|
||||
public function __construct(public TestService $service)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
interface TestInterface
|
||||
{
|
||||
public function getMessage(): string;
|
||||
}
|
||||
|
||||
class TestImplementation implements TestInterface
|
||||
{
|
||||
public function getMessage(): string
|
||||
{
|
||||
return 'Implementation';
|
||||
}
|
||||
}
|
||||
|
||||
test('container can create simple class without dependencies', function () {
|
||||
$container = new DefaultContainer();
|
||||
|
||||
$service = $container->get(TestService::class);
|
||||
|
||||
expect($service)->toBeInstanceOf(TestService::class);
|
||||
expect($service->message)->toBe('Hello World');
|
||||
});
|
||||
|
||||
test('container resolves dependencies automatically', function () {
|
||||
$container = new DefaultContainer();
|
||||
|
||||
$service = $container->get(DependentService::class);
|
||||
|
||||
expect($service)->toBeInstanceOf(DependentService::class);
|
||||
expect($service->service)->toBeInstanceOf(TestService::class);
|
||||
expect($service->service->message)->toBe('Hello World');
|
||||
});
|
||||
|
||||
test('container can bind interfaces to implementations', function () {
|
||||
$container = new DefaultContainer();
|
||||
|
||||
$container->bind(TestInterface::class, TestImplementation::class);
|
||||
|
||||
$service = $container->get(TestInterface::class);
|
||||
|
||||
expect($service)->toBeInstanceOf(TestImplementation::class);
|
||||
expect($service->getMessage())->toBe('Implementation');
|
||||
});
|
||||
|
||||
test('container can bind with closures', function () {
|
||||
$container = new DefaultContainer();
|
||||
|
||||
$container->bind(TestService::class, function () {
|
||||
return new TestService('Custom Message');
|
||||
});
|
||||
|
||||
$service = $container->get(TestService::class);
|
||||
|
||||
expect($service->message)->toBe('Custom Message');
|
||||
});
|
||||
|
||||
test('container can register singletons', function () {
|
||||
$container = new DefaultContainer();
|
||||
|
||||
$container->singleton(TestService::class, TestService::class);
|
||||
|
||||
$service1 = $container->get(TestService::class);
|
||||
$service2 = $container->get(TestService::class);
|
||||
|
||||
expect($service1)->toBe($service2); // Same instance
|
||||
});
|
||||
|
||||
test('container can store instances directly', function () {
|
||||
$container = new DefaultContainer();
|
||||
|
||||
$instance = new TestService('Direct Instance');
|
||||
$container->instance(TestService::class, $instance);
|
||||
|
||||
$retrieved = $container->get(TestService::class);
|
||||
|
||||
expect($retrieved)->toBe($instance);
|
||||
expect($retrieved->message)->toBe('Direct Instance');
|
||||
});
|
||||
|
||||
test('container has method works correctly', function () {
|
||||
$container = new DefaultContainer();
|
||||
|
||||
expect($container->has(TestService::class))->toBeTrue(); // Can be auto-wired
|
||||
expect($container->has('NonExistentClass'))->toBeFalse();
|
||||
|
||||
$container->bind('bound-service', TestService::class);
|
||||
expect($container->has('bound-service'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('container forget removes bindings', function () {
|
||||
$container = new DefaultContainer();
|
||||
|
||||
$container->bind('test-binding', TestService::class);
|
||||
expect($container->has('test-binding'))->toBeTrue();
|
||||
|
||||
$container->forget('test-binding');
|
||||
expect($container->has('test-binding'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('container can get service ids', function () {
|
||||
$container = new DefaultContainer();
|
||||
|
||||
$container->bind('service-1', TestService::class);
|
||||
$container->instance('service-2', new TestService());
|
||||
|
||||
$serviceIds = $container->getServiceIds();
|
||||
|
||||
expect($serviceIds)->toContain('service-1');
|
||||
expect($serviceIds)->toContain('service-2');
|
||||
expect($serviceIds)->toContain(DefaultContainer::class); // Self-registered
|
||||
});
|
||||
|
||||
test('container can flush all bindings', function () {
|
||||
$container = new DefaultContainer();
|
||||
|
||||
$container->bind('test-1', TestService::class);
|
||||
$container->instance('test-2', new TestService());
|
||||
|
||||
$container->flush();
|
||||
|
||||
// Should still contain self-registration
|
||||
$serviceIds = $container->getServiceIds();
|
||||
expect($serviceIds)->toContain(DefaultContainer::class);
|
||||
expect($serviceIds)->not->toContain('test-1');
|
||||
expect($serviceIds)->not->toContain('test-2');
|
||||
});
|
||||
|
||||
test('container method invoker works', function () {
|
||||
$container = new DefaultContainer();
|
||||
|
||||
$service = new class () {
|
||||
public function method(TestService $service): string
|
||||
{
|
||||
return $service->message;
|
||||
}
|
||||
};
|
||||
|
||||
$result = $container->invoker->call($service, 'method');
|
||||
|
||||
expect($result)->toBe('Hello World');
|
||||
});
|
||||
201
tests/Unit/Framework/Http/MiddlewarePipelineTest.php
Normal file
201
tests/Unit/Framework/Http/MiddlewarePipelineTest.php
Normal file
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Framework\Http;
|
||||
|
||||
use App\Framework\Http\HttpMiddleware;
|
||||
use App\Framework\Http\HttpResponse;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Http\MiddlewareContext;
|
||||
use App\Framework\Http\MiddlewareManager;
|
||||
use App\Framework\Http\Next;
|
||||
use App\Framework\Http\Request;
|
||||
use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\Status;
|
||||
|
||||
test('middleware pipeline executes in order', function () {
|
||||
$executionOrder = [];
|
||||
|
||||
$middleware1 = new class ($executionOrder) implements HttpMiddleware {
|
||||
public function __construct(private array &$order)
|
||||
{
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
$this->order[] = 'before-1';
|
||||
$context = $next($context);
|
||||
$this->order[] = 'after-1';
|
||||
|
||||
return $context;
|
||||
}
|
||||
};
|
||||
|
||||
$middleware2 = new class ($executionOrder) implements HttpMiddleware {
|
||||
public function __construct(private array &$order)
|
||||
{
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
$this->order[] = 'before-2';
|
||||
$context = $next($context);
|
||||
$this->order[] = 'after-2';
|
||||
|
||||
return $context;
|
||||
}
|
||||
};
|
||||
|
||||
$manager = new MiddlewareManager();
|
||||
$manager->addMiddleware($middleware1, 100);
|
||||
$manager->addMiddleware($middleware2, 50);
|
||||
|
||||
$request = new Request(Method::GET, '/test', [], '', []);
|
||||
$stateManager = new RequestStateManager();
|
||||
|
||||
$finalHandler = function (MiddlewareContext $context) use (&$executionOrder) {
|
||||
$executionOrder[] = 'handler';
|
||||
|
||||
return $context->withResponse(new HttpResponse(Status::OK));
|
||||
};
|
||||
|
||||
$context = $manager->process(new MiddlewareContext($request), $finalHandler, $stateManager);
|
||||
|
||||
expect($executionOrder)->toBe([
|
||||
'before-1',
|
||||
'before-2',
|
||||
'handler',
|
||||
'after-2',
|
||||
'after-1',
|
||||
]);
|
||||
});
|
||||
|
||||
test('middleware can short-circuit pipeline', function () {
|
||||
$executed = [];
|
||||
|
||||
$middleware1 = new class ($executed) implements HttpMiddleware {
|
||||
public function __construct(private array &$executed)
|
||||
{
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
$this->executed[] = 'middleware-1';
|
||||
|
||||
// Short-circuit by not calling next
|
||||
return $context->withResponse(new HttpResponse(Status::FORBIDDEN));
|
||||
}
|
||||
};
|
||||
|
||||
$middleware2 = new class ($executed) implements HttpMiddleware {
|
||||
public function __construct(private array &$executed)
|
||||
{
|
||||
}
|
||||
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
$this->executed[] = 'middleware-2';
|
||||
|
||||
return $next($context);
|
||||
}
|
||||
};
|
||||
|
||||
$manager = new MiddlewareManager();
|
||||
$manager->addMiddleware($middleware1, 100);
|
||||
$manager->addMiddleware($middleware2, 50);
|
||||
|
||||
$request = new Request(Method::GET, '/test', [], '', []);
|
||||
$stateManager = new RequestStateManager();
|
||||
|
||||
$finalHandler = function (MiddlewareContext $context) use (&$executed) {
|
||||
$executed[] = 'handler';
|
||||
|
||||
return $context->withResponse(new HttpResponse(Status::OK));
|
||||
};
|
||||
|
||||
$context = $manager->process(new MiddlewareContext($request), $finalHandler, $stateManager);
|
||||
|
||||
expect($executed)->toBe(['middleware-1']);
|
||||
expect($context->response?->status)->toBe(Status::FORBIDDEN);
|
||||
});
|
||||
|
||||
test('middleware can modify request', function () {
|
||||
$modifyMiddleware = new class () implements HttpMiddleware {
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
$modifiedHeaders = $context->request->headers;
|
||||
$modifiedHeaders['X-Custom-Header'] = 'test-value';
|
||||
|
||||
$modifiedRequest = new Request(
|
||||
$context->request->method,
|
||||
$context->request->path,
|
||||
$modifiedHeaders,
|
||||
$context->request->body,
|
||||
$context->request->server
|
||||
);
|
||||
|
||||
return $next($context->withRequest($modifiedRequest));
|
||||
}
|
||||
};
|
||||
|
||||
$manager = new MiddlewareManager();
|
||||
$manager->addMiddleware($modifyMiddleware);
|
||||
|
||||
$request = new Request(Method::GET, '/test', [], '', []);
|
||||
$stateManager = new RequestStateManager();
|
||||
|
||||
$receivedRequest = null;
|
||||
$finalHandler = function (MiddlewareContext $context) use (&$receivedRequest) {
|
||||
$receivedRequest = $context->request;
|
||||
|
||||
return $context->withResponse(new HttpResponse(Status::OK));
|
||||
};
|
||||
|
||||
$manager->process(new MiddlewareContext($request), $finalHandler, $stateManager);
|
||||
|
||||
expect($receivedRequest?->headers['X-Custom-Header'] ?? null)->toBe('test-value');
|
||||
});
|
||||
|
||||
test('middleware state management works', function () {
|
||||
$stateManager = new RequestStateManager();
|
||||
|
||||
$middleware1 = new class () implements HttpMiddleware {
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
$stateManager->set('key1', 'value1');
|
||||
$stateManager->set('shared', 'from-middleware-1');
|
||||
|
||||
return $next($context);
|
||||
}
|
||||
};
|
||||
|
||||
$middleware2 = new class () implements HttpMiddleware {
|
||||
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
|
||||
{
|
||||
expect($stateManager->get('key1'))->toBe('value1');
|
||||
expect($stateManager->get('shared'))->toBe('from-middleware-1');
|
||||
|
||||
$stateManager->set('key2', 'value2');
|
||||
$stateManager->set('shared', 'from-middleware-2');
|
||||
|
||||
return $next($context);
|
||||
}
|
||||
};
|
||||
|
||||
$manager = new MiddlewareManager();
|
||||
$manager->addMiddleware($middleware1);
|
||||
$manager->addMiddleware($middleware2);
|
||||
|
||||
$request = new Request(Method::GET, '/test', [], '', []);
|
||||
|
||||
$finalHandler = function (MiddlewareContext $context) use ($stateManager) {
|
||||
expect($stateManager->get('key1'))->toBe('value1');
|
||||
expect($stateManager->get('key2'))->toBe('value2');
|
||||
expect($stateManager->get('shared'))->toBe('from-middleware-2');
|
||||
|
||||
return $context->withResponse(new HttpResponse(Status::OK));
|
||||
};
|
||||
|
||||
$manager->process(new MiddlewareContext($request), $finalHandler, $stateManager);
|
||||
});
|
||||
241
tests/Unit/Framework/Ksuid/KsuidGeneratorTest.php
Normal file
241
tests/Unit/Framework/Ksuid/KsuidGeneratorTest.php
Normal file
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Framework\Ksuid\Ksuid;
|
||||
use App\Framework\Ksuid\KsuidGenerator;
|
||||
use App\Framework\Random\SecureRandomGenerator;
|
||||
|
||||
it('generates KSUID with current timestamp', function () {
|
||||
$generator = new KsuidGenerator(new SecureRandomGenerator());
|
||||
$ksuid = $generator->generate();
|
||||
|
||||
expect($ksuid)->toBeInstanceOf(Ksuid::class);
|
||||
expect($ksuid->getTimestamp())->toBeGreaterThanOrEqual(time() - 1);
|
||||
expect($ksuid->getTimestamp())->toBeLessThanOrEqual(time() + 1);
|
||||
});
|
||||
|
||||
it('generates KSUID at specific timestamp', function () {
|
||||
$generator = new KsuidGenerator(new SecureRandomGenerator());
|
||||
$timestamp = time() - 3600; // 1 hour ago
|
||||
|
||||
$ksuid = $generator->generateAt($timestamp);
|
||||
|
||||
expect($ksuid->getTimestamp())->toBe($timestamp);
|
||||
});
|
||||
|
||||
it('generates KSUID at specific DateTime', function () {
|
||||
$generator = new KsuidGenerator(new SecureRandomGenerator());
|
||||
$dateTime = new DateTimeImmutable('2021-01-01 12:00:00 UTC');
|
||||
|
||||
$ksuid = $generator->generateAtDateTime($dateTime);
|
||||
|
||||
expect($ksuid->getTimestamp())->toBe($dateTime->getTimestamp());
|
||||
});
|
||||
|
||||
it('generates KSUID in the past', function () {
|
||||
$generator = new KsuidGenerator(new SecureRandomGenerator());
|
||||
$secondsAgo = 3600; // 1 hour ago
|
||||
|
||||
$ksuid = $generator->generateInPast($secondsAgo);
|
||||
$expectedTimestamp = time() - $secondsAgo;
|
||||
|
||||
expect($ksuid->getTimestamp())->toBeGreaterThanOrEqual($expectedTimestamp - 1);
|
||||
expect($ksuid->getTimestamp())->toBeLessThanOrEqual($expectedTimestamp + 1);
|
||||
});
|
||||
|
||||
it('generates batch of KSUIDs', function () {
|
||||
$generator = new KsuidGenerator(new SecureRandomGenerator());
|
||||
$count = 10;
|
||||
|
||||
$ksuids = $generator->generateBatch($count);
|
||||
|
||||
expect($ksuids)->toHaveCount($count);
|
||||
expect($ksuids[0])->toBeInstanceOf(Ksuid::class);
|
||||
|
||||
// All should have the same timestamp
|
||||
$firstTimestamp = $ksuids[0]->getTimestamp();
|
||||
foreach ($ksuids as $ksuid) {
|
||||
expect($ksuid->getTimestamp())->toBe($firstTimestamp);
|
||||
}
|
||||
|
||||
// All should be unique
|
||||
$values = array_map(fn ($ksuid) => $ksuid->toString(), $ksuids);
|
||||
$uniqueValues = array_unique($values);
|
||||
expect($uniqueValues)->toHaveCount($count);
|
||||
});
|
||||
|
||||
it('generates batch with custom timestamp', function () {
|
||||
$generator = new KsuidGenerator(new SecureRandomGenerator());
|
||||
$timestamp = time() - 7200; // 2 hours ago
|
||||
$count = 5;
|
||||
|
||||
$ksuids = $generator->generateBatch($count, $timestamp);
|
||||
|
||||
expect($ksuids)->toHaveCount($count);
|
||||
foreach ($ksuids as $ksuid) {
|
||||
expect($ksuid->getTimestamp())->toBe($timestamp);
|
||||
}
|
||||
});
|
||||
|
||||
it('generates sequence of KSUIDs', function () {
|
||||
$generator = new KsuidGenerator(new SecureRandomGenerator());
|
||||
$count = 5;
|
||||
$interval = 10; // 10 seconds apart
|
||||
|
||||
$ksuids = $generator->generateSequence($count, $interval);
|
||||
|
||||
expect($ksuids)->toHaveCount($count);
|
||||
|
||||
// Check timestamps are incrementing
|
||||
for ($i = 1; $i < $count; $i++) {
|
||||
$timeDiff = $ksuids[$i]->getTimestamp() - $ksuids[$i - 1]->getTimestamp();
|
||||
expect($timeDiff)->toBe($interval);
|
||||
}
|
||||
});
|
||||
|
||||
it('generates KSUID with prefix', function () {
|
||||
$generator = new KsuidGenerator(new SecureRandomGenerator());
|
||||
$prefix = 'test';
|
||||
|
||||
$ksuid = $generator->generateWithPrefix($prefix);
|
||||
$payload = $ksuid->getPayload();
|
||||
|
||||
expect(substr($payload, 0, strlen($prefix)))->toBe($prefix);
|
||||
expect(strlen($payload))->toBe(Ksuid::PAYLOAD_BYTES);
|
||||
});
|
||||
|
||||
it('validates KSUID strings', function () {
|
||||
$generator = new KsuidGenerator(new SecureRandomGenerator());
|
||||
|
||||
$validKsuid = $generator->generate();
|
||||
expect($generator->isValid($validKsuid->toString()))->toBeTrue();
|
||||
|
||||
expect($generator->isValid('invalid'))->toBeFalse();
|
||||
expect($generator->isValid(''))->toBeFalse();
|
||||
expect($generator->isValid(str_repeat('!', Ksuid::ENCODED_LENGTH)))->toBeFalse();
|
||||
});
|
||||
|
||||
it('parses KSUID strings', function () {
|
||||
$generator = new KsuidGenerator(new SecureRandomGenerator());
|
||||
$original = $generator->generate();
|
||||
|
||||
$parsed = $generator->parse($original->toString());
|
||||
|
||||
expect($parsed->equals($original))->toBeTrue();
|
||||
});
|
||||
|
||||
it('gets min/max KSUIDs for timestamp', function () {
|
||||
$generator = new KsuidGenerator(new SecureRandomGenerator());
|
||||
$timestamp = time();
|
||||
|
||||
$min = $generator->getMinForTimestamp($timestamp);
|
||||
$max = $generator->getMaxForTimestamp($timestamp);
|
||||
|
||||
expect($min->getTimestamp())->toBe($timestamp);
|
||||
expect($max->getTimestamp())->toBe($timestamp);
|
||||
expect($min->compare($max))->toBeLessThan(0);
|
||||
|
||||
// Min should have all zero payload
|
||||
expect($min->getPayload())->toBe(str_repeat("\0", Ksuid::PAYLOAD_BYTES));
|
||||
|
||||
// Max should have all 0xFF payload
|
||||
expect($max->getPayload())->toBe(str_repeat("\xFF", Ksuid::PAYLOAD_BYTES));
|
||||
});
|
||||
|
||||
it('generates time range for queries', function () {
|
||||
$generator = new KsuidGenerator(new SecureRandomGenerator());
|
||||
$startTime = time() - 3600;
|
||||
$endTime = time();
|
||||
|
||||
$range = $generator->generateTimeRange($startTime, $endTime);
|
||||
|
||||
expect($range)->toHaveKey('min');
|
||||
expect($range)->toHaveKey('max');
|
||||
expect($range['min']->getTimestamp())->toBe($startTime);
|
||||
expect($range['max']->getTimestamp())->toBe($endTime);
|
||||
});
|
||||
|
||||
it('throws exception for invalid batch count', function () {
|
||||
$generator = new KsuidGenerator(new SecureRandomGenerator());
|
||||
|
||||
expect(fn () => $generator->generateBatch(0))
|
||||
->toThrow(InvalidArgumentException::class, 'Count must be positive');
|
||||
|
||||
expect(fn () => $generator->generateBatch(10001))
|
||||
->toThrow(InvalidArgumentException::class, 'Batch size cannot exceed 10000');
|
||||
});
|
||||
|
||||
it('throws exception for invalid sequence count', function () {
|
||||
$generator = new KsuidGenerator(new SecureRandomGenerator());
|
||||
|
||||
expect(fn () => $generator->generateSequence(0))
|
||||
->toThrow(InvalidArgumentException::class, 'Count must be positive');
|
||||
|
||||
expect(fn () => $generator->generateSequence(1001))
|
||||
->toThrow(InvalidArgumentException::class, 'Sequence size cannot exceed 1000');
|
||||
});
|
||||
|
||||
it('throws exception for negative interval', function () {
|
||||
$generator = new KsuidGenerator(new SecureRandomGenerator());
|
||||
|
||||
expect(fn () => $generator->generateSequence(5, -1))
|
||||
->toThrow(InvalidArgumentException::class, 'Interval must be non-negative');
|
||||
});
|
||||
|
||||
it('throws exception for timestamp before epoch', function () {
|
||||
$generator = new KsuidGenerator(new SecureRandomGenerator());
|
||||
$beforeEpoch = Ksuid::EPOCH - 1;
|
||||
|
||||
expect(fn () => $generator->generateAt($beforeEpoch))
|
||||
->toThrow(InvalidArgumentException::class, 'Timestamp cannot be before KSUID epoch');
|
||||
});
|
||||
|
||||
it('throws exception for prefix too long', function () {
|
||||
$generator = new KsuidGenerator(new SecureRandomGenerator());
|
||||
$longPrefix = str_repeat('x', Ksuid::PAYLOAD_BYTES); // Full payload size
|
||||
|
||||
expect(fn () => $generator->generateWithPrefix($longPrefix))
|
||||
->toThrow(InvalidArgumentException::class, 'Prefix cannot exceed 15 bytes');
|
||||
});
|
||||
|
||||
it('throws exception for invalid time range', function () {
|
||||
$generator = new KsuidGenerator(new SecureRandomGenerator());
|
||||
$startTime = time();
|
||||
$endTime = time() - 3600; // Before start time
|
||||
|
||||
expect(fn () => $generator->generateTimeRange($startTime, $endTime))
|
||||
->toThrow(InvalidArgumentException::class, 'Start timestamp must be before end timestamp');
|
||||
});
|
||||
|
||||
it('creates generator using factory method', function () {
|
||||
$randomGen = new SecureRandomGenerator();
|
||||
$generator = KsuidGenerator::create($randomGen);
|
||||
|
||||
expect($generator)->toBeInstanceOf(KsuidGenerator::class);
|
||||
expect($generator->generate())->toBeInstanceOf(Ksuid::class);
|
||||
});
|
||||
|
||||
it('generates sortable KSUIDs by timestamp', function () {
|
||||
$generator = new KsuidGenerator(new SecureRandomGenerator());
|
||||
|
||||
$older = $generator->generateAt(time() - 3600);
|
||||
$newer = $generator->generateAt(time());
|
||||
|
||||
// Lexicographic comparison should match timestamp order
|
||||
expect($older->compare($newer))->toBeLessThan(0);
|
||||
expect($older->toString() < $newer->toString())->toBeTrue();
|
||||
});
|
||||
|
||||
it('generates unique KSUIDs across multiple calls', function () {
|
||||
$generator = new KsuidGenerator(new SecureRandomGenerator());
|
||||
$ksuids = [];
|
||||
$count = 1000;
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$ksuids[] = $generator->generate()->toString();
|
||||
}
|
||||
|
||||
$uniqueKsuids = array_unique($ksuids);
|
||||
expect($uniqueKsuids)->toHaveCount($count);
|
||||
});
|
||||
177
tests/Unit/Framework/Ksuid/KsuidTest.php
Normal file
177
tests/Unit/Framework/Ksuid/KsuidTest.php
Normal file
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Framework\Ksuid\Ksuid;
|
||||
|
||||
it('creates KSUID from string', function () {
|
||||
$value = '2SwcbqZrBNGd67ZJYmPKx42wKZj';
|
||||
$ksuid = Ksuid::fromString($value);
|
||||
|
||||
expect($ksuid->toString())->toBe($value);
|
||||
expect($ksuid->getValue())->toBe($value);
|
||||
});
|
||||
|
||||
it('creates KSUID from bytes', function () {
|
||||
$bytes = str_repeat("\x00", Ksuid::TOTAL_BYTES);
|
||||
$ksuid = Ksuid::fromBytes($bytes);
|
||||
|
||||
expect($ksuid->getBytes())->toBe($bytes);
|
||||
expect(strlen($ksuid->toString()))->toBe(Ksuid::ENCODED_LENGTH);
|
||||
});
|
||||
|
||||
it('creates KSUID from timestamp and payload', function () {
|
||||
$timestamp = time();
|
||||
$payload = random_bytes(Ksuid::PAYLOAD_BYTES);
|
||||
|
||||
$ksuid = Ksuid::fromTimestampAndPayload($timestamp, $payload);
|
||||
|
||||
expect($ksuid->getTimestamp())->toBe($timestamp);
|
||||
expect($ksuid->getPayload())->toBe($payload);
|
||||
});
|
||||
|
||||
it('validates KSUID length', function () {
|
||||
expect(fn () => Ksuid::fromString('toolong' . str_repeat('a', 30)))
|
||||
->toThrow(InvalidArgumentException::class, 'KSUID must be exactly 27 characters long');
|
||||
|
||||
expect(fn () => Ksuid::fromString('tooshort'))
|
||||
->toThrow(InvalidArgumentException::class, 'KSUID must be exactly 27 characters long');
|
||||
});
|
||||
|
||||
it('validates KSUID characters', function () {
|
||||
expect(fn () => Ksuid::fromString(str_repeat('!', Ksuid::ENCODED_LENGTH)))
|
||||
->toThrow(InvalidArgumentException::class, 'KSUID contains invalid characters');
|
||||
});
|
||||
|
||||
it('validates payload size', function () {
|
||||
$timestamp = time();
|
||||
$shortPayload = random_bytes(Ksuid::PAYLOAD_BYTES - 1);
|
||||
|
||||
expect(fn () => Ksuid::fromTimestampAndPayload($timestamp, $shortPayload))
|
||||
->toThrow(InvalidArgumentException::class, 'Payload must be exactly 16 bytes');
|
||||
});
|
||||
|
||||
it('validates timestamp before epoch', function () {
|
||||
$earlyTimestamp = Ksuid::EPOCH - 1;
|
||||
$payload = random_bytes(Ksuid::PAYLOAD_BYTES);
|
||||
|
||||
expect(fn () => Ksuid::fromTimestampAndPayload($earlyTimestamp, $payload))
|
||||
->toThrow(InvalidArgumentException::class, 'Timestamp cannot be before KSUID epoch');
|
||||
});
|
||||
|
||||
it('parses timestamp correctly', function () {
|
||||
$expectedTimestamp = 1609459200; // 2021-01-01 00:00:00 UTC
|
||||
$payload = random_bytes(Ksuid::PAYLOAD_BYTES);
|
||||
|
||||
$ksuid = Ksuid::fromTimestampAndPayload($expectedTimestamp, $payload);
|
||||
|
||||
expect($ksuid->getTimestamp())->toBe($expectedTimestamp);
|
||||
});
|
||||
|
||||
it('gets DateTime from timestamp', function () {
|
||||
$timestamp = 1609459200; // 2021-01-01 00:00:00 UTC
|
||||
$payload = random_bytes(Ksuid::PAYLOAD_BYTES);
|
||||
|
||||
$ksuid = Ksuid::fromTimestampAndPayload($timestamp, $payload);
|
||||
$dateTime = $ksuid->getDateTime();
|
||||
|
||||
expect($dateTime->getTimestamp())->toBe($timestamp);
|
||||
expect($dateTime->format('Y-m-d H:i:s'))->toBe('2021-01-01 00:00:00');
|
||||
});
|
||||
|
||||
it('checks equality between KSUIDs', function () {
|
||||
$value = '2SwcbqZrBNGd67ZJYmPKx42wKZj';
|
||||
$ksuid1 = Ksuid::fromString($value);
|
||||
$ksuid2 = Ksuid::fromString($value);
|
||||
$ksuid3 = Ksuid::fromString('2SwcbqZrBNGd67ZJYmPKx42wKZk');
|
||||
|
||||
expect($ksuid1->equals($ksuid2))->toBeTrue();
|
||||
expect($ksuid1->equals($ksuid3))->toBeFalse();
|
||||
});
|
||||
|
||||
it('compares KSUIDs for sorting', function () {
|
||||
$value1 = '2SwcbqZrBNGd67ZJYmPKx42wKZj';
|
||||
$value2 = '2SwcbqZrBNGd67ZJYmPKx42wKZk'; // Lexicographically greater
|
||||
|
||||
$ksuid1 = Ksuid::fromString($value1);
|
||||
$ksuid2 = Ksuid::fromString($value2);
|
||||
|
||||
expect($ksuid1->compare($ksuid2))->toBeLessThan(0);
|
||||
expect($ksuid2->compare($ksuid1))->toBeGreaterThan(0);
|
||||
expect($ksuid1->compare($ksuid1))->toBe(0);
|
||||
});
|
||||
|
||||
it('checks age comparisons', function () {
|
||||
$olderTimestamp = time() - 3600; // 1 hour ago
|
||||
$newerTimestamp = time();
|
||||
|
||||
$payload1 = random_bytes(Ksuid::PAYLOAD_BYTES);
|
||||
$payload2 = random_bytes(Ksuid::PAYLOAD_BYTES);
|
||||
|
||||
$olderKsuid = Ksuid::fromTimestampAndPayload($olderTimestamp, $payload1);
|
||||
$newerKsuid = Ksuid::fromTimestampAndPayload($newerTimestamp, $payload2);
|
||||
|
||||
expect($olderKsuid->isOlderThan($newerKsuid))->toBeTrue();
|
||||
expect($newerKsuid->isNewerThan($olderKsuid))->toBeTrue();
|
||||
expect($olderKsuid->isNewerThan($newerKsuid))->toBeFalse();
|
||||
expect($newerKsuid->isOlderThan($olderKsuid))->toBeFalse();
|
||||
});
|
||||
|
||||
it('calculates age in seconds', function () {
|
||||
$timestamp = time() - 100; // 100 seconds ago
|
||||
$payload = random_bytes(Ksuid::PAYLOAD_BYTES);
|
||||
|
||||
$ksuid = Ksuid::fromTimestampAndPayload($timestamp, $payload);
|
||||
$age = $ksuid->getAgeInSeconds();
|
||||
|
||||
expect($age)->toBeGreaterThanOrEqual(99);
|
||||
expect($age)->toBeLessThanOrEqual(101); // Allow 1 second tolerance
|
||||
});
|
||||
|
||||
it('converts to string using magic method', function () {
|
||||
$value = '2SwcbqZrBNGd67ZJYmPKx42wKZj';
|
||||
$ksuid = Ksuid::fromString($value);
|
||||
|
||||
expect((string)$ksuid)->toBe($value);
|
||||
});
|
||||
|
||||
it('handles Base62 encoding/decoding correctly', function () {
|
||||
$originalBytes = random_bytes(Ksuid::TOTAL_BYTES);
|
||||
$ksuid = Ksuid::fromBytes($originalBytes);
|
||||
$decodedBytes = $ksuid->getBytes();
|
||||
|
||||
expect($decodedBytes)->toBe($originalBytes);
|
||||
});
|
||||
|
||||
it('handles zero timestamp correctly', function () {
|
||||
$epochTimestamp = Ksuid::EPOCH; // Exactly at epoch
|
||||
$payload = str_repeat("\x00", Ksuid::PAYLOAD_BYTES);
|
||||
|
||||
$ksuid = Ksuid::fromTimestampAndPayload($epochTimestamp, $payload);
|
||||
|
||||
expect($ksuid->getTimestamp())->toBe($epochTimestamp);
|
||||
|
||||
// Should create the minimum possible KSUID
|
||||
$expectedEncoded = str_repeat('0', Ksuid::ENCODED_LENGTH);
|
||||
expect($ksuid->toString())->toBe($expectedEncoded);
|
||||
});
|
||||
|
||||
it('handles maximum values correctly', function () {
|
||||
$maxTimestamp = Ksuid::EPOCH + 0xFFFFFFFF; // Max 32-bit timestamp
|
||||
$maxPayload = str_repeat("\xFF", Ksuid::PAYLOAD_BYTES);
|
||||
|
||||
$ksuid = Ksuid::fromTimestampAndPayload($maxTimestamp, $maxPayload);
|
||||
|
||||
expect($ksuid->getTimestamp())->toBe($maxTimestamp);
|
||||
expect($ksuid->getPayload())->toBe($maxPayload);
|
||||
});
|
||||
|
||||
it('throws exception for empty KSUID', function () {
|
||||
expect(fn () => Ksuid::fromString(''))
|
||||
->toThrow(InvalidArgumentException::class, 'KSUID cannot be empty');
|
||||
});
|
||||
|
||||
it('throws exception for invalid bytes length', function () {
|
||||
expect(fn () => Ksuid::fromBytes('tooshort'))
|
||||
->toThrow(InvalidArgumentException::class, 'KSUID bytes must be exactly 20 bytes');
|
||||
});
|
||||
211
tests/Unit/Framework/NanoId/NanoIdGeneratorTest.php
Normal file
211
tests/Unit/Framework/NanoId/NanoIdGeneratorTest.php
Normal file
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Framework\NanoId\NanoId;
|
||||
use App\Framework\NanoId\NanoIdGenerator;
|
||||
use App\Framework\Random\SecureRandomGenerator;
|
||||
|
||||
it('creates generator with default settings', function () {
|
||||
$generator = new NanoIdGenerator(new SecureRandomGenerator());
|
||||
$nanoId = $generator->generate();
|
||||
|
||||
expect($nanoId)->toBeInstanceOf(NanoId::class);
|
||||
expect($nanoId->getLength())->toBe(NanoId::DEFAULT_SIZE);
|
||||
});
|
||||
|
||||
it('creates generator with custom settings', function () {
|
||||
$generator = new NanoIdGenerator(new SecureRandomGenerator(), 10, 'ABC123');
|
||||
$nanoId = $generator->generate();
|
||||
|
||||
expect($nanoId->getLength())->toBe(10);
|
||||
expect($nanoId->matchesAlphabet('ABC123'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('generates NanoId with custom size', function () {
|
||||
$generator = new NanoIdGenerator(new SecureRandomGenerator());
|
||||
$nanoId = $generator->generateWithSize(15);
|
||||
|
||||
expect($nanoId->getLength())->toBe(15);
|
||||
});
|
||||
|
||||
it('generates NanoId with custom alphabet', function () {
|
||||
$generator = new NanoIdGenerator(new SecureRandomGenerator());
|
||||
$nanoId = $generator->generateWithAlphabet('XYZ789');
|
||||
|
||||
expect($nanoId->matchesAlphabet('XYZ789'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('generates custom NanoId with size and alphabet', function () {
|
||||
$generator = new NanoIdGenerator(new SecureRandomGenerator());
|
||||
$nanoId = $generator->generateCustom(8, 'ABCD');
|
||||
|
||||
expect($nanoId->getLength())->toBe(8);
|
||||
expect($nanoId->matchesAlphabet('ABCD'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('generates safe NanoId', function () {
|
||||
$generator = new NanoIdGenerator(new SecureRandomGenerator());
|
||||
$nanoId = $generator->generateSafe();
|
||||
|
||||
expect($nanoId->isSafe())->toBeTrue();
|
||||
});
|
||||
|
||||
it('generates numeric NanoId', function () {
|
||||
$generator = new NanoIdGenerator(new SecureRandomGenerator());
|
||||
$nanoId = $generator->generateNumeric();
|
||||
|
||||
expect($nanoId->isNumeric())->toBeTrue();
|
||||
});
|
||||
|
||||
it('generates lowercase alphanumeric NanoId', function () {
|
||||
$generator = new NanoIdGenerator(new SecureRandomGenerator());
|
||||
$nanoId = $generator->generateLowercase();
|
||||
|
||||
expect($nanoId->toString())->toMatch('/^[0-9a-z]+$/');
|
||||
});
|
||||
|
||||
it('generates uppercase alphanumeric NanoId', function () {
|
||||
$generator = new NanoIdGenerator(new SecureRandomGenerator());
|
||||
$nanoId = $generator->generateUppercase();
|
||||
|
||||
expect($nanoId->toString())->toMatch('/^[0-9A-Z]+$/');
|
||||
});
|
||||
|
||||
it('generates NanoId for entity types', function () {
|
||||
$generator = new NanoIdGenerator(new SecureRandomGenerator());
|
||||
|
||||
$userNanoId = $generator->generateForEntity('user');
|
||||
expect($userNanoId->toString())->toStartWith('usr_');
|
||||
|
||||
$orderNanoId = $generator->generateForEntity('order');
|
||||
expect($orderNanoId->toString())->toStartWith('ord_');
|
||||
|
||||
$customNanoId = $generator->generateForEntity('widget');
|
||||
expect($customNanoId->toString())->toStartWith('wid_');
|
||||
});
|
||||
|
||||
it('generates time-prefixed NanoId', function () {
|
||||
$generator = new NanoIdGenerator(new SecureRandomGenerator());
|
||||
$nanoId = $generator->generateTimePrefixed();
|
||||
|
||||
expect($nanoId->toString())->toContain('_');
|
||||
|
||||
// Check that prefix is a valid base36 timestamp
|
||||
$parts = explode('_', $nanoId->toString());
|
||||
expect($parts)->toHaveCount(2);
|
||||
|
||||
$timestamp = base_convert($parts[0], 36, 10);
|
||||
expect($timestamp)->toBeNumeric();
|
||||
expect((int)$timestamp)->toBeLessThanOrEqual(time());
|
||||
expect((int)$timestamp)->toBeGreaterThan(time() - 10); // Within last 10 seconds
|
||||
});
|
||||
|
||||
it('generates batch of unique NanoIds', function () {
|
||||
$generator = new NanoIdGenerator(new SecureRandomGenerator());
|
||||
$batch = $generator->generateBatch(100);
|
||||
|
||||
expect($batch)->toHaveCount(100);
|
||||
expect($batch[0])->toBeInstanceOf(NanoId::class);
|
||||
|
||||
// Check uniqueness
|
||||
$values = array_map(fn ($id) => $id->toString(), $batch);
|
||||
$uniqueValues = array_unique($values);
|
||||
expect($uniqueValues)->toHaveCount(100);
|
||||
});
|
||||
|
||||
it('validates NanoIds correctly', function () {
|
||||
$generator = new NanoIdGenerator(new SecureRandomGenerator());
|
||||
|
||||
$validId = $generator->generate()->toString();
|
||||
expect($generator->isValid($validId))->toBeTrue();
|
||||
|
||||
expect($generator->isValid(''))->toBeFalse();
|
||||
expect($generator->isValid(str_repeat('a', 256)))->toBeFalse();
|
||||
|
||||
// Test with custom alphabet generator
|
||||
$customGenerator = new NanoIdGenerator(new SecureRandomGenerator(), 10, 'ABC');
|
||||
expect($customGenerator->isValid('ABCABC'))->toBeTrue();
|
||||
expect($customGenerator->isValid('XYZ123'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('creates generator using factory methods', function () {
|
||||
$randomGen = new SecureRandomGenerator();
|
||||
|
||||
$defaultGenerator = NanoIdGenerator::create($randomGen);
|
||||
expect($defaultGenerator->generate())->toBeInstanceOf(NanoId::class);
|
||||
|
||||
$safeGenerator = NanoIdGenerator::createSafe($randomGen);
|
||||
expect($safeGenerator->generate()->isSafe())->toBeTrue();
|
||||
|
||||
$numericGenerator = NanoIdGenerator::createNumeric($randomGen);
|
||||
expect($numericGenerator->generate()->isNumeric())->toBeTrue();
|
||||
});
|
||||
|
||||
it('throws exception for invalid default size', function () {
|
||||
$randomGen = new SecureRandomGenerator();
|
||||
expect(fn () => new NanoIdGenerator($randomGen, 0))->toThrow(InvalidArgumentException::class, 'Default size must be between 1 and 255');
|
||||
expect(fn () => new NanoIdGenerator($randomGen, 256))->toThrow(InvalidArgumentException::class, 'Default size must be between 1 and 255');
|
||||
});
|
||||
|
||||
it('throws exception for empty default alphabet', function () {
|
||||
$randomGen = new SecureRandomGenerator();
|
||||
expect(fn () => new NanoIdGenerator($randomGen, 21, ''))->toThrow(InvalidArgumentException::class, 'Default alphabet cannot be empty');
|
||||
});
|
||||
|
||||
it('throws exception for invalid batch count', function () {
|
||||
$generator = new NanoIdGenerator(new SecureRandomGenerator());
|
||||
|
||||
expect(fn () => $generator->generateBatch(0))->toThrow(InvalidArgumentException::class, 'Count must be positive');
|
||||
expect(fn () => $generator->generateBatch(10001))->toThrow(InvalidArgumentException::class, 'Batch size cannot exceed 10000');
|
||||
});
|
||||
|
||||
it('throws exception for invalid generation parameters', function () {
|
||||
$generator = new NanoIdGenerator(new SecureRandomGenerator());
|
||||
|
||||
// Test invalid size
|
||||
expect(fn () => $generator->generateWithSize(0))->toThrow(InvalidArgumentException::class, 'Size must be between 1 and 255');
|
||||
expect(fn () => $generator->generateWithSize(256))->toThrow(InvalidArgumentException::class, 'Size must be between 1 and 255');
|
||||
|
||||
// Test empty alphabet
|
||||
expect(fn () => $generator->generateWithAlphabet(''))->toThrow(InvalidArgumentException::class, 'Alphabet cannot be empty');
|
||||
|
||||
// Test alphabet too long
|
||||
$longAlphabet = str_repeat('a', 256);
|
||||
expect(fn () => $generator->generateWithAlphabet($longAlphabet))->toThrow(InvalidArgumentException::class, 'Alphabet cannot exceed 255 characters');
|
||||
});
|
||||
|
||||
it('generates different entity type prefixes correctly', function () {
|
||||
$generator = new NanoIdGenerator(new SecureRandomGenerator());
|
||||
|
||||
$prefixes = [
|
||||
'user' => 'usr_',
|
||||
'order' => 'ord_',
|
||||
'product' => 'prd_',
|
||||
'session' => 'ses_',
|
||||
'token' => 'tok_',
|
||||
'transaction' => 'txn_',
|
||||
'invoice' => 'inv_',
|
||||
'customer' => 'cus_',
|
||||
'payment' => 'pay_',
|
||||
'subscription' => 'sub_',
|
||||
];
|
||||
|
||||
foreach ($prefixes as $entity => $expectedPrefix) {
|
||||
$nanoId = $generator->generateForEntity($entity);
|
||||
expect($nanoId->toString())->toStartWith($expectedPrefix);
|
||||
}
|
||||
});
|
||||
|
||||
it('generates unique IDs across multiple calls', function () {
|
||||
$generator = new NanoIdGenerator(new SecureRandomGenerator());
|
||||
$ids = [];
|
||||
$count = 1000;
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$ids[] = $generator->generate()->toString();
|
||||
}
|
||||
|
||||
$uniqueIds = array_unique($ids);
|
||||
expect(count($uniqueIds))->toBe($count);
|
||||
});
|
||||
136
tests/Unit/Framework/NanoId/NanoIdTest.php
Normal file
136
tests/Unit/Framework/NanoId/NanoIdTest.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Framework\NanoId\NanoId;
|
||||
|
||||
it('creates NanoId from string', function () {
|
||||
$value = 'test123ABC';
|
||||
$nanoId = NanoId::fromString($value);
|
||||
|
||||
expect($nanoId->toString())->toBe($value);
|
||||
expect($nanoId->getValue())->toBe($value);
|
||||
});
|
||||
|
||||
it('validates NanoId alphabet matching', function () {
|
||||
$nanoId = NanoId::fromString('ABC123');
|
||||
|
||||
expect($nanoId->matchesAlphabet('ABC123'))->toBeTrue();
|
||||
expect($nanoId->matchesAlphabet('XYZ'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('checks if NanoId is default alphabet', function () {
|
||||
$defaultId = NanoId::fromString('abcDEF123_-');
|
||||
expect($defaultId->isDefault())->toBeTrue();
|
||||
|
||||
$nonDefaultId = NanoId::fromString('abc!@#');
|
||||
expect($nonDefaultId->isDefault())->toBeFalse();
|
||||
});
|
||||
|
||||
it('checks if NanoId is safe alphabet', function () {
|
||||
$safeId = NanoId::fromString('abcDEF23456789');
|
||||
expect($safeId->isSafe())->toBeTrue();
|
||||
|
||||
$unsafeId = NanoId::fromString('abc0O1I');
|
||||
expect($unsafeId->isSafe())->toBeFalse();
|
||||
});
|
||||
|
||||
it('checks if NanoId is numeric', function () {
|
||||
$numericId = NanoId::fromString('123456789');
|
||||
expect($numericId->isNumeric())->toBeTrue();
|
||||
|
||||
$alphaId = NanoId::fromString('abc123');
|
||||
expect($alphaId->isNumeric())->toBeFalse();
|
||||
});
|
||||
|
||||
it('gets NanoId length correctly', function () {
|
||||
$nanoId = NanoId::fromString('12345');
|
||||
expect($nanoId->getLength())->toBe(5);
|
||||
});
|
||||
|
||||
it('checks equality between NanoIds', function () {
|
||||
$value = 'sameId123';
|
||||
$nanoId1 = NanoId::fromString($value);
|
||||
$nanoId2 = NanoId::fromString($value);
|
||||
$nanoId3 = NanoId::fromString('differentId');
|
||||
|
||||
expect($nanoId1->equals($nanoId2))->toBeTrue();
|
||||
expect($nanoId1->equals($nanoId3))->toBeFalse();
|
||||
});
|
||||
|
||||
it('adds prefix to NanoId', function () {
|
||||
$nanoId = NanoId::fromString('abc123');
|
||||
$prefixed = $nanoId->withPrefix('user_');
|
||||
|
||||
expect($prefixed->toString())->toBe('user_abc123');
|
||||
});
|
||||
|
||||
it('adds suffix to NanoId', function () {
|
||||
$nanoId = NanoId::fromString('abc123');
|
||||
$suffixed = $nanoId->withSuffix('_v2');
|
||||
|
||||
expect($suffixed->toString())->toBe('abc123_v2');
|
||||
});
|
||||
|
||||
it('truncates NanoId', function () {
|
||||
$nanoId = NanoId::fromString('verylongnanoid123456');
|
||||
$truncated = $nanoId->truncate(10);
|
||||
|
||||
expect($truncated->toString())->toBe('verylongna');
|
||||
expect($truncated->getLength())->toBe(10);
|
||||
});
|
||||
|
||||
it('throws exception for empty NanoId', function () {
|
||||
expect(fn () => NanoId::fromString(''))->toThrow(InvalidArgumentException::class, 'NanoId cannot be empty');
|
||||
});
|
||||
|
||||
it('throws exception for NanoId exceeding 255 characters', function () {
|
||||
$longString = str_repeat('a', 256);
|
||||
expect(fn () => NanoId::fromString($longString))->toThrow(InvalidArgumentException::class, 'NanoId cannot exceed 255 characters');
|
||||
});
|
||||
|
||||
it('throws exception for empty prefix', function () {
|
||||
$nanoId = NanoId::fromString('test');
|
||||
expect(fn () => $nanoId->withPrefix(''))->toThrow(InvalidArgumentException::class, 'Prefix cannot be empty');
|
||||
});
|
||||
|
||||
it('throws exception for empty suffix', function () {
|
||||
$nanoId = NanoId::fromString('test');
|
||||
expect(fn () => $nanoId->withSuffix(''))->toThrow(InvalidArgumentException::class, 'Suffix cannot be empty');
|
||||
});
|
||||
|
||||
it('throws exception for invalid truncate length', function () {
|
||||
$nanoId = NanoId::fromString('test');
|
||||
expect(fn () => $nanoId->truncate(0))->toThrow(InvalidArgumentException::class, 'Length must be positive');
|
||||
});
|
||||
|
||||
it('converts NanoId to string using magic method', function () {
|
||||
$nanoId = NanoId::fromString('test123');
|
||||
|
||||
expect((string)$nanoId)->toBe('test123');
|
||||
});
|
||||
|
||||
it('does not truncate when length is greater than NanoId length', function () {
|
||||
$nanoId = NanoId::fromString('short');
|
||||
$truncated = $nanoId->truncate(10);
|
||||
|
||||
expect($truncated->toString())->toBe('short');
|
||||
expect($truncated->equals($nanoId))->toBeTrue();
|
||||
});
|
||||
|
||||
it('validates alphabet patterns correctly', function () {
|
||||
$defaultId = NanoId::fromString('abc123DEF_-');
|
||||
expect($defaultId->matchesAlphabet(NanoId::DEFAULT_ALPHABET))->toBeTrue();
|
||||
|
||||
$safeId = NanoId::fromString('abc23456789DEF');
|
||||
expect($safeId->matchesAlphabet(NanoId::SAFE_ALPHABET))->toBeTrue();
|
||||
|
||||
$numericId = NanoId::fromString('123456789');
|
||||
expect($numericId->matchesAlphabet(NanoId::NUMBERS))->toBeTrue();
|
||||
|
||||
$lowercaseId = NanoId::fromString('abc123def');
|
||||
expect($lowercaseId->matchesAlphabet(NanoId::LOWERCASE_ALPHANUMERIC))->toBeTrue();
|
||||
|
||||
$uppercaseId = NanoId::fromString('ABC123DEF');
|
||||
expect($uppercaseId->matchesAlphabet(NanoId::UPPERCASE_ALPHANUMERIC))->toBeTrue();
|
||||
});
|
||||
424
tests/Unit/Framework/Router/SubdomainRoutingTest.php
Normal file
424
tests/Unit/Framework/Router/SubdomainRoutingTest.php
Normal file
@@ -0,0 +1,424 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Core\RouteCompiler;
|
||||
use App\Framework\Core\ValueObjects\ClassName;
|
||||
use App\Framework\Core\ValueObjects\MethodName;
|
||||
use App\Framework\Discovery\ValueObjects\AttributeTarget;
|
||||
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
|
||||
use App\Framework\Http\Headers;
|
||||
use App\Framework\Http\HttpRequest;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Http\ServerEnvironment;
|
||||
use App\Framework\Router\HttpRouter;
|
||||
use App\Framework\Router\RouteMatchSuccess;
|
||||
|
||||
it('compiles routes with exact subdomain patterns', function () {
|
||||
$compiler = new RouteCompiler();
|
||||
|
||||
// Create a discovered route with exact subdomain
|
||||
$discoveredRoute = new DiscoveredAttribute(
|
||||
className: ClassName::create('TestController'),
|
||||
attributeClass: Route::class,
|
||||
target: AttributeTarget::METHOD,
|
||||
methodName: MethodName::create('apiEndpoint'),
|
||||
arguments: [
|
||||
'path' => '/api/test',
|
||||
'method' => Method::GET,
|
||||
'subdomain' => 'api',
|
||||
],
|
||||
additionalData: ['parameters' => []]
|
||||
);
|
||||
|
||||
$compiled = $compiler->compile($discoveredRoute);
|
||||
|
||||
expect($compiled)->toHaveKey('GET')
|
||||
->and($compiled['GET'])->toHaveKey('exact:api')
|
||||
->and($compiled['GET']['exact:api']['static'])->toHaveKey('/api/test');
|
||||
});
|
||||
|
||||
it('compiles routes with wildcard subdomain patterns', function () {
|
||||
$compiler = new RouteCompiler();
|
||||
|
||||
$discoveredRoute = new DiscoveredAttribute(
|
||||
className: ClassName::create('TestController'),
|
||||
attributeClass: Route::class,
|
||||
target: AttributeTarget::METHOD,
|
||||
methodName: MethodName::create('wildcardEndpoint'),
|
||||
arguments: [
|
||||
'path' => '/wildcard',
|
||||
'method' => Method::GET,
|
||||
'subdomain' => '*.app',
|
||||
],
|
||||
additionalData: ['parameters' => []]
|
||||
);
|
||||
|
||||
$compiled = $compiler->compile($discoveredRoute);
|
||||
|
||||
expect($compiled)->toHaveKey('GET')
|
||||
->and($compiled['GET'])->toHaveKey('wildcard:*.app')
|
||||
->and($compiled['GET']['wildcard:*.app']['static'])->toHaveKey('/wildcard');
|
||||
});
|
||||
|
||||
it('compiles routes with multiple subdomain patterns', function () {
|
||||
$compiler = new RouteCompiler();
|
||||
|
||||
$discoveredRoute = new DiscoveredAttribute(
|
||||
className: ClassName::create('TestController'),
|
||||
attributeClass: Route::class,
|
||||
target: AttributeTarget::METHOD,
|
||||
methodName: MethodName::create('multiSubdomain'),
|
||||
arguments: [
|
||||
'path' => '/multi',
|
||||
'method' => Method::GET,
|
||||
'subdomain' => ['api', 'admin'],
|
||||
],
|
||||
additionalData: ['parameters' => []]
|
||||
);
|
||||
|
||||
$compiled = $compiler->compile($discoveredRoute);
|
||||
|
||||
expect($compiled['GET'])->toHaveKey('exact:api')
|
||||
->and($compiled['GET'])->toHaveKey('exact:admin')
|
||||
->and($compiled['GET']['exact:api']['static'])->toHaveKey('/multi')
|
||||
->and($compiled['GET']['exact:admin']['static'])->toHaveKey('/multi');
|
||||
});
|
||||
|
||||
it('falls back to default routes when no subdomain specified', function () {
|
||||
$compiler = new RouteCompiler();
|
||||
|
||||
$discoveredRoute = new DiscoveredAttribute(
|
||||
className: ClassName::create('TestController'),
|
||||
attributeClass: Route::class,
|
||||
target: AttributeTarget::METHOD,
|
||||
methodName: MethodName::create('defaultRoute'),
|
||||
arguments: [
|
||||
'path' => '/default',
|
||||
'method' => Method::GET,
|
||||
'subdomain' => [],
|
||||
],
|
||||
additionalData: ['parameters' => []]
|
||||
);
|
||||
|
||||
$compiled = $compiler->compile($discoveredRoute);
|
||||
|
||||
expect($compiled)->toHaveKey('GET')
|
||||
->and($compiled['GET'])->toHaveKey('default')
|
||||
->and($compiled['GET']['default']['static'])->toHaveKey('/default');
|
||||
});
|
||||
|
||||
it('router matches exact subdomain routes correctly', function () {
|
||||
$compiler = new RouteCompiler();
|
||||
|
||||
// Create routes for different subdomains
|
||||
$apiRoute = new DiscoveredAttribute(
|
||||
className: ClassName::create('ApiController'),
|
||||
attributeClass: Route::class,
|
||||
target: AttributeTarget::METHOD,
|
||||
methodName: MethodName::create('index'),
|
||||
arguments: [
|
||||
'path' => '/test',
|
||||
'method' => Method::GET,
|
||||
'subdomain' => 'api',
|
||||
],
|
||||
additionalData: ['parameters' => []]
|
||||
);
|
||||
|
||||
$defaultRoute = new DiscoveredAttribute(
|
||||
className: ClassName::create('WebController'),
|
||||
attributeClass: Route::class,
|
||||
target: AttributeTarget::METHOD,
|
||||
methodName: MethodName::create('index'),
|
||||
arguments: [
|
||||
'path' => '/test',
|
||||
'method' => Method::GET,
|
||||
'subdomain' => [],
|
||||
],
|
||||
additionalData: ['parameters' => []]
|
||||
);
|
||||
|
||||
$compiledRoutes = $compiler->compileOptimized($apiRoute, $defaultRoute);
|
||||
$router = new HttpRouter($compiledRoutes);
|
||||
|
||||
// Test API subdomain request
|
||||
$apiRequest = new HttpRequest(
|
||||
method: Method::GET,
|
||||
path: '/test',
|
||||
headers: new Headers(['Host' => 'api.example.com']),
|
||||
server: new ServerEnvironment(['HTTP_HOST' => 'api.example.com'])
|
||||
);
|
||||
|
||||
$apiContext = $router->match($apiRequest);
|
||||
expect($apiContext->match)->toBeInstanceOf(RouteMatchSuccess::class);
|
||||
|
||||
if ($apiContext->match instanceof RouteMatchSuccess) {
|
||||
expect($apiContext->match->route->controller)->toBe('ApiController');
|
||||
}
|
||||
|
||||
// Test default domain request
|
||||
$webRequest = new HttpRequest(
|
||||
method: Method::GET,
|
||||
path: '/test',
|
||||
headers: new Headers(['Host' => 'example.com']),
|
||||
server: new ServerEnvironment(['HTTP_HOST' => 'example.com'])
|
||||
);
|
||||
|
||||
$webContext = $router->match($webRequest);
|
||||
expect($webContext->match)->toBeInstanceOf(RouteMatchSuccess::class);
|
||||
|
||||
if ($webContext->match instanceof RouteMatchSuccess) {
|
||||
expect($webContext->match->route->controller)->toBe('WebController');
|
||||
}
|
||||
});
|
||||
|
||||
it('router matches wildcard subdomain patterns', function () {
|
||||
$compiler = new RouteCompiler();
|
||||
|
||||
$wildcardRoute = new DiscoveredAttribute(
|
||||
className: ClassName::create('TenantController'),
|
||||
attributeClass: Route::class,
|
||||
target: AttributeTarget::METHOD,
|
||||
methodName: MethodName::create('dashboard'),
|
||||
arguments: [
|
||||
'path' => '/dashboard',
|
||||
'method' => Method::GET,
|
||||
'subdomain' => '*.app',
|
||||
],
|
||||
additionalData: ['parameters' => []]
|
||||
);
|
||||
|
||||
$compiledRoutes = $compiler->compileOptimized($wildcardRoute);
|
||||
$router = new HttpRouter($compiledRoutes);
|
||||
|
||||
// Test wildcard match
|
||||
$tenantRequest = new HttpRequest(
|
||||
method: Method::GET,
|
||||
path: '/dashboard',
|
||||
headers: new Headers(['Host' => 'tenant1.app.example.com']),
|
||||
server: new ServerEnvironment(['HTTP_HOST' => 'tenant1.app.example.com'])
|
||||
);
|
||||
|
||||
$context = $router->match($tenantRequest);
|
||||
expect($context->match)->toBeInstanceOf(RouteMatchSuccess::class);
|
||||
|
||||
if ($context->match instanceof RouteMatchSuccess) {
|
||||
expect($context->match->route->controller)->toBe('TenantController');
|
||||
}
|
||||
});
|
||||
|
||||
it('router prioritizes exact subdomain over wildcard patterns', function () {
|
||||
$compiler = new RouteCompiler();
|
||||
|
||||
$exactRoute = new DiscoveredAttribute(
|
||||
className: ClassName::create('ExactController'),
|
||||
attributeClass: Route::class,
|
||||
target: AttributeTarget::METHOD,
|
||||
methodName: MethodName::create('special'),
|
||||
arguments: [
|
||||
'path' => '/special',
|
||||
'method' => Method::GET,
|
||||
'subdomain' => 'admin',
|
||||
],
|
||||
additionalData: ['parameters' => []]
|
||||
);
|
||||
|
||||
$wildcardRoute = new DiscoveredAttribute(
|
||||
className: ClassName::create('WildcardController'),
|
||||
attributeClass: Route::class,
|
||||
target: AttributeTarget::METHOD,
|
||||
methodName: MethodName::create('general'),
|
||||
arguments: [
|
||||
'path' => '/special',
|
||||
'method' => Method::GET,
|
||||
'subdomain' => '*',
|
||||
],
|
||||
additionalData: ['parameters' => []]
|
||||
);
|
||||
|
||||
$compiledRoutes = $compiler->compileOptimized($exactRoute, $wildcardRoute);
|
||||
$router = new HttpRouter($compiledRoutes);
|
||||
|
||||
// Test that exact match takes precedence
|
||||
$request = new HttpRequest(
|
||||
method: Method::GET,
|
||||
path: '/special',
|
||||
headers: new Headers(['Host' => 'admin.example.com']),
|
||||
server: new ServerEnvironment(['HTTP_HOST' => 'admin.example.com'])
|
||||
);
|
||||
|
||||
$context = $router->match($request);
|
||||
expect($context->match)->toBeInstanceOf(RouteMatchSuccess::class);
|
||||
|
||||
if ($context->match instanceof RouteMatchSuccess) {
|
||||
expect($context->match->route->controller)->toBe('ExactController');
|
||||
}
|
||||
});
|
||||
|
||||
it('compiles dynamic routes with subdomain patterns', function () {
|
||||
$compiler = new RouteCompiler();
|
||||
|
||||
$dynamicRoute = new DiscoveredAttribute(
|
||||
className: ClassName::create('ApiController'),
|
||||
attributeClass: Route::class,
|
||||
target: AttributeTarget::METHOD,
|
||||
methodName: MethodName::create('getUser'),
|
||||
arguments: [
|
||||
'path' => '/api/users/{id}',
|
||||
'method' => Method::GET,
|
||||
'subdomain' => 'api',
|
||||
],
|
||||
additionalData: ['parameters' => []]
|
||||
);
|
||||
|
||||
$compiled = $compiler->compile($dynamicRoute);
|
||||
|
||||
expect($compiled['GET']['exact:api']['dynamic'])->toHaveCount(1)
|
||||
->and($compiled['GET']['exact:api']['dynamic'][0]->path)->toBe('/api/users/{id}')
|
||||
->and($compiled['GET']['exact:api']['dynamic'][0]->paramNames)->toBe(['id']);
|
||||
});
|
||||
|
||||
it('extracts subdomain correctly from various host patterns', function () {
|
||||
$compiler = new RouteCompiler();
|
||||
|
||||
$testRoute = new DiscoveredAttribute(
|
||||
className: ClassName::create('TestController'),
|
||||
attributeClass: Route::class,
|
||||
target: AttributeTarget::METHOD,
|
||||
methodName: MethodName::create('test'),
|
||||
arguments: [
|
||||
'path' => '/test',
|
||||
'method' => Method::GET,
|
||||
'subdomain' => 'api',
|
||||
],
|
||||
additionalData: ['parameters' => []]
|
||||
);
|
||||
|
||||
$compiledRoutes = $compiler->compileOptimized($testRoute);
|
||||
$router = new HttpRouter($compiledRoutes);
|
||||
|
||||
// Test various host patterns
|
||||
$testCases = [
|
||||
'api.example.com' => 'api',
|
||||
'www.example.com' => '', // www is ignored
|
||||
'example.com' => '', // no subdomain
|
||||
'sub.api.example.com' => 'sub.api', // full subdomain part
|
||||
'test.localhost' => 'test', // development domain
|
||||
'localhost' => '', // main localhost
|
||||
];
|
||||
|
||||
foreach ($testCases as $host => $expectedSubdomain) {
|
||||
$request = new HttpRequest(
|
||||
method: Method::GET,
|
||||
path: '/test',
|
||||
headers: new Headers(['Host' => $host]),
|
||||
server: new ServerEnvironment(['HTTP_HOST' => $host])
|
||||
);
|
||||
|
||||
// Use reflection to test the private extractSubdomain method
|
||||
$reflection = new ReflectionClass($router);
|
||||
$method = $reflection->getMethod('extractSubdomain');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$result = $method->invoke($router, $host);
|
||||
expect($result)->toBe($expectedSubdomain, "Failed for host: $host");
|
||||
}
|
||||
});
|
||||
|
||||
it('default routes are NOT accessible via subdomain', function () {
|
||||
$compiler = new RouteCompiler();
|
||||
|
||||
// Create a default route (no subdomain specified)
|
||||
$defaultRoute = new DiscoveredAttribute(
|
||||
className: ClassName::create('HomeController'),
|
||||
attributeClass: Route::class,
|
||||
target: AttributeTarget::METHOD,
|
||||
methodName: MethodName::create('index'),
|
||||
arguments: [
|
||||
'path' => '/home',
|
||||
'method' => Method::GET,
|
||||
'subdomain' => [], // No subdomain = default route
|
||||
],
|
||||
additionalData: ['parameters' => []]
|
||||
);
|
||||
|
||||
$compiledRoutes = $compiler->compileOptimized($defaultRoute);
|
||||
$router = new HttpRouter($compiledRoutes);
|
||||
|
||||
// Test 1: Should work on main domain (no subdomain)
|
||||
$mainDomainRequest = new HttpRequest(
|
||||
method: Method::GET,
|
||||
path: '/home',
|
||||
headers: new Headers(['Host' => 'example.com']),
|
||||
server: new ServerEnvironment(['HTTP_HOST' => 'example.com'])
|
||||
);
|
||||
|
||||
$mainContext = $router->match($mainDomainRequest);
|
||||
expect($mainContext->match)->toBeInstanceOf(RouteMatchSuccess::class);
|
||||
|
||||
// Test 2: Should NOT work on subdomain
|
||||
$subdomainRequest = new HttpRequest(
|
||||
method: Method::GET,
|
||||
path: '/home',
|
||||
headers: new Headers(['Host' => 'api.example.com']),
|
||||
server: new ServerEnvironment(['HTTP_HOST' => 'api.example.com'])
|
||||
);
|
||||
|
||||
$subdomainContext = $router->match($subdomainRequest);
|
||||
expect($subdomainContext->match)->toBeInstanceOf(\App\Framework\Router\NoRouteMatch::class);
|
||||
});
|
||||
|
||||
it('subdomain-specific routes are NOT accessible via main domain', function () {
|
||||
$compiler = new RouteCompiler();
|
||||
|
||||
// Create a subdomain-specific route
|
||||
$apiRoute = new DiscoveredAttribute(
|
||||
className: ClassName::create('ApiController'),
|
||||
attributeClass: Route::class,
|
||||
target: AttributeTarget::METHOD,
|
||||
methodName: MethodName::create('endpoint'),
|
||||
arguments: [
|
||||
'path' => '/api/data',
|
||||
'method' => Method::GET,
|
||||
'subdomain' => 'api', // Only accessible via api.domain.com
|
||||
],
|
||||
additionalData: ['parameters' => []]
|
||||
);
|
||||
|
||||
$compiledRoutes = $compiler->compileOptimized($apiRoute);
|
||||
$router = new HttpRouter($compiledRoutes);
|
||||
|
||||
// Test 1: Should work on correct subdomain
|
||||
$correctSubdomainRequest = new HttpRequest(
|
||||
method: Method::GET,
|
||||
path: '/api/data',
|
||||
headers: new Headers(['Host' => 'api.example.com']),
|
||||
server: new ServerEnvironment(['HTTP_HOST' => 'api.example.com'])
|
||||
);
|
||||
|
||||
$correctContext = $router->match($correctSubdomainRequest);
|
||||
expect($correctContext->match)->toBeInstanceOf(RouteMatchSuccess::class);
|
||||
|
||||
// Test 2: Should NOT work on main domain
|
||||
$mainDomainRequest = new HttpRequest(
|
||||
method: Method::GET,
|
||||
path: '/api/data',
|
||||
headers: new Headers(['Host' => 'example.com']),
|
||||
server: new ServerEnvironment(['HTTP_HOST' => 'example.com'])
|
||||
);
|
||||
|
||||
$mainContext = $router->match($mainDomainRequest);
|
||||
expect($mainContext->match)->toBeInstanceOf(\App\Framework\Router\NoRouteMatch::class);
|
||||
|
||||
// Test 3: Should NOT work on wrong subdomain
|
||||
$wrongSubdomainRequest = new HttpRequest(
|
||||
method: Method::GET,
|
||||
path: '/api/data',
|
||||
headers: new Headers(['Host' => 'admin.example.com']),
|
||||
server: new ServerEnvironment(['HTTP_HOST' => 'admin.example.com'])
|
||||
);
|
||||
|
||||
$wrongContext = $router->match($wrongSubdomainRequest);
|
||||
expect($wrongContext->match)->toBeInstanceOf(\App\Framework\Router\NoRouteMatch::class);
|
||||
});
|
||||
Reference in New Issue
Block a user