Files
michaelschiemer/tests/Unit/Framework/Config/EncryptedEnvLoaderTest.php
Michael Schiemer 24cbbccf4c feat: update deployment configuration and encrypted env loader
- Update Ansible playbooks and roles for application deployment
- Add new Gitea/Traefik troubleshooting playbooks
- Update Docker Compose configurations (base, local, staging, production)
- Enhance EncryptedEnvLoader with improved error handling
- Add deployment scripts (autossh setup, migration, secret testing)
- Update CI/CD workflows and documentation
- Add Semaphore stack configuration
2025-11-02 20:38:06 +01:00

971 lines
33 KiB
PHP

<?php
declare(strict_types=1);
use App\Framework\Config\EncryptedEnvLoader;
use App\Framework\Config\EnvFileParser;
use App\Framework\Config\Environment;
use App\Framework\Encryption\EncryptionFactory;
use App\Framework\Filesystem\ValueObjects\FilePath;
describe('EncryptedEnvLoader', function () {
beforeEach(function () {
$this->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);
}
if (isset($this->envBaseFile) && file_exists($this->envBaseFile)) {
unlink($this->envBaseFile);
}
if (isset($this->envLocalFile) && file_exists($this->envLocalFile)) {
unlink($this->envLocalFile);
}
if (isset($this->envStagingFile) && file_exists($this->envStagingFile)) {
unlink($this->envStagingFile);
}
});
describe('load()', function () {
it('loads environment from .env file', function () {
$this->envFile = $this->testDir . '/.env';
file_put_contents($this->envFile, <<<ENV
APP_NAME=TestApp
APP_ENV=development
DB_HOST=localhost
DB_PORT=3306
ENV);
$env = $this->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, <<<ENV
APP_NAME=TestApp
APP_ENV=development
ENV);
$filePath = FilePath::create($this->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, <<<ENV
APP_NAME=TestApp
ENCRYPTION_KEY=test_encryption_key_32_chars_long
ENV);
// Second pass: Load .env.secrets
$this->secretsFile = $this->testDir . '/.env.secrets';
file_put_contents($this->secretsFile, <<<ENV
SECRET_API_KEY=my_secret_api_key
ENV);
$env = $this->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, <<<ENV
APP_NAME=TestApp
ENCRYPTION_KEY=invalid_key
ENV);
// Corrupted secrets file that will fail decryption
$this->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, <<<ENV
APP_ENV=production
DB_HOST=localhost
DB_PORT=3307
DB_NAME=mydb
ENV);
$env = $this->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, <<<ENV
APP_NAME=TestApp
APP_ENV=production
ENV);
$this->envProductionFile = $this->testDir . '/.env.production';
file_put_contents($this->envProductionFile, <<<ENV
PRODUCTION_SETTING=enabled
DB_HOST=prod_host
ENV);
$env = $this->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, <<<ENV
DB_HOST=file_production_host
DB_PORT=3306
ENV);
$env = $this->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() - Base + Override Pattern', function () {
it('loads .env.base first, then .env.local (local overrides base)', function () {
$_ENV['APP_ENV'] = 'development';
// Base file with common variables
$this->envBaseFile = $this->testDir . '/.env.base';
file_put_contents($this->envBaseFile, <<<ENV
APP_NAME=BaseApp
DB_HOST=db
DB_PORT=5432
DB_DATABASE=michaelschiemer
CACHE_PREFIX=app
ENV);
// Local file with overrides
$this->envLocalFile = $this->testDir . '/.env.local';
file_put_contents($this->envLocalFile, <<<ENV
APP_ENV=development
APP_DEBUG=true
DB_HOST=localhost
DB_PORT=3307
CACHE_PREFIX=local
ENV);
$env = $this->loader->loadEnvironment($this->testDir);
// Base values
expect($env->get('APP_NAME'))->toBe('BaseApp');
expect($env->get('DB_DATABASE'))->toBe('michaelschiemer');
// Local overrides
expect($env->get('APP_ENV'))->toBe('development');
expect($env->getBool('APP_DEBUG'))->toBeTrue();
expect($env->get('DB_HOST'))->toBe('localhost');
expect($env->getInt('DB_PORT'))->toBe(3307);
expect($env->get('CACHE_PREFIX'))->toBe('local');
});
it('loads .env.local only if .env.base exists', function () {
$_ENV['APP_ENV'] = 'development';
// Only .env.local (should fallback to legacy .env)
$this->envLocalFile = $this->testDir . '/.env.local';
file_put_contents($this->envLocalFile, <<<ENV
APP_ENV=development
DB_HOST=localhost
ENV);
// Legacy .env file (fallback)
$this->envFile = $this->testDir . '/.env';
file_put_contents($this->envFile, <<<ENV
APP_NAME=LegacyApp
DB_PORT=3306
ENV);
$env = $this->loader->loadEnvironment($this->testDir);
// Should load from legacy .env (fallback)
expect($env->get('APP_NAME'))->toBe('LegacyApp');
expect($env->getInt('DB_PORT'))->toBe(3306);
// .env.local should not be loaded if .env.base doesn't exist
// (Fallback logic: only load .env.local if .env.base exists)
});
it('falls back to legacy .env if .env.base and .env.local do not exist', function () {
$_ENV['APP_ENV'] = 'development';
// Only legacy .env file
$this->envFile = $this->testDir . '/.env';
file_put_contents($this->envFile, <<<ENV
APP_NAME=LegacyApp
APP_ENV=development
DB_HOST=localhost
ENV);
$env = $this->loader->loadEnvironment($this->testDir);
// Should load from legacy .env
expect($env->get('APP_NAME'))->toBe('LegacyApp');
expect($env->get('APP_ENV'))->toBe('development');
expect($env->get('DB_HOST'))->toBe('localhost');
});
it('prioritizes system ENV over .env.base and .env.local', function () {
$_ENV['APP_ENV'] = 'development';
$_ENV['DB_HOST'] = 'system_host';
$this->envBaseFile = $this->testDir . '/.env.base';
file_put_contents($this->envBaseFile, <<<ENV
APP_NAME=BaseApp
DB_HOST=db
ENV);
$this->envLocalFile = $this->testDir . '/.env.local';
file_put_contents($this->envLocalFile, <<<ENV
DB_HOST=localhost
ENV);
$env = $this->loader->loadEnvironment($this->testDir);
// System ENV should win
expect($env->get('DB_HOST'))->toBe('system_host');
// Base values should be loaded
expect($env->get('APP_NAME'))->toBe('BaseApp');
});
it('merges .env.base, .env.local, and .env.secrets correctly', function () {
$_ENV['APP_ENV'] = 'development';
$this->envBaseFile = $this->testDir . '/.env.base';
file_put_contents($this->envBaseFile, <<<ENV
APP_NAME=BaseApp
DB_HOST=db
ENV);
$this->envLocalFile = $this->testDir . '/.env.local';
file_put_contents($this->envLocalFile, <<<ENV
DB_HOST=localhost
ENV);
$this->secretsFile = $this->testDir . '/.env.secrets';
file_put_contents($this->secretsFile, <<<ENV
SECRET_API_KEY=my_secret
ENV);
$encryptionKey = 'test_encryption_key_32_chars_long';
$env = $this->loader->loadEnvironment($this->testDir, $encryptionKey);
// Base + Local + Secrets
expect($env->get('APP_NAME'))->toBe('BaseApp');
expect($env->get('DB_HOST'))->toBe('localhost');
expect($env->get('SECRET_API_KEY'))->toBe('my_secret');
});
it('loads .env.staging in staging environment', function () {
$_ENV['APP_ENV'] = 'staging';
$this->envBaseFile = $this->testDir . '/.env.base';
file_put_contents($this->envBaseFile, <<<ENV
APP_NAME=BaseApp
DB_HOST=db
ENV);
$this->envStagingFile = $this->testDir . '/.env.staging';
file_put_contents($this->envStagingFile, <<<ENV
APP_ENV=staging
APP_DEBUG=false
DB_HOST=staging_db
STAGING_FEATURE=enabled
ENV);
$env = $this->loader->loadEnvironment($this->testDir);
// Base values
expect($env->get('APP_NAME'))->toBe('BaseApp');
// Staging overrides
expect($env->get('APP_ENV'))->toBe('staging');
expect($env->getBool('APP_DEBUG'))->toBeFalse();
expect($env->get('DB_HOST'))->toBe('staging_db');
expect($env->get('STAGING_FEATURE'))->toBe('enabled');
});
it('prioritizes .env.staging over .env.local in staging environment', function () {
$_ENV['APP_ENV'] = 'staging';
$this->envBaseFile = $this->testDir . '/.env.base';
file_put_contents($this->envBaseFile, <<<ENV
DB_HOST=db
ENV);
$this->envLocalFile = $this->testDir . '/.env.local';
file_put_contents($this->envLocalFile, <<<ENV
DB_HOST=localhost
ENV);
$this->envStagingFile = $this->testDir . '/.env.staging';
file_put_contents($this->envStagingFile, <<<ENV
DB_HOST=staging_host
ENV);
$env = $this->loader->loadEnvironment($this->testDir);
// Staging should win
expect($env->get('DB_HOST'))->toBe('staging_host');
});
});
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, <<<ENV
APP_ENV=development
DB_HOST=localhost
DB_PORT=3307
ENV);
$env = $this->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, <<<ENV
APP_NAME=TestApp
APP_ENV=development
ENV);
$this->envDevelopmentFile = $this->testDir . '/.env.development';
file_put_contents($this->envDevelopmentFile, <<<ENV
DEBUG=true
DB_HOST=localhost
ENV);
$env = $this->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, <<<ENV
APP_ENV=development
DB_HOST=localhost
DEBUG=false
ENV);
$this->envDevelopmentFile = $this->testDir . '/.env.development';
file_put_contents($this->envDevelopmentFile, <<<ENV
DB_HOST=dev_override
DEBUG=true
ENV);
$env = $this->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, <<<ENV
SECRET_API_KEY=my_secret
SECRET_DB_PASSWORD=db_secret
ENV);
$encryptionKey = 'test_encryption_key_32_chars_long';
$env = $this->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, <<<ENV
APP_ENV=development
DB_HOST=localhost
ENV);
$this->secretsFile = $this->testDir . '/.env.secrets';
file_put_contents($this->secretsFile, <<<ENV
SECRET_API_KEY=my_secret
SECRET_DB_PASSWORD=db_secret
ENV);
$encryptionKey = 'test_encryption_key_32_chars_long';
$env = $this->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, <<<ENV
# Test secrets file
SECRET_API_KEY=my_plain_secret
SECRET_DB_PASSWORD=plain_password
NORMAL_VAR=not_encrypted
ENV);
$encryptionKey = 'test_encryption_key_32_chars_long';
$encryptedCount = $this->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, <<<ENV
SECRET_API_KEY=my_plain_secret
SECRET_DB_PASSWORD=plain_password
SECRET_OTHER=other_secret
ENV);
$encryptionKey = 'test_encryption_key_32_chars_long';
$keysToEncrypt = ['SECRET_API_KEY', 'SECRET_OTHER'];
$encryptedCount = $this->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, <<<ENV
SECRET_API_KEY={$alreadyEncrypted}
SECRET_DB_PASSWORD=plain_password
ENV);
$encryptedCount = $this->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, <<<ENV
SECRET_API_KEY="quoted_value"
SECRET_DB_PASSWORD='single_quoted'
ENV);
$encryptionKey = 'test_encryption_key_32_chars_long';
$encryptedCount = $this->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, <<<ENV
# Comment line 1
# Comment line 2
# Comment line 3
ENV);
$env = $this->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, <<<ENV
APP_NAME=TestApp
DB_HOST=localhost
ENV);
$env = $this->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, <<<ENV
APP_ENV=development
ENV);
$this->envDevelopmentFile = $this->testDir . '/.env.development';
file_put_contents($this->envDevelopmentFile, <<<ENV
DEV_SETTING=enabled
ENV);
// Since .env says development, should load .env.development
$env = $this->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, <<<ENV
APP_NAME=BaseApp
DB_HOST=localhost
DEBUG=false
ENV);
// Layer 2: Environment-specific
$this->envDevelopmentFile = $this->testDir . '/.env.development';
file_put_contents($this->envDevelopmentFile, <<<ENV
DB_HOST=dev_host
DEBUG=true
ENV);
// Layer 3: Secrets
$this->secretsFile = $this->testDir . '/.env.secrets';
file_put_contents($this->secretsFile, <<<ENV
SECRET_KEY=my_secret
ENV);
$encryptionKey = 'test_encryption_key_32_chars_long';
$env = $this->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');
});
});
});