feat: CI/CD pipeline setup complete - Ansible playbooks updated, secrets configured, workflow ready

This commit is contained in:
2025-10-31 01:39:24 +01:00
parent 55c04e4fd0
commit e26eb2aa12
601 changed files with 44184 additions and 32477 deletions

View File

@@ -0,0 +1,645 @@
<?php
declare(strict_types=1);
use App\Framework\Config\DockerSecretsResolver;
use App\Framework\Filesystem\ValueObjects\FilePath;
describe('DockerSecretsResolver', function () {
beforeEach(function () {
$this->resolver = new DockerSecretsResolver();
// Create temporary test directory
$this->testDir = '/home/michael/dev/michaelschiemer/tests/tmp';
if (!is_dir($this->testDir)) {
mkdir($this->testDir, 0777, true);
}
});
afterEach(function () {
// Clean up test files
if (isset($this->testFile) && file_exists($this->testFile)) {
unlink($this->testFile);
}
});
describe('resolve()', function () {
it('resolves secret from file when {KEY}_FILE exists', function () {
$this->testFile = $this->testDir . '/db_password_secret';
file_put_contents($this->testFile, 'super_secret_password');
$variables = [
'DB_PASSWORD_FILE' => $this->testFile
];
$result = $this->resolver->resolve('DB_PASSWORD', $variables);
expect($result)->toBe('super_secret_password');
});
it('trims whitespace from file content', function () {
$this->testFile = $this->testDir . '/api_key_secret';
file_put_contents($this->testFile, " secret_api_key_123 \n ");
$variables = [
'API_KEY_FILE' => $this->testFile
];
$result = $this->resolver->resolve('API_KEY', $variables);
expect($result)->toBe('secret_api_key_123');
});
it('trims newlines and whitespace', function () {
$this->testFile = $this->testDir . '/token_secret';
file_put_contents($this->testFile, "\nmy_token\n");
$variables = [
'TOKEN_FILE' => $this->testFile
];
$result = $this->resolver->resolve('TOKEN', $variables);
expect($result)->toBe('my_token');
});
it('returns null when {KEY}_FILE variable does not exist', function () {
$variables = [
'DB_PASSWORD' => 'plain_password',
'SOME_OTHER_VAR' => 'value'
];
$result = $this->resolver->resolve('DB_PASSWORD', $variables);
expect($result)->toBeNull();
});
it('returns null when file path is not a string', function () {
$variables = [
'DB_PASSWORD_FILE' => 12345 // Not a string
];
$result = $this->resolver->resolve('DB_PASSWORD', $variables);
expect($result)->toBeNull();
});
it('returns null when file path is an array', function () {
$variables = [
'DB_PASSWORD_FILE' => ['path', 'to', 'file']
];
$result = $this->resolver->resolve('DB_PASSWORD', $variables);
expect($result)->toBeNull();
});
it('returns null when file does not exist', function () {
$variables = [
'DB_PASSWORD_FILE' => $this->testDir . '/non_existent_file'
];
$result = $this->resolver->resolve('DB_PASSWORD', $variables);
expect($result)->toBeNull();
});
it('returns null when file is not readable', function () {
$this->testFile = $this->testDir . '/unreadable_secret';
file_put_contents($this->testFile, 'secret');
chmod($this->testFile, 0000); // Make unreadable
$variables = [
'DB_PASSWORD_FILE' => $this->testFile
];
$result = $this->resolver->resolve('DB_PASSWORD', $variables);
expect($result)->toBeNull();
// Restore permissions for cleanup
chmod($this->testFile, 0644);
});
it('returns null when FilePath creation fails', function () {
$variables = [
'DB_PASSWORD_FILE' => '' // Invalid path
];
$result = $this->resolver->resolve('DB_PASSWORD', $variables);
expect($result)->toBeNull();
});
it('handles multi-line secret files', function () {
$this->testFile = $this->testDir . '/multi_line_secret';
file_put_contents($this->testFile, "line1\nline2\nline3");
$variables = [
'MULTI_LINE_FILE' => $this->testFile
];
$result = $this->resolver->resolve('MULTI_LINE', $variables);
expect($result)->toBe("line1\nline2\nline3");
});
it('handles empty file', function () {
$this->testFile = $this->testDir . '/empty_secret';
file_put_contents($this->testFile, '');
$variables = [
'EMPTY_FILE' => $this->testFile
];
$result = $this->resolver->resolve('EMPTY', $variables);
expect($result)->toBe('');
});
it('handles file with only whitespace', function () {
$this->testFile = $this->testDir . '/whitespace_secret';
file_put_contents($this->testFile, " \n\n ");
$variables = [
'WHITESPACE_FILE' => $this->testFile
];
$result = $this->resolver->resolve('WHITESPACE', $variables);
expect($result)->toBe('');
});
it('resolves secrets with special characters', function () {
$this->testFile = $this->testDir . '/special_chars_secret';
file_put_contents($this->testFile, 'p@$$w0rd!#%&*()');
$variables = [
'SPECIAL_CHARS_FILE' => $this->testFile
];
$result = $this->resolver->resolve('SPECIAL_CHARS', $variables);
expect($result)->toBe('p@$$w0rd!#%&*()');
});
it('resolves secrets with unicode characters', function () {
$this->testFile = $this->testDir . '/unicode_secret';
file_put_contents($this->testFile, 'pässwörd_日本語');
$variables = [
'UNICODE_FILE' => $this->testFile
];
$result = $this->resolver->resolve('UNICODE', $variables);
expect($result)->toBe('pässwörd_日本語');
});
it('resolves absolute file paths', function () {
$this->testFile = $this->testDir . '/absolute_path_secret';
file_put_contents($this->testFile, 'absolute_secret');
$variables = [
'ABS_PATH_FILE' => $this->testFile
];
$result = $this->resolver->resolve('ABS_PATH', $variables);
expect($result)->toBe('absolute_secret');
});
});
describe('hasSecret()', function () {
it('returns true when secret exists and is readable', function () {
$this->testFile = $this->testDir . '/db_password_secret';
file_put_contents($this->testFile, 'secret');
$variables = [
'DB_PASSWORD_FILE' => $this->testFile
];
$result = $this->resolver->hasSecret('DB_PASSWORD', $variables);
expect($result)->toBeTrue();
});
it('returns false when {KEY}_FILE variable does not exist', function () {
$variables = [
'DB_PASSWORD' => 'plain_password'
];
$result = $this->resolver->hasSecret('DB_PASSWORD', $variables);
expect($result)->toBeFalse();
});
it('returns false when file does not exist', function () {
$variables = [
'DB_PASSWORD_FILE' => $this->testDir . '/non_existent_file'
];
$result = $this->resolver->hasSecret('DB_PASSWORD', $variables);
expect($result)->toBeFalse();
});
it('returns false when file is not readable', function () {
$this->testFile = $this->testDir . '/unreadable_secret';
file_put_contents($this->testFile, 'secret');
chmod($this->testFile, 0000);
$variables = [
'DB_PASSWORD_FILE' => $this->testFile
];
$result = $this->resolver->hasSecret('DB_PASSWORD', $variables);
expect($result)->toBeFalse();
// Restore permissions for cleanup
chmod($this->testFile, 0644);
});
it('returns true for empty file', function () {
$this->testFile = $this->testDir . '/empty_secret';
file_put_contents($this->testFile, '');
$variables = [
'EMPTY_FILE' => $this->testFile
];
$result = $this->resolver->hasSecret('EMPTY', $variables);
expect($result)->toBeTrue();
});
it('returns false when file path is not a string', function () {
$variables = [
'DB_PASSWORD_FILE' => 12345
];
$result = $this->resolver->hasSecret('DB_PASSWORD', $variables);
expect($result)->toBeFalse();
});
});
describe('resolveMultiple()', function () {
beforeEach(function () {
// Create multiple secret files
$this->dbPasswordFile = $this->testDir . '/db_password';
$this->apiKeyFile = $this->testDir . '/api_key';
$this->smtpPasswordFile = $this->testDir . '/smtp_password';
file_put_contents($this->dbPasswordFile, 'db_secret_123');
file_put_contents($this->apiKeyFile, 'api_key_456');
file_put_contents($this->smtpPasswordFile, 'smtp_secret_789');
});
afterEach(function () {
// Clean up multiple test files
foreach ([$this->dbPasswordFile, $this->apiKeyFile, $this->smtpPasswordFile] as $file) {
if (file_exists($file)) {
unlink($file);
}
}
});
it('resolves multiple secrets successfully', function () {
$variables = [
'DB_PASSWORD_FILE' => $this->dbPasswordFile,
'API_KEY_FILE' => $this->apiKeyFile,
'SMTP_PASSWORD_FILE' => $this->smtpPasswordFile
];
$keys = ['DB_PASSWORD', 'API_KEY', 'SMTP_PASSWORD'];
$result = $this->resolver->resolveMultiple($keys, $variables);
expect($result)->toBe([
'DB_PASSWORD' => 'db_secret_123',
'API_KEY' => 'api_key_456',
'SMTP_PASSWORD' => 'smtp_secret_789'
]);
});
it('resolves only available secrets', function () {
$variables = [
'DB_PASSWORD_FILE' => $this->dbPasswordFile,
'API_KEY_FILE' => $this->apiKeyFile
// SMTP_PASSWORD_FILE missing
];
$keys = ['DB_PASSWORD', 'API_KEY', 'SMTP_PASSWORD'];
$result = $this->resolver->resolveMultiple($keys, $variables);
expect($result)->toBe([
'DB_PASSWORD' => 'db_secret_123',
'API_KEY' => 'api_key_456'
// SMTP_PASSWORD not in result
]);
});
it('returns empty array when no secrets are available', function () {
$variables = [
'DB_PASSWORD' => 'plain_password',
'API_KEY' => 'plain_key'
];
$keys = ['DB_PASSWORD', 'API_KEY', 'SMTP_PASSWORD'];
$result = $this->resolver->resolveMultiple($keys, $variables);
expect($result)->toBe([]);
});
it('returns empty array for empty keys list', function () {
$variables = [
'DB_PASSWORD_FILE' => $this->dbPasswordFile
];
$result = $this->resolver->resolveMultiple([], $variables);
expect($result)->toBe([]);
});
it('skips non-existent files', function () {
$variables = [
'DB_PASSWORD_FILE' => $this->dbPasswordFile,
'API_KEY_FILE' => $this->testDir . '/non_existent',
'SMTP_PASSWORD_FILE' => $this->smtpPasswordFile
];
$keys = ['DB_PASSWORD', 'API_KEY', 'SMTP_PASSWORD'];
$result = $this->resolver->resolveMultiple($keys, $variables);
expect($result)->toBe([
'DB_PASSWORD' => 'db_secret_123',
'SMTP_PASSWORD' => 'smtp_secret_789'
]);
});
it('skips unreadable files', function () {
chmod($this->apiKeyFile, 0000);
$variables = [
'DB_PASSWORD_FILE' => $this->dbPasswordFile,
'API_KEY_FILE' => $this->apiKeyFile,
'SMTP_PASSWORD_FILE' => $this->smtpPasswordFile
];
$keys = ['DB_PASSWORD', 'API_KEY', 'SMTP_PASSWORD'];
$result = $this->resolver->resolveMultiple($keys, $variables);
expect($result)->toBe([
'DB_PASSWORD' => 'db_secret_123',
'SMTP_PASSWORD' => 'smtp_secret_789'
]);
// Restore permissions for cleanup
chmod($this->apiKeyFile, 0644);
});
it('trims whitespace from all resolved secrets', function () {
file_put_contents($this->dbPasswordFile, " db_secret \n");
file_put_contents($this->apiKeyFile, "\n api_key ");
$variables = [
'DB_PASSWORD_FILE' => $this->dbPasswordFile,
'API_KEY_FILE' => $this->apiKeyFile
];
$keys = ['DB_PASSWORD', 'API_KEY'];
$result = $this->resolver->resolveMultiple($keys, $variables);
expect($result)->toBe([
'DB_PASSWORD' => 'db_secret',
'API_KEY' => 'api_key'
]);
});
it('handles mixed success and failure scenarios', function () {
$variables = [
'DB_PASSWORD_FILE' => $this->dbPasswordFile,
'API_KEY_FILE' => 12345, // Invalid (not string)
'SMTP_PASSWORD_FILE' => $this->smtpPasswordFile,
'MISSING_FILE' => $this->testDir . '/non_existent'
];
$keys = ['DB_PASSWORD', 'API_KEY', 'SMTP_PASSWORD', 'MISSING'];
$result = $this->resolver->resolveMultiple($keys, $variables);
expect($result)->toBe([
'DB_PASSWORD' => 'db_secret_123',
'SMTP_PASSWORD' => 'smtp_secret_789'
]);
});
it('preserves order of successfully resolved secrets', function () {
$variables = [
'DB_PASSWORD_FILE' => $this->dbPasswordFile,
'API_KEY_FILE' => $this->apiKeyFile,
'SMTP_PASSWORD_FILE' => $this->smtpPasswordFile
];
$keys = ['SMTP_PASSWORD', 'DB_PASSWORD', 'API_KEY'];
$result = $this->resolver->resolveMultiple($keys, $variables);
expect($result)->toBe([
'SMTP_PASSWORD' => 'smtp_secret_789',
'DB_PASSWORD' => 'db_secret_123',
'API_KEY' => 'api_key_456'
]);
});
it('handles duplicate keys in input', function () {
$variables = [
'DB_PASSWORD_FILE' => $this->dbPasswordFile,
'API_KEY_FILE' => $this->apiKeyFile
];
$keys = ['DB_PASSWORD', 'API_KEY', 'DB_PASSWORD', 'API_KEY'];
$result = $this->resolver->resolveMultiple($keys, $variables);
// Last duplicate wins
expect($result)->toBe([
'DB_PASSWORD' => 'db_secret_123',
'API_KEY' => 'api_key_456'
]);
});
});
describe('edge cases', function () {
it('handles very long file paths', function () {
$longPath = $this->testDir . '/' . str_repeat('a', 200) . '_secret';
$this->testFile = $longPath;
file_put_contents($this->testFile, 'long_path_secret');
$variables = [
'LONG_PATH_FILE' => $this->testFile
];
$result = $this->resolver->resolve('LONG_PATH', $variables);
expect($result)->toBe('long_path_secret');
});
it('handles very large file content', function () {
$this->testFile = $this->testDir . '/large_secret';
$largeContent = str_repeat('secret', 10000); // 60KB content
file_put_contents($this->testFile, $largeContent);
$variables = [
'LARGE_FILE' => $this->testFile
];
$result = $this->resolver->resolve('LARGE', $variables);
expect($result)->toBe($largeContent);
});
it('handles file with null bytes', function () {
$this->testFile = $this->testDir . '/null_bytes_secret';
file_put_contents($this->testFile, "secret\0with\0nulls");
$variables = [
'NULL_BYTES_FILE' => $this->testFile
];
$result = $this->resolver->resolve('NULL_BYTES', $variables);
expect($result)->toBe("secret\0with\0nulls");
});
it('handles concurrent access to same file', function () {
$this->testFile = $this->testDir . '/concurrent_secret';
file_put_contents($this->testFile, 'concurrent_value');
$variables = [
'CONCURRENT_FILE' => $this->testFile
];
// Simulate concurrent access
$result1 = $this->resolver->resolve('CONCURRENT', $variables);
$result2 = $this->resolver->resolve('CONCURRENT', $variables);
expect($result1)->toBe('concurrent_value');
expect($result2)->toBe('concurrent_value');
});
it('handles symlinks to secret files', function () {
$this->testFile = $this->testDir . '/original_secret';
$symlinkPath = $this->testDir . '/symlink_secret';
file_put_contents($this->testFile, 'symlink_value');
symlink($this->testFile, $symlinkPath);
$variables = [
'SYMLINK_FILE' => $symlinkPath
];
$result = $this->resolver->resolve('SYMLINK', $variables);
expect($result)->toBe('symlink_value');
// Cleanup symlink
unlink($symlinkPath);
});
it('returns null for directories', function () {
$dirPath = $this->testDir . '/secret_dir';
mkdir($dirPath, 0777, true);
$variables = [
'DIR_FILE' => $dirPath
];
$result = $this->resolver->resolve('DIR', $variables);
expect($result)->toBeNull();
// Cleanup directory
rmdir($dirPath);
});
});
describe('Docker Secrets real-world scenarios', function () {
it('resolves typical Docker Swarm secret', function () {
$this->testFile = $this->testDir . '/docker_secret';
// Docker secrets typically have trailing newline
file_put_contents($this->testFile, "production_password\n");
$variables = [
'DB_PASSWORD_FILE' => $this->testFile
];
$result = $this->resolver->resolve('DB_PASSWORD', $variables);
expect($result)->toBe('production_password');
});
it('resolves secrets from /run/secrets path pattern', function () {
// Simulate Docker Swarm /run/secrets pattern
$secretsDir = $this->testDir . '/run/secrets';
mkdir($secretsDir, 0777, true);
$this->testFile = $secretsDir . '/db_password';
file_put_contents($this->testFile, 'swarm_secret');
$variables = [
'DB_PASSWORD_FILE' => $this->testFile
];
$result = $this->resolver->resolve('DB_PASSWORD', $variables);
expect($result)->toBe('swarm_secret');
// Cleanup
unlink($this->testFile);
rmdir($secretsDir);
});
it('handles multiple Docker secrets in production environment', function () {
$secretsDir = $this->testDir . '/run/secrets';
mkdir($secretsDir, 0777, true);
$dbPassword = $secretsDir . '/db_password';
$apiKey = $secretsDir . '/api_key';
$jwtSecret = $secretsDir . '/jwt_secret';
file_put_contents($dbPassword, "prod_db_pass\n");
file_put_contents($apiKey, "prod_api_key\n");
file_put_contents($jwtSecret, "prod_jwt_secret\n");
$variables = [
'DB_PASSWORD_FILE' => $dbPassword,
'API_KEY_FILE' => $apiKey,
'JWT_SECRET_FILE' => $jwtSecret
];
$keys = ['DB_PASSWORD', 'API_KEY', 'JWT_SECRET'];
$result = $this->resolver->resolveMultiple($keys, $variables);
expect($result)->toBe([
'DB_PASSWORD' => 'prod_db_pass',
'API_KEY' => 'prod_api_key',
'JWT_SECRET' => 'prod_jwt_secret'
]);
// Cleanup
unlink($dbPassword);
unlink($apiKey);
unlink($jwtSecret);
rmdir($secretsDir);
});
});
});

View File

@@ -0,0 +1,770 @@
<?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);
}
});
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() - 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');
});
});
});

View File

@@ -0,0 +1,496 @@
<?php
declare(strict_types=1);
use App\Framework\Config\EnvFileParser;
use App\Framework\Filesystem\ValueObjects\FilePath;
describe('EnvFileParser', function () {
beforeEach(function () {
$this->parser = new EnvFileParser();
});
describe('parseString()', function () {
it('parses basic key=value pairs', function () {
$content = <<<ENV
KEY1=value1
KEY2=value2
KEY3=value3
ENV;
$result = $this->parser->parseString($content);
expect($result)->toBe([
'KEY1' => 'value1',
'KEY2' => 'value2',
'KEY3' => 'value3',
]);
});
it('handles empty lines', function () {
$content = <<<ENV
KEY1=value1
KEY2=value2
KEY3=value3
ENV;
$result = $this->parser->parseString($content);
expect($result)->toBe([
'KEY1' => 'value1',
'KEY2' => 'value2',
'KEY3' => 'value3',
]);
});
it('skips comment lines', function () {
$content = <<<ENV
# This is a comment
KEY1=value1
# Another comment
KEY2=value2
ENV;
$result = $this->parser->parseString($content);
expect($result)->toBe([
'KEY1' => 'value1',
'KEY2' => 'value2',
]);
});
it('removes double quotes from values', function () {
$content = <<<ENV
KEY1="quoted value"
KEY2="value with spaces"
KEY3="123"
ENV;
$result = $this->parser->parseString($content);
expect($result)->toBe([
'KEY1' => 'quoted value',
'KEY2' => 'value with spaces',
'KEY3' => 123, // Numeric string gets cast to int
]);
});
it('removes single quotes from values', function () {
$content = <<<ENV
KEY1='single quoted'
KEY2='value with spaces'
KEY3='true'
ENV;
$result = $this->parser->parseString($content);
expect($result)->toBe([
'KEY1' => 'single quoted',
'KEY2' => 'value with spaces',
'KEY3' => true, // 'true' gets cast to boolean
]);
});
it('casts "true" to boolean true', function () {
$content = <<<ENV
BOOL1=true
BOOL2=True
BOOL3=TRUE
ENV;
$result = $this->parser->parseString($content);
expect($result['BOOL1'])->toBeTrue();
expect($result['BOOL2'])->toBeTrue();
expect($result['BOOL3'])->toBeTrue();
});
it('casts "false" to boolean false', function () {
$content = <<<ENV
BOOL1=false
BOOL2=False
BOOL3=FALSE
ENV;
$result = $this->parser->parseString($content);
expect($result['BOOL1'])->toBeFalse();
expect($result['BOOL2'])->toBeFalse();
expect($result['BOOL3'])->toBeFalse();
});
it('casts "null" to null', function () {
$content = <<<ENV
NULL1=null
NULL2=Null
NULL3=NULL
ENV;
$result = $this->parser->parseString($content);
expect($result['NULL1'])->toBeNull();
expect($result['NULL2'])->toBeNull();
expect($result['NULL3'])->toBeNull();
});
it('casts numeric strings to integers', function () {
$content = <<<ENV
INT1=0
INT2=123
INT3=456
INT4=-789
ENV;
$result = $this->parser->parseString($content);
expect($result['INT1'])->toBe(0);
expect($result['INT2'])->toBe(123);
expect($result['INT3'])->toBe(456);
expect($result['INT4'])->toBe(-789);
});
it('casts numeric strings with decimals to floats', function () {
$content = <<<ENV
FLOAT1=0.0
FLOAT2=123.45
FLOAT3=-67.89
ENV;
$result = $this->parser->parseString($content);
expect($result['FLOAT1'])->toBe(0.0);
expect($result['FLOAT2'])->toBe(123.45);
expect($result['FLOAT3'])->toBe(-67.89);
});
it('keeps non-numeric strings as strings', function () {
$content = <<<ENV
STR1=hello
STR2=world123
STR3=abc-def
ENV;
$result = $this->parser->parseString($content);
expect($result['STR1'])->toBe('hello');
expect($result['STR2'])->toBe('world123');
expect($result['STR3'])->toBe('abc-def');
});
it('trims whitespace around keys and values', function () {
$content = <<<ENV
KEY1 = value1
KEY2= value2
KEY3=value3
ENV;
$result = $this->parser->parseString($content);
expect($result)->toBe([
'KEY1' => 'value1',
'KEY2' => 'value2',
'KEY3' => 'value3',
]);
});
it('handles empty values', function () {
$content = <<<ENV
EMPTY1=
EMPTY2=""
EMPTY3=''
ENV;
$result = $this->parser->parseString($content);
expect($result['EMPTY1'])->toBe('');
expect($result['EMPTY2'])->toBe('');
expect($result['EMPTY3'])->toBe('');
});
it('handles values with equals signs', function () {
$content = <<<ENV
URL=https://example.com?param=value
EQUATION=a=b+c
ENV;
$result = $this->parser->parseString($content);
expect($result['URL'])->toBe('https://example.com?param=value');
expect($result['EQUATION'])->toBe('a=b+c');
});
it('skips lines without equals sign', function () {
$content = <<<ENV
KEY1=value1
INVALID_LINE_WITHOUT_EQUALS
KEY2=value2
ENV;
$result = $this->parser->parseString($content);
expect($result)->toBe([
'KEY1' => 'value1',
'KEY2' => 'value2',
]);
});
it('handles mixed content with all features', function () {
$content = <<<ENV
# Database configuration
DB_HOST=localhost
DB_PORT=3306
DB_NAME="my_database"
DB_USER='root'
DB_PASS=
# Application settings
APP_ENV=production
APP_DEBUG=false
APP_URL=https://example.com
# Feature flags
FEATURE_ENABLED=true
CACHE_TTL=3600
API_TIMEOUT=30.5
ENV;
$result = $this->parser->parseString($content);
expect($result)->toBe([
'DB_HOST' => 'localhost',
'DB_PORT' => 3306,
'DB_NAME' => 'my_database',
'DB_USER' => 'root',
'DB_PASS' => '',
'APP_ENV' => 'production',
'APP_DEBUG' => false,
'APP_URL' => 'https://example.com',
'FEATURE_ENABLED' => true,
'CACHE_TTL' => 3600,
'API_TIMEOUT' => 30.5,
]);
});
});
describe('removeQuotes()', function () {
it('removes double quotes', function () {
$result = $this->parser->removeQuotes('"quoted value"');
expect($result)->toBe('quoted value');
});
it('removes single quotes', function () {
$result = $this->parser->removeQuotes("'quoted value'");
expect($result)->toBe('quoted value');
});
it('keeps unquoted values unchanged', function () {
$result = $this->parser->removeQuotes('unquoted');
expect($result)->toBe('unquoted');
});
it('keeps mismatched quotes unchanged', function () {
$result1 = $this->parser->removeQuotes('"mismatched\'');
$result2 = $this->parser->removeQuotes('\'mismatched"');
expect($result1)->toBe('"mismatched\'');
expect($result2)->toBe('\'mismatched"');
});
it('keeps empty quoted strings as empty', function () {
$result1 = $this->parser->removeQuotes('""');
$result2 = $this->parser->removeQuotes("''");
expect($result1)->toBe('');
expect($result2)->toBe('');
});
it('keeps single quote character unchanged', function () {
$result = $this->parser->removeQuotes('"');
expect($result)->toBe('"');
});
});
describe('parse() with FilePath', function () {
beforeEach(function () {
// Create temporary test directory
$this->testDir = '/home/michael/dev/michaelschiemer/tests/tmp';
if (!is_dir($this->testDir)) {
mkdir($this->testDir, 0777, true);
}
});
afterEach(function () {
// Clean up test files
if (isset($this->testFile) && file_exists($this->testFile)) {
unlink($this->testFile);
}
});
it('parses file with FilePath object', function () {
$this->testFile = $this->testDir . '/test.env';
file_put_contents($this->testFile, "KEY1=value1\nKEY2=value2");
$filePath = FilePath::create($this->testFile);
$result = $this->parser->parse($filePath);
expect($result)->toBe([
'KEY1' => 'value1',
'KEY2' => 'value2',
]);
});
it('parses file with string path', function () {
$this->testFile = $this->testDir . '/test.env';
file_put_contents($this->testFile, "KEY1=value1\nKEY2=value2");
$result = $this->parser->parse($this->testFile);
expect($result)->toBe([
'KEY1' => 'value1',
'KEY2' => 'value2',
]);
});
it('returns empty array for non-existent file', function () {
$result = $this->parser->parse($this->testDir . '/non-existent.env');
expect($result)->toBe([]);
});
it('returns empty array for unreadable file', function () {
$this->testFile = $this->testDir . '/unreadable.env';
file_put_contents($this->testFile, "KEY=value");
chmod($this->testFile, 0000); // Make unreadable
$result = $this->parser->parse($this->testFile);
expect($result)->toBe([]);
// Restore permissions for cleanup
chmod($this->testFile, 0644);
});
it('parses real .env file format', function () {
$this->testFile = $this->testDir . '/.env.test';
$content = <<<ENV
# Environment Configuration
APP_NAME="My Application"
APP_ENV=production
APP_DEBUG=false
# Database
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=homestead
DB_USERNAME=homestead
DB_PASSWORD=secret
# Redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
ENV;
file_put_contents($this->testFile, $content);
$result = $this->parser->parse($this->testFile);
expect($result)->toBe([
'APP_NAME' => 'My Application',
'APP_ENV' => 'production',
'APP_DEBUG' => false,
'DB_CONNECTION' => 'mysql',
'DB_HOST' => '127.0.0.1',
'DB_PORT' => 3306,
'DB_DATABASE' => 'homestead',
'DB_USERNAME' => 'homestead',
'DB_PASSWORD' => 'secret',
'REDIS_HOST' => '127.0.0.1',
'REDIS_PASSWORD' => null,
'REDIS_PORT' => 6379,
]);
});
});
describe('edge cases', function () {
it('handles Windows line endings (CRLF)', function () {
$content = "KEY1=value1\r\nKEY2=value2\r\n";
$result = $this->parser->parseString($content);
expect($result)->toBe([
'KEY1' => 'value1',
'KEY2' => 'value2',
]);
});
it('handles mixed line endings', function () {
$content = "KEY1=value1\nKEY2=value2\r\nKEY3=value3\r";
$result = $this->parser->parseString($content);
expect($result)->toBe([
'KEY1' => 'value1',
'KEY2' => 'value2',
'KEY3' => 'value3',
]);
});
it('handles keys with underscores and numbers', function () {
$content = <<<ENV
VAR_1=value1
VAR_2_TEST=value2
VAR123=value3
ENV;
$result = $this->parser->parseString($content);
expect($result)->toBe([
'VAR_1' => 'value1',
'VAR_2_TEST' => 'value2',
'VAR123' => 'value3',
]);
});
it('handles special characters in values', function () {
$content = <<<ENV
SPECIAL1=value!@#$%
SPECIAL2=value^&*()
SPECIAL3=value[]{}
ENV;
$result = $this->parser->parseString($content);
expect($result['SPECIAL1'])->toBe('value!@#$%');
expect($result['SPECIAL2'])->toBe('value^&*()');
expect($result['SPECIAL3'])->toBe('value[]{}');
});
it('handles URL values correctly', function () {
$content = <<<ENV
URL1=http://localhost:8080
URL2=https://example.com/path?param=value&other=123
URL3=mysql://user:pass@localhost:3306/database
ENV;
$result = $this->parser->parseString($content);
expect($result['URL1'])->toBe('http://localhost:8080');
expect($result['URL2'])->toBe('https://example.com/path?param=value&other=123');
expect($result['URL3'])->toBe('mysql://user:pass@localhost:3306/database');
});
it('overrides duplicate keys with last value', function () {
$content = <<<ENV
KEY=value1
KEY=value2
KEY=value3
ENV;
$result = $this->parser->parseString($content);
expect($result['KEY'])->toBe('value3');
});
});
});

View File

@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
use App\Framework\Config\Environment;
describe('Environment - Docker Secrets Support', function () {
beforeEach(function () {
// Setup test directory for secret files
$this->secretsDir = sys_get_temp_dir() . '/test-secrets-' . uniqid();
mkdir($this->secretsDir, 0700, true);
});
afterEach(function () {
// Cleanup test directory
if (is_dir($this->secretsDir)) {
array_map('unlink', glob($this->secretsDir . '/*'));
rmdir($this->secretsDir);
}
});
it('returns direct environment variable when available', function () {
$env = new Environment(['DB_PASSWORD' => 'direct_password']);
$result = $env->get('DB_PASSWORD');
expect($result)->toBe('direct_password');
});
it('reads from *_FILE when direct variable not available', function () {
// Create secret file
$secretFile = $this->secretsDir . '/db_password';
file_put_contents($secretFile, 'file_password');
$env = new Environment(['DB_PASSWORD_FILE' => $secretFile]);
$result = $env->get('DB_PASSWORD');
expect($result)->toBe('file_password');
});
it('trims whitespace from file contents', function () {
// Docker secrets often have trailing newlines
$secretFile = $this->secretsDir . '/db_password';
file_put_contents($secretFile, "file_password\n");
$env = new Environment(['DB_PASSWORD_FILE' => $secretFile]);
$result = $env->get('DB_PASSWORD');
expect($result)->toBe('file_password');
});
it('prioritizes direct variable over *_FILE', function () {
$secretFile = $this->secretsDir . '/db_password';
file_put_contents($secretFile, 'file_password');
$env = new Environment([
'DB_PASSWORD' => 'direct_password',
'DB_PASSWORD_FILE' => $secretFile
]);
$result = $env->get('DB_PASSWORD');
expect($result)->toBe('direct_password')
->and($result)->not->toBe('file_password');
});
it('returns default when neither direct nor *_FILE available', function () {
$env = new Environment([]);
$result = $env->get('DB_PASSWORD', 'default_value');
expect($result)->toBe('default_value');
});
it('returns null when file path does not exist', function () {
$env = new Environment(['DB_PASSWORD_FILE' => '/nonexistent/path']);
$result = $env->get('DB_PASSWORD');
expect($result)->toBeNull();
});
it('returns null when file path is not readable', function () {
$secretFile = $this->secretsDir . '/unreadable';
file_put_contents($secretFile, 'password');
chmod($secretFile, 0000); // Make unreadable
$env = new Environment(['DB_PASSWORD_FILE' => $secretFile]);
$result = $env->get('DB_PASSWORD');
expect($result)->toBeNull();
chmod($secretFile, 0600); // Restore for cleanup
});
it('handles multiple secrets from files', function () {
// Create multiple secret files
$dbPasswordFile = $this->secretsDir . '/db_password';
$apiKeyFile = $this->secretsDir . '/api_key';
file_put_contents($dbPasswordFile, 'db_secret');
file_put_contents($apiKeyFile, 'api_secret');
$env = new Environment([
'DB_PASSWORD_FILE' => $dbPasswordFile,
'API_KEY_FILE' => $apiKeyFile
]);
expect($env->get('DB_PASSWORD'))->toBe('db_secret')
->and($env->get('API_KEY'))->toBe('api_secret');
});
it('works with EnvKey enum', function () {
$secretFile = $this->secretsDir . '/db_password';
file_put_contents($secretFile, 'enum_password');
$env = new Environment(['DB_PASSWORD_FILE' => $secretFile]);
// Assuming DB_PASSWORD exists in EnvKey enum
$result = $env->get('DB_PASSWORD');
expect($result)->toBe('enum_password');
});
it('handles empty secret files gracefully', function () {
$secretFile = $this->secretsDir . '/empty';
file_put_contents($secretFile, '');
$env = new Environment(['SECRET_FILE' => $secretFile]);
$result = $env->get('SECRET');
expect($result)->toBe(''); // Empty string, not null
});
it('ignores non-string *_FILE values', function () {
$env = new Environment(['DB_PASSWORD_FILE' => 12345]); // Invalid type
$result = $env->get('DB_PASSWORD');
expect($result)->toBeNull();
});
it('real-world Docker Swarm secrets scenario', function () {
// Simulate Docker Swarm secrets mount
$dbPasswordFile = $this->secretsDir . '/db_password';
$appKeyFile = $this->secretsDir . '/app_key';
file_put_contents($dbPasswordFile, "MyS3cur3P@ssw0rd\n"); // With newline
file_put_contents($appKeyFile, "base64:abcdef123456");
$env = new Environment([
'DB_HOST' => 'localhost', // Direct env var
'DB_PORT' => '3306', // Direct env var
'DB_PASSWORD_FILE' => $dbPasswordFile, // From Docker secret
'APP_KEY_FILE' => $appKeyFile, // From Docker secret
]);
// Verify all values work correctly
expect($env->get('DB_HOST'))->toBe('localhost')
->and($env->get('DB_PORT'))->toBe('3306')
->and($env->get('DB_PASSWORD'))->toBe('MyS3cur3P@ssw0rd')
->and($env->get('APP_KEY'))->toBe('base64:abcdef123456');
});
it('works with getRequired() for secrets from files', function () {
$secretFile = $this->secretsDir . '/required_secret';
file_put_contents($secretFile, 'required_value');
$env = new Environment(['REQUIRED_SECRET_FILE' => $secretFile]);
$result = $env->getRequired('REQUIRED_SECRET');
expect($result)->toBe('required_value');
});
it('throws exception with getRequired() when secret file not found', function () {
$env = new Environment(['REQUIRED_SECRET_FILE' => '/nonexistent/path']);
expect(fn() => $env->getRequired('REQUIRED_SECRET'))
->toThrow(App\Framework\Config\Exceptions\RequiredEnvironmentVariableException::class);
});
});

View File

@@ -0,0 +1,734 @@
<?php
declare(strict_types=1);
use App\Framework\Config\DockerSecretsResolver;
use App\Framework\Config\EnvKey;
use App\Framework\Config\Environment;
use App\Framework\Exception\Config\EnvironmentVariableNotFoundException;
describe('Environment', function () {
beforeEach(function () {
// Create test environment variables
$this->testVariables = [
'APP_NAME' => 'TestApp',
'APP_ENV' => 'development',
'APP_DEBUG' => 'true',
'DB_HOST' => 'localhost',
'DB_PORT' => '3306',
'DB_NAME' => 'testdb',
'CACHE_TTL' => '3600',
'API_TIMEOUT' => '30.5',
'FEATURE_FLAGS' => 'feature1,feature2,feature3',
'EMPTY_STRING' => '',
'NULL_VALUE' => 'null',
'FALSE_VALUE' => 'false',
'ZERO_VALUE' => '0',
];
$this->dockerSecretsResolver = new DockerSecretsResolver();
$this->environment = new Environment($this->testVariables, $this->dockerSecretsResolver);
});
describe('get()', function () {
it('returns string value for existing key', function () {
$value = $this->environment->get('APP_NAME');
expect($value)->toBe('TestApp');
});
it('returns null for non-existent key', function () {
$value = $this->environment->get('NON_EXISTENT');
expect($value)->toBeNull();
});
it('returns default value for non-existent key', function () {
$value = $this->environment->get('NON_EXISTENT', 'default_value');
expect($value)->toBe('default_value');
});
it('returns empty string when value is empty', function () {
$value = $this->environment->get('EMPTY_STRING');
expect($value)->toBe('');
});
it('returns string "null" for null value', function () {
$value = $this->environment->get('NULL_VALUE');
expect($value)->toBe('null');
});
it('accepts EnvKey enum', function () {
$value = $this->environment->get(EnvKey::APP_NAME);
expect($value)->toBe('TestApp');
});
});
describe('getInt()', function () {
it('returns integer for numeric string', function () {
$value = $this->environment->getInt('DB_PORT');
expect($value)->toBe(3306);
expect($value)->toBeInt();
});
it('returns null for non-existent key', function () {
$value = $this->environment->getInt('NON_EXISTENT');
expect($value)->toBeNull();
});
it('returns default value for non-existent key', function () {
$value = $this->environment->getInt('NON_EXISTENT', 9999);
expect($value)->toBe(9999);
});
it('returns zero for "0" string', function () {
$value = $this->environment->getInt('ZERO_VALUE');
expect($value)->toBe(0);
expect($value)->toBeInt();
});
it('accepts EnvKey enum', function () {
$value = $this->environment->getInt(EnvKey::DB_PORT);
expect($value)->toBe(3306);
});
it('handles negative integers', function () {
$env = new Environment(['NEGATIVE' => '-42'], $this->dockerSecretsResolver);
$value = $env->getInt('NEGATIVE');
expect($value)->toBe(-42);
});
});
describe('getBool()', function () {
it('returns true for "true" string', function () {
$value = $this->environment->getBool('APP_DEBUG');
expect($value)->toBeTrue();
});
it('returns false for "false" string', function () {
$value = $this->environment->getBool('FALSE_VALUE');
expect($value)->toBeFalse();
});
it('returns null for non-existent key', function () {
$value = $this->environment->getBool('NON_EXISTENT');
expect($value)->toBeNull();
});
it('returns default value for non-existent key', function () {
$value = $this->environment->getBool('NON_EXISTENT', true);
expect($value)->toBeTrue();
});
it('handles case-insensitive true values', function () {
$env = new Environment([
'BOOL1' => 'true',
'BOOL2' => 'True',
'BOOL3' => 'TRUE',
'BOOL4' => '1',
], $this->dockerSecretsResolver);
expect($env->getBool('BOOL1'))->toBeTrue();
expect($env->getBool('BOOL2'))->toBeTrue();
expect($env->getBool('BOOL3'))->toBeTrue();
expect($env->getBool('BOOL4'))->toBeTrue();
});
it('handles case-insensitive false values', function () {
$env = new Environment([
'BOOL1' => 'false',
'BOOL2' => 'False',
'BOOL3' => 'FALSE',
'BOOL4' => '0',
'BOOL5' => '',
], $this->dockerSecretsResolver);
expect($env->getBool('BOOL1'))->toBeFalse();
expect($env->getBool('BOOL2'))->toBeFalse();
expect($env->getBool('BOOL3'))->toBeFalse();
expect($env->getBool('BOOL4'))->toBeFalse();
expect($env->getBool('BOOL5'))->toBeFalse();
});
it('accepts EnvKey enum', function () {
$value = $this->environment->getBool(EnvKey::APP_DEBUG);
expect($value)->toBeTrue();
});
});
describe('getFloat()', function () {
it('returns float for decimal string', function () {
$value = $this->environment->getFloat('API_TIMEOUT');
expect($value)->toBe(30.5);
expect($value)->toBeFloat();
});
it('returns null for non-existent key', function () {
$value = $this->environment->getFloat('NON_EXISTENT');
expect($value)->toBeNull();
});
it('returns default value for non-existent key', function () {
$value = $this->environment->getFloat('NON_EXISTENT', 99.99);
expect($value)->toBe(99.99);
});
it('converts integer string to float', function () {
$value = $this->environment->getFloat('DB_PORT');
expect($value)->toBe(3306.0);
expect($value)->toBeFloat();
});
it('handles negative floats', function () {
$env = new Environment(['NEGATIVE' => '-42.5'], $this->dockerSecretsResolver);
$value = $env->getFloat('NEGATIVE');
expect($value)->toBe(-42.5);
});
it('accepts EnvKey enum', function () {
$value = $this->environment->getFloat(EnvKey::API_TIMEOUT);
expect($value)->toBe(30.5);
});
});
describe('getArray()', function () {
it('returns array from comma-separated string', function () {
$value = $this->environment->getArray('FEATURE_FLAGS');
expect($value)->toBe(['feature1', 'feature2', 'feature3']);
expect($value)->toBeArray();
});
it('returns null for non-existent key', function () {
$value = $this->environment->getArray('NON_EXISTENT');
expect($value)->toBeNull();
});
it('returns default value for non-existent key', function () {
$value = $this->environment->getArray('NON_EXISTENT', ['default1', 'default2']);
expect($value)->toBe(['default1', 'default2']);
});
it('returns single-element array for non-comma value', function () {
$value = $this->environment->getArray('APP_NAME');
expect($value)->toBe(['TestApp']);
});
it('trims whitespace from array elements', function () {
$env = new Environment([
'WHITESPACE_ARRAY' => 'value1 , value2 , value3 ',
], $this->dockerSecretsResolver);
$value = $env->getArray('WHITESPACE_ARRAY');
expect($value)->toBe(['value1', 'value2', 'value3']);
});
it('returns empty array for empty string', function () {
$value = $this->environment->getArray('EMPTY_STRING');
expect($value)->toBe(['']);
});
it('accepts custom separator', function () {
$env = new Environment([
'PIPE_SEPARATED' => 'value1|value2|value3',
], $this->dockerSecretsResolver);
$value = $env->getArray('PIPE_SEPARATED', separator: '|');
expect($value)->toBe(['value1', 'value2', 'value3']);
});
it('accepts EnvKey enum', function () {
$value = $this->environment->getArray(EnvKey::FEATURE_FLAGS);
expect($value)->toBe(['feature1', 'feature2', 'feature3']);
});
});
describe('getEnum()', function () {
it('returns enum value for valid string', function () {
$value = $this->environment->getEnum('APP_ENV', AppEnvironment::class);
expect($value)->toBeInstanceOf(AppEnvironment::class);
expect($value)->toBe(AppEnvironment::DEVELOPMENT);
});
it('returns null for non-existent key', function () {
$value = $this->environment->getEnum('NON_EXISTENT', AppEnvironment::class);
expect($value)->toBeNull();
});
it('returns default value for non-existent key', function () {
$value = $this->environment->getEnum(
'NON_EXISTENT',
AppEnvironment::class,
AppEnvironment::PRODUCTION
);
expect($value)->toBe(AppEnvironment::PRODUCTION);
});
it('handles case-insensitive enum matching', function () {
$env = new Environment([
'ENV1' => 'development',
'ENV2' => 'Development',
'ENV3' => 'DEVELOPMENT',
], $this->dockerSecretsResolver);
expect($env->getEnum('ENV1', AppEnvironment::class))->toBe(AppEnvironment::DEVELOPMENT);
expect($env->getEnum('ENV2', AppEnvironment::class))->toBe(AppEnvironment::DEVELOPMENT);
expect($env->getEnum('ENV3', AppEnvironment::class))->toBe(AppEnvironment::DEVELOPMENT);
});
it('accepts EnvKey enum for key parameter', function () {
$value = $this->environment->getEnum(EnvKey::APP_ENV, AppEnvironment::class);
expect($value)->toBe(AppEnvironment::DEVELOPMENT);
});
it('handles backed enum with integer value', function () {
$env = new Environment(['STATUS' => '1'], $this->dockerSecretsResolver);
$value = $env->getEnum('STATUS', TestIntEnum::class);
expect($value)->toBe(TestIntEnum::ACTIVE);
});
});
describe('require()', function () {
it('returns value for existing key', function () {
$value = $this->environment->require('APP_NAME');
expect($value)->toBe('TestApp');
});
it('throws exception for non-existent key', function () {
$this->environment->require('NON_EXISTENT');
})->throws(EnvironmentVariableNotFoundException::class);
it('accepts EnvKey enum', function () {
$value = $this->environment->require(EnvKey::APP_NAME);
expect($value)->toBe('TestApp');
});
it('throws exception with helpful message', function () {
try {
$this->environment->require('MISSING_VARIABLE');
$this->fail('Expected EnvironmentVariableNotFoundException');
} catch (EnvironmentVariableNotFoundException $e) {
expect($e->getMessage())->toContain('MISSING_VARIABLE');
expect($e->getMessage())->toContain('required');
}
});
});
describe('requireInt()', function () {
it('returns integer for existing numeric key', function () {
$value = $this->environment->requireInt('DB_PORT');
expect($value)->toBe(3306);
expect($value)->toBeInt();
});
it('throws exception for non-existent key', function () {
$this->environment->requireInt('NON_EXISTENT');
})->throws(EnvironmentVariableNotFoundException::class);
it('accepts EnvKey enum', function () {
$value = $this->environment->requireInt(EnvKey::DB_PORT);
expect($value)->toBe(3306);
});
});
describe('requireBool()', function () {
it('returns boolean for existing boolean key', function () {
$value = $this->environment->requireBool('APP_DEBUG');
expect($value)->toBeTrue();
});
it('throws exception for non-existent key', function () {
$this->environment->requireBool('NON_EXISTENT');
})->throws(EnvironmentVariableNotFoundException::class);
it('accepts EnvKey enum', function () {
$value = $this->environment->requireBool(EnvKey::APP_DEBUG);
expect($value)->toBeTrue();
});
});
describe('requireFloat()', function () {
it('returns float for existing numeric key', function () {
$value = $this->environment->requireFloat('API_TIMEOUT');
expect($value)->toBe(30.5);
expect($value)->toBeFloat();
});
it('throws exception for non-existent key', function () {
$this->environment->requireFloat('NON_EXISTENT');
})->throws(EnvironmentVariableNotFoundException::class);
it('accepts EnvKey enum', function () {
$value = $this->environment->requireFloat(EnvKey::API_TIMEOUT);
expect($value)->toBe(30.5);
});
});
describe('requireArray()', function () {
it('returns array for existing comma-separated key', function () {
$value = $this->environment->requireArray('FEATURE_FLAGS');
expect($value)->toBe(['feature1', 'feature2', 'feature3']);
expect($value)->toBeArray();
});
it('throws exception for non-existent key', function () {
$this->environment->requireArray('NON_EXISTENT');
})->throws(EnvironmentVariableNotFoundException::class);
it('accepts EnvKey enum', function () {
$value = $this->environment->requireArray(EnvKey::FEATURE_FLAGS);
expect($value)->toBe(['feature1', 'feature2', 'feature3']);
});
it('accepts custom separator', function () {
$env = new Environment([
'PIPE_SEPARATED' => 'value1|value2|value3',
], $this->dockerSecretsResolver);
$value = $env->requireArray('PIPE_SEPARATED', separator: '|');
expect($value)->toBe(['value1', 'value2', 'value3']);
});
});
describe('requireEnum()', function () {
it('returns enum value for valid string', function () {
$value = $this->environment->requireEnum('APP_ENV', AppEnvironment::class);
expect($value)->toBeInstanceOf(AppEnvironment::class);
expect($value)->toBe(AppEnvironment::DEVELOPMENT);
});
it('throws exception for non-existent key', function () {
$this->environment->requireEnum('NON_EXISTENT', AppEnvironment::class);
})->throws(EnvironmentVariableNotFoundException::class);
it('accepts EnvKey enum for key parameter', function () {
$value = $this->environment->requireEnum(EnvKey::APP_ENV, AppEnvironment::class);
expect($value)->toBe(AppEnvironment::DEVELOPMENT);
});
});
describe('has()', function () {
it('returns true for existing key', function () {
$exists = $this->environment->has('APP_NAME');
expect($exists)->toBeTrue();
});
it('returns false for non-existent key', function () {
$exists = $this->environment->has('NON_EXISTENT');
expect($exists)->toBeFalse();
});
it('returns true for empty string value', function () {
$exists = $this->environment->has('EMPTY_STRING');
expect($exists)->toBeTrue();
});
it('accepts EnvKey enum', function () {
$exists = $this->environment->has(EnvKey::APP_NAME);
expect($exists)->toBeTrue();
});
});
describe('all()', function () {
it('returns all environment variables', function () {
$allVars = $this->environment->all();
expect($allVars)->toBe($this->testVariables);
expect($allVars)->toBeArray();
});
it('returns empty array for empty environment', function () {
$emptyEnv = new Environment([], $this->dockerSecretsResolver);
$allVars = $emptyEnv->all();
expect($allVars)->toBe([]);
});
});
describe('Docker Secrets Integration', function () {
it('resolves Docker secret when file exists', function () {
// Create temporary Docker secret file
$secretPath = '/tmp/docker_secret_test';
file_put_contents($secretPath, 'secret_value_from_file');
$env = new Environment([
'DB_PASSWORD_FILE' => $secretPath,
], $this->dockerSecretsResolver);
$value = $env->get('DB_PASSWORD');
expect($value)->toBe('secret_value_from_file');
// Cleanup
unlink($secretPath);
});
it('returns null when Docker secret file does not exist', function () {
$env = new Environment([
'DB_PASSWORD_FILE' => '/nonexistent/path/to/secret',
], $this->dockerSecretsResolver);
$value = $env->get('DB_PASSWORD');
expect($value)->toBeNull();
});
it('prioritizes direct variable over Docker secret', function () {
// Create temporary Docker secret file
$secretPath = '/tmp/docker_secret_test';
file_put_contents($secretPath, 'secret_from_file');
$env = new Environment([
'DB_PASSWORD' => 'direct_value',
'DB_PASSWORD_FILE' => $secretPath,
], $this->dockerSecretsResolver);
$value = $env->get('DB_PASSWORD');
// Direct variable should win
expect($value)->toBe('direct_value');
// Cleanup
unlink($secretPath);
});
it('trims whitespace from Docker secret content', function () {
// Create temporary Docker secret file with whitespace
$secretPath = '/tmp/docker_secret_test';
file_put_contents($secretPath, " secret_value_with_whitespace \n");
$env = new Environment([
'API_KEY_FILE' => $secretPath,
], $this->dockerSecretsResolver);
$value = $env->get('API_KEY');
expect($value)->toBe('secret_value_with_whitespace');
// Cleanup
unlink($secretPath);
});
it('handles multiple Docker secrets', function () {
// Create multiple secret files
$dbPasswordPath = '/tmp/db_password_secret';
$apiKeyPath = '/tmp/api_key_secret';
file_put_contents($dbPasswordPath, 'db_secret_123');
file_put_contents($apiKeyPath, 'api_secret_456');
$env = new Environment([
'DB_PASSWORD_FILE' => $dbPasswordPath,
'API_KEY_FILE' => $apiKeyPath,
], $this->dockerSecretsResolver);
expect($env->get('DB_PASSWORD'))->toBe('db_secret_123');
expect($env->get('API_KEY'))->toBe('api_secret_456');
// Cleanup
unlink($dbPasswordPath);
unlink($apiKeyPath);
});
});
describe('Edge Cases', function () {
it('handles keys with underscores', function () {
$env = new Environment([
'SOME_LONG_KEY_NAME' => 'value',
], $this->dockerSecretsResolver);
$value = $env->get('SOME_LONG_KEY_NAME');
expect($value)->toBe('value');
});
it('handles keys with numbers', function () {
$env = new Environment([
'VAR_123' => 'value',
], $this->dockerSecretsResolver);
$value = $env->get('VAR_123');
expect($value)->toBe('value');
});
it('handles very long values', function () {
$longValue = str_repeat('a', 10000);
$env = new Environment([
'LONG_VALUE' => $longValue,
], $this->dockerSecretsResolver);
$value = $env->get('LONG_VALUE');
expect($value)->toBe($longValue);
});
it('handles special characters in values', function () {
$env = new Environment([
'SPECIAL' => 'value!@#$%^&*()_+-=[]{}|;:\'",.<>?/`~',
], $this->dockerSecretsResolver);
$value = $env->get('SPECIAL');
expect($value)->toBe('value!@#$%^&*()_+-=[]{}|;:\'",.<>?/`~');
});
it('handles unicode characters', function () {
$env = new Environment([
'UNICODE' => 'Hello 世界 🌍',
], $this->dockerSecretsResolver);
$value = $env->get('UNICODE');
expect($value)->toBe('Hello 世界 🌍');
});
it('handles JSON strings as values', function () {
$jsonString = '{"key":"value","nested":{"foo":"bar"}}';
$env = new Environment([
'JSON_CONFIG' => $jsonString,
], $this->dockerSecretsResolver);
$value = $env->get('JSON_CONFIG');
expect($value)->toBe($jsonString);
expect(json_decode($value, true))->toBe([
'key' => 'value',
'nested' => ['foo' => 'bar'],
]);
});
it('handles URL strings as values', function () {
$env = new Environment([
'DATABASE_URL' => 'mysql://user:pass@localhost:3306/dbname',
], $this->dockerSecretsResolver);
$value = $env->get('DATABASE_URL');
expect($value)->toBe('mysql://user:pass@localhost:3306/dbname');
});
it('handles base64-encoded values', function () {
$base64Value = base64_encode('secret_data');
$env = new Environment([
'ENCODED_SECRET' => $base64Value,
], $this->dockerSecretsResolver);
$value = $env->get('ENCODED_SECRET');
expect($value)->toBe($base64Value);
expect(base64_decode($value))->toBe('secret_data');
});
});
describe('Type Coercion Edge Cases', function () {
it('handles non-numeric string for getInt', function () {
$env = new Environment([
'NOT_A_NUMBER' => 'abc',
], $this->dockerSecretsResolver);
$value = $env->getInt('NOT_A_NUMBER');
expect($value)->toBe(0); // PHP's (int) cast behavior
});
it('handles non-boolean string for getBool', function () {
$env = new Environment([
'NOT_A_BOOL' => 'maybe',
], $this->dockerSecretsResolver);
$value = $env->getBool('NOT_A_BOOL');
expect($value)->toBeFalse(); // Non-empty string that's not "true" or "1"
});
it('handles scientific notation for getFloat', function () {
$env = new Environment([
'SCIENTIFIC' => '1.5e3',
], $this->dockerSecretsResolver);
$value = $env->getFloat('SCIENTIFIC');
expect($value)->toBe(1500.0);
});
it('handles hexadecimal strings for getInt', function () {
$env = new Environment([
'HEX_VALUE' => '0xFF',
], $this->dockerSecretsResolver);
$value = $env->getInt('HEX_VALUE');
expect($value)->toBe(0); // String "0xFF" casts to 0
});
});
});
// Test enums for getEnum() testing
enum AppEnvironment: string
{
case DEVELOPMENT = 'development';
case STAGING = 'staging';
case PRODUCTION = 'production';
}
enum TestIntEnum: int
{
case INACTIVE = 0;
case ACTIVE = 1;
case PENDING = 2;
}