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); }); }); });