encryptionFactory = new EncryptionFactory(); $this->parser = new EnvFileParser(); $this->loader = new EncryptedEnvLoader($this->encryptionFactory, $this->parser); // Create temporary test directory $this->testDir = '/home/michael/dev/michaelschiemer/tests/tmp'; if (!is_dir($this->testDir)) { mkdir($this->testDir, 0777, true); } // Backup and clear real environment variables $this->originalEnv = $_ENV; $this->originalServer = $_SERVER; $_ENV = []; $_SERVER = []; }); afterEach(function () { // Restore original environment $_ENV = $this->originalEnv; $_SERVER = $this->originalServer; // Clean up test files if (isset($this->envFile) && file_exists($this->envFile)) { unlink($this->envFile); } if (isset($this->secretsFile) && file_exists($this->secretsFile)) { unlink($this->secretsFile); } if (isset($this->envProductionFile) && file_exists($this->envProductionFile)) { unlink($this->envProductionFile); } if (isset($this->envDevelopmentFile) && file_exists($this->envDevelopmentFile)) { unlink($this->envDevelopmentFile); } }); describe('load()', function () { it('loads environment from .env file', function () { $this->envFile = $this->testDir . '/.env'; file_put_contents($this->envFile, <<loader->load($this->testDir); expect($env->get('APP_NAME'))->toBe('TestApp'); expect($env->get('APP_ENV'))->toBe('development'); expect($env->get('DB_HOST'))->toBe('localhost'); expect($env->getInt('DB_PORT'))->toBe(3306); }); it('loads environment with FilePath object', function () { $this->envFile = $this->testDir . '/.env'; file_put_contents($this->envFile, <<testDir); $env = $this->loader->load($filePath); expect($env->get('APP_NAME'))->toBe('TestApp'); expect($env->get('APP_ENV'))->toBe('development'); }); it('returns empty environment when no .env file exists', function () { $env = $this->loader->load($this->testDir); expect($env->get('APP_NAME'))->toBeNull(); }); it('performs two-pass loading when ENCRYPTION_KEY present', function () { // First pass: Load .env with ENCRYPTION_KEY $this->envFile = $this->testDir . '/.env'; file_put_contents($this->envFile, <<secretsFile = $this->testDir . '/.env.secrets'; file_put_contents($this->secretsFile, <<loader->load($this->testDir); // Should have loaded from both files expect($env->get('APP_NAME'))->toBe('TestApp'); expect($env->get('ENCRYPTION_KEY'))->toBe('test_encryption_key_32_chars_long'); expect($env->get('SECRET_API_KEY'))->toBe('my_secret_api_key'); }); it('continues without secrets when encryption fails', function () { $this->envFile = $this->testDir . '/.env'; file_put_contents($this->envFile, <<secretsFile = $this->testDir . '/.env.secrets'; file_put_contents($this->secretsFile, 'CORRUPTED_DATA'); // Should not throw - graceful degradation $env = $this->loader->load($this->testDir); expect($env->get('APP_NAME'))->toBe('TestApp'); expect($env->get('ENCRYPTION_KEY'))->toBe('invalid_key'); }); }); describe('loadEnvironment() - Production Priority', function () { it('prioritizes Docker ENV vars over .env file in production', function () { // Simulate Docker environment variables $_ENV['APP_ENV'] = 'production'; $_ENV['DB_HOST'] = 'docker_mysql'; $_ENV['DB_PORT'] = '3306'; // .env file with different values $this->envFile = $this->testDir . '/.env'; file_put_contents($this->envFile, <<loader->loadEnvironment($this->testDir); // Docker ENV vars should take precedence expect($env->get('DB_HOST'))->toBe('docker_mysql'); expect($env->getInt('DB_PORT'))->toBe(3306); // .env values only used if not in Docker ENV expect($env->get('DB_NAME'))->toBe('mydb'); }); it('loads environment-specific file in production', function () { $_ENV['APP_ENV'] = 'production'; $this->envFile = $this->testDir . '/.env'; file_put_contents($this->envFile, <<envProductionFile = $this->testDir . '/.env.production'; file_put_contents($this->envProductionFile, <<loader->loadEnvironment($this->testDir); expect($env->get('APP_NAME'))->toBe('TestApp'); expect($env->get('PRODUCTION_SETTING'))->toBe('enabled'); expect($env->get('DB_HOST'))->toBe('prod_host'); }); it('does not override Docker ENV with production file', function () { $_ENV['APP_ENV'] = 'production'; $_ENV['DB_HOST'] = 'docker_production_host'; $this->envFile = $this->testDir . '/.env'; file_put_contents($this->envFile, 'APP_ENV=production'); $this->envProductionFile = $this->testDir . '/.env.production'; file_put_contents($this->envProductionFile, <<loader->loadEnvironment($this->testDir); // Docker ENV should win expect($env->get('DB_HOST'))->toBe('docker_production_host'); // New variables from production file should be added expect($env->getInt('DB_PORT'))->toBe(3306); }); }); describe('loadEnvironment() - Development Priority', function () { it('allows .env file to override system environment in development', function () { // Simulate system environment $_ENV['APP_ENV'] = 'development'; $_ENV['DB_HOST'] = 'system_host'; // .env file with different values $this->envFile = $this->testDir . '/.env'; file_put_contents($this->envFile, <<loader->loadEnvironment($this->testDir); // .env file should override in development expect($env->get('DB_HOST'))->toBe('localhost'); expect($env->getInt('DB_PORT'))->toBe(3307); }); it('loads environment-specific file in development', function () { $_ENV['APP_ENV'] = 'development'; $this->envFile = $this->testDir . '/.env'; file_put_contents($this->envFile, <<envDevelopmentFile = $this->testDir . '/.env.development'; file_put_contents($this->envDevelopmentFile, <<loader->loadEnvironment($this->testDir); expect($env->get('APP_NAME'))->toBe('TestApp'); expect($env->getBool('DEBUG'))->toBeTrue(); expect($env->get('DB_HOST'))->toBe('localhost'); }); it('allows development file to override .env in development', function () { $_ENV['APP_ENV'] = 'development'; $this->envFile = $this->testDir . '/.env'; file_put_contents($this->envFile, <<envDevelopmentFile = $this->testDir . '/.env.development'; file_put_contents($this->envDevelopmentFile, <<loader->loadEnvironment($this->testDir); // Development file should override .env expect($env->get('DB_HOST'))->toBe('dev_override'); expect($env->getBool('DEBUG'))->toBeTrue(); }); }); describe('loadSystemEnvironment() - getenv() Priority Fix', function () { it('loads from getenv() first (PHP-FPM compatibility)', function () { // Simulate PHP-FPM scenario where $_ENV is empty but getenv() works $_ENV = []; $_SERVER = []; // getenv() returns values (simulated by putenv for test) putenv('TEST_VAR=from_getenv'); $env = $this->loader->loadEnvironment($this->testDir); // Should have loaded from getenv() expect($env->get('TEST_VAR'))->toBe('from_getenv'); // Cleanup putenv('TEST_VAR'); }); it('falls back to $_ENV when getenv() is empty', function () { $_ENV['FALLBACK_VAR'] = 'from_env_array'; $_SERVER = []; $env = $this->loader->loadEnvironment($this->testDir); expect($env->get('FALLBACK_VAR'))->toBe('from_env_array'); }); it('falls back to $_SERVER when both getenv() and $_ENV are empty', function () { $_ENV = []; $_SERVER['SERVER_VAR'] = 'from_server_array'; $env = $this->loader->loadEnvironment($this->testDir); expect($env->get('SERVER_VAR'))->toBe('from_server_array'); }); it('filters non-string values from $_SERVER', function () { $_ENV = []; $_SERVER['STRING_VAR'] = 'valid_string'; $_SERVER['ARRAY_VAR'] = ['invalid', 'array']; $_SERVER['INT_VAR'] = 123; // Will be converted to string $env = $this->loader->loadEnvironment($this->testDir); expect($env->get('STRING_VAR'))->toBe('valid_string'); expect($env->get('ARRAY_VAR'))->toBeNull(); // Filtered out }); it('prefers getenv() over $_ENV and $_SERVER', function () { // Simulate all three sources with different values putenv('PRIORITY_TEST=from_getenv'); $_ENV['PRIORITY_TEST'] = 'from_env_array'; $_SERVER['PRIORITY_TEST'] = 'from_server_array'; $env = $this->loader->loadEnvironment($this->testDir); // getenv() should win expect($env->get('PRIORITY_TEST'))->toBe('from_getenv'); // Cleanup putenv('PRIORITY_TEST'); }); }); describe('loadEnvironment() - .env.secrets Support', function () { it('loads secrets file when encryption key provided', function () { $this->envFile = $this->testDir . '/.env'; file_put_contents($this->envFile, 'APP_ENV=development'); $this->secretsFile = $this->testDir . '/.env.secrets'; file_put_contents($this->secretsFile, <<loader->loadEnvironment($this->testDir, $encryptionKey); expect($env->get('SECRET_API_KEY'))->toBe('my_secret'); expect($env->get('SECRET_DB_PASSWORD'))->toBe('db_secret'); }); it('skips secrets file when encryption key is null', function () { $this->envFile = $this->testDir . '/.env'; file_put_contents($this->envFile, 'APP_ENV=development'); $this->secretsFile = $this->testDir . '/.env.secrets'; file_put_contents($this->secretsFile, 'SECRET_API_KEY=my_secret'); $env = $this->loader->loadEnvironment($this->testDir, encryptionKey: null); expect($env->get('SECRET_API_KEY'))->toBeNull(); }); it('skips secrets file when it does not exist', function () { $this->envFile = $this->testDir . '/.env'; file_put_contents($this->envFile, 'APP_ENV=development'); // No secrets file created $encryptionKey = 'test_encryption_key_32_chars_long'; // Should not throw $env = $this->loader->loadEnvironment($this->testDir, $encryptionKey); expect($env->get('APP_ENV'))->toBe('development'); }); it('merges secrets with existing variables', function () { $this->envFile = $this->testDir . '/.env'; file_put_contents($this->envFile, <<secretsFile = $this->testDir . '/.env.secrets'; file_put_contents($this->secretsFile, <<loader->loadEnvironment($this->testDir, $encryptionKey); // Base .env variables expect($env->get('APP_ENV'))->toBe('development'); expect($env->get('DB_HOST'))->toBe('localhost'); // Secrets expect($env->get('SECRET_API_KEY'))->toBe('my_secret'); expect($env->get('SECRET_DB_PASSWORD'))->toBe('db_secret'); }); }); describe('generateSecretsTemplate()', function () { it('generates secrets template file', function () { $filePath = $this->loader->generateSecretsTemplate($this->testDir); $this->secretsFile = $filePath->toString(); expect(file_exists($this->secretsFile))->toBeTrue(); $content = file_get_contents($this->secretsFile); // Check header expect($content)->toContain('# .env.secrets - Encrypted secrets file'); expect($content)->toContain('# Generated on'); // Check instructions expect($content)->toContain('# Instructions:'); expect($content)->toContain('# 1. Set ENCRYPTION_KEY in your main .env file'); // Check example format expect($content)->toContain('# Example:'); expect($content)->toContain('# SECRET_API_KEY=ENC[base64encodedencryptedvalue]'); // Check default secret keys expect($content)->toContain('# SECRET_ENCRYPTION_KEY='); expect($content)->toContain('# SECRET_DATABASE_PASSWORD='); expect($content)->toContain('# SECRET_API_KEY='); expect($content)->toContain('# SECRET_JWT_SECRET='); }); it('includes custom secret keys in template', function () { $customKeys = [ 'SECRET_STRIPE_KEY', 'SECRET_AWS_ACCESS_KEY', 'SECRET_MAILGUN_KEY' ]; $filePath = $this->loader->generateSecretsTemplate($this->testDir, $customKeys); $this->secretsFile = $filePath->toString(); $content = file_get_contents($this->secretsFile); // Check custom keys are included expect($content)->toContain('# SECRET_STRIPE_KEY='); expect($content)->toContain('# SECRET_AWS_ACCESS_KEY='); expect($content)->toContain('# SECRET_MAILGUN_KEY='); // Default keys should still be present expect($content)->toContain('# SECRET_API_KEY='); }); it('removes duplicate keys in template', function () { $customKeys = [ 'SECRET_API_KEY', // Duplicate of default 'SECRET_CUSTOM_KEY' ]; $filePath = $this->loader->generateSecretsTemplate($this->testDir, $customKeys); $this->secretsFile = $filePath->toString(); $content = file_get_contents($this->secretsFile); // Count occurrences of SECRET_API_KEY $count = substr_count($content, 'SECRET_API_KEY'); // Should only appear once (no duplicates) expect($count)->toBe(1); }); }); describe('encryptSecretsInFile()', function () { it('encrypts secrets with SECRET_ prefix', function () { $this->secretsFile = $this->testDir . '/.env.test'; file_put_contents($this->secretsFile, <<loader->encryptSecretsInFile( $this->secretsFile, $encryptionKey ); expect($encryptedCount)->toBe(2); $content = file_get_contents($this->secretsFile); // Secrets should be encrypted (not plain text) expect($content)->not->toContain('my_plain_secret'); expect($content)->not->toContain('plain_password'); // Normal var should remain unchanged expect($content)->toContain('NORMAL_VAR=not_encrypted'); // Comments should be preserved expect($content)->toContain('# Test secrets file'); }); it('encrypts only specified keys when provided', function () { $this->secretsFile = $this->testDir . '/.env.test'; file_put_contents($this->secretsFile, <<loader->encryptSecretsInFile( $this->secretsFile, $encryptionKey, $keysToEncrypt ); expect($encryptedCount)->toBe(2); $content = file_get_contents($this->secretsFile); // Specified secrets should be encrypted expect($content)->not->toContain('my_plain_secret'); expect($content)->not->toContain('other_secret'); // Non-specified secret should remain plain (but still there) expect($content)->toContain('SECRET_DB_PASSWORD=plain_password'); }); it('skips already encrypted values', function () { $this->secretsFile = $this->testDir . '/.env.test'; $encryptionKey = 'test_encryption_key_32_chars_long'; $encryption = $this->encryptionFactory->createBest($encryptionKey); $alreadyEncrypted = $encryption->encrypt('already_encrypted_value'); file_put_contents($this->secretsFile, <<loader->encryptSecretsInFile( $this->secretsFile, $encryptionKey ); // Only 1 should be encrypted (the plain one) expect($encryptedCount)->toBe(1); }); it('returns 0 when file does not exist', function () { $nonExistentFile = $this->testDir . '/non_existent.env'; $encryptionKey = 'test_encryption_key_32_chars_long'; $encryptedCount = $this->loader->encryptSecretsInFile( $nonExistentFile, $encryptionKey ); expect($encryptedCount)->toBe(0); }); it('returns 0 when file is not readable', function () { $this->secretsFile = $this->testDir . '/.env.unreadable'; file_put_contents($this->secretsFile, 'SECRET_KEY=value'); chmod($this->secretsFile, 0000); $encryptionKey = 'test_encryption_key_32_chars_long'; $encryptedCount = $this->loader->encryptSecretsInFile( $this->secretsFile, $encryptionKey ); expect($encryptedCount)->toBe(0); // Restore permissions for cleanup chmod($this->secretsFile, 0644); }); it('removes quotes before encryption', function () { $this->secretsFile = $this->testDir . '/.env.test'; file_put_contents($this->secretsFile, <<loader->encryptSecretsInFile( $this->secretsFile, $encryptionKey ); expect($encryptedCount)->toBe(2); // Values should be encrypted without quotes $content = file_get_contents($this->secretsFile); expect($content)->not->toContain('"quoted_value"'); expect($content)->not->toContain("'single_quoted'"); }); }); describe('validateEncryptionSetup()', function () { it('returns no issues when setup is valid', function () { $encryptionKey = str_repeat('a', 32); // 32 characters $this->secretsFile = $this->testDir . '/.env.secrets'; file_put_contents($this->secretsFile, 'SECRET_KEY=value'); $issues = $this->loader->validateEncryptionSetup($this->testDir, $encryptionKey); expect($issues)->toBe([]); }); it('reports missing encryption key', function () { $issues = $this->loader->validateEncryptionSetup($this->testDir, encryptionKey: null); expect($issues)->toHaveCount(1); expect($issues[0]['type'])->toBe('missing_key'); expect($issues[0]['severity'])->toBe('high'); expect($issues[0]['message'])->toBe('No encryption key provided'); }); it('reports weak encryption key', function () { $weakKey = 'short_key'; // Less than 32 characters $issues = $this->loader->validateEncryptionSetup($this->testDir, $weakKey); expect($issues)->toHaveCount(1); expect($issues[0]['type'])->toBe('weak_key'); expect($issues[0]['severity'])->toBe('high'); expect($issues[0]['message'])->toContain('at least 32 characters'); }); it('reports missing secrets file', function () { $encryptionKey = str_repeat('a', 32); // No secrets file created $issues = $this->loader->validateEncryptionSetup($this->testDir, $encryptionKey); expect($issues)->toHaveCount(1); expect($issues[0]['type'])->toBe('missing_secrets_file'); expect($issues[0]['severity'])->toBe('medium'); expect($issues[0]['message'])->toBe('.env.secrets file not found'); }); it('reports multiple issues', function () { $weakKey = 'weak'; // Weak key // No secrets file $issues = $this->loader->validateEncryptionSetup($this->testDir, $weakKey); expect($issues)->toHaveCount(2); $types = array_column($issues, 'type'); expect($types)->toContain('weak_key'); expect($types)->toContain('missing_secrets_file'); }); }); describe('Edge Cases', function () { it('handles empty .env file', function () { $this->envFile = $this->testDir . '/.env'; file_put_contents($this->envFile, ''); $env = $this->loader->load($this->testDir); expect($env->get('APP_NAME'))->toBeNull(); }); it('handles .env file with only comments', function () { $this->envFile = $this->testDir . '/.env'; file_put_contents($this->envFile, <<loader->load($this->testDir); expect($env->get('APP_NAME'))->toBeNull(); }); it('handles .env file with blank lines', function () { $this->envFile = $this->testDir . '/.env'; file_put_contents($this->envFile, <<loader->load($this->testDir); expect($env->get('APP_NAME'))->toBe('TestApp'); expect($env->get('DB_HOST'))->toBe('localhost'); }); it('re-checks APP_ENV after .env loading', function () { // System says production $_ENV['APP_ENV'] = 'production'; // But .env overrides to development (in development mode, this works) $this->envFile = $this->testDir . '/.env'; file_put_contents($this->envFile, <<envDevelopmentFile = $this->testDir . '/.env.development'; file_put_contents($this->envDevelopmentFile, <<loader->loadEnvironment($this->testDir); expect($env->get('APP_ENV'))->toBe('production'); // System ENV wins in production check // But development file should be loaded based on re-check }); it('handles multiple environment file layers', function () { $_ENV['APP_ENV'] = 'development'; // Layer 1: Base .env $this->envFile = $this->testDir . '/.env'; file_put_contents($this->envFile, <<envDevelopmentFile = $this->testDir . '/.env.development'; file_put_contents($this->envDevelopmentFile, <<secretsFile = $this->testDir . '/.env.secrets'; file_put_contents($this->secretsFile, <<loader->loadEnvironment($this->testDir, $encryptionKey); // Base layer expect($env->get('APP_NAME'))->toBe('BaseApp'); // Override by development expect($env->get('DB_HOST'))->toBe('dev_host'); expect($env->getBool('DEBUG'))->toBeTrue(); // Secrets layer expect($env->get('SECRET_KEY'))->toBe('my_secret'); }); }); });