validator = FileValidator::createDefault(); $this->cachedValidator = new CachedFileValidator( validator: $this->validator, cacheTtl: 60, maxCacheSize: 10 ); }); describe('Path Validation Caching', function () { it('caches successful path validation', function () { $path = '/valid/path/file.txt'; // First call - cache miss $this->cachedValidator->validatePath($path); // Second call - cache hit (should not throw) $this->cachedValidator->validatePath($path); $stats = $this->cachedValidator->getCacheStats(); expect($stats['path_cache_size'])->toBe(1); }); it('caches failed path validation', function () { $invalidPath = '/path/with/../traversal'; // First call - cache miss, throws exception try { $this->cachedValidator->validatePath($invalidPath); $this->fail('Expected FileValidationException'); } catch (FileValidationException $e) { expect($e->getMessage())->toContain('Path traversal'); } // Second call - cache hit, throws same exception try { $this->cachedValidator->validatePath($invalidPath); $this->fail('Expected FileValidationException from cache'); } catch (FileValidationException $e) { expect($e->getMessage())->toContain('Path traversal'); } $stats = $this->cachedValidator->getCacheStats(); expect($stats['path_cache_size'])->toBe(1); }); it('detects path traversal on first call and caches result', function () { $traversalPath = '/etc/../passwd'; // Should throw on first call expect(fn() => $this->cachedValidator->validatePath($traversalPath)) ->toThrow(FileValidationException::class); // Should throw on second call (from cache) expect(fn() => $this->cachedValidator->validatePath($traversalPath)) ->toThrow(FileValidationException::class); }); it('detects null bytes and caches result', function () { $nullBytePath = "/path/with\0nullbyte"; // Should throw on first call expect(fn() => $this->cachedValidator->validatePath($nullBytePath)) ->toThrow(FileValidationException::class); // Should throw on second call (from cache) expect(fn() => $this->cachedValidator->validatePath($nullBytePath)) ->toThrow(FileValidationException::class); }); }); describe('Extension Validation Caching', function () { it('caches successful extension validation', function () { $path = '/valid/path/file.txt'; // First call - cache miss $this->cachedValidator->validateExtension($path); // Second call - cache hit $this->cachedValidator->validateExtension($path); $stats = $this->cachedValidator->getCacheStats(); expect($stats['extension_cache_size'])->toBe(1); }); it('caches failed extension validation', function () { $strictValidator = FileValidator::createStrict(['json', 'txt']); $cached = new CachedFileValidator($strictValidator); $phpPath = '/path/file.php'; // First call - cache miss, throws exception expect(fn() => $cached->validateExtension($phpPath)) ->toThrow(FileValidationException::class); // Second call - cache hit, throws same exception expect(fn() => $cached->validateExtension($phpPath)) ->toThrow(FileValidationException::class); $stats = $cached->getCacheStats(); expect($stats['extension_cache_size'])->toBe(1); }); it('validates different extensions separately', function () { $this->cachedValidator->validateExtension('/file1.txt'); $this->cachedValidator->validateExtension('/file2.json'); $this->cachedValidator->validateExtension('/file3.csv'); $stats = $this->cachedValidator->getCacheStats(); expect($stats['extension_cache_size'])->toBe(3); }); }); describe('LRU Cache Eviction', function () { it('evicts oldest entry when cache is full', function () { $cached = new CachedFileValidator( validator: $this->validator, maxCacheSize: 3 ); // Fill cache with 3 entries $cached->validatePath('/path1'); $cached->validatePath('/path2'); $cached->validatePath('/path3'); $stats = $cached->getCacheStats(); expect($stats['path_cache_size'])->toBe(3); // Add 4th entry - should evict oldest (/path1) $cached->validatePath('/path4'); $stats = $cached->getCacheStats(); expect($stats['path_cache_size'])->toBe(3); // Still 3 (max size) }); it('maintains LRU order on cache hit', function () { $cached = new CachedFileValidator( validator: $this->validator, maxCacheSize: 3 ); // Fill cache $cached->validatePath('/path1'); $cached->validatePath('/path2'); $cached->validatePath('/path3'); // Access /path1 again (should move to end) $cached->validatePath('/path1'); // Add new entry - should evict /path2 (oldest) $cached->validatePath('/path4'); $stats = $cached->getCacheStats(); expect($stats['path_cache_size'])->toBe(3); }); }); describe('Cache TTL Expiration', function () { it('expires cache entries after TTL', function () { $cached = new CachedFileValidator( validator: $this->validator, cacheTtl: 1 // 1 second TTL ); $path = '/valid/path.txt'; // Cache the validation $cached->validatePath($path); // Wait for TTL to expire sleep(2); // This should be a cache miss (expired) $cached->validatePath($path); // Cache should still have 1 entry (re-cached) $stats = $cached->getCacheStats(); expect($stats['path_cache_size'])->toBe(1); }); }); describe('Non-Cached Validations', function () { it('does not cache file size validation', function () { $fileSize = FileSize::fromBytes(1024); // Multiple calls should not affect cache $this->cachedValidator->validateFileSize($fileSize); $this->cachedValidator->validateFileSize($fileSize); $stats = $this->cachedValidator->getCacheStats(); expect($stats['path_cache_size'])->toBe(0); expect($stats['extension_cache_size'])->toBe(0); }); it('does not cache existence checks', function () { // Create a temporary test file $testFile = sys_get_temp_dir() . '/test_cached_validator_' . uniqid() . '.txt'; file_put_contents($testFile, 'test'); try { $this->cachedValidator->validateExists($testFile); $this->cachedValidator->validateExists($testFile); $stats = $this->cachedValidator->getCacheStats(); expect($stats['path_cache_size'])->toBe(0); } finally { if (file_exists($testFile)) { unlink($testFile); } } }); it('does not cache permission checks', function () { // Use temp directory for permission test $testDir = sys_get_temp_dir(); $this->cachedValidator->validateWritable($testDir); $this->cachedValidator->validateWritable($testDir); $stats = $this->cachedValidator->getCacheStats(); expect($stats['path_cache_size'])->toBe(0); }); }); describe('Composite Validations', function () { it('caches path and extension in validateUpload', function () { $path = '/upload/file.jpg'; $fileSize = FileSize::fromBytes(1024); $this->cachedValidator->validateUpload($path, $fileSize); $stats = $this->cachedValidator->getCacheStats(); expect($stats['path_cache_size'])->toBe(1); expect($stats['extension_cache_size'])->toBe(1); }); it('caches path in validateRead', function () { $testFile = sys_get_temp_dir() . '/test_read_' . uniqid() . '.txt'; file_put_contents($testFile, 'test'); try { $this->cachedValidator->validateRead($testFile); $stats = $this->cachedValidator->getCacheStats(); expect($stats['path_cache_size'])->toBe(1); } finally { if (file_exists($testFile)) { unlink($testFile); } } }); it('caches path and extension in validateWrite', function () { $testDir = sys_get_temp_dir() . '/test_write_dir_' . uniqid(); mkdir($testDir); try { $path = $testDir . '/file.txt'; $this->cachedValidator->validateWrite($path); $stats = $this->cachedValidator->getCacheStats(); expect($stats['path_cache_size'])->toBe(1); expect($stats['extension_cache_size'])->toBe(1); } finally { if (is_dir($testDir)) { rmdir($testDir); } } }); }); describe('Cache Management', function () { it('can clear all caches', function () { $this->cachedValidator->validatePath('/path1'); $this->cachedValidator->validatePath('/path2'); $this->cachedValidator->validateExtension('/file.txt'); $statsBefore = $this->cachedValidator->getCacheStats(); expect($statsBefore['path_cache_size'])->toBe(2); expect($statsBefore['extension_cache_size'])->toBe(1); $this->cachedValidator->clearCache(); $statsAfter = $this->cachedValidator->getCacheStats(); expect($statsAfter['path_cache_size'])->toBe(0); expect($statsAfter['extension_cache_size'])->toBe(0); }); it('provides accurate cache statistics', function () { $stats = $this->cachedValidator->getCacheStats(); expect($stats)->toHaveKeys([ 'path_cache_size', 'extension_cache_size', 'max_cache_size', 'cache_ttl_seconds' ]); expect($stats['max_cache_size'])->toBe(10); expect($stats['cache_ttl_seconds'])->toBe(60); }); }); describe('Delegation Methods', function () { it('delegates isExtensionAllowed to wrapped validator', function () { expect($this->cachedValidator->isExtensionAllowed('txt'))->toBeTrue(); expect($this->cachedValidator->isExtensionAllowed('exe'))->toBeFalse(); }); it('delegates getAllowedExtensions to wrapped validator', function () { $result = $this->cachedValidator->getAllowedExtensions(); expect($result)->toBeNull(); // Default validator has no whitelist }); it('delegates getBlockedExtensions to wrapped validator', function () { $result = $this->cachedValidator->getBlockedExtensions(); expect($result)->toBeArray(); expect($result)->toContain('exe'); }); it('delegates getMaxFileSize to wrapped validator', function () { $result = $this->cachedValidator->getMaxFileSize(); expect($result)->toBeInstanceOf(FileSize::class); }); }); describe('Performance Characteristics', function () { it('maintains cache hits for repeated validations', function () { $path = '/repeated/path/file.txt'; // First call - cache miss $this->cachedValidator->validatePath($path); $statsBefore = $this->cachedValidator->getCacheStats(); expect($statsBefore['path_cache_size'])->toBe(1); // Repeated calls - cache hits for ($i = 0; $i < 100; $i++) { $this->cachedValidator->validatePath($path); } // Cache size should remain 1 (same path cached) $statsAfter = $this->cachedValidator->getCacheStats(); expect($statsAfter['path_cache_size'])->toBe(1); }); }); });