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:
2025-08-11 20:13:26 +02:00
parent 59fd3dd3b1
commit 55a330b223
3683 changed files with 2956207 additions and 16948 deletions

View 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);
});

View 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);
});

View 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');
});

View 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());
});

View 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
});

View 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');
});

View 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
});

View 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);
});

View 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');
});

View 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);
});

View 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);
});

View 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');
});

View 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);
});

View 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();
});

View 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);
});