diff --git a/src/workato_platform_cli/cli/commands/profiles.py b/src/workato_platform_cli/cli/commands/profiles.py index bcbbea9..8b9af5f 100644 --- a/src/workato_platform_cli/cli/commands/profiles.py +++ b/src/workato_platform_cli/cli/commands/profiles.py @@ -360,5 +360,53 @@ async def delete( click.echo(f"❌ Failed to delete profile '{profile_name}'") +@profiles.command() +@click.argument("profile_name") +@handle_cli_exceptions +@inject +async def create( + profile_name: str, + config_manager: ConfigManager = Provide[Container.config_manager], +) -> None: + """Create a new profile with API credentials""" + # Check if profile already exists + existing_profile = config_manager.profile_manager.get_profile(profile_name) + if existing_profile: + click.echo(f"❌ Profile '{profile_name}' already exists") + click.echo("💡 Use 'workato profiles use' to switch to it") + click.echo("💡 Or use 'workato profiles delete' to remove it first") + return + + click.echo(f"🔧 Creating profile: {profile_name}") + click.echo() + + # Create profile interactively + try: + ( + profile_data, + token, + ) = await config_manager.profile_manager.create_profile_interactive( + profile_name + ) + except click.ClickException: + click.echo("❌ Profile creation cancelled") + return + + # Save profile + try: + config_manager.profile_manager.set_profile(profile_name, profile_data, token) + except ValueError as e: + click.echo(f"❌ Failed to save profile: {e}") + return + + # Set as current profile + config_manager.profile_manager.set_current_profile(profile_name) + + click.echo(f"✅ Profile '{profile_name}' created successfully") + click.echo(f"✅ Set '{profile_name}' as the active profile") + click.echo() + click.echo("💡 You can now use this profile with Workato CLI commands") + + # Add show as click argument command show = click.argument("profile_name")(show) diff --git a/src/workato_platform_cli/cli/utils/config/manager.py b/src/workato_platform_cli/cli/utils/config/manager.py index d6f06ee..ce9910b 100644 --- a/src/workato_platform_cli/cli/utils/config/manager.py +++ b/src/workato_platform_cli/cli/utils/config/manager.py @@ -544,38 +544,9 @@ async def _prompt_and_validate_credentials( async def _create_new_profile(self, profile_name: str) -> None: """Create a new profile interactively""" - # Select region - click.echo("📍 Select your Workato region") - region_result = await self.profile_manager.select_region_interactive() - - if not region_result: - raise click.ClickException("Setup cancelled") - - selected_region = region_result - - # Get API token - token = await asyncio.to_thread( - get_token_with_smart_paste, - prompt_text="API token", - ) - if not token.strip(): - raise click.ClickException("API token cannot be empty") - - # 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, + # Create profile using shared helper + profile_data, token = await self.profile_manager.create_profile_interactive( + profile_name ) # Save profile and token diff --git a/src/workato_platform_cli/cli/utils/config/profiles.py b/src/workato_platform_cli/cli/utils/config/profiles.py index a736d37..992dadd 100644 --- a/src/workato_platform_cli/cli/utils/config/profiles.py +++ b/src/workato_platform_cli/cli/utils/config/profiles.py @@ -1,5 +1,6 @@ """Profile management for multiple Workato environments.""" +import asyncio import contextlib import json import os @@ -9,6 +10,7 @@ from urllib.parse import urlparse import asyncclick as click +import certifi import inquirer import keyring @@ -16,6 +18,10 @@ from keyring.compat import properties from keyring.errors import KeyringError, NoKeyringError +from workato_platform_cli import Workato +from workato_platform_cli.cli.utils.token_input import get_token_with_smart_paste +from workato_platform_cli.client.workato_api.configuration import Configuration + from .models import AVAILABLE_REGIONS, ProfileData, ProfilesConfig, RegionInfo @@ -489,3 +495,62 @@ async def select_region_interactive( return RegionInfo(region="custom", name="Custom URL", url=custom_url) return selected_region + + async def create_profile_interactive( + self, profile_name: str + ) -> tuple[ProfileData, str]: + """Create a new profile with interactive prompts for region and token. + + Performs region selection, token input, credential validation, and returns + the validated profile data and token. + + Args: + profile_name: Name of the profile being created + + Returns: + tuple[ProfileData, str]: The validated profile data and API token + + Raises: + click.ClickException: If setup is cancelled, token is empty, + or validation fails + """ + # Step 1: Select region + click.echo("📍 Select your Workato region") + selected_region = await self.select_region_interactive(profile_name) + + if not selected_region: + raise click.ClickException("Setup cancelled") + + # Step 2: Get API token + token = await asyncio.to_thread( + get_token_with_smart_paste, + prompt_text="API token", + ) + if not token.strip(): + raise click.ClickException("API token cannot be empty") + + # Step 3: Test authentication and get workspace info + click.echo("🔄 Validating credentials...") + api_config = Configuration( + access_token=token, host=selected_region.url, ssl_ca_cert=certifi.where() + ) + + 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}") from e + + # Step 4: Create profile data + 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, + ) + + click.echo(f"✅ Authenticated as: {user_info.name}") + + return profile_data, token diff --git a/tests/unit/commands/test_profiles.py b/tests/unit/commands/test_profiles.py index 03d96ed..4d4b0b3 100644 --- a/tests/unit/commands/test_profiles.py +++ b/tests/unit/commands/test_profiles.py @@ -4,11 +4,13 @@ from collections.abc import Callable from pathlib import Path -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch +import asyncclick as click import pytest from workato_platform_cli.cli.commands.profiles import ( + create, delete, list_profiles, show, @@ -953,3 +955,160 @@ async def test_status_json_exception_handling( parsed = json.loads(output) assert parsed["project"]["configured"] is False + + +@pytest.mark.asyncio +async def test_create_profile_success( + capsys: pytest.CaptureFixture[str], + make_config_manager: Callable[..., Mock], + profile_data_factory: Callable[..., ProfileData], +) -> None: + """Test successful profile creation.""" + # Mock profile data returned from create_profile_interactive + profile_data = profile_data_factory( + region="us", + region_url="https://www.workato.com", + workspace_id=123, + ) + + config_manager = make_config_manager( + get_profile=Mock(return_value=None), # Profile doesn't exist yet + set_profile=Mock(), + set_current_profile=Mock(), + ) + + # Mock the create_profile_interactive method + config_manager.profile_manager.create_profile_interactive = AsyncMock( + return_value=(profile_data, "test_token") + ) + + assert create.callback + await create.callback(profile_name="new_profile", config_manager=config_manager) + + output = capsys.readouterr().out + assert "✅ Profile 'new_profile' created successfully" in output + assert "✅ Set 'new_profile' as the active profile" in output + + # Verify profile was set and made current + config_manager.profile_manager.set_profile.assert_called_once() + config_manager.profile_manager.set_current_profile.assert_called_once_with( + "new_profile" + ) + + +@pytest.mark.asyncio +async def test_create_profile_already_exists( + capsys: pytest.CaptureFixture[str], + profile_data_factory: Callable[..., ProfileData], + make_config_manager: Callable[..., Mock], +) -> None: + """Test creating a profile that already exists.""" + profile = profile_data_factory() + config_manager = make_config_manager( + get_profile=Mock(return_value=profile), # Profile already exists + ) + + assert create.callback + await create.callback(profile_name="existing", config_manager=config_manager) + + output = capsys.readouterr().out + assert "❌ Profile 'existing' already exists" in output + assert "Use 'workato profiles use' to switch to it" in output + + +@pytest.mark.asyncio +async def test_create_profile_cancelled_region_selection( + capsys: pytest.CaptureFixture[str], + make_config_manager: Callable[..., Mock], +) -> None: + """Test profile creation when region selection is cancelled.""" + config_manager = make_config_manager( + get_profile=Mock(return_value=None), + ) + + # Mock create_profile_interactive to raise ClickException (cancelled) + config_manager.profile_manager.create_profile_interactive = AsyncMock( + side_effect=click.ClickException("Setup cancelled") + ) + + assert create.callback + await create.callback(profile_name="new_profile", config_manager=config_manager) + + output = capsys.readouterr().out + assert "❌ Profile creation cancelled" in output + + +@pytest.mark.asyncio +async def test_create_profile_empty_token( + capsys: pytest.CaptureFixture[str], + make_config_manager: Callable[..., Mock], +) -> None: + """Test profile creation with empty token.""" + config_manager = make_config_manager( + get_profile=Mock(return_value=None), + ) + + # Mock create_profile_interactive to raise ClickException (empty token) + config_manager.profile_manager.create_profile_interactive = AsyncMock( + side_effect=click.ClickException("API token cannot be empty") + ) + + assert create.callback + await create.callback(profile_name="new_profile", config_manager=config_manager) + + output = capsys.readouterr().out + assert "❌ Profile creation cancelled" in output + + +@pytest.mark.asyncio +async def test_create_profile_authentication_failure( + capsys: pytest.CaptureFixture[str], + make_config_manager: Callable[..., Mock], +) -> None: + """Test profile creation when authentication fails.""" + config_manager = make_config_manager( + get_profile=Mock(return_value=None), + ) + + # Mock create_profile_interactive to raise ClickException (auth failed) + config_manager.profile_manager.create_profile_interactive = AsyncMock( + side_effect=click.ClickException("Authentication failed: Invalid credentials") + ) + + assert create.callback + await create.callback(profile_name="new_profile", config_manager=config_manager) + + output = capsys.readouterr().out + assert "❌ Profile creation cancelled" in output + + +@pytest.mark.asyncio +async def test_create_profile_keyring_failure( + capsys: pytest.CaptureFixture[str], + make_config_manager: Callable[..., Mock], + profile_data_factory: Callable[..., ProfileData], +) -> None: + """Test profile creation when keyring storage fails.""" + # Mock profile data returned from create_profile_interactive + profile_data = profile_data_factory( + region="us", + region_url="https://www.workato.com", + workspace_id=123, + ) + + config_manager = make_config_manager( + get_profile=Mock(return_value=None), + set_profile=Mock(side_effect=ValueError("Failed to store token in keyring")), + ) + + # Mock create_profile_interactive to succeed, but set_profile will fail + config_manager.profile_manager.create_profile_interactive = AsyncMock( + return_value=(profile_data, "test_token") + ) + + assert create.callback + await create.callback(profile_name="new_profile", config_manager=config_manager) + + output = capsys.readouterr().out + assert "❌ Failed to save profile:" in output + assert "Failed to store token in keyring" in output diff --git a/tests/unit/config/test_manager.py b/tests/unit/config/test_manager.py index b28c202..c83b9cc 100644 --- a/tests/unit/config/test_manager.py +++ b/tests/unit/config/test_manager.py @@ -1,5 +1,6 @@ """Tests for ConfigManager.""" +import asyncio import json from datetime import datetime @@ -1801,6 +1802,16 @@ async def test_setup_profile_and_project_new_flow( ConfigManager.__module__ + ".Workato", StubWorkato, ) + # Also mock Workato in profiles module since + # create_profile_interactive uses it + monkeypatch.setattr( + "workato_platform_cli.Workato", + StubWorkato, + ) + monkeypatch.setattr( + "workato_platform_cli.cli.utils.config.profiles.Workato", + StubWorkato, + ) StubProjectManager.available_projects = [] StubProjectManager.created_projects = [] monkeypatch.setattr( @@ -1829,7 +1840,16 @@ async def fake_prompt(message: str, **_: object) -> str: fake_prompt, ) - # Mock get_token_with_smart_paste + # Mock asyncio.to_thread to avoid calling the real get_token_with_smart_paste + async def fake_to_thread(func: Any, *args: Any, **kwargs: Any) -> str: + if func.__name__ == "get_token_with_smart_paste": + return "token-123" + return await asyncio.to_thread(func, *args, **kwargs) + + monkeypatch.setattr( + "workato_platform_cli.cli.utils.config.profiles.asyncio.to_thread", + fake_to_thread, + ) monkeypatch.setattr( ConfigManager.__module__ + ".get_token_with_smart_paste", lambda **kwargs: "token-123", @@ -2131,23 +2151,20 @@ async def test_create_new_profile_custom_region( StubWorkato, ) - # Mock select_region_interactive to return a custom region - from workato_platform_cli.cli.utils.config.models import RegionInfo + # Mock create_profile_interactive to return profile data and token + from workato_platform_cli.cli.utils.config.models import ProfileData - async def mock_select_region() -> RegionInfo: - return RegionInfo( - region="custom", - name="Custom URL", - url="https://custom.workato.test", + async def mock_create_profile(profile_name: str) -> tuple[ProfileData, str]: + return ( + ProfileData( + region="custom", + region_url="https://custom.workato.test", + workspace_id=123, + ), + "custom-token", ) - mock_profile_manager.select_region_interactive = mock_select_region - - # Mock get_token_with_smart_paste - monkeypatch.setattr( - ConfigManager.__module__ + ".get_token_with_smart_paste", - lambda **kwargs: "custom-token", - ) + mock_profile_manager.create_profile_interactive = mock_create_profile config_manager = ConfigManager(config_dir=tmp_path, skip_validation=True) await config_manager._create_new_profile("custom") @@ -2174,11 +2191,11 @@ async def test_create_new_profile_cancelled( manager = ConfigManager(config_dir=tmp_path, skip_validation=True) manager.profile_manager = mock_profile_manager - # Mock select_region_interactive to return None (cancelled) - async def mock_select_region_cancelled() -> None: - return None + # Mock create_profile_interactive to raise ClickException (cancelled) + async def mock_create_profile_cancelled(profile_name: str) -> tuple: + raise click.ClickException("Setup cancelled") - mock_profile_manager.select_region_interactive = mock_select_region_cancelled + mock_profile_manager.create_profile_interactive = mock_create_profile_cancelled with pytest.raises(click.ClickException, match="Setup cancelled"): await manager._create_new_profile("dev") @@ -2195,15 +2212,12 @@ async def test_create_new_profile_requires_token( manager = ConfigManager(config_dir=tmp_path, skip_validation=True) manager.profile_manager = mock_profile_manager - monkeypatch.setattr( - ConfigManager.__module__ + ".inquirer.prompt", - lambda _questions: {"region": "US Data Center (https://www.workato.com)"}, - ) + # Mock create_profile_interactive to raise ClickException (empty token) + async def mock_create_profile_empty_token(profile_name: str) -> tuple: + raise click.ClickException("API token cannot be empty") - # Mock get_token_with_smart_paste to return blank token - monkeypatch.setattr( - ConfigManager.__module__ + ".get_token_with_smart_paste", - lambda **kwargs: " ", + mock_profile_manager.create_profile_interactive = ( + mock_create_profile_empty_token ) with pytest.raises(click.ClickException, match="API token cannot be empty"):