Files
michaelschiemer/tests/Unit/Framework/Filesystem/CachedFileValidatorTest.php
Michael Schiemer fc3d7e6357 feat(Production): Complete production deployment infrastructure
- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
2025-10-25 19:18:37 +02:00

359 lines
13 KiB
PHP

<?php
declare(strict_types=1);
use App\Framework\Filesystem\CachedFileValidator;
use App\Framework\Filesystem\FileValidator;
use App\Framework\Filesystem\Exceptions\FileValidationException;
use App\Framework\Core\ValueObjects\FileSize;
describe('CachedFileValidator', function () {
beforeEach(function () {
$this->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);
});
});
});