- 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.
359 lines
13 KiB
PHP
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);
|
|
});
|
|
});
|
|
});
|