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

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

View File

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