diff --git a/phase_cli/cmd/open_console.py b/phase_cli/cmd/open_console.py index 47a2db2a..288d3a4a 100644 --- a/phase_cli/cmd/open_console.py +++ b/phase_cli/cmd/open_console.py @@ -1,4 +1,5 @@ import os +import sys from phase_cli.utils.misc import get_default_user_host, get_default_user_org, open_browser, find_phase_config from phase_cli.utils.const import PHASE_SECRETS_DIR @@ -25,3 +26,4 @@ def phase_open_console(): open_browser(url) except Exception as e: print(f"Error opening Phase console: {e}") + sys.exit(1) diff --git a/phase_cli/cmd/open_docs.py b/phase_cli/cmd/open_docs.py index 49044496..80478ec8 100644 --- a/phase_cli/cmd/open_docs.py +++ b/phase_cli/cmd/open_docs.py @@ -1,6 +1,11 @@ -import os +import sys import webbrowser def phase_open_docs(): - url = "https://docs.phase.dev/cli/commands" - webbrowser.open(url) \ No newline at end of file + """Opens the Phase documentation in a web browser""" + try: + url = "https://docs.phase.dev/cli/commands" + webbrowser.open(url) + except Exception as e: + print(f"Error opening Phase documentation: {e}") + sys.exit(1) \ No newline at end of file diff --git a/phase_cli/cmd/run.py b/phase_cli/cmd/run.py index cc296142..19a4ce2a 100644 --- a/phase_cli/cmd/run.py +++ b/phase_cli/cmd/run.py @@ -65,7 +65,10 @@ def phase_run_inject(command, env_name=None, phase_app=None, phase_app_id=None, # Print the message with the number of secrets injected console.log(f"🚀 Injected [bold magenta]{secret_count}[/] secrets from the [bold green]{environment_message}[/] environment.\n") - subprocess.run(command, shell=True, env=new_env) + process = subprocess.run(command, shell=True, env=new_env) + + # Exit with the same code as the subprocess + sys.exit(process.returncode) except ValueError as e: console.log(f"Error: {e}") diff --git a/phase_cli/cmd/secrets/create.py b/phase_cli/cmd/secrets/create.py index 1932e8a6..b0a87bdd 100644 --- a/phase_cli/cmd/secrets/create.py +++ b/phase_cli/cmd/secrets/create.py @@ -42,7 +42,7 @@ def phase_secrets_create(key=None, env_name=None, phase_app=None, phase_app_id=N value = generate_random_secret(random_type, random_length) except ValueError as e: console.log(f"Error: {e}") - return + sys.exit(1) else: # Check if input is being piped if sys.stdin.isatty(): @@ -67,6 +67,8 @@ def phase_secrets_create(key=None, env_name=None, phase_app=None, phase_app_id=N else: # Print an error message if the response status code indicates an error console.log(f"Error: Failed to create secret. HTTP Status Code: {response.status_code}") + sys.exit(1) except ValueError as e: console.log(f"Error: {e}") + sys.exit(1) diff --git a/phase_cli/cmd/secrets/delete.py b/phase_cli/cmd/secrets/delete.py index fc695124..fb3e6f8e 100644 --- a/phase_cli/cmd/secrets/delete.py +++ b/phase_cli/cmd/secrets/delete.py @@ -1,3 +1,4 @@ +import sys from phase_cli.utils.phase_io import Phase from phase_cli.cmd.secrets.list import phase_list_secrets from rich.console import Console @@ -39,3 +40,4 @@ def phase_secrets_delete(keys_to_delete: List[str] = None, env_name: str = None, except ValueError as e: console.log(f"Error: {e}") + sys.exit(1) diff --git a/phase_cli/cmd/secrets/export.py b/phase_cli/cmd/secrets/export.py index 47c5ceca..160d74c7 100644 --- a/phase_cli/cmd/secrets/export.py +++ b/phase_cli/cmd/secrets/export.py @@ -85,6 +85,12 @@ def phase_secrets_env_export(env_name=None, phase_app=None, phase_app_id=None, k # Filter secrets if specific keys are requested if keys: + # Check if any requested keys don't exist + missing_keys = [key for key in keys if key not in all_secrets_dict] + if missing_keys: + console.log(f"Error: 🥡 Failed to export. The following secret(s) do not exist: {', '.join(missing_keys)}") + sys.exit(1) + filtered_secrets_dict = {key: all_secrets_dict[key] for key in keys if key in all_secrets_dict} else: filtered_secrets_dict = all_secrets_dict @@ -113,6 +119,7 @@ def phase_secrets_env_export(env_name=None, phase_app=None, phase_app_id=None, k except ValueError as e: console.log(f"Error: {e}") + sys.exit(1) def export_json(secrets_dict): diff --git a/phase_cli/cmd/secrets/get.py b/phase_cli/cmd/secrets/get.py index b05fb552..25954338 100644 --- a/phase_cli/cmd/secrets/get.py +++ b/phase_cli/cmd/secrets/get.py @@ -1,3 +1,4 @@ +import sys from phase_cli.utils.phase_io import Phase from rich.console import Console import json @@ -26,7 +27,7 @@ def phase_secrets_get(key, env_name=None, phase_app=None, phase_app_id=None, tag # Check that secret_data was found and is a dictionary if not secret_data: console.log("🔍 Secret not found...") - return + sys.exit(1) if not isinstance(secret_data, dict): raise ValueError("Unexpected format: secret data is not a dictionary") @@ -36,3 +37,4 @@ def phase_secrets_get(key, env_name=None, phase_app=None, phase_app_id=None, tag except ValueError as e: console.log(f"Error: {e}") + sys.exit(1) diff --git a/phase_cli/cmd/secrets/import_env.py b/phase_cli/cmd/secrets/import_env.py index 79004223..898a1683 100644 --- a/phase_cli/cmd/secrets/import_env.py +++ b/phase_cli/cmd/secrets/import_env.py @@ -49,6 +49,8 @@ def phase_secrets_env_import(env_file, env_name=None, phase_app=None, phase_app_ else: # Print an error message if the response status code indicates an error console.log(f"Error: Failed to import secrets. HTTP Status Code: {response.status_code}") + sys.exit(1) except ValueError as e: console.log(f"Error: {e}") + sys.exit(1) diff --git a/phase_cli/cmd/secrets/list.py b/phase_cli/cmd/secrets/list.py index d2f95b59..5969aa54 100644 --- a/phase_cli/cmd/secrets/list.py +++ b/phase_cli/cmd/secrets/list.py @@ -1,3 +1,4 @@ +import sys from phase_cli.utils.phase_io import Phase from phase_cli.utils.misc import render_tree_with_tables from rich.console import Console @@ -36,3 +37,4 @@ def phase_list_secrets(show=False, env_name=None, phase_app=None, phase_app_id=N except ValueError as e: console.log(f"Error: {e}") + sys.exit(1) diff --git a/phase_cli/cmd/secrets/update.py b/phase_cli/cmd/secrets/update.py index 9f5b4f3c..616e5b44 100644 --- a/phase_cli/cmd/secrets/update.py +++ b/phase_cli/cmd/secrets/update.py @@ -44,7 +44,7 @@ def phase_secrets_update(key, env_name=None, phase_app=None, phase_app_id=None, new_value = generate_random_secret(random_type, random_length) except ValueError as e: console.log(f"Error: {e}") - return + sys.exit(1) elif not override: if sys.stdin.isatty(): new_value = getpass.getpass(f"✨ Please enter the new value for {key} (hidden): ") @@ -74,4 +74,5 @@ def phase_secrets_update(key, env_name=None, phase_app=None, phase_app_id=None, else: console.log(f"{response}") except ValueError as e: - console.log(f"⚠️ Error occurred while updating the secret: {e}") \ No newline at end of file + console.log(f"⚠️ Error occurred while updating the secret: {e}") + sys.exit(1) \ No newline at end of file diff --git a/phase_cli/cmd/update.py b/phase_cli/cmd/update.py index 1ce7e03e..809e8115 100644 --- a/phase_cli/cmd/update.py +++ b/phase_cli/cmd/update.py @@ -1,6 +1,7 @@ import requests import subprocess import os +import sys def phase_cli_update(): # URL of the remote bash script @@ -27,7 +28,10 @@ def phase_cli_update(): print("Update completed successfully.") except requests.RequestException as e: print(f"Error fetching the update script: {e}") + sys.exit(1) except subprocess.CalledProcessError: print("Error executing the update script.") + sys.exit(1) except Exception as e: - print(f"An unexpected error occurred: {e}") \ No newline at end of file + print(f"An unexpected error occurred: {e}") + sys.exit(1) \ No newline at end of file diff --git a/phase_cli/cmd/users/keyring.py b/phase_cli/cmd/users/keyring.py index 17bb71f6..ebba44bd 100644 --- a/phase_cli/cmd/users/keyring.py +++ b/phase_cli/cmd/users/keyring.py @@ -1,9 +1,14 @@ import keyring +import sys def show_keyring_info(): - kr = keyring.get_keyring() - print(f"Current keyring backend: {kr.__class__.__name__}") - print("Supported keyring backends:") - for backend in keyring.backend.get_all_keyring(): - print(f"- {backend.__class__.__name__}") + try: + kr = keyring.get_keyring() + print(f"Current keyring backend: {kr.__class__.__name__}") + print("Supported keyring backends:") + for backend in keyring.backend.get_all_keyring(): + print(f"- {backend.__class__.__name__}") + except Exception as e: + print(f"Error accessing keyring information: {e}") + sys.exit(1) \ No newline at end of file diff --git a/phase_cli/cmd/users/switch.py b/phase_cli/cmd/users/switch.py index 265b6947..a1231037 100644 --- a/phase_cli/cmd/users/switch.py +++ b/phase_cli/cmd/users/switch.py @@ -1,5 +1,6 @@ import json import os +import sys from questionary import select, Separator from phase_cli.utils.const import CONFIG_FILE @@ -7,17 +8,25 @@ def load_config(): if not os.path.exists(CONFIG_FILE): print("No configuration found. Please run 'phase auth' to set up your configuration.") return None - with open(CONFIG_FILE, 'r') as f: - return json.load(f) + try: + with open(CONFIG_FILE, 'r') as f: + return json.load(f) + except json.JSONDecodeError: + print("Error reading the config file. The file may be corrupted or not in the expected format.") + sys.exit(1) def save_config(config_data): - with open(CONFIG_FILE, 'w') as f: - json.dump(config_data, f, indent=4) + try: + with open(CONFIG_FILE, 'w') as f: + json.dump(config_data, f, indent=4) + except Exception as e: + print(f"Error saving configuration: {e}") + sys.exit(1) def switch_user(): config_data = load_config() if not config_data: - return + sys.exit(1) # Prepare user choices, including a visual separator as a title. # Also, create a mapping for the partial UUID to the full UUID. @@ -54,6 +63,9 @@ def switch_user(): break else: print("User switch failed.") - break + sys.exit(1) except KeyboardInterrupt: sys.exit(0) + except Exception as e: + print(f"Error switching user: {e}") + sys.exit(1) diff --git a/phase_cli/cmd/users/whoami.py b/phase_cli/cmd/users/whoami.py index be4ceff2..9df70b25 100644 --- a/phase_cli/cmd/users/whoami.py +++ b/phase_cli/cmd/users/whoami.py @@ -1,4 +1,5 @@ import json +import sys from phase_cli.utils.const import CONFIG_FILE @@ -16,14 +17,14 @@ def phase_users_whoami(): if not default_user_id: print("No default user set.") - return + sys.exit(1) # Find the user details matching the default user ID default_user = next((user for user in config_data["phase-users"] if user["id"] == default_user_id), None) if not default_user: print("Default user not found in the users list.") - return + sys.exit(1) # Print the default user details print(f"✉️\u200A Email: {default_user['email']}") @@ -33,5 +34,7 @@ def phase_users_whoami(): except FileNotFoundError: print(f"Config file not found at {CONFIG_FILE}.") + sys.exit(1) except json.JSONDecodeError: print("Error reading the config file. The file may be corrupted or not in the expected format.") + sys.exit(1) diff --git a/phase_cli/main.py b/phase_cli/main.py index 789f18b8..e89a1e45 100755 --- a/phase_cli/main.py +++ b/phase_cli/main.py @@ -61,7 +61,7 @@ def error(self, message): print (description) print(phaseASCii) self.print_help() - sys.exit(2) + sys.exit(0) def add_subparsers(self, **kwargs): kwargs['title'] = 'Commands' diff --git a/tests/phase_run.py b/tests/phase_run.py index fb5f61a0..ea9f8dcd 100644 --- a/tests/phase_run.py +++ b/tests/phase_run.py @@ -2,9 +2,6 @@ from unittest.mock import patch, MagicMock from phase_cli.utils.phase_io import Phase from phase_cli.utils.secret_referencing import resolve_all_secrets -import subprocess -import os -import sys from phase_cli.cmd.run import phase_run_inject @@ -15,7 +12,8 @@ class TestPhaseRunInject(unittest.TestCase): @patch('phase_cli.cmd.run.Progress') @patch('phase_cli.cmd.run.resolve_all_secrets') @patch('phase_cli.cmd.run.Phase') - def test_phase_run_inject_success(self, MockPhase, mock_resolve_all_secrets, MockProgress, MockConsole, mock_subprocess_run): + @patch('phase_cli.cmd.run.sys.exit') + def test_phase_run_inject_success(self, mock_exit, MockPhase, mock_resolve_all_secrets, MockProgress, MockConsole, mock_subprocess_run): # Arrange phase: set up mock objects and their return values mock_phase_instance = MockPhase.return_value mock_console_instance = MockConsole.return_value @@ -30,6 +28,11 @@ def test_phase_run_inject_success(self, MockPhase, mock_resolve_all_secrets, Moc # Mock the resolve_all_secrets function to return the secret value as is mock_resolve_all_secrets.side_effect = lambda value, all_secrets, phase, app, env: value + # Mock subprocess.run to return a successful exit code + mock_process = MagicMock() + mock_process.returncode = 0 + mock_subprocess_run.return_value = mock_process + command = 'echo "Hello World"' env_name = 'dev' phase_app = 'app' @@ -47,13 +50,16 @@ def test_phase_run_inject_success(self, MockPhase, mock_resolve_all_secrets, Moc self.assertIn('OTHER_SECRET', new_env) self.assertEqual(new_env['SECRET_KEY'], 'secret_value') self.assertEqual(new_env['OTHER_SECRET'], 'other_value') + # Verify that sys.exit was called with the process's return code + mock_exit.assert_called_once_with(0) @patch('phase_cli.cmd.run.subprocess.run') @patch('phase_cli.cmd.run.Console') @patch('phase_cli.cmd.run.Progress') @patch('phase_cli.cmd.run.resolve_all_secrets') @patch('phase_cli.cmd.run.Phase') - def test_phase_run_inject_with_different_env(self, MockPhase, mock_resolve_all_secrets, MockProgress, MockConsole, mock_subprocess_run): + @patch('phase_cli.cmd.run.sys.exit') + def test_phase_run_inject_with_different_env(self, mock_exit, MockPhase, mock_resolve_all_secrets, MockProgress, MockConsole, mock_subprocess_run): # Arrange phase: set up mock objects and their return values mock_phase_instance = MockPhase.return_value mock_console_instance = MockConsole.return_value @@ -68,6 +74,11 @@ def test_phase_run_inject_with_different_env(self, MockPhase, mock_resolve_all_s # Mock the resolve_all_secrets function to return the secret value as is mock_resolve_all_secrets.side_effect = lambda value, all_secrets, phase, app, env: value + # Mock subprocess.run to return a successful exit code + mock_process = MagicMock() + mock_process.returncode = 0 + mock_subprocess_run.return_value = mock_process + command = 'echo "Hello World"' env_name = 'prod' phase_app = 'app' @@ -85,11 +96,14 @@ def test_phase_run_inject_with_different_env(self, MockPhase, mock_resolve_all_s self.assertIn('OTHER_SECRET', new_env) self.assertEqual(new_env['SECRET_KEY'], 'secret_value_prod') self.assertEqual(new_env['OTHER_SECRET'], 'other_value_prod') + # Verify that sys.exit was called with the process's return code + mock_exit.assert_called_once_with(0) @patch('phase_cli.cmd.run.Console') @patch('phase_cli.cmd.run.Progress') @patch('phase_cli.cmd.run.Phase') - def test_phase_run_inject_error_handling(self, MockPhase, MockProgress, MockConsole): + @patch('phase_cli.cmd.run.sys.exit') + def test_phase_run_inject_error_handling(self, mock_exit, MockPhase, MockProgress, MockConsole): # Arrange phase: set up mock objects and their return values mock_phase_instance = MockPhase.return_value mock_console_instance = MockConsole.return_value @@ -102,20 +116,21 @@ def test_phase_run_inject_error_handling(self, MockPhase, MockProgress, MockCons env_name = 'dev' phase_app = 'app' - # Act and Assert phase: execute the function under test and expect a SystemExit exception - with self.assertRaises(SystemExit): - phase_run_inject(command, env_name=env_name, phase_app=phase_app) + # Act phase: execute the function under test + phase_run_inject(command, env_name=env_name, phase_app=phase_app) - # Verify that the error message was logged + # Verify that the error message was logged and sys.exit was called with 1 mock_console_instance.log.assert_called_with("Error: Some error occurred") mock_phase_instance.get.assert_called_once_with(env_name=env_name, app_name=phase_app, app_id=None, tag=None, path='/') + mock_exit.assert_called_once_with(1) @patch('phase_cli.cmd.run.subprocess.run') @patch('phase_cli.cmd.run.Console') @patch('phase_cli.cmd.run.Progress') @patch('phase_cli.cmd.run.resolve_all_secrets') @patch('phase_cli.cmd.run.Phase') - def test_phase_run_inject_with_app_id(self, MockPhase, mock_resolve_all_secrets, MockProgress, MockConsole, mock_subprocess_run): + @patch('phase_cli.cmd.run.sys.exit') + def test_phase_run_inject_with_app_id(self, mock_exit, MockPhase, mock_resolve_all_secrets, MockProgress, MockConsole, mock_subprocess_run): # Arrange phase: set up mock objects and their return values mock_phase_instance = MockPhase.return_value mock_console_instance = MockConsole.return_value @@ -128,6 +143,11 @@ def test_phase_run_inject_with_app_id(self, MockPhase, mock_resolve_all_secrets, mock_resolve_all_secrets.side_effect = lambda value, all_secrets, phase, app, env: value + # Mock subprocess.run to return a successful exit code + mock_process = MagicMock() + mock_process.returncode = 0 + mock_subprocess_run.return_value = mock_process + command = 'echo "Hello World"' env_name = 'dev' app_id = 'test-app-id' @@ -137,4 +157,43 @@ def test_phase_run_inject_with_app_id(self, MockPhase, mock_resolve_all_secrets, phase_run_inject(command, env_name=env_name, phase_app_id=app_id) # Assert phase: verify app_id is used instead of app_name - mock_phase_instance.get.assert_called_once_with(env_name=env_name, app_name=None, app_id=app_id, tag=None, path='/') \ No newline at end of file + mock_phase_instance.get.assert_called_once_with(env_name=env_name, app_name=None, app_id=app_id, tag=None, path='/') + # Verify that sys.exit was called with the process's return code + mock_exit.assert_called_once_with(0) + + @patch('phase_cli.cmd.run.subprocess.run') + @patch('phase_cli.cmd.run.Console') + @patch('phase_cli.cmd.run.Progress') + @patch('phase_cli.cmd.run.resolve_all_secrets') + @patch('phase_cli.cmd.run.Phase') + @patch('phase_cli.cmd.run.sys.exit') + def test_phase_run_inject_command_failure(self, mock_exit, MockPhase, mock_resolve_all_secrets, MockProgress, MockConsole, mock_subprocess_run): + # Arrange phase: set up mock objects and their return values + mock_phase_instance = MockPhase.return_value + mock_console_instance = MockConsole.return_value + mock_progress_instance = MockProgress.return_value.__enter__.return_value + + # Mock the return value of the get method + mock_phase_instance.get.return_value = [ + {'key': 'SECRET_KEY', 'value': 'secret_value', 'environment': 'dev', 'path': '/', 'application': 'app'} + ] + + mock_resolve_all_secrets.side_effect = lambda value, all_secrets, phase, app, env: value + + # Mock subprocess.run to return a non-zero exit code to simulate command failure + mock_process = MagicMock() + mock_process.returncode = 127 # Command not found error code + mock_subprocess_run.return_value = mock_process + + command = 'unknown_command' + env_name = 'dev' + phase_app = 'app' + + # Act phase: execute the function under test + with patch.dict('os.environ', {}, clear=True): + phase_run_inject(command, env_name=env_name, phase_app=phase_app) + + # Assert phase: verify the behavior of the function + mock_subprocess_run.assert_called_once_with(command, shell=True, env=unittest.mock.ANY) + # Verify that sys.exit was called with the non-zero return code from the subprocess + mock_exit.assert_called_once_with(127) diff --git a/tests/secrets_export.py b/tests/secrets_export.py index bf72a19f..1430b06f 100644 --- a/tests/secrets_export.py +++ b/tests/secrets_export.py @@ -4,6 +4,7 @@ import csv import io import toml +import sys from xml.etree import ElementTree as ET from configparser import ConfigParser import hcl2 @@ -156,3 +157,35 @@ def test_phase_secrets_env_export_kv_format(mock_phase, capsys): # Verify the exported secrets match the original secrets assert exported_secrets == secrets_dict + +@patch('phase_cli.cmd.secrets.export.Phase') +@patch('phase_cli.cmd.secrets.export.sys.exit') +def test_phase_secrets_env_export_error_handling(mock_exit, mock_phase): + # Arrange: Set up the mock Phase instance to raise a ValueError + mock_phase_instance = mock_phase.return_value + error_message = "API request failed" + mock_phase_instance.get.side_effect = ValueError(error_message) + + # Act: Call the function that should handle the error + phase_secrets_env_export() + + # Assert: Verify sys.exit was called with exit code 1 + mock_exit.assert_called_once_with(1) + + +@patch('phase_cli.cmd.secrets.export.Console') +@patch('phase_cli.cmd.secrets.export.Phase') +@patch('phase_cli.cmd.secrets.export.sys.exit') +def test_phase_secrets_env_export_logs_error_message(mock_exit, mock_phase, mock_console): + # Arrange: Set up mocks + mock_phase_instance = mock_phase.return_value + mock_console_instance = mock_console.return_value + error_message = "API request failed" + mock_phase_instance.get.side_effect = ValueError(error_message) + + # Act: Call the function that should handle the error + phase_secrets_env_export() + + # Assert: Verify the error was logged and sys.exit was called + mock_console_instance.log.assert_called_once_with(f"Error: {error_message}") + mock_exit.assert_called_once_with(1) diff --git a/tests/secrets_get.py b/tests/secrets_get.py new file mode 100644 index 00000000..cc690f8c --- /dev/null +++ b/tests/secrets_get.py @@ -0,0 +1,104 @@ +import unittest +import json +from unittest.mock import patch +from phase_cli.cmd.secrets.get import phase_secrets_get + +class TestPhaseSecretsGet(unittest.TestCase): + + def setUp(self): + # Reset all mocks before each test to avoid interference between tests + self.patcher_console = patch('phase_cli.cmd.secrets.get.Console') + self.mock_console = self.patcher_console.start() + self.mock_console_instance = self.mock_console.return_value + + self.patcher_phase = patch('phase_cli.cmd.secrets.get.Phase') + self.mock_phase = self.patcher_phase.start() + self.mock_phase_instance = self.mock_phase.return_value + + self.patcher_exit = patch('phase_cli.cmd.secrets.get.sys.exit') + self.mock_exit = self.patcher_exit.start() + + def tearDown(self): + # Ensure all patches are properly stopped + self.patcher_console.stop() + self.patcher_phase.stop() + self.patcher_exit.stop() + + def test_phase_secrets_get_success(self): + # Mock the return value to simulate a successful secret fetch + mock_secret = { + "key": "TEST_KEY", + "value": "test_value", + "environment": "dev", + "application": "test-app", + "path": "/" + } + self.mock_phase_instance.get.return_value = [mock_secret] + + # Spy on print function to verify output + with patch('builtins.print') as mock_print: + # Act: Call the function + phase_secrets_get("TEST_KEY", env_name="dev", phase_app="test-app") + + # Assert: Verify the correct output was printed + mock_print.assert_called_once_with(json.dumps(mock_secret, indent=4)) + + # Verify sys.exit was not called (function completes normally) + self.mock_exit.assert_not_called() + + # Verify that Phase.get was called with correct args + self.mock_phase_instance.get.assert_called_once_with( + env_name="dev", + keys=["TEST_KEY"], + app_name="test-app", + app_id=None, + tag=None, + path="/" + ) + + def test_phase_secrets_get_secret_not_found(self): + # Mock the return value to simulate no secrets found + self.mock_phase_instance.get.return_value = [] + + # Act: Call the function + phase_secrets_get("NONEXISTENT_KEY", env_name="dev", phase_app="test-app") + + # Assert: Verify error message was logged + # We'll check that the log was called with the right message without asserting exactly once + self.mock_console_instance.log.assert_any_call("🔍 Secret not found...") + + # Verify sys.exit was called with exit code 1 + self.mock_exit.assert_any_call(1) + + def test_phase_secrets_get_invalid_format(self): + # Create a non-dictionary secret to test the error condition + self.mock_phase_instance.get.return_value = [] + + # We need to make next() return a non-dictionary value + # Let's use patch to replace the next() call with a custom function + with patch('phase_cli.cmd.secrets.get.next', return_value="this is not a dict"): + # Act: Call the function + phase_secrets_get("TEST_KEY", env_name="dev", phase_app="test-app") + + # Assert: Verify error message was logged with the appropriate error + self.mock_console_instance.log.assert_any_call("Error: Unexpected format: secret data is not a dictionary") + + # Verify sys.exit was called with exit code 1 + self.mock_exit.assert_any_call(1) + + def test_phase_secrets_get_api_error(self): + # Mock Phase.get to raise a ValueError (API error) + error_message = "API request failed" + self.mock_phase_instance.get.side_effect = ValueError(error_message) + + # Act: Call the function + phase_secrets_get("TEST_KEY", env_name="dev", phase_app="test-app") + + # Assert: Verify error message was logged + self.mock_console_instance.log.assert_called_once_with(f"Error: {error_message}") + + # Verify sys.exit was called with exit code 1 + self.mock_exit.assert_any_call(1) + +if __name__ == '__main__': + unittest.main()