diff --git a/composer.json b/composer.json index 2834438..42c840b 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,9 @@ "minimum-stability": "stable", "require": { "php": "^8.4", - "neuron-php/application": "0.8.*" + "neuron-php/application": "0.8.*", + "neuron-php/data": "0.9.*", + "symfony/yaml": "^6.4" }, "require-dev": { "phpunit/phpunit": "^9.0", @@ -25,8 +27,7 @@ }, "autoload-dev": { "psr-4": { - "Tests\\": "tests/", - "Neuron\\Core\\": "../core/src/Core/" + "Tests\\": "tests/" } }, "bin": [ diff --git a/src/Cli/Commands/Secrets/EditCommand.php b/src/Cli/Commands/Secrets/EditCommand.php new file mode 100644 index 0000000..6e73633 --- /dev/null +++ b/src/Cli/Commands/Secrets/EditCommand.php @@ -0,0 +1,151 @@ +addOption( 'env', 'e', true, 'Environment to edit (default: base secrets)' ); + $this->addOption( 'editor', null, true, 'Editor to use (default: vi)' ); + $this->addOption( 'config', 'c', true, 'Config directory path (default: config)' ); + $this->addOption( 'verbose', 'v', false, 'Verbose output' ); + } + + /** + * @inheritDoc + */ + public function execute(): int + { + $configPath = $this->input->getOption( 'config', 'config' ); + $env = $this->input->getOption( 'env' ); + + // Handle editor option - could be null, true (flag without value), or a string + $editorOption = $this->input->getOption( 'editor' ); + if( is_string( $editorOption ) && $editorOption !== '' ) + { + $editor = $editorOption; + } + else + { + $editor = getenv( 'EDITOR' ) ?: 'vi'; + } + + // Determine paths based on environment + if( $env ) + { + $credentialsPath = $configPath . '/secrets/' . $env . '.yml.enc'; + $keyPath = $configPath . '/secrets/' . $env . '.key'; + $this->output->info( "Editing {$env} environment secrets..." ); + } + else + { + $credentialsPath = $configPath . '/secrets.yml.enc'; + $keyPath = $configPath . '/master.key'; + $this->output->info( "Editing base secrets..." ); + } + + // Create SecretManager + $this->secretManager = new SecretManager(); + + try + { + // Ensure key exists + if( !file_exists( $keyPath ) ) + { + $this->output->warning( "Key file not found at: {$keyPath}" ); + $this->output->info( "Generating new encryption key..." ); + + // Ensure directory exists + $dir = dirname( $keyPath ); + if( !is_dir( $dir ) ) + { + if( !mkdir( $dir, 0755, true ) ) + { + $this->output->error( "Failed to create directory: {$dir}" ); + return 1; + } + } + + $this->secretManager->generateKey( $keyPath ); + $this->output->success( "Generated new key at: {$keyPath}" ); + $this->output->warning( "IMPORTANT: Add {$keyPath} to .gitignore!" ); + } + + // Edit the secrets + $result = $this->secretManager->edit( $credentialsPath, $keyPath, $editor ); + + if( $result ) + { + $this->output->success( "Secrets saved to: {$credentialsPath}" ); + + // First time setup reminder - check for .gitignore in project root + $projectRoot = dirname( $configPath ); + $gitignorePath = $projectRoot . '/.gitignore'; + if( !$env && !file_exists( $gitignorePath ) ) + { + $this->output->newLine(); + $this->output->warning( "Remember to:" ); + $this->output->write( "1. Add {$keyPath} to .gitignore" ); + $this->output->write( "2. Commit {$credentialsPath} to version control" ); + $this->output->write( "3. Share {$keyPath} securely with your team" ); + } + } + else + { + $this->output->error( "Failed to save secrets" ); + return 1; + } + } + catch( \Exception $e ) + { + $this->output->error( "Error editing secrets: " . $e->getMessage() ); + + if( $this->input->hasOption( 'verbose' ) ) + { + $this->output->write( $e->getTraceAsString() ); + } + + return 1; + } + + return 0; + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Secrets/Key/GenerateCommand.php b/src/Cli/Commands/Secrets/Key/GenerateCommand.php new file mode 100644 index 0000000..b135bc0 --- /dev/null +++ b/src/Cli/Commands/Secrets/Key/GenerateCommand.php @@ -0,0 +1,174 @@ +addOption( 'env', 'e', true, 'Environment for the key (default: master key)' ); + $this->addOption( 'config', 'c', true, 'Config directory path (default: config)' ); + $this->addOption( 'force', 'f', false, 'Overwrite existing key file' ); + $this->addOption( 'show', 's', false, 'Display the generated key' ); + $this->addOption( 'verbose', 'v', false, 'Verbose output' ); + } + + /** + * @inheritDoc + */ + public function execute(): int + { + $configPath = $this->input->getOption( 'config', 'config' ); + $env = $this->input->getOption( 'env' ); + $force = $this->input->hasOption( 'force' ); + $show = $this->input->hasOption( 'show' ); + + // Determine key path based on environment + if( $env ) + { + $keyPath = $configPath . '/secrets/' . $env . '.key'; + $keyName = $env . ' environment key'; + + // Ensure directory exists + $dir = dirname( $keyPath ); + if( !is_dir( $dir ) ) + { + if( !mkdir( $dir, 0755, true ) ) + { + $this->output->error( "Failed to create directory: {$dir}" ); + return 1; + } + } + } + else + { + $keyPath = $configPath . '/master.key'; + $keyName = 'master key'; + + // Ensure directory exists + $dir = dirname( $keyPath ); + if( !is_dir( $dir ) ) + { + if( !mkdir( $dir, 0755, true ) ) + { + $this->output->error( "Failed to create directory: {$dir}" ); + return 1; + } + } + } + + // Check if key already exists + if( file_exists( $keyPath ) && !$force ) + { + $this->output->error( "Key file already exists: {$keyPath}" ); + $this->output->info( "Use --force to overwrite the existing key." ); + $this->output->warning( "WARNING: Overwriting will make existing encrypted files unreadable!" ); + return 1; + } + + // Warn about overwriting + if( file_exists( $keyPath ) && $force ) + { + $this->output->warning( "You are about to overwrite an existing key!" ); + $this->output->warning( "This will make any files encrypted with the old key unreadable." ); + + if( !$this->confirm( "Are you absolutely sure you want to continue?" ) ) + { + $this->output->info( "Operation cancelled." ); + return 0; + } + } + + // Create SecretManager and generate key + $this->secretManager = new SecretManager(); + + try + { + $key = $this->secretManager->generateKey( $keyPath, $force ); + + $this->output->success( "Generated {$keyName} at: {$keyPath}" ); + + // Show the key if requested + if( $show ) + { + $this->output->newLine(); + $this->output->section( "Generated Key" ); + $this->output->write( $key ); + $this->output->newLine(); + $this->output->warning( "This key is shown only once. Store it securely!" ); + } + + // Display instructions + $this->output->newLine(); + $this->output->info( "Next steps:" ); + $this->output->write( "1. Add {$keyPath} to .gitignore (NEVER commit this file)" ); + $this->output->write( "2. Share this key securely with your team" ); + $this->output->write( "3. Use 'neuron secrets:edit" . ($env ? " --env={$env}" : "") . "' to add secrets" ); + + // Environment variable alternative + $envVar = 'NEURON_' . strtoupper( + str_replace( ['/', '.', '-'], '_', basename( $keyPath, '.key' ) ) + ) . '_KEY'; + $this->output->newLine(); + $this->output->info( "Alternative: Set the key as an environment variable:" ); + if( $show ) + { + $this->output->write( "export {$envVar}={$key}" ); + } + else + { + $this->output->write( "export {$envVar}=" ); + } + } + catch( \Exception $e ) + { + $this->output->error( "Error generating key: " . $e->getMessage() ); + + if( $this->input->hasOption( 'verbose' ) ) + { + $this->output->write( $e->getTraceAsString() ); + } + + return 1; + } + + return 0; + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Secrets/ShowCommand.php b/src/Cli/Commands/Secrets/ShowCommand.php new file mode 100644 index 0000000..0c5a6d8 --- /dev/null +++ b/src/Cli/Commands/Secrets/ShowCommand.php @@ -0,0 +1,170 @@ +addOption( 'env', 'e', true, 'Environment to show (default: base secrets)' ); + $this->addOption( 'key', 'k', true, 'Show only specific key/section' ); + $this->addOption( 'config', 'c', true, 'Config directory path (default: config)' ); + $this->addOption( 'force', 'f', false, 'Skip confirmation prompt' ); + $this->addOption( 'verbose', 'v', false, 'Verbose output' ); + } + + /** + * @inheritDoc + */ + public function execute(): int + { + $configPath = $this->input->getOption( 'config', 'config' ); + $env = $this->input->getOption( 'env' ); + $specificKey = $this->input->getOption( 'key' ); + $force = $this->input->hasOption( 'force' ); + + // Security confirmation for production + if( !$force && $env === 'production' ) + { + $this->output->warning( "You are about to display production secrets!" ); + + if( !$this->confirm( "Are you sure you want to continue?" ) ) + { + $this->output->info( "Operation cancelled." ); + return 0; + } + } + + // Determine paths based on environment + if( $env ) + { + $credentialsPath = $configPath . '/secrets/' . $env . '.yml.enc'; + $keyPath = $configPath . '/secrets/' . $env . '.key'; + $title = ucfirst( $env ) . " Environment Secrets"; + } + else + { + $credentialsPath = $configPath . '/secrets.yml.enc'; + $keyPath = $configPath . '/master.key'; + $title = "Base Secrets"; + } + + // Check if files exist + if( !file_exists( $credentialsPath ) ) + { + $this->output->error( "Secrets file not found: {$credentialsPath}" ); + $this->output->info( "Use 'neuron secrets:edit" . ($env ? " --env={$env}" : "") . "' to create it." ); + return 1; + } + + if( !file_exists( $keyPath ) && !$this->checkEnvironmentKey( $keyPath ) ) + { + $this->output->error( "Key file not found: {$keyPath}" ); + $this->output->info( "The key might be in an environment variable or you need to obtain it from your team." ); + return 1; + } + + // Create SecretManager and decrypt + // Note: SecretManager::show() internally calls readKey() which handles both file and environment variable sources + $this->secretManager = new SecretManager(); + + try + { + // The show() method will read the key from either the file or environment variable + $decrypted = $this->secretManager->show( $credentialsPath, $keyPath ); + $data = Yaml::parse( $decrypted ); + + // Filter to specific key if requested + if( $specificKey ) + { + if( isset( $data[$specificKey] ) ) + { + $data = [$specificKey => $data[$specificKey]]; + } + else + { + $this->output->error( "Key '{$specificKey}' not found in secrets" ); + return 1; + } + } + + // Display the secrets + $this->output->section( $title ); + $this->output->newLine(); + + // Format and display YAML + $formatted = Yaml::dump( $data, 4, 2 ); + $this->output->write( $formatted ); + + // Security reminder + $this->output->newLine(); + $this->output->warning( "Remember: Never share or commit decrypted secrets!" ); + } + catch( \Exception $e ) + { + $this->output->error( "Error decrypting secrets: " . $e->getMessage() ); + + if( $this->input->hasOption( 'verbose' ) ) + { + $this->output->write( $e->getTraceAsString() ); + } + + return 1; + } + + return 0; + } + + /** + * Check if key exists in environment variable + * + * @param string $keyPath + * @return bool + */ + private function checkEnvironmentKey( string $keyPath ): bool + { + $envKey = 'NEURON_' . strtoupper( + str_replace( ['/', '.', '-'], '_', basename( $keyPath, '.key' ) ) + ) . '_KEY'; + + return getenv( $envKey ) !== false; + } +} \ No newline at end of file diff --git a/tests/Cli/Commands/Secrets/EditCommandTest.php b/tests/Cli/Commands/Secrets/EditCommandTest.php new file mode 100644 index 0000000..bf8f80e --- /dev/null +++ b/tests/Cli/Commands/Secrets/EditCommandTest.php @@ -0,0 +1,329 @@ +testConfigPath = sys_get_temp_dir() . '/test_secrets_' . uniqid(); + mkdir( $this->testConfigPath, 0755, true ); + + $this->command = new EditCommand(); + $this->command->configure(); + } + + protected function tearDown(): void + { + parent::tearDown(); + + // Clean up test files + $this->removeDirectory( $this->testConfigPath ); + } + + /** + * Test that the command has the correct name + */ + public function testGetName(): void + { + $this->assertEquals( 'secrets:edit', $this->command->getName() ); + } + + /** + * Test that the command has a description + */ + public function testGetDescription(): void + { + $this->assertEquals( 'Edit encrypted secrets file', $this->command->getDescription() ); + } + + /** + * Test that the command configures options correctly + */ + public function testConfigure(): void + { + // Test that options are configured by checking if we can create input with them + $input = new Input( [ + '--config=' . $this->testConfigPath, + '--env=test', + '--editor=vim' + ] ); + $input->parse( $this->command ); + + // Options should be available + $this->assertEquals( $this->testConfigPath, $input->getOption( 'config', 'config' ) ); + $this->assertEquals( 'test', $input->getOption( 'env' ) ); + $this->assertEquals( 'vim', $input->getOption( 'editor' ) ); + } + + /** + * Test editing base secrets when key exists + */ + public function testExecuteWithExistingKey(): void + { + // Create a test key file + $keyPath = $this->testConfigPath . '/master.key'; + $secretManager = new SecretManager(); + $key = $secretManager->generateKey( $keyPath ); + + // Create a test credentials file + $credentialsPath = $this->testConfigPath . '/secrets.yml.enc'; + $tempPlaintextPath = $this->testConfigPath . '/temp_plaintext.yml'; + $testData = "test:\n secret: value"; + file_put_contents( $tempPlaintextPath, $testData ); + $secretManager->encrypt( $tempPlaintextPath, $credentialsPath, $keyPath ); + unlink( $tempPlaintextPath ); + + // Create input with options + $input = new Input( [ + '--config=' . $this->testConfigPath, + '--editor=echo' // Use echo as a no-op editor for testing + ] ); + $input->parse( $this->command ); + + // Create output + $output = new Output( false ); + + $this->command->setInput( $input ); + $this->command->setOutput( $output ); + + // Capture output + ob_start(); + $result = $this->command->execute(); + $outputContent = ob_get_clean(); + + // Execute should succeed + $this->assertEquals( 0, $result ); + + // Key and credentials should still exist + $this->assertFileExists( $keyPath ); + $this->assertFileExists( $credentialsPath ); + + // Check output messages + $this->assertStringContainsString( 'Editing base secrets...', $outputContent ); + $this->assertStringContainsString( "Secrets saved to: {$credentialsPath}", $outputContent ); + } + + /** + * Test editing environment-specific secrets + */ + public function testExecuteWithEnvironment(): void + { + // Create secrets directory + mkdir( $this->testConfigPath . '/secrets', 0755, true ); + + // Create input with options + $input = new Input( [ + '--config=' . $this->testConfigPath, + '--env=production', + '--editor=echo' // Use echo as a no-op editor + ] ); + $input->parse( $this->command ); + + // Create output + $output = new Output( false ); + + $this->command->setInput( $input ); + $this->command->setOutput( $output ); + + // Capture output + ob_start(); + $result = $this->command->execute(); + $outputContent = ob_get_clean(); + + $keyPath = $this->testConfigPath . '/secrets/production.key'; + $credentialsPath = $this->testConfigPath . '/secrets/production.yml.enc'; + + // Execute should succeed + $this->assertEquals( 0, $result ); + + // Key should be generated + $this->assertFileExists( $keyPath ); + $this->assertFileExists( $credentialsPath ); + + // Check output messages + $this->assertStringContainsString( 'Editing production environment secrets...', $outputContent ); + $this->assertStringContainsString( "Key file not found at: {$keyPath}", $outputContent ); + $this->assertStringContainsString( "Secrets saved to: {$credentialsPath}", $outputContent ); + } + + /** + * Test that environment directory is created when missing + */ + public function testExecuteCreatesEnvironmentDirectory(): void + { + // Do NOT create the secrets directory - let the command create it + + // Create input with options + $input = new Input( [ + '--config=' . $this->testConfigPath, + '--env=staging', + '--editor=echo' // Use echo as a no-op editor + ] ); + $input->parse( $this->command ); + + // Create output + $output = new Output( false ); + + $this->command->setInput( $input ); + $this->command->setOutput( $output ); + + // Capture output + ob_start(); + $result = $this->command->execute(); + $outputContent = ob_get_clean(); + + $secretsDir = $this->testConfigPath . '/secrets'; + $keyPath = $secretsDir . '/staging.key'; + $credentialsPath = $secretsDir . '/staging.yml.enc'; + + // Execute should succeed + $this->assertEquals( 0, $result ); + + // Directory should be created + $this->assertDirectoryExists( $secretsDir ); + + // Key and credentials should be generated + $this->assertFileExists( $keyPath ); + $this->assertFileExists( $credentialsPath ); + + // Check output messages + $this->assertStringContainsString( 'Editing staging environment secrets...', $outputContent ); + $this->assertStringContainsString( "Key file not found at: {$keyPath}", $outputContent ); + $this->assertStringContainsString( "Generating new encryption key...", $outputContent ); + $this->assertStringContainsString( "Generated new key at: {$keyPath}", $outputContent ); + $this->assertStringContainsString( "Secrets saved to: {$credentialsPath}", $outputContent ); + } + + /** + * Test editor option handling with various input types + */ + public function testEditorOptionHandling(): void + { + // Create a key file first + $keyPath = $this->testConfigPath . '/master.key'; + $secretManager = new SecretManager(); + $secretManager->generateKey( $keyPath ); + + // Create initial credentials file + $credentialsPath = $this->testConfigPath . '/secrets.yml.enc'; + $tempPlaintextPath = $this->testConfigPath . '/temp.yml'; + file_put_contents( $tempPlaintextPath, "test: value" ); + $secretManager->encrypt( $tempPlaintextPath, $credentialsPath, $keyPath ); + unlink( $tempPlaintextPath ); + + // Test 1: Editor option with value + $input1 = new Input( [ + '--config=' . $this->testConfigPath, + '--editor=echo' // Use echo as a no-op editor + ] ); + $input1->parse( $this->command ); + + $output1 = new Output( false ); + $this->command->setInput( $input1 ); + $this->command->setOutput( $output1 ); + + // This should use 'echo' as editor + ob_start(); + $result1 = $this->command->execute(); + $outputContent1 = ob_get_clean(); + + // Should succeed with echo editor + $this->assertEquals( 0, $result1 ); + $this->assertStringContainsString( "Secrets saved to", $outputContent1 ); + + // Test 2: Editor option without value (becomes boolean true) + $input2 = new Input( [ + '--config=' . $this->testConfigPath, + '--editor' // No value - becomes boolean true + ] ); + $input2->parse( $this->command ); + + $output2 = new Output( false ); + $this->command->setInput( $input2 ); + $this->command->setOutput( $output2 ); + + // This should fall back to EDITOR env var or 'vi' + // Set a test editor to avoid vi + putenv( 'EDITOR=echo' ); + + ob_start(); + $result2 = $this->command->execute(); + $outputContent2 = ob_get_clean(); + + // Should succeed with fallback to env var + $this->assertEquals( 0, $result2 ); + $this->assertStringContainsString( "Secrets saved to", $outputContent2 ); + + // Clean up env var + putenv( 'EDITOR' ); + } + + /** + * Test that error is handled gracefully + */ + public function testExecuteWithError(): void + { + // Create a non-writable directory + $nonWritablePath = sys_get_temp_dir() . '/test_non_writable_' . uniqid(); + mkdir( $nonWritablePath, 0000, true ); // Create with no permissions + + // Create input with options pointing to the non-writable path + $input = new Input( [ + '--config=' . $nonWritablePath + ] ); + $input->parse( $this->command ); + + // Create output + $output = new Output( false ); + + $this->command->setInput( $input ); + $this->command->setOutput( $output ); + + // Capture output + ob_start(); + $result = $this->command->execute(); + $outputContent = ob_get_clean(); + + // Execute should fail + $this->assertEquals( 1, $result ); + + // Check error message + $this->assertStringContainsString( 'Error editing secrets:', $outputContent ); + + // Clean up: restore permissions and remove directory + chmod( $nonWritablePath, 0755 ); + rmdir( $nonWritablePath ); + } + + /** + * Helper to remove directory recursively + */ + private function removeDirectory( string $dir ): void + { + if( !is_dir( $dir ) ) + { + return; + } + + $files = array_diff( scandir( $dir ), ['.', '..'] ); + foreach( $files as $file ) + { + $path = $dir . '/' . $file; + is_dir( $path ) ? $this->removeDirectory( $path ) : unlink( $path ); + } + rmdir( $dir ); + } +} \ No newline at end of file diff --git a/tests/Cli/Commands/Secrets/Key/GenerateCommandTest.php b/tests/Cli/Commands/Secrets/Key/GenerateCommandTest.php new file mode 100644 index 0000000..07d90e6 --- /dev/null +++ b/tests/Cli/Commands/Secrets/Key/GenerateCommandTest.php @@ -0,0 +1,283 @@ +testConfigPath = sys_get_temp_dir() . '/test_secrets_generate_' . uniqid(); + mkdir( $this->testConfigPath, 0755, true ); + + $this->command = new GenerateCommand(); + $this->command->configure(); + } + + protected function tearDown(): void + { + parent::tearDown(); + + // Clean up test files + $this->removeDirectory( $this->testConfigPath ); + } + + /** + * Test that the command has the correct name + */ + public function testGetName(): void + { + $this->assertEquals( 'secrets:key:generate', $this->command->getName() ); + } + + /** + * Test that the command has a description + */ + public function testGetDescription(): void + { + $this->assertEquals( 'Generate a new encryption key for secrets', $this->command->getDescription() ); + } + + /** + * Test generating master key + */ + public function testExecuteGeneratesMasterKey(): void + { + // Create input with options + $input = new Input( [ '--config=' . $this->testConfigPath ] ); + $input->parse( $this->command ); + + // Create output + $output = new Output( false ); + + $this->command->setInput( $input ); + $this->command->setOutput( $output ); + + // Capture output + ob_start(); + $result = $this->command->execute(); + $outputContent = ob_get_clean(); + + // Execute should succeed + $this->assertEquals( 0, $result ); + + // Key file should exist + $keyPath = $this->testConfigPath . '/master.key'; + $this->assertFileExists( $keyPath ); + + // Key should be 64 hex characters + $key = file_get_contents( $keyPath ); + $this->assertEquals( 64, strlen( $key ) ); + $this->assertMatchesRegularExpression( '/^[a-f0-9]{64}$/i', $key ); + + // Check output contains success message + $this->assertStringContainsString( "Generated master key at: {$keyPath}", $outputContent ); + $this->assertStringContainsString( "Next steps:", $outputContent ); + } + + /** + * Test generating environment-specific key + */ + public function testExecuteGeneratesEnvironmentKey(): void + { + // Create input with options + $input = new Input( [ + '--config=' . $this->testConfigPath, + '--env=production' + ] ); + $input->parse( $this->command ); + + // Create output + $output = new Output( false ); + + $this->command->setInput( $input ); + $this->command->setOutput( $output ); + + // Capture output + ob_start(); + $result = $this->command->execute(); + $outputContent = ob_get_clean(); + + // Execute should succeed + $this->assertEquals( 0, $result ); + + // Directory and key file should exist + $this->assertDirectoryExists( $this->testConfigPath . '/secrets' ); + $keyPath = $this->testConfigPath . '/secrets/production.key'; + $this->assertFileExists( $keyPath ); + + // Check output contains success message + $this->assertStringContainsString( "Generated production environment key at: {$keyPath}", $outputContent ); + } + + /** + * Test that config directory is created for master key when missing + */ + public function testExecuteCreatesMasterKeyDirectory(): void + { + // Use a non-existent config path + $nonExistentPath = sys_get_temp_dir() . '/test_config_' . uniqid() . '/config'; + + // Create input with options pointing to non-existent path + $input = new Input( [ '--config=' . $nonExistentPath ] ); + $input->parse( $this->command ); + + // Create output + $output = new Output( false ); + + $this->command->setInput( $input ); + $this->command->setOutput( $output ); + + // Capture output + ob_start(); + $result = $this->command->execute(); + $outputContent = ob_get_clean(); + + // Execute should succeed + $this->assertEquals( 0, $result ); + + // Directory should be created + $this->assertDirectoryExists( $nonExistentPath ); + + // Key file should exist + $keyPath = $nonExistentPath . '/master.key'; + $this->assertFileExists( $keyPath ); + + // Check output contains success message + $this->assertStringContainsString( "Generated master key at: {$keyPath}", $outputContent ); + + // Clean up + unlink( $keyPath ); + rmdir( $nonExistentPath ); + rmdir( dirname( $nonExistentPath ) ); + } + + /** + * Test that key is only shown when --show flag is used + */ + public function testKeyOnlyShownWithShowFlag(): void + { + // Test WITHOUT --show flag (default) + $input = new Input( [ '--config=' . $this->testConfigPath ] ); + $input->parse( $this->command ); + + $output = new Output( false ); + + $this->command->setInput( $input ); + $this->command->setOutput( $output ); + + ob_start(); + $result = $this->command->execute(); + $outputContentNoShow = ob_get_clean(); + + $this->assertEquals( 0, $result ); + + $keyPath = $this->testConfigPath . '/master.key'; + $this->assertFileExists( $keyPath ); + + // Read the actual key + $actualKey = file_get_contents( $keyPath ); + + // Key should NOT be in the output (except in the placeholder) + $this->assertStringNotContainsString( "export NEURON_MASTER_KEY={$actualKey}", $outputContentNoShow ); + $this->assertStringContainsString( "export NEURON_MASTER_KEY=", $outputContentNoShow ); + $this->assertStringNotContainsString( "Generated Key", $outputContentNoShow ); + + // Clean up before second test + unlink( $keyPath ); + + // Test WITH --show flag + $input2 = new Input( [ + '--config=' . $this->testConfigPath, + '--show' + ] ); + $input2->parse( $this->command ); + + $output2 = new Output( false ); + + $this->command->setInput( $input2 ); + $this->command->setOutput( $output2 ); + + ob_start(); + $result2 = $this->command->execute(); + $outputContentWithShow = ob_get_clean(); + + $this->assertEquals( 0, $result2 ); + + // Read the new key + $actualKey2 = file_get_contents( $keyPath ); + + // Key SHOULD be in the output + $this->assertStringContainsString( "export NEURON_MASTER_KEY={$actualKey2}", $outputContentWithShow ); + $this->assertStringNotContainsString( "assertStringContainsString( "Generated Key", $outputContentWithShow ); + $this->assertStringContainsString( $actualKey2, $outputContentWithShow ); + $this->assertStringContainsString( "This key is shown only once", $outputContentWithShow ); + } + + /** + * Test error when key already exists without force + */ + public function testExecuteErrorWhenKeyExistsWithoutForce(): void + { + // Create an existing key file + $keyPath = $this->testConfigPath . '/master.key'; + file_put_contents( $keyPath, 'existing_key' ); + + // Create input with options + $input = new Input( [ '--config=' . $this->testConfigPath ] ); + $input->parse( $this->command ); + + // Create output + $output = new Output( false ); + + $this->command->setInput( $input ); + $this->command->setOutput( $output ); + + // Capture output + ob_start(); + $result = $this->command->execute(); + $outputContent = ob_get_clean(); + + // Execute should fail + $this->assertEquals( 1, $result ); + + // Original key should still exist + $this->assertEquals( 'existing_key', file_get_contents( $keyPath ) ); + + // Check error messages + $this->assertStringContainsString( "Key file already exists: {$keyPath}", $outputContent ); + $this->assertStringContainsString( 'Use --force to overwrite the existing key.', $outputContent ); + $this->assertStringContainsString( 'WARNING: Overwriting will make existing encrypted files unreadable!', $outputContent ); + } + + /** + * Helper to remove directory recursively + */ + private function removeDirectory( string $dir ): void + { + if( !is_dir( $dir ) ) + { + return; + } + + $files = array_diff( scandir( $dir ), ['.', '..'] ); + foreach( $files as $file ) + { + $path = $dir . '/' . $file; + is_dir( $path ) ? $this->removeDirectory( $path ) : unlink( $path ); + } + rmdir( $dir ); + } +} \ No newline at end of file diff --git a/tests/Cli/Commands/Secrets/ShowCommandTest.php b/tests/Cli/Commands/Secrets/ShowCommandTest.php new file mode 100644 index 0000000..8e37fce --- /dev/null +++ b/tests/Cli/Commands/Secrets/ShowCommandTest.php @@ -0,0 +1,333 @@ +testConfigPath = sys_get_temp_dir() . '/test_secrets_show_' . uniqid(); + mkdir( $this->testConfigPath, 0755, true ); + + $this->command = new ShowCommand(); + $this->command->configure(); + $this->secretManager = new SecretManager(); + } + + protected function tearDown(): void + { + parent::tearDown(); + + // Clean up test files + $this->removeDirectory( $this->testConfigPath ); + } + + /** + * Test that the command has the correct name + */ + public function testGetName(): void + { + $this->assertEquals( 'secrets:show', $this->command->getName() ); + } + + /** + * Test that the command has a description + */ + public function testGetDescription(): void + { + $this->assertEquals( 'Show decrypted secrets', $this->command->getDescription() ); + } + + /** + * Test showing base secrets + */ + public function testExecuteShowsBaseSecrets(): void + { + // Create test secrets + $keyPath = $this->testConfigPath . '/master.key'; + $credentialsPath = $this->testConfigPath . '/secrets.yml.enc'; + + $key = $this->secretManager->generateKey( $keyPath ); + + $tempPlaintextPath = $this->testConfigPath . '/temp_plaintext.yml'; + $testData = "database:\n password: secret123\napi:\n key: abc123"; + file_put_contents( $tempPlaintextPath, $testData ); + $this->secretManager->encrypt( $tempPlaintextPath, $credentialsPath, $keyPath ); + unlink( $tempPlaintextPath ); + + // Create input with options + $input = new Input( [ '--config=' . $this->testConfigPath ] ); + $input->parse( $this->command ); + + // Create output + $output = new Output( false ); + + $this->command->setInput( $input ); + $this->command->setOutput( $output ); + + // Capture output + ob_start(); + $result = $this->command->execute(); + $outputContent = ob_get_clean(); + + // Execute should succeed + $this->assertEquals( 0, $result ); + + // Check output contains secrets + $this->assertStringContainsString( 'Base Secrets', $outputContent ); + $this->assertStringContainsString( 'database:', $outputContent ); + $this->assertStringContainsString( 'Remember: Never share or commit decrypted secrets!', $outputContent ); + } + + /** + * Test showing specific key + */ + public function testExecuteShowsSpecificKey(): void + { + // Create test secrets + $keyPath = $this->testConfigPath . '/master.key'; + $credentialsPath = $this->testConfigPath . '/secrets.yml.enc'; + + $key = $this->secretManager->generateKey( $keyPath ); + + $tempPlaintextPath = $this->testConfigPath . '/temp_plaintext.yml'; + $testData = "database:\n password: secret123\napi:\n key: abc123"; + file_put_contents( $tempPlaintextPath, $testData ); + $this->secretManager->encrypt( $tempPlaintextPath, $credentialsPath, $keyPath ); + unlink( $tempPlaintextPath ); + + // Create input with options + $input = new Input( [ + '--config=' . $this->testConfigPath, + '--key=database' + ] ); + $input->parse( $this->command ); + + // Create output + $output = new Output( false ); + + $this->command->setInput( $input ); + $this->command->setOutput( $output ); + + // Capture output + ob_start(); + $result = $this->command->execute(); + $outputContent = ob_get_clean(); + + // Execute should succeed + $this->assertEquals( 0, $result ); + + // Check output contains only the database key + $this->assertStringContainsString( 'database:', $outputContent ); + $this->assertStringNotContainsString( 'api:', $outputContent ); + } + + /** + * Test production environment confirmation prompt + */ + public function testExecuteProductionConfirmation(): void + { + // Create test secrets for production environment + mkdir( $this->testConfigPath . '/secrets', 0755, true ); + $keyPath = $this->testConfigPath . '/secrets/production.key'; + $credentialsPath = $this->testConfigPath . '/secrets/production.yml.enc'; + + $key = $this->secretManager->generateKey( $keyPath ); + + $tempPlaintextPath = $this->testConfigPath . '/temp_plaintext.yml'; + $testData = "database:\n password: production_secret"; + file_put_contents( $tempPlaintextPath, $testData ); + $this->secretManager->encrypt( $tempPlaintextPath, $credentialsPath, $keyPath ); + unlink( $tempPlaintextPath ); + + // Test 1: User confirms - secrets should be shown + $input = new Input( [ + '--config=' . $this->testConfigPath, + '--env=production' + ] ); + $input->parse( $this->command ); + + $output = new Output( false ); + + // Set up test input reader to confirm + $inputReader = new TestInputReader(); + $inputReader->addResponse( 'yes' ); + + $this->command->setInput( $input ); + $this->command->setOutput( $output ); + $this->command->setInputReader( $inputReader ); + + // Capture output + ob_start(); + $result = $this->command->execute(); + $outputContent = ob_get_clean(); + + // Should succeed and show secrets + $this->assertEquals( 0, $result ); + $this->assertStringContainsString( 'You are about to display production secrets!', $outputContent ); + $this->assertStringContainsString( 'production_secret', $outputContent ); + + // Test 2: User cancels - secrets should NOT be shown + $input2 = new Input( [ + '--config=' . $this->testConfigPath, + '--env=production' + ] ); + $input2->parse( $this->command ); + + $output2 = new Output( false ); + + // Set up test input reader to cancel + $inputReader2 = new TestInputReader(); + $inputReader2->addResponse( 'no' ); + + $this->command->setInput( $input2 ); + $this->command->setOutput( $output2 ); + $this->command->setInputReader( $inputReader2 ); + + // Capture output + ob_start(); + $result2 = $this->command->execute(); + $outputContent2 = ob_get_clean(); + + // Should exit gracefully without showing secrets + $this->assertEquals( 0, $result2 ); + $this->assertStringContainsString( 'Operation cancelled.', $outputContent2 ); + $this->assertStringNotContainsString( 'production_secret', $outputContent2 ); + + // Test 3: Force flag should skip confirmation + $input3 = new Input( [ + '--config=' . $this->testConfigPath, + '--env=production', + '--force' + ] ); + $input3->parse( $this->command ); + + $output3 = new Output( false ); + + $this->command->setInput( $input3 ); + $this->command->setOutput( $output3 ); + // No input reader needed - force skips confirmation + + // Capture output + ob_start(); + $result3 = $this->command->execute(); + $outputContent3 = ob_get_clean(); + + // Should succeed without confirmation prompt + $this->assertEquals( 0, $result3 ); + $this->assertStringNotContainsString( 'You are about to display production secrets!', $outputContent3 ); + $this->assertStringContainsString( 'production_secret', $outputContent3 ); + } + + /** + * Test showing secrets using environment variable for key + */ + public function testExecuteWithEnvironmentVariable(): void + { + // Create test secrets + $keyPath = $this->testConfigPath . '/master.key'; + $credentialsPath = $this->testConfigPath . '/secrets.yml.enc'; + + // Generate a key and store it + $key = $this->secretManager->generateKey( $keyPath ); + + // Create encrypted secrets + $tempPlaintextPath = $this->testConfigPath . '/temp_plaintext.yml'; + $testData = "database:\n host: localhost\n password: env_secret"; + file_put_contents( $tempPlaintextPath, $testData ); + $this->secretManager->encrypt( $tempPlaintextPath, $credentialsPath, $keyPath ); + unlink( $tempPlaintextPath ); + + // Read the key value and delete the key file + $keyValue = trim( file_get_contents( $keyPath ) ); + unlink( $keyPath ); + + // Set the key as an environment variable + putenv( "NEURON_MASTER_KEY={$keyValue}" ); + + // Create input and output + $input = new Input( [ '--config=' . $this->testConfigPath ] ); + $input->parse( $this->command ); + + $output = new Output( false ); + + $this->command->setInput( $input ); + $this->command->setOutput( $output ); + + // Capture output + ob_start(); + $result = $this->command->execute(); + $outputContent = ob_get_clean(); + + // Should succeed using the environment variable + $this->assertEquals( 0, $result ); + $this->assertStringContainsString( 'Base Secrets', $outputContent ); + $this->assertStringContainsString( 'env_secret', $outputContent ); + + // Clean up environment variable + putenv( "NEURON_MASTER_KEY" ); + } + + /** + * Test error when secrets file not found + */ + public function testExecuteErrorWhenSecretsFileNotFound(): void + { + // Create input with options (no secrets file exists) + $input = new Input( [ '--config=' . $this->testConfigPath ] ); + $input->parse( $this->command ); + + // Create output + $output = new Output( false ); + + $this->command->setInput( $input ); + $this->command->setOutput( $output ); + + // Capture output + ob_start(); + $result = $this->command->execute(); + $outputContent = ob_get_clean(); + + // Execute should fail + $this->assertEquals( 1, $result ); + + $credentialsPath = $this->testConfigPath . '/secrets.yml.enc'; + + // Check error messages + $this->assertStringContainsString( "Secrets file not found: {$credentialsPath}", $outputContent ); + $this->assertStringContainsString( "Use 'neuron secrets:edit' to create it.", $outputContent ); + } + + /** + * Helper to remove directory recursively + */ + private function removeDirectory( string $dir ): void + { + if( !is_dir( $dir ) ) + { + return; + } + + $files = array_diff( scandir( $dir ), ['.', '..'] ); + foreach( $files as $file ) + { + $path = $dir . '/' . $file; + is_dir( $path ) ? $this->removeDirectory( $path ) : unlink( $path ); + } + rmdir( $dir ); + } +} \ No newline at end of file