diff --git a/src/workato_platform_cli/cli/utils/config/manager.py b/src/workato_platform_cli/cli/utils/config/manager.py index cd52cc1..26c8245 100644 --- a/src/workato_platform_cli/cli/utils/config/manager.py +++ b/src/workato_platform_cli/cli/utils/config/manager.py @@ -143,6 +143,14 @@ async def _setup_non_interactive( region_info = self._match_host_to_region(api_url) region = region_info.region + # Validate credentials are available before attempting authentication + if not api_token: + raise click.ClickException( + f"Profile '{current_profile_name}' exists but credentials not found " + "in keychain. Please run 'workato init' interactively or set " + "WORKATO_API_TOKEN environment variable." + ) + # Map region to URL if region == "custom": if not api_url: @@ -341,6 +349,34 @@ async def _setup_profile(self) -> str: await self._create_new_profile(profile_name) else: profile_name = answers["profile_choice"] + + # Check if credentials exist in keychain for existing profile + existing_profile = self.profile_manager.get_profile(profile_name) + token = self.profile_manager._get_token_from_keyring(profile_name) + + if existing_profile and not token: + # Credentials missing from keychain - prompt for re-entry + click.echo("⚠️ Credentials not found for this profile") + + # Get region info from existing profile + region_info = RegionInfo( + region=existing_profile.region, + url=existing_profile.region_url, + name=existing_profile.region, + ) + + # Prompt and validate credentials + ( + profile_data, + validated_token, + ) = await self._prompt_and_validate_credentials( + profile_name, region_info + ) + + # Update profile with validated credentials + self.profile_manager.set_profile( + profile_name, profile_data, validated_token + ) else: profile_name = ( await click.prompt("Enter profile name", default="default", type=str) @@ -468,6 +504,55 @@ async def _create_profile_with_env_vars( click.echo(f"✅ Authenticated as: {user_info.name}") click.echo("✓ Using environment variables for authentication") + async def _prompt_and_validate_credentials( + self, profile_name: str, region_info: RegionInfo + ) -> tuple[ProfileData, str]: + """Prompt for API credentials and validate them with an API call. + Args: + profile_name: Name of the profile being configured + region_info: Region information containing the API URL + Returns: + tuple[ProfileData, str]: Validated profile data and API token + Raises: + click.ClickException: If token is empty or validation fails + """ + # Prompt for API token + click.echo("🔐 Enter your API token") + token = await click.prompt("Enter your Workato API token", hide_input=True) + + # Validate token is not empty + if not token.strip(): + raise click.ClickException("API token cannot be empty") + + # Create API configuration with the token and region URL + api_config = Configuration( + access_token=token, host=region_info.url, ssl_ca_cert=certifi.where() + ) + + # Make API call to test authentication and get workspace info + try: + async with Workato(configuration=api_config) as workato_api_client: + user_info = await workato_api_client.users_api.get_workspace_details() + except Exception as e: + raise click.ClickException( + f"Authentication failed: {e}\n" + "Please verify your API token is correct and try again." + ) from e + + # Create and return ProfileData object with workspace info + if not region_info.url: + raise click.ClickException("Region URL is required") + + profile_data = ProfileData( + region=region_info.region, + region_url=region_info.url, + workspace_id=user_info.id, + ) + + click.echo(f"✅ Authenticated as: {user_info.name}") + + return profile_data, token + async def _create_new_profile(self, profile_name: str) -> None: """Create a new profile interactively""" # Select region @@ -480,32 +565,13 @@ async def _create_new_profile(self, profile_name: str) -> None: selected_region = region_result - # Get API token - click.echo("🔐 Enter your API token") - token = await click.prompt("Enter your Workato API token", hide_input=True) - if not token.strip(): - click.echo("❌ No token provided") - sys.exit(1) - - # Test authentication and get workspace info - api_config = Configuration( - access_token=token, host=selected_region.url, ssl_ca_cert=certifi.where() - ) - - async with Workato(configuration=api_config) as workato_api_client: - user_info = await workato_api_client.users_api.get_workspace_details() - - # Create and save profile - if not selected_region.url: - raise click.ClickException("Region URL is required") - profile_data = ProfileData( - region=selected_region.region, - region_url=selected_region.url, - workspace_id=user_info.id, + # Prompt for credentials and validate with API + profile_data, token = await self._prompt_and_validate_credentials( + profile_name, selected_region ) + # Save profile and token self.profile_manager.set_profile(profile_name, profile_data, token) - click.echo(f"✅ Authenticated as: {user_info.name}") async def _setup_project(self, profile_name: str, workspace_root: Path) -> None: """Setup project interactively""" diff --git a/src/workato_platform_cli/cli/utils/exception_handler.py b/src/workato_platform_cli/cli/utils/exception_handler.py index af2f5eb..9b5b1b8 100644 --- a/src/workato_platform_cli/cli/utils/exception_handler.py +++ b/src/workato_platform_cli/cli/utils/exception_handler.py @@ -145,6 +145,10 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: except click.Abort: # Let Click handle Abort - don't catch it raise + except click.ClickException as e: + # Handle ClickException specifically - show message without error type + _handle_click_exception(e) + raise SystemExit(1) from None except Exception as e: # Catch-all for any exceptions during initialization _handle_generic_cli_error(e) @@ -175,6 +179,10 @@ def sync_wrapper(*args: Any, **kwargs: Any) -> Any: except click.Abort: # Let Click handle Abort - don't catch it raise + except click.ClickException as e: + # Handle ClickException specifically - show message without error type + _handle_click_exception(e) + raise SystemExit(1) from None except Exception as e: # Catch-all for any exceptions during initialization _handle_generic_cli_error(e) @@ -510,6 +518,24 @@ def _handle_ssl_error(e: aiohttp.ClientSSLError | ssl.SSLError) -> None: click.echo(" • Your network is not intercepting HTTPS connections") +def _handle_click_exception(e: click.ClickException) -> None: + """Handle click.ClickException with clean error message.""" + output_mode = _get_output_mode() + + error_msg = str(e) + + if output_mode == "json": + error_data = { + "status": "error", + "error": error_msg, + "error_code": "CLI_ERROR", + } + click.echo(json.dumps(error_data)) + return + + click.echo(f"❌ {error_msg}") + + def _handle_generic_cli_error(e: Exception) -> None: """Handle any other unexpected CLI errors with a generic message.""" output_mode = _get_output_mode() diff --git a/tests/unit/commands/test_init.py b/tests/unit/commands/test_init.py index c6c3dc6..349e7c6 100644 --- a/tests/unit/commands/test_init.py +++ b/tests/unit/commands/test_init.py @@ -86,6 +86,11 @@ async def test_init_non_interactive_success(monkeypatch: pytest.MonkeyPatch) -> "get_project_directory", return_value=None, ), + patch.object( + mock_config_manager, + "validate_environment_config", + return_value=(True, []), + ), patch.object( mock_config_manager.profile_manager, "resolve_environment_variables", @@ -437,6 +442,11 @@ async def test_init_non_empty_directory_non_interactive_json( "get_project_directory", return_value=project_dir, ), + patch.object( + mock_config_manager, + "validate_environment_config", + return_value=(True, []), + ), ): mock_initialize = AsyncMock(return_value=mock_config_manager) monkeypatch.setattr( @@ -487,6 +497,11 @@ async def test_init_non_empty_directory_non_interactive_table( "get_project_directory", return_value=project_dir, ), + patch.object( + mock_config_manager, + "validate_environment_config", + return_value=(True, []), + ), ): mock_initialize = AsyncMock(return_value=mock_config_manager) monkeypatch.setattr( @@ -532,6 +547,11 @@ async def test_init_non_empty_directory_interactive_cancelled( "get_project_directory", return_value=project_dir, ), + patch.object( + mock_config_manager, + "validate_environment_config", + return_value=(True, []), + ), ): mock_initialize = AsyncMock(return_value=mock_config_manager) monkeypatch.setattr( @@ -649,6 +669,11 @@ async def test_init_json_output_mode_success( "get_project_directory", return_value=None, ), + patch.object( + mock_config_manager, + "validate_environment_config", + return_value=(True, []), + ), patch.object( mock_config_manager.profile_manager, "resolve_environment_variables", @@ -803,6 +828,11 @@ async def test_init_cli_managed_files_ignored_non_interactive( "get_project_directory", return_value=project_dir, ), + patch.object( + mock_config_manager, + "validate_environment_config", + return_value=(True, []), + ), patch.object( mock_config_manager.profile_manager, "resolve_environment_variables", @@ -865,6 +895,11 @@ async def test_init_user_files_with_cli_files_triggers_warning( "get_project_directory", return_value=project_dir, ), + patch.object( + mock_config_manager, + "validate_environment_config", + return_value=(True, []), + ), ): mock_initialize = AsyncMock(return_value=mock_config_manager) monkeypatch.setattr( @@ -913,6 +948,11 @@ async def test_init_only_workatoenv_file_ignored( "get_project_directory", return_value=project_dir, ), + patch.object( + mock_config_manager, + "validate_environment_config", + return_value=(True, []), + ), patch.object( mock_config_manager.profile_manager, "resolve_environment_variables", @@ -1279,3 +1319,237 @@ async def test_init_non_interactive_partial_env_vars_json( # Verify JSON output is valid and contains expected profile data assert "profile" in result assert result["profile"]["region"] == "us" + + +@pytest.mark.asyncio +async def test_init_non_interactive_profile_missing_credentials_json( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test non-interactive mode with missing credentials in JSON output mode.""" + # Mock ConfigManager.initialize to raise ClickException + # (simulating missing credentials) + mock_initialize = AsyncMock( + side_effect=click.ClickException( + "Profile 'test-profile' exists but credentials not found in keychain. " + "Please run 'workato init' interactively or set WORKATO_API_TOKEN " + "environment variable." + ) + ) + monkeypatch.setattr( + init_module.ConfigManager, + "initialize", + mock_initialize, + ) + + output = StringIO() + monkeypatch.setattr(init_module.click, "echo", lambda msg: output.write(msg)) + + # Mock get_current_context to return output_mode + mock_ctx = Mock() + mock_ctx.params = {"output_mode": "json"} + monkeypatch.setattr( + init_module.click, + "get_current_context", + lambda silent=True: mock_ctx, + ) + + assert init_module.init.callback + with pytest.raises(SystemExit) as exc_info: + await init_module.init.callback( + profile="test-profile", + region=None, + api_token=None, + api_url=None, + project_name=None, + project_id=123, + non_interactive=True, + output_mode="json", + ) + + assert exc_info.value.code == 1 + result = json.loads(output.getvalue()) + assert result["status"] == "error" + assert "credentials not found" in result["error"] + assert "test-profile" in result["error"] + assert result["error_code"] == "CLI_ERROR" + + +@pytest.mark.asyncio +async def test_init_non_interactive_profile_missing_credentials_table( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test non-interactive mode with missing credentials in table output mode.""" + # Mock ConfigManager.initialize to raise ClickException + # (simulating missing credentials) + mock_initialize = AsyncMock( + side_effect=click.ClickException( + "Profile 'test-profile' exists but credentials not found in keychain. " + "Please run 'workato init' interactively or set WORKATO_API_TOKEN " + "environment variable." + ) + ) + monkeypatch.setattr( + init_module.ConfigManager, + "initialize", + mock_initialize, + ) + + output = StringIO() + monkeypatch.setattr(init_module.click, "echo", lambda msg: output.write(msg)) + + # Mock get_current_context to return output_mode + mock_ctx = Mock() + mock_ctx.params = {"output_mode": "table"} + monkeypatch.setattr( + init_module.click, + "get_current_context", + lambda silent=True: mock_ctx, + ) + + assert init_module.init.callback + with pytest.raises(SystemExit) as exc_info: + await init_module.init.callback( + profile="test-profile", + region=None, + api_token=None, + api_url=None, + project_name=None, + project_id=123, + non_interactive=True, + output_mode="table", + ) + + assert exc_info.value.code == 1 + # Verify error message was shown + output_text = output.getvalue() + assert "credentials not found" in output_text + assert "test-profile" in output_text + + +@pytest.mark.asyncio +async def test_init_non_interactive_profile_with_valid_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test non-interactive mode with valid credentials proceeds normally.""" + mock_config_manager = Mock() + mock_workato_client = Mock() + workato_context = AsyncMock() + + with ( + patch.object( + mock_config_manager, + "load_config", + return_value=Mock(profile="test-profile"), + ), + patch.object( + mock_config_manager, + "get_project_directory", + return_value=None, + ), + patch.object( + mock_config_manager, + "validate_environment_config", + return_value=(True, []), + ), + patch.object( + mock_config_manager.profile_manager, + "resolve_environment_variables", + return_value=("test-token", "https://api.workato.com"), + ), + patch.object(workato_context, "__aenter__", return_value=mock_workato_client), + patch.object(workato_context, "__aexit__", return_value=False), + ): + mock_initialize = AsyncMock(return_value=mock_config_manager) + monkeypatch.setattr( + init_module.ConfigManager, + "initialize", + mock_initialize, + ) + + mock_pull = AsyncMock() + monkeypatch.setattr(init_module, "_pull_project", mock_pull) + + monkeypatch.setattr(init_module, "Workato", lambda **_: workato_context) + monkeypatch.setattr(init_module, "Configuration", lambda **_: Mock()) + + monkeypatch.setattr(init_module.click, "echo", lambda *args, **kwargs: None) + + assert init_module.init.callback + await init_module.init.callback( + profile="test-profile", + region=None, + api_token=None, + api_url=None, + project_name=None, + project_id=123, + non_interactive=True, + output_mode="table", + ) + + # Should proceed without error + mock_pull.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_init_non_interactive_new_profile_with_region_and_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test non-interactive mode creating new profile with region and token. + + This test verifies that credential validation is skipped when creating + a new profile with both region and token provided. + """ + mock_config_manager = Mock() + mock_workato_client = Mock() + workato_context = AsyncMock() + + with ( + patch.object( + mock_config_manager, + "load_config", + return_value=Mock(profile="new-profile"), + ), + patch.object( + mock_config_manager, + "get_project_directory", + return_value=None, + ), + patch.object( + mock_config_manager.profile_manager, + "resolve_environment_variables", + return_value=("new-token", "https://api.workato.com"), + ), + patch.object(workato_context, "__aenter__", return_value=mock_workato_client), + patch.object(workato_context, "__aexit__", return_value=False), + ): + mock_initialize = AsyncMock(return_value=mock_config_manager) + monkeypatch.setattr( + init_module.ConfigManager, + "initialize", + mock_initialize, + ) + + mock_pull = AsyncMock() + monkeypatch.setattr(init_module, "_pull_project", mock_pull) + + monkeypatch.setattr(init_module, "Workato", lambda **_: workato_context) + monkeypatch.setattr(init_module, "Configuration", lambda **_: Mock()) + + monkeypatch.setattr(init_module.click, "echo", lambda *args, **kwargs: None) + + # Should not call validate_environment_config when creating new profile + # (region and api_token are both provided) + assert init_module.init.callback + await init_module.init.callback( + profile="new-profile", + region="us", + api_token="new-token", + api_url=None, + project_name=None, + project_id=123, + non_interactive=True, + output_mode="table", + ) + + # Should proceed without error and not check credentials + mock_pull.assert_awaited_once() diff --git a/tests/unit/config/test_manager.py b/tests/unit/config/test_manager.py index 1ab2295..a36a18d 100644 --- a/tests/unit/config/test_manager.py +++ b/tests/unit/config/test_manager.py @@ -1981,6 +1981,124 @@ def fake_inquirer_prompt(questions: list[Any]) -> dict[str, str]: assert profile_name == "existing" assert any("Profile:" in line for line in outputs) + @pytest.mark.asyncio + async def test_setup_profile_existing_with_valid_credentials( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + mock_profile_manager: Mock, + ) -> None: + """Existing profile with valid credentials should not prompt for re-entry.""" + + mock_profile_manager.set_profile( + "existing", + ProfileData( + region="us", region_url="https://www.workato.com", workspace_id=1 + ), + "existing-token", + ) + mock_profile_manager.set_current_profile("existing") + + # Mock _get_token_from_keyring to return valid token + mock_profile_manager._get_token_from_keyring.return_value = "existing-token" + + monkeypatch.setattr( + ConfigManager.__module__ + ".ProfileManager", + lambda: mock_profile_manager, + ) + + outputs: list[str] = [] + monkeypatch.setattr( + ConfigManager.__module__ + ".click.echo", + lambda msg="": outputs.append(str(msg)), + ) + + def fake_inquirer_prompt(questions: list[Any]) -> dict[str, str]: + assert questions[0].message == "Select a profile" + return {"profile_choice": "existing"} + + monkeypatch.setattr( + ConfigManager.__module__ + ".inquirer.prompt", + fake_inquirer_prompt, + ) + + config_manager = ConfigManager(config_dir=tmp_path, skip_validation=True) + profile_name = await config_manager._setup_profile() + assert profile_name == "existing" + assert any("Profile:" in line for line in outputs) + # Should not show credential warning + assert not any("Credentials not found" in line for line in outputs) + + @pytest.mark.asyncio + async def test_setup_profile_existing_with_missing_credentials( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + mock_profile_manager: Mock, + ) -> None: + """Existing profile with missing credentials should prompt for re-entry.""" + + existing_profile = ProfileData( + region="us", region_url="https://www.workato.com", workspace_id=1 + ) + mock_profile_manager.set_profile("existing", existing_profile, None) + mock_profile_manager.set_current_profile("existing") + + # Mock _get_token_from_keyring to return None (missing credentials) + mock_profile_manager._get_token_from_keyring.return_value = None + + # Mock get_profile to return the existing profile + mock_profile_manager.get_profile.return_value = existing_profile + + monkeypatch.setattr( + ConfigManager.__module__ + ".ProfileManager", + lambda: mock_profile_manager, + ) + monkeypatch.setattr( + ConfigManager.__module__ + ".Workato", + StubWorkato, + ) + + outputs: list[str] = [] + monkeypatch.setattr( + ConfigManager.__module__ + ".click.echo", + lambda msg="": outputs.append(str(msg)), + ) + + prompt_answers = { + "Enter your Workato API token": ["new-token"], + } + + async def fake_prompt( + text: str, type: Any = None, hide_input: bool = False + ) -> Any: + return prompt_answers.get(text, [""])[0] + + monkeypatch.setattr( + ConfigManager.__module__ + ".click.prompt", + fake_prompt, + ) + + def fake_inquirer_prompt(questions: list[Any]) -> dict[str, str]: + assert questions[0].message == "Select a profile" + return {"profile_choice": "existing"} + + monkeypatch.setattr( + ConfigManager.__module__ + ".inquirer.prompt", + fake_inquirer_prompt, + ) + + config_manager = ConfigManager(config_dir=tmp_path, skip_validation=True) + profile_name = await config_manager._setup_profile() + assert profile_name == "existing" + + # Should show credential warning + assert any("Credentials not found" in line for line in outputs) + assert any("Enter your API token" in line for line in outputs) + + # Should call set_profile with new credentials + mock_profile_manager.set_profile.assert_called() + @pytest.mark.asyncio async def test_create_new_profile_custom_region( self, @@ -2086,9 +2204,198 @@ async def fake_prompt(message: str, **_: Any) -> str: fake_prompt, ) - with pytest.raises(SystemExit): + with pytest.raises(click.ClickException, match="API token cannot be empty"): await manager._create_new_profile("dev") + @pytest.mark.asyncio + async def test_prompt_and_validate_credentials_success( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + mock_profile_manager: Mock, + ) -> None: + """Test successful credential prompt and validation.""" + from workato_platform_cli.cli.utils.config.models import RegionInfo + + manager = ConfigManager(config_dir=tmp_path, skip_validation=True) + manager.profile_manager = mock_profile_manager + + monkeypatch.setattr( + ConfigManager.__module__ + ".Workato", + StubWorkato, + ) + + async def fake_prompt(message: str, **_: Any) -> str: + if "API token" in message: + return "valid-token-123" + return "unused" + + monkeypatch.setattr( + ConfigManager.__module__ + ".click.prompt", + fake_prompt, + ) + + outputs: list[str] = [] + monkeypatch.setattr( + ConfigManager.__module__ + ".click.echo", + lambda msg="": outputs.append(str(msg)), + ) + + region_info = RegionInfo( + region="us", name="US Data Center", url="https://www.workato.com" + ) + profile_data, token = await manager._prompt_and_validate_credentials( + "test-profile", region_info + ) + + assert profile_data.region == "us" + assert profile_data.region_url == "https://www.workato.com" + assert profile_data.workspace_id == 101 + assert token == "valid-token-123" + assert any("Authenticated as" in msg for msg in outputs) + + @pytest.mark.asyncio + async def test_prompt_and_validate_credentials_empty_token( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + mock_profile_manager: Mock, + ) -> None: + """Test that empty token raises ClickException.""" + from workato_platform_cli.cli.utils.config.models import RegionInfo + + manager = ConfigManager(config_dir=tmp_path, skip_validation=True) + manager.profile_manager = mock_profile_manager + + async def fake_prompt(message: str, **_: Any) -> str: + if "API token" in message: + return " " # Empty/whitespace token + return "unused" + + monkeypatch.setattr( + ConfigManager.__module__ + ".click.prompt", + fake_prompt, + ) + + outputs: list[str] = [] + monkeypatch.setattr( + ConfigManager.__module__ + ".click.echo", + lambda msg="": outputs.append(str(msg)), + ) + + region_info = RegionInfo( + region="us", name="US Data Center", url="https://www.workato.com" + ) + + with pytest.raises(click.ClickException) as excinfo: + await manager._prompt_and_validate_credentials("test-profile", region_info) + + assert "token cannot be empty" in str(excinfo.value) + + @pytest.mark.asyncio + async def test_prompt_and_validate_credentials_api_failure( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + mock_profile_manager: Mock, + ) -> None: + """Test that API validation failure raises appropriate exception.""" + from workato_platform_cli.cli.utils.config.models import RegionInfo + + manager = ConfigManager(config_dir=tmp_path, skip_validation=True) + manager.profile_manager = mock_profile_manager + + # Create a Workato stub that raises an exception + class FailingWorkato: + def __init__(self, configuration: Configuration) -> None: + self.configuration = configuration + self.users_api = FailingUsersAPI() + + async def __aenter__(self) -> "FailingWorkato": + return self + + async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None: + return None + + class FailingUsersAPI: + async def get_workspace_details(self) -> User: + raise Exception("Authentication failed: Invalid token") + + monkeypatch.setattr( + ConfigManager.__module__ + ".Workato", + FailingWorkato, + ) + + async def fake_prompt(message: str, **_: Any) -> str: + if "API token" in message: + return "invalid-token" + return "unused" + + monkeypatch.setattr( + ConfigManager.__module__ + ".click.prompt", + fake_prompt, + ) + + outputs: list[str] = [] + monkeypatch.setattr( + ConfigManager.__module__ + ".click.echo", + lambda msg="": outputs.append(str(msg)), + ) + + region_info = RegionInfo( + region="us", name="US Data Center", url="https://www.workato.com" + ) + + with pytest.raises(click.ClickException) as excinfo: + await manager._prompt_and_validate_credentials("test-profile", region_info) + + assert "Authentication failed" in str(excinfo.value) + + @pytest.mark.asyncio + async def test_prompt_and_validate_credentials_keyring_disabled( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + mock_profile_manager: Mock, + ) -> None: + """Test credential validation returns correct data.""" + from workato_platform_cli.cli.utils.config.models import RegionInfo + + manager = ConfigManager(config_dir=tmp_path, skip_validation=True) + manager.profile_manager = mock_profile_manager + + monkeypatch.setattr( + ConfigManager.__module__ + ".Workato", + StubWorkato, + ) + + async def fake_prompt(message: str, **_: Any) -> str: + if "API token" in message: + return "valid-token-123" + return "unused" + + monkeypatch.setattr( + ConfigManager.__module__ + ".click.prompt", + fake_prompt, + ) + + outputs: list[str] = [] + monkeypatch.setattr( + ConfigManager.__module__ + ".click.echo", + lambda msg="": outputs.append(str(msg)), + ) + + region_info = RegionInfo( + region="us", name="US Data Center", url="https://www.workato.com" + ) + profile_data, token = await manager._prompt_and_validate_credentials( + "test-profile", region_info + ) + + assert profile_data.region == "us" + assert token == "valid-token-123" + assert any("Authenticated as" in msg for msg in outputs) + @pytest.mark.asyncio async def test_setup_profile_existing_create_new_success( self,