feat: CI/CD pipeline setup complete - Ansible playbooks updated, secrets configured, workflow ready
This commit is contained in:
645
tests/Unit/Framework/Config/DockerSecretsResolverTest.php
Normal file
645
tests/Unit/Framework/Config/DockerSecretsResolverTest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
770
tests/Unit/Framework/Config/EncryptedEnvLoaderTest.php
Normal file
770
tests/Unit/Framework/Config/EncryptedEnvLoaderTest.php
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
496
tests/Unit/Framework/Config/EnvFileParserTest.php
Normal file
496
tests/Unit/Framework/Config/EnvFileParserTest.php
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
185
tests/Unit/Framework/Config/EnvironmentDockerSecretsTest.php
Normal file
185
tests/Unit/Framework/Config/EnvironmentDockerSecretsTest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
734
tests/Unit/Framework/Config/EnvironmentTest.php
Normal file
734
tests/Unit/Framework/Config/EnvironmentTest.php
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user