From da2b2bacfda5755207a4ec02e91c22da5baa13a3 Mon Sep 17 00:00:00 2001 From: Petr Simecek Date: Thu, 26 Feb 2026 13:01:02 +0100 Subject: [PATCH] Phase 2: Project management (client, config store, service, CLI) Implement the full project management lifecycle: add, list, remove, edit, status. - client.py: KeboolaClient with httpx, retry (exponential backoff for 429/5xx), timeouts (connect=5s, read=30s, write=10s, pool=5s), User-Agent header, verify_token(), list_components(), get_config_detail(), token masking in errors - config_store.py: Full ConfigStore with platformdirs, load/save with 0o600 permissions, add/remove/edit/get project, version checking, error handling - services/project_service.py: ProjectService with DI (client_factory callable), add/remove/edit/list/status with token verification and masked output - commands/project.py: All commands fully flag-based (non-interactive), proper exit codes (0/3/4/5), Rich table output for human mode, JSON for machine mode - cli.py: Wired up context object with config_store and project_service - models.py: Added TokenVerifyResponse model - Support for KBC_TOKEN and KBC_STORAGE_API_URL env vars Tests: 114 passing (40 original + 74 new) - test_client.py: verify_token success/401, retry on 503/429, timeout, token masking - test_config_store.py: load empty, add/remove/edit, permissions, version check - test_services.py: add/remove/edit/list/status with mocks, mixed success/failure - test_cli.py: project add/list/remove/edit/status via CliRunner, JSON and human mode Co-Authored-By: Claude Opus 4.6 --- src/keboola_agent_cli/cli.py | 9 +- src/keboola_agent_cli/client.py | 207 ++++++++ src/keboola_agent_cli/commands/project.py | 160 +++++- src/keboola_agent_cli/config_store.py | 97 +++- src/keboola_agent_cli/models.py | 10 + .../services/project_service.py | 225 ++++++++ tests/test_cli.py | 483 ++++++++++++++++++ tests/test_client.py | 428 ++++++++++++++++ tests/test_config_store.py | 347 +++++++++++++ tests/test_services.py | 463 +++++++++++++++++ 10 files changed, 2407 insertions(+), 22 deletions(-) create mode 100644 src/keboola_agent_cli/client.py create mode 100644 src/keboola_agent_cli/services/project_service.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_client.py create mode 100644 tests/test_config_store.py create mode 100644 tests/test_services.py diff --git a/src/keboola_agent_cli/cli.py b/src/keboola_agent_cli/cli.py index 0e29d86f..780e6b5b 100644 --- a/src/keboola_agent_cli/cli.py +++ b/src/keboola_agent_cli/cli.py @@ -1,7 +1,6 @@ """Typer root application with global options and subcommand registration.""" import sys -from typing import Optional import typer @@ -9,7 +8,9 @@ from .commands.context import context_command from .commands.doctor import doctor_command from .commands.project import project_app +from .config_store import ConfigStore from .output import OutputFormatter +from .services.project_service import ProjectService app = typer.Typer( name="kbagent", @@ -54,8 +55,14 @@ def main( verbose=verbose, ) + config_store = ConfigStore() + + project_service = ProjectService(config_store=config_store) + ctx.ensure_object(dict) ctx.obj["formatter"] = formatter ctx.obj["json_output"] = json_output ctx.obj["verbose"] = verbose ctx.obj["no_color"] = effective_no_color + ctx.obj["config_store"] = config_store + ctx.obj["project_service"] = project_service diff --git a/src/keboola_agent_cli/client.py b/src/keboola_agent_cli/client.py new file mode 100644 index 00000000..afe19f54 --- /dev/null +++ b/src/keboola_agent_cli/client.py @@ -0,0 +1,207 @@ +"""Keboola API client with retry, timeouts, and token masking. + +This is the only module that communicates with the Keboola Storage API. +All HTTP details, endpoint URLs, and error mapping are encapsulated here. +""" + +import time +from typing import Any + +import httpx + +from . import __version__ +from .errors import KeboolaApiError, mask_token +from .models import TokenVerifyResponse + +RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504} +MAX_RETRIES = 3 +BACKOFF_BASE = 1.0 # seconds; delays: 1s, 2s, 4s + + +class KeboolaClient: + """HTTP client for the Keboola Storage API. + + Provides methods to interact with Keboola endpoints with built-in + retry logic (exponential backoff for 429/5xx), timeouts, and + automatic token masking in error messages. + """ + + def __init__(self, stack_url: str, token: str) -> None: + self._stack_url = stack_url.rstrip("/") + self._token = token + self._masked_token = mask_token(token) + self._client = httpx.Client( + base_url=self._stack_url, + timeout=httpx.Timeout(connect=5.0, read=30.0, write=10.0, pool=5.0), + headers={ + "X-StorageApi-Token": token, + "User-Agent": f"keboola-agent-cli/{__version__}", + }, + ) + + def close(self) -> None: + """Close the underlying HTTP client.""" + self._client.close() + + def __enter__(self) -> "KeboolaClient": + return self + + def __exit__(self, *args: Any) -> None: + self.close() + + def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: + """Execute an HTTP request with retry and exponential backoff. + + Retries on status codes 429, 500, 502, 503, 504 up to MAX_RETRIES times + with exponential backoff (1s, 2s, 4s). + + Raises: + KeboolaApiError: On HTTP errors (with masked token) or after retries exhausted. + """ + last_exception: Exception | None = None + last_response: httpx.Response | None = None + + for attempt in range(MAX_RETRIES): + try: + response = self._client.request(method, path, **kwargs) + + if response.status_code < 400: + return response + + if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES - 1: + delay = BACKOFF_BASE * (2 ** attempt) + time.sleep(delay) + last_response = response + continue + + self._raise_api_error(response) + + except httpx.TimeoutException as exc: + if attempt < MAX_RETRIES - 1: + delay = BACKOFF_BASE * (2 ** attempt) + time.sleep(delay) + last_exception = exc + continue + raise KeboolaApiError( + message=f"Request timed out connecting to {self._stack_url} (token: {self._masked_token})", + status_code=0, + error_code="TIMEOUT", + retryable=True, + ) from exc + + except httpx.ConnectError as exc: + if attempt < MAX_RETRIES - 1: + delay = BACKOFF_BASE * (2 ** attempt) + time.sleep(delay) + last_exception = exc + continue + raise KeboolaApiError( + message=f"Cannot connect to {self._stack_url} (token: {self._masked_token})", + status_code=0, + error_code="CONNECTION_ERROR", + retryable=True, + ) from exc + + if last_response is not None: + self._raise_api_error(last_response) + + raise KeboolaApiError( + message=f"Request failed after {MAX_RETRIES} retries to {self._stack_url} (token: {self._masked_token})", + status_code=0, + error_code="RETRY_EXHAUSTED", + retryable=True, + ) + + def _raise_api_error(self, response: httpx.Response) -> None: + """Convert an HTTP error response into a KeboolaApiError.""" + status = response.status_code + + try: + body = response.json() + api_message = body.get("error", body.get("message", response.text)) + except Exception: + api_message = response.text + + if status == 401: + raise KeboolaApiError( + message=f"Invalid or expired token (token: {self._masked_token}): {api_message}", + status_code=status, + error_code="INVALID_TOKEN", + retryable=False, + ) + + if status == 403: + raise KeboolaApiError( + message=f"Access denied (token: {self._masked_token}): {api_message}", + status_code=status, + error_code="ACCESS_DENIED", + retryable=False, + ) + + if status == 404: + raise KeboolaApiError( + message=f"Resource not found: {api_message}", + status_code=status, + error_code="NOT_FOUND", + retryable=False, + ) + + retryable = status in RETRYABLE_STATUS_CODES + raise KeboolaApiError( + message=f"API error {status} from {self._stack_url} (token: {self._masked_token}): {api_message}", + status_code=status, + error_code="API_ERROR", + retryable=retryable, + ) + + def verify_token(self) -> TokenVerifyResponse: + """Verify the storage API token and retrieve project information. + + Returns: + TokenVerifyResponse with project name, ID, and token description. + + Raises: + KeboolaApiError: If token is invalid (401) or other API error. + """ + response = self._request("GET", "/v2/storage/tokens/verify") + data = response.json() + + return TokenVerifyResponse( + token_id=str(data.get("id", "")), + token_description=data.get("description", ""), + project_id=data.get("owner", {}).get("id", 0), + project_name=data.get("owner", {}).get("name", ""), + owner_name=data.get("owner", {}).get("name", ""), + ) + + def list_components(self, component_type: str | None = None) -> list[dict[str, Any]]: + """List components with their configurations. + + Args: + component_type: Optional filter (extractor, writer, transformation, application). + + Returns: + List of component dicts from the API. + """ + params: dict[str, str] = {"include": "configuration"} + if component_type: + params["componentType"] = component_type + + response = self._request("GET", "/v2/storage/components", params=params) + return response.json() + + def get_config_detail(self, component_id: str, config_id: str) -> dict[str, Any]: + """Get detailed information about a specific configuration. + + Args: + component_id: The component ID (e.g. keboola.ex-db-snowflake). + config_id: The configuration ID. + + Returns: + Configuration detail dict from the API. + """ + response = self._request( + "GET", + f"/v2/storage/components/{component_id}/configs/{config_id}", + ) + return response.json() diff --git a/src/keboola_agent_cli/commands/project.py b/src/keboola_agent_cli/commands/project.py index 91640a5d..c7eedd7f 100644 --- a/src/keboola_agent_cli/commands/project.py +++ b/src/keboola_agent_cli/commands/project.py @@ -1,10 +1,18 @@ -"""Project management commands - add, list, remove, edit, status.""" +"""Project management commands - add, list, remove, edit, status. -from typing import Optional +Thin CLI layer: parses arguments, calls ProjectService, formats output. +No business logic belongs here. +""" + +from typing import Any, Optional import typer +from rich.console import Console +from rich.table import Table +from ..errors import ConfigError, KeboolaApiError from ..output import OutputFormatter +from ..services.project_service import ProjectService project_app = typer.Typer(help="Manage connected Keboola projects") @@ -14,6 +22,69 @@ def _get_formatter(ctx: typer.Context) -> OutputFormatter: return ctx.obj["formatter"] +def _get_service(ctx: typer.Context) -> ProjectService: + """Retrieve the ProjectService from the Typer context.""" + return ctx.obj["project_service"] + + +def _format_project_table(console: Console, projects: list[dict[str, Any]]) -> None: + """Render a Rich table of projects for human output.""" + if not projects: + console.print("No projects configured. Use [bold]kbagent project add[/bold] to add one.") + return + + table = Table(title="Connected Projects") + table.add_column("Alias", style="bold cyan") + table.add_column("Project Name") + table.add_column("Project ID", justify="right") + table.add_column("Stack URL") + table.add_column("Token", style="dim") + table.add_column("Default", justify="center") + + for p in projects: + default_marker = "*" if p.get("is_default") else "" + table.add_row( + p["alias"], + p.get("project_name", ""), + str(p.get("project_id", "")), + p["stack_url"], + p["token"], + default_marker, + ) + + console.print(table) + + +def _format_status_table(console: Console, statuses: list[dict[str, Any]]) -> None: + """Render a Rich table of project connectivity statuses.""" + if not statuses: + console.print("No projects configured.") + return + + table = Table(title="Project Status") + table.add_column("Alias", style="bold cyan") + table.add_column("Status") + table.add_column("Response Time", justify="right") + table.add_column("Project Name") + table.add_column("Stack URL") + + for s in statuses: + if s["status"] == "ok": + status_str = "[bold green]OK[/bold green]" + else: + status_str = f"[bold red]ERROR[/bold red]: {s.get('error', 'Unknown')}" + response_time = f"{s.get('response_time_ms', 0)}ms" + table.add_row( + s["alias"], + status_str, + response_time, + s.get("project_name", ""), + s["stack_url"], + ) + + console.print(table) + + @project_app.command("add") def project_add( ctx: typer.Context, @@ -21,19 +92,49 @@ def project_add( url: str = typer.Option( "https://connection.keboola.com", help="Keboola stack URL", + envvar="KBC_STORAGE_API_URL", + ), + token: str = typer.Option( + ..., + help="Storage API token", + envvar="KBC_TOKEN", ), - token: str = typer.Option(..., help="Storage API token"), ) -> None: """Add a new Keboola project connection.""" formatter = _get_formatter(ctx) - formatter.output("Not yet implemented", lambda c, d: c.print(d)) + service = _get_service(ctx) + + try: + result = service.add_project(alias=alias, stack_url=url, token=token) + formatter.output(result, lambda c, d: c.print( + f"[bold green]Success:[/bold green] Project [bold]{d['alias']}[/bold] added " + f"(project: {d['project_name']}, id: {d['project_id']})" + )) + except KeboolaApiError as exc: + exit_code = 3 if exc.error_code == "INVALID_TOKEN" else 4 + formatter.error( + message=exc.message, + error_code=exc.error_code, + retryable=exc.retryable, + ) + raise typer.Exit(code=exit_code) + except ConfigError as exc: + formatter.error(message=exc.message, error_code="CONFIG_ERROR") + raise typer.Exit(code=5) @project_app.command("list") def project_list(ctx: typer.Context) -> None: """List all connected Keboola projects.""" formatter = _get_formatter(ctx) - formatter.output([], lambda c, d: c.print("Not yet implemented")) + service = _get_service(ctx) + + try: + projects = service.list_projects() + formatter.output(projects, _format_project_table) + except ConfigError as exc: + formatter.error(message=exc.message, error_code="CONFIG_ERROR") + raise typer.Exit(code=5) @project_app.command("remove") @@ -43,7 +144,16 @@ def project_remove( ) -> None: """Remove a Keboola project connection.""" formatter = _get_formatter(ctx) - formatter.output("Not yet implemented", lambda c, d: c.print(d)) + service = _get_service(ctx) + + try: + result = service.remove_project(alias=alias) + formatter.output(result, lambda c, d: c.print( + f"[bold green]Success:[/bold green] {d['message']}" + )) + except ConfigError as exc: + formatter.error(message=exc.message, error_code="CONFIG_ERROR") + raise typer.Exit(code=5) @project_app.command("edit") @@ -55,7 +165,24 @@ def project_edit( ) -> None: """Edit an existing Keboola project connection.""" formatter = _get_formatter(ctx) - formatter.output("Not yet implemented", lambda c, d: c.print(d)) + service = _get_service(ctx) + + try: + result = service.edit_project(alias=alias, stack_url=url, token=token) + formatter.output(result, lambda c, d: c.print( + f"[bold green]Success:[/bold green] Project [bold]{d['alias']}[/bold] updated." + )) + except KeboolaApiError as exc: + exit_code = 3 if exc.error_code == "INVALID_TOKEN" else 4 + formatter.error( + message=exc.message, + error_code=exc.error_code, + retryable=exc.retryable, + ) + raise typer.Exit(code=exit_code) + except ConfigError as exc: + formatter.error(message=exc.message, error_code="CONFIG_ERROR") + raise typer.Exit(code=5) @project_app.command("status") @@ -65,4 +192,21 @@ def project_status( ) -> None: """Test connectivity to connected Keboola projects.""" formatter = _get_formatter(ctx) - formatter.output("Not yet implemented", lambda c, d: c.print(d)) + service = _get_service(ctx) + + aliases = [project] if project else None + + try: + statuses = service.get_status(aliases=aliases) + formatter.output(statuses, _format_status_table) + except ConfigError as exc: + formatter.error(message=exc.message, error_code="CONFIG_ERROR") + raise typer.Exit(code=5) + except KeboolaApiError as exc: + exit_code = 3 if exc.error_code == "INVALID_TOKEN" else 4 + formatter.error( + message=exc.message, + error_code=exc.error_code, + retryable=exc.retryable, + ) + raise typer.Exit(code=exit_code) diff --git a/src/keboola_agent_cli/config_store.py b/src/keboola_agent_cli/config_store.py index 4890dc3a..fa15263c 100644 --- a/src/keboola_agent_cli/config_store.py +++ b/src/keboola_agent_cli/config_store.py @@ -1,14 +1,19 @@ """Persistent configuration store for Keboola Agent CLI. Manages reading and writing of config.json with project connections. +File permissions are set to 0600 to protect stored tokens. """ +import json from pathlib import Path import platformdirs +from .errors import ConfigError from .models import AppConfig, ProjectConfig +CURRENT_CONFIG_VERSION = 1 + class ConfigStore: """Handles persistence of application configuration to disk. @@ -35,31 +40,87 @@ def load(self) -> AppConfig: """Load configuration from disk. Returns an empty AppConfig if the file does not exist. + Validates the config version and raises ConfigError on mismatch or corruption. + + Raises: + ConfigError: If the config file is corrupted or has an unsupported version. """ if not self._config_path.exists(): return AppConfig() - raw = self._config_path.read_text(encoding="utf-8") - return AppConfig.model_validate_json(raw) + + try: + raw = self._config_path.read_text(encoding="utf-8") + except OSError as exc: + raise ConfigError(f"Cannot read config file {self._config_path}: {exc}") from exc + + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + raise ConfigError(f"Config file is not valid JSON: {exc}") from exc + + version = data.get("version", 1) + if version > CURRENT_CONFIG_VERSION: + raise ConfigError( + f"Config file version {version} is newer than supported version " + f"{CURRENT_CONFIG_VERSION}. Please upgrade keboola-agent-cli." + ) + + try: + return AppConfig.model_validate(data) + except Exception as exc: + raise ConfigError(f"Config file has invalid structure: {exc}") from exc def save(self, config: AppConfig) -> None: - """Save configuration to disk with secure file permissions (0600).""" - self._config_dir.mkdir(parents=True, exist_ok=True) - json_str = config.model_dump_json(indent=2) - self._config_path.write_text(json_str + "\n", encoding="utf-8") - self._config_path.chmod(0o600) + """Save configuration to disk with secure file permissions (0600). + + Creates the config directory if it does not exist. + + Raises: + ConfigError: If the file cannot be written. + """ + try: + self._config_dir.mkdir(parents=True, exist_ok=True) + json_str = config.model_dump_json(indent=2) + self._config_path.write_text(json_str + "\n", encoding="utf-8") + self._config_path.chmod(0o600) + except OSError as exc: + raise ConfigError(f"Cannot write config file {self._config_path}: {exc}") from exc def add_project(self, alias: str, project: ProjectConfig) -> None: - """Add a project to the configuration.""" + """Add a project to the configuration. + + Sets it as default if no default is set yet. + + Args: + alias: Human-friendly project name. + project: Project configuration with stack URL, token, and project info. + + Raises: + ConfigError: If the alias already exists. + """ config = self.load() + if alias in config.projects: + raise ConfigError(f"Project '{alias}' already exists. Use 'project edit' to modify it.") config.projects[alias] = project if not config.default_project: config.default_project = alias self.save(config) def remove_project(self, alias: str) -> None: - """Remove a project from the configuration.""" + """Remove a project from the configuration. + + Updates the default project if the removed project was the default. + + Args: + alias: The project alias to remove. + + Raises: + ConfigError: If the alias does not exist. + """ config = self.load() - config.projects.pop(alias, None) + if alias not in config.projects: + raise ConfigError(f"Project '{alias}' not found.") + del config.projects[alias] if config.default_project == alias: config.default_project = next(iter(config.projects), "") self.save(config) @@ -69,11 +130,21 @@ def get_project(self, alias: str) -> ProjectConfig | None: config = self.load() return config.projects.get(alias) - def edit_project(self, alias: str, **kwargs: str | int) -> None: - """Update fields on an existing project.""" + def edit_project(self, alias: str, **kwargs: str | int | None) -> None: + """Update fields on an existing project. + + Only non-None keyword arguments are applied. + + Args: + alias: The project alias to edit. + **kwargs: Fields to update (stack_url, token, project_name, project_id). + + Raises: + ConfigError: If the alias does not exist. + """ config = self.load() if alias not in config.projects: - return + raise ConfigError(f"Project '{alias}' not found.") project = config.projects[alias] for key, value in kwargs.items(): if hasattr(project, key) and value is not None: diff --git a/src/keboola_agent_cli/models.py b/src/keboola_agent_cli/models.py index 81a7b59f..6c52fcd9 100644 --- a/src/keboola_agent_cli/models.py +++ b/src/keboola_agent_cli/models.py @@ -25,6 +25,16 @@ class AppConfig(BaseModel): ) +class TokenVerifyResponse(BaseModel): + """Response from the Keboola token verification endpoint.""" + + token_id: str = Field(default="", description="Token identifier") + token_description: str = Field(default="", description="Human-readable token description") + project_id: int = Field(default=0, description="Keboola project numeric ID") + project_name: str = Field(default="", description="Keboola project name") + owner_name: str = Field(default="", description="Project owner name") + + class ErrorResponse(BaseModel): """Structured error response for JSON output mode.""" diff --git a/src/keboola_agent_cli/services/project_service.py b/src/keboola_agent_cli/services/project_service.py new file mode 100644 index 00000000..7c2477c5 --- /dev/null +++ b/src/keboola_agent_cli/services/project_service.py @@ -0,0 +1,225 @@ +"""Project management service - business logic for add/remove/edit/list/status. + +Orchestrates config persistence and API calls without knowing about CLI or HTTP details. +""" + +import time +from typing import Any, Callable + +from ..client import KeboolaClient +from ..config_store import ConfigStore +from ..errors import ConfigError, KeboolaApiError, mask_token +from ..models import ProjectConfig + +ClientFactory = Callable[[str, str], KeboolaClient] + + +def default_client_factory(stack_url: str, token: str) -> KeboolaClient: + """Create a KeboolaClient with the given stack URL and token.""" + return KeboolaClient(stack_url=stack_url, token=token) + + +class ProjectService: + """Business logic for managing Keboola project connections. + + Uses dependency injection for config_store and client_factory to enable + easy testing with mocks. + """ + + def __init__( + self, + config_store: ConfigStore, + client_factory: ClientFactory | None = None, + ) -> None: + self._config_store = config_store + self._client_factory = client_factory or default_client_factory + + def add_project(self, alias: str, stack_url: str, token: str) -> dict[str, Any]: + """Add a new project connection after verifying the token. + + Calls the Keboola API to verify the token and extract project info, + then saves the project to the config store. + + Args: + alias: Human-friendly project name. + stack_url: Keboola stack URL. + token: Storage API token. + + Returns: + Dict with project details (alias, project_name, project_id, stack_url, masked_token). + + Raises: + KeboolaApiError: If token verification fails. + ConfigError: If the alias already exists. + """ + client = self._client_factory(stack_url, token) + try: + token_info = client.verify_token() + finally: + client.close() + + project = ProjectConfig( + stack_url=stack_url, + token=token, + project_name=token_info.project_name, + project_id=token_info.project_id, + ) + + self._config_store.add_project(alias, project) + + return { + "alias": alias, + "project_name": token_info.project_name, + "project_id": token_info.project_id, + "stack_url": stack_url, + "token": mask_token(token), + } + + def remove_project(self, alias: str) -> dict[str, str]: + """Remove a project from the configuration. + + Args: + alias: The project alias to remove. + + Returns: + Dict confirming the removal. + + Raises: + ConfigError: If the alias does not exist. + """ + self._config_store.remove_project(alias) + return {"alias": alias, "message": f"Project '{alias}' removed."} + + def edit_project( + self, + alias: str, + stack_url: str | None = None, + token: str | None = None, + ) -> dict[str, Any]: + """Edit an existing project's configuration. + + If the token is changed, re-verifies it against the API to update + project name and ID. + + Args: + alias: The project alias to edit. + stack_url: New stack URL (if changing). + token: New token (if changing). + + Returns: + Dict with updated project details. + + Raises: + KeboolaApiError: If token re-verification fails. + ConfigError: If the alias does not exist or no changes provided. + """ + existing = self._config_store.get_project(alias) + if existing is None: + raise ConfigError(f"Project '{alias}' not found.") + + if stack_url is None and token is None: + raise ConfigError("No changes specified. Provide --url and/or --token.") + + updates: dict[str, str | int] = {} + + if stack_url is not None: + updates["stack_url"] = stack_url + + if token is not None: + effective_url = stack_url if stack_url is not None else existing.stack_url + client = self._client_factory(effective_url, token) + try: + token_info = client.verify_token() + finally: + client.close() + updates["token"] = token + updates["project_name"] = token_info.project_name + updates["project_id"] = token_info.project_id + + self._config_store.edit_project(alias, **updates) + + updated = self._config_store.get_project(alias) + assert updated is not None # we just edited it + + return { + "alias": alias, + "project_name": updated.project_name, + "project_id": updated.project_id, + "stack_url": updated.stack_url, + "token": mask_token(updated.token), + } + + def list_projects(self) -> list[dict[str, Any]]: + """List all configured projects. + + Returns: + List of dicts with project details (token masked). + """ + config = self._config_store.load() + result = [] + for alias, project in config.projects.items(): + result.append({ + "alias": alias, + "project_name": project.project_name, + "project_id": project.project_id, + "stack_url": project.stack_url, + "token": mask_token(project.token), + "is_default": alias == config.default_project, + }) + return result + + def get_status(self, aliases: list[str] | None = None) -> list[dict[str, Any]]: + """Check connectivity status for one or more projects. + + For each project, verifies the token against the API and measures + response time. + + Args: + aliases: Specific project aliases to check (None = all projects). + + Returns: + List of dicts with status, response time, and project details. + + Raises: + ConfigError: If a specified alias does not exist. + """ + config = self._config_store.load() + + if aliases: + projects_to_check = {} + for alias in aliases: + if alias not in config.projects: + raise ConfigError(f"Project '{alias}' not found.") + projects_to_check[alias] = config.projects[alias] + else: + projects_to_check = config.projects + + results = [] + for alias, project in projects_to_check.items(): + status_entry: dict[str, Any] = { + "alias": alias, + "stack_url": project.stack_url, + "token": mask_token(project.token), + } + + client = self._client_factory(project.stack_url, project.token) + start_time = time.monotonic() + try: + token_info = client.verify_token() + elapsed = time.monotonic() - start_time + status_entry["status"] = "ok" + status_entry["response_time_ms"] = round(elapsed * 1000) + status_entry["project_name"] = token_info.project_name + status_entry["project_id"] = token_info.project_id + except KeboolaApiError as exc: + elapsed = time.monotonic() - start_time + status_entry["status"] = "error" + status_entry["response_time_ms"] = round(elapsed * 1000) + status_entry["error"] = exc.message + status_entry["error_code"] = exc.error_code + finally: + client.close() + + results.append(status_entry) + + return results diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 00000000..e9ce630f --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,483 @@ +"""Tests for CLI commands via CliRunner - project add, list in JSON and human mode.""" + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from keboola_agent_cli.cli import app +from keboola_agent_cli.config_store import ConfigStore +from keboola_agent_cli.errors import ConfigError, KeboolaApiError +from keboola_agent_cli.models import TokenVerifyResponse +from keboola_agent_cli.services.project_service import ProjectService + +runner = CliRunner() + + +def _make_mock_client( + project_name: str = "Test Project", + project_id: int = 1234, +) -> MagicMock: + """Create a mock client for the factory.""" + mock_client = MagicMock() + mock_client.verify_token.return_value = TokenVerifyResponse( + token_id="12345", + token_description="My Token", + project_id=project_id, + project_name=project_name, + owner_name=project_name, + ) + return mock_client + + +class TestProjectAdd: + """Tests for `kbagent project add` command.""" + + def test_project_add_success_json(self, tmp_path: Path) -> None: + """project add with --json outputs structured success response.""" + mock_client = _make_mock_client(project_name="Prod Project", project_id=5678) + config_dir = tmp_path / "config" + config_dir.mkdir() + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockService: + + store_instance = ConfigStore(config_dir=config_dir) + MockStore.return_value = store_instance + + service_instance = ProjectService( + config_store=store_instance, + client_factory=lambda url, token: mock_client, + ) + MockService.return_value = service_instance + + result = runner.invoke(app, [ + "--json", + "project", "add", + "--alias", "prod", + "--url", "https://connection.keboola.com", + "--token", "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ]) + + assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}" + output = json.loads(result.output) + assert output["status"] == "ok" + assert output["data"]["alias"] == "prod" + assert output["data"]["project_name"] == "Prod Project" + assert output["data"]["project_id"] == 5678 + # Token should be masked + assert "10493007" not in output["data"]["token"] + + def test_project_add_success_human(self, tmp_path: Path) -> None: + """project add in human mode outputs success message.""" + mock_client = _make_mock_client() + config_dir = tmp_path / "config" + config_dir.mkdir() + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockService: + + store_instance = ConfigStore(config_dir=config_dir) + MockStore.return_value = store_instance + + service_instance = ProjectService( + config_store=store_instance, + client_factory=lambda url, token: mock_client, + ) + MockService.return_value = service_instance + + result = runner.invoke(app, [ + "project", "add", + "--alias", "test", + "--url", "https://connection.keboola.com", + "--token", "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ]) + + assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}" + assert "test" in result.output + assert "Success" in result.output or "Test Project" in result.output + + def test_project_add_invalid_token_exit_code_3(self, tmp_path: Path) -> None: + """project add with invalid token returns exit code 3.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + fail_client = MagicMock() + fail_client.verify_token.side_effect = KeboolaApiError( + message="Invalid token", + status_code=401, + error_code="INVALID_TOKEN", + retryable=False, + ) + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockService: + + store_instance = ConfigStore(config_dir=config_dir) + MockStore.return_value = store_instance + + service_instance = ProjectService( + config_store=store_instance, + client_factory=lambda url, token: fail_client, + ) + MockService.return_value = service_instance + + result = runner.invoke(app, [ + "--json", + "project", "add", + "--alias", "bad", + "--token", "invalid-token-abcdefgh", + ]) + + assert result.exit_code == 3 + output = json.loads(result.output) + assert output["status"] == "error" + assert output["error"]["code"] == "INVALID_TOKEN" + + def test_project_add_timeout_exit_code_4(self, tmp_path: Path) -> None: + """project add with network timeout returns exit code 4.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + timeout_client = MagicMock() + timeout_client.verify_token.side_effect = KeboolaApiError( + message="Request timed out", + status_code=0, + error_code="TIMEOUT", + retryable=True, + ) + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockService: + + store_instance = ConfigStore(config_dir=config_dir) + MockStore.return_value = store_instance + + service_instance = ProjectService( + config_store=store_instance, + client_factory=lambda url, token: timeout_client, + ) + MockService.return_value = service_instance + + result = runner.invoke(app, [ + "--json", + "project", "add", + "--alias", "timeout", + "--token", "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ]) + + assert result.exit_code == 4 + + +class TestProjectList: + """Tests for `kbagent project list` command.""" + + def test_project_list_json_empty(self, tmp_path: Path) -> None: + """project list --json with no projects returns empty data.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockService: + + store_instance = ConfigStore(config_dir=config_dir) + MockStore.return_value = store_instance + MockService.return_value = ProjectService(config_store=store_instance) + + result = runner.invoke(app, ["--json", "project", "list"]) + + assert result.exit_code == 0 + output = json.loads(result.output) + assert output["status"] == "ok" + assert output["data"] == [] + + def test_project_list_json_with_projects(self, tmp_path: Path) -> None: + """project list --json returns project data with masked tokens.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + mock_client = _make_mock_client() + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockService: + + store_instance = ConfigStore(config_dir=config_dir) + MockStore.return_value = store_instance + + service_instance = ProjectService( + config_store=store_instance, + client_factory=lambda url, token: mock_client, + ) + MockService.return_value = service_instance + + # Add a project first + runner.invoke(app, [ + "project", "add", + "--alias", "test", + "--url", "https://connection.keboola.com", + "--token", "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ]) + + result = runner.invoke(app, ["--json", "project", "list"]) + + assert result.exit_code == 0 + output = json.loads(result.output) + assert output["status"] == "ok" + assert len(output["data"]) == 1 + assert output["data"][0]["alias"] == "test" + # Token must be masked + full_token = "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k" + assert output["data"][0]["token"] != full_token + + def test_project_list_human_mode(self, tmp_path: Path) -> None: + """project list in human mode outputs a Rich table.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + mock_client = _make_mock_client(project_name="My Production") + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockService: + + store_instance = ConfigStore(config_dir=config_dir) + MockStore.return_value = store_instance + + service_instance = ProjectService( + config_store=store_instance, + client_factory=lambda url, token: mock_client, + ) + MockService.return_value = service_instance + + runner.invoke(app, [ + "project", "add", + "--alias", "prod", + "--url", "https://connection.keboola.com", + "--token", "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ]) + + result = runner.invoke(app, ["project", "list"]) + + assert result.exit_code == 0 + assert "prod" in result.output + assert "Connected Projects" in result.output + + def test_project_list_human_empty(self, tmp_path: Path) -> None: + """project list in human mode with no projects shows helpful message.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockService: + + store_instance = ConfigStore(config_dir=config_dir) + MockStore.return_value = store_instance + MockService.return_value = ProjectService(config_store=store_instance) + + result = runner.invoke(app, ["project", "list"]) + + assert result.exit_code == 0 + assert "No projects configured" in result.output + + +class TestProjectRemove: + """Tests for `kbagent project remove` command.""" + + def test_project_remove_success_json(self, tmp_path: Path) -> None: + """project remove --json returns structured success.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + mock_client = _make_mock_client() + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockService: + + store_instance = ConfigStore(config_dir=config_dir) + MockStore.return_value = store_instance + + service_instance = ProjectService( + config_store=store_instance, + client_factory=lambda url, token: mock_client, + ) + MockService.return_value = service_instance + + runner.invoke(app, [ + "project", "add", + "--alias", "test", + "--token", "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ]) + + result = runner.invoke(app, [ + "--json", + "project", "remove", + "--alias", "test", + ]) + + assert result.exit_code == 0 + output = json.loads(result.output) + assert output["status"] == "ok" + assert output["data"]["alias"] == "test" + + def test_project_remove_nonexistent_exit_code_5(self, tmp_path: Path) -> None: + """project remove with nonexistent alias returns exit code 5.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockService: + + store_instance = ConfigStore(config_dir=config_dir) + MockStore.return_value = store_instance + MockService.return_value = ProjectService(config_store=store_instance) + + result = runner.invoke(app, [ + "--json", + "project", "remove", + "--alias", "nonexistent", + ]) + + assert result.exit_code == 5 + output = json.loads(result.output) + assert output["status"] == "error" + assert output["error"]["code"] == "CONFIG_ERROR" + + +class TestProjectStatus: + """Tests for `kbagent project status` command.""" + + def test_project_status_json(self, tmp_path: Path) -> None: + """project status --json returns connectivity info.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + mock_client = _make_mock_client(project_name="Prod", project_id=123) + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockService: + + store_instance = ConfigStore(config_dir=config_dir) + MockStore.return_value = store_instance + + service_instance = ProjectService( + config_store=store_instance, + client_factory=lambda url, token: mock_client, + ) + MockService.return_value = service_instance + + runner.invoke(app, [ + "project", "add", + "--alias", "prod", + "--token", "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ]) + + result = runner.invoke(app, ["--json", "project", "status"]) + + assert result.exit_code == 0 + output = json.loads(result.output) + assert output["status"] == "ok" + assert len(output["data"]) == 1 + assert output["data"][0]["alias"] == "prod" + assert output["data"][0]["status"] == "ok" + assert "response_time_ms" in output["data"][0] + + def test_project_status_human(self, tmp_path: Path) -> None: + """project status in human mode shows status table.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + mock_client = _make_mock_client() + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockService: + + store_instance = ConfigStore(config_dir=config_dir) + MockStore.return_value = store_instance + + service_instance = ProjectService( + config_store=store_instance, + client_factory=lambda url, token: mock_client, + ) + MockService.return_value = service_instance + + runner.invoke(app, [ + "project", "add", + "--alias", "test", + "--token", "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ]) + + result = runner.invoke(app, ["project", "status"]) + + assert result.exit_code == 0 + assert "Project Status" in result.output + + +class TestProjectEdit: + """Tests for `kbagent project edit` command.""" + + def test_project_edit_url_json(self, tmp_path: Path) -> None: + """project edit --url with --json updates URL and returns result.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + mock_client = _make_mock_client() + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockService: + + store_instance = ConfigStore(config_dir=config_dir) + MockStore.return_value = store_instance + + service_instance = ProjectService( + config_store=store_instance, + client_factory=lambda url, token: mock_client, + ) + MockService.return_value = service_instance + + runner.invoke(app, [ + "project", "add", + "--alias", "test", + "--url", "https://old.keboola.com", + "--token", "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ]) + + result = runner.invoke(app, [ + "--json", + "project", "edit", + "--alias", "test", + "--url", "https://new.keboola.com", + ]) + + assert result.exit_code == 0 + output = json.loads(result.output) + assert output["status"] == "ok" + assert output["data"]["stack_url"] == "https://new.keboola.com" + + def test_project_edit_config_error_exit_code_5(self, tmp_path: Path) -> None: + """project edit with no changes returns exit code 5.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + mock_client = _make_mock_client() + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockService: + + store_instance = ConfigStore(config_dir=config_dir) + MockStore.return_value = store_instance + + service_instance = ProjectService( + config_store=store_instance, + client_factory=lambda url, token: mock_client, + ) + MockService.return_value = service_instance + + runner.invoke(app, [ + "project", "add", + "--alias", "test", + "--token", "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ]) + + result = runner.invoke(app, [ + "--json", + "project", "edit", + "--alias", "test", + ]) + + assert result.exit_code == 5 + output = json.loads(result.output) + assert output["status"] == "error" diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 00000000..f0910c72 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,428 @@ +"""Tests for KeboolaClient - verify_token, retries, timeouts, error handling.""" + +import httpx +import pytest + +from keboola_agent_cli.client import KeboolaClient, MAX_RETRIES +from keboola_agent_cli.errors import KeboolaApiError + + +VERIFY_TOKEN_RESPONSE = { + "id": "12345", + "description": "My test token", + "owner": { + "id": 1234, + "name": "Test Project", + }, +} + + +class TestVerifyToken: + """Tests for verify_token() success and failure paths.""" + + def test_verify_token_success(self, httpx_mock) -> None: + """verify_token() returns TokenVerifyResponse with project info on success.""" + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/tokens/verify", + json=VERIFY_TOKEN_RESPONSE, + status_code=200, + ) + + client = KeboolaClient( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) + result = client.verify_token() + + assert result.project_name == "Test Project" + assert result.project_id == 1234 + assert result.token_description == "My test token" + assert result.token_id == "12345" + client.close() + + def test_verify_token_401_error(self, httpx_mock) -> None: + """verify_token() raises KeboolaApiError with INVALID_TOKEN on 401.""" + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/tokens/verify", + json={"error": "Invalid access token"}, + status_code=401, + ) + + client = KeboolaClient( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) + + with pytest.raises(KeboolaApiError) as exc_info: + client.verify_token() + + assert exc_info.value.error_code == "INVALID_TOKEN" + assert exc_info.value.status_code == 401 + assert exc_info.value.retryable is False + client.close() + + def test_verify_token_403_error(self, httpx_mock) -> None: + """verify_token() raises KeboolaApiError with ACCESS_DENIED on 403.""" + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/tokens/verify", + json={"error": "Access denied"}, + status_code=403, + ) + + client = KeboolaClient( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) + + with pytest.raises(KeboolaApiError) as exc_info: + client.verify_token() + + assert exc_info.value.error_code == "ACCESS_DENIED" + assert exc_info.value.status_code == 403 + client.close() + + +class TestRetryBehavior: + """Tests for retry on 5xx and 429 status codes.""" + + def test_retry_on_503_then_success(self, httpx_mock) -> None: + """Client retries on 503 and succeeds on subsequent attempt.""" + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/tokens/verify", + status_code=503, + text="Service Unavailable", + ) + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/tokens/verify", + json=VERIFY_TOKEN_RESPONSE, + status_code=200, + ) + + client = KeboolaClient( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) + + # Monkeypatch time.sleep to avoid actual delays in tests + import keboola_agent_cli.client as client_module + original_sleep = client_module.time.sleep + client_module.time.sleep = lambda x: None + try: + result = client.verify_token() + assert result.project_name == "Test Project" + finally: + client_module.time.sleep = original_sleep + client.close() + + def test_retry_exhausted_raises_error(self, httpx_mock) -> None: + """Client raises KeboolaApiError after exhausting retries on persistent 503.""" + for _ in range(MAX_RETRIES): + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/tokens/verify", + status_code=503, + text="Service Unavailable", + ) + + client = KeboolaClient( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) + + import keboola_agent_cli.client as client_module + original_sleep = client_module.time.sleep + client_module.time.sleep = lambda x: None + try: + with pytest.raises(KeboolaApiError) as exc_info: + client.verify_token() + assert exc_info.value.retryable is True + finally: + client_module.time.sleep = original_sleep + client.close() + + def test_retry_on_429(self, httpx_mock) -> None: + """Client retries on 429 (rate limit) and succeeds on next attempt.""" + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/tokens/verify", + status_code=429, + text="Rate limit exceeded", + ) + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/tokens/verify", + json=VERIFY_TOKEN_RESPONSE, + status_code=200, + ) + + client = KeboolaClient( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) + + import keboola_agent_cli.client as client_module + original_sleep = client_module.time.sleep + client_module.time.sleep = lambda x: None + try: + result = client.verify_token() + assert result.project_name == "Test Project" + finally: + client_module.time.sleep = original_sleep + client.close() + + def test_no_retry_on_400(self, httpx_mock) -> None: + """Client does NOT retry on 400 (client error).""" + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/tokens/verify", + status_code=400, + json={"error": "Bad request"}, + ) + + client = KeboolaClient( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) + + with pytest.raises(KeboolaApiError) as exc_info: + client.verify_token() + + assert exc_info.value.status_code == 400 + assert exc_info.value.retryable is False + client.close() + + +class TestTimeoutHandling: + """Tests for timeout handling.""" + + def test_timeout_raises_api_error(self, httpx_mock) -> None: + """Timeout exceptions are wrapped in KeboolaApiError with TIMEOUT code.""" + httpx_mock.add_exception( + httpx.ReadTimeout("Read timed out"), + url="https://connection.keboola.com/v2/storage/tokens/verify", + ) + httpx_mock.add_exception( + httpx.ReadTimeout("Read timed out"), + url="https://connection.keboola.com/v2/storage/tokens/verify", + ) + httpx_mock.add_exception( + httpx.ReadTimeout("Read timed out"), + url="https://connection.keboola.com/v2/storage/tokens/verify", + ) + + client = KeboolaClient( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) + + import keboola_agent_cli.client as client_module + original_sleep = client_module.time.sleep + client_module.time.sleep = lambda x: None + try: + with pytest.raises(KeboolaApiError) as exc_info: + client.verify_token() + assert exc_info.value.error_code == "TIMEOUT" + assert exc_info.value.retryable is True + finally: + client_module.time.sleep = original_sleep + client.close() + + def test_connect_error_raises_api_error(self, httpx_mock) -> None: + """Connection errors are wrapped in KeboolaApiError with CONNECTION_ERROR code.""" + httpx_mock.add_exception( + httpx.ConnectError("Connection refused"), + url="https://connection.keboola.com/v2/storage/tokens/verify", + ) + httpx_mock.add_exception( + httpx.ConnectError("Connection refused"), + url="https://connection.keboola.com/v2/storage/tokens/verify", + ) + httpx_mock.add_exception( + httpx.ConnectError("Connection refused"), + url="https://connection.keboola.com/v2/storage/tokens/verify", + ) + + client = KeboolaClient( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) + + import keboola_agent_cli.client as client_module + original_sleep = client_module.time.sleep + client_module.time.sleep = lambda x: None + try: + with pytest.raises(KeboolaApiError) as exc_info: + client.verify_token() + assert exc_info.value.error_code == "CONNECTION_ERROR" + assert exc_info.value.retryable is True + finally: + client_module.time.sleep = original_sleep + client.close() + + +class TestTokenMaskingInErrors: + """Tests that token is never fully exposed in error messages.""" + + def test_401_error_masks_token(self, httpx_mock) -> None: + """Token is masked in 401 error messages.""" + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/tokens/verify", + json={"error": "Invalid token"}, + status_code=401, + ) + + full_token = "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k" + client = KeboolaClient( + stack_url="https://connection.keboola.com", + token=full_token, + ) + + with pytest.raises(KeboolaApiError) as exc_info: + client.verify_token() + + # Full token must NOT appear in the error message + assert full_token not in exc_info.value.message + # Masked form should appear + assert "901-...pt0k" in exc_info.value.message + client.close() + + def test_timeout_error_masks_token(self, httpx_mock) -> None: + """Token is masked in timeout error messages.""" + for _ in range(MAX_RETRIES): + httpx_mock.add_exception( + httpx.ReadTimeout("Read timed out"), + url="https://connection.keboola.com/v2/storage/tokens/verify", + ) + + full_token = "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k" + client = KeboolaClient( + stack_url="https://connection.keboola.com", + token=full_token, + ) + + import keboola_agent_cli.client as client_module + original_sleep = client_module.time.sleep + client_module.time.sleep = lambda x: None + try: + with pytest.raises(KeboolaApiError) as exc_info: + client.verify_token() + assert full_token not in exc_info.value.message + assert "901-...pt0k" in exc_info.value.message + finally: + client_module.time.sleep = original_sleep + client.close() + + +class TestClientHeaders: + """Tests that the client sends correct headers.""" + + def test_user_agent_header(self, httpx_mock) -> None: + """Client sends User-Agent header with version.""" + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/tokens/verify", + json=VERIFY_TOKEN_RESPONSE, + status_code=200, + ) + + client = KeboolaClient( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) + client.verify_token() + + request = httpx_mock.get_request() + assert "keboola-agent-cli/" in request.headers["user-agent"] + client.close() + + def test_storage_api_token_header(self, httpx_mock) -> None: + """Client sends X-StorageApi-Token header.""" + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/tokens/verify", + json=VERIFY_TOKEN_RESPONSE, + status_code=200, + ) + + token = "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k" + client = KeboolaClient( + stack_url="https://connection.keboola.com", + token=token, + ) + client.verify_token() + + request = httpx_mock.get_request() + assert request.headers["x-storageapi-token"] == token + client.close() + + +class TestContextManager: + """Tests for context manager support.""" + + def test_context_manager(self, httpx_mock) -> None: + """Client can be used as a context manager.""" + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/tokens/verify", + json=VERIFY_TOKEN_RESPONSE, + status_code=200, + ) + + with KeboolaClient( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) as client: + result = client.verify_token() + assert result.project_name == "Test Project" + + +class TestListComponents: + """Tests for list_components().""" + + def test_list_components_success(self, httpx_mock) -> None: + """list_components() returns component list from API.""" + components = [ + {"id": "keboola.ex-db-snowflake", "type": "extractor", "name": "Snowflake"}, + ] + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/components?include=configuration", + json=components, + status_code=200, + ) + + with KeboolaClient( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) as client: + result = client.list_components() + assert len(result) == 1 + assert result[0]["id"] == "keboola.ex-db-snowflake" + + def test_list_components_with_type_filter(self, httpx_mock) -> None: + """list_components(component_type) sends componentType query param.""" + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/components?include=configuration&componentType=extractor", + json=[], + status_code=200, + ) + + with KeboolaClient( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) as client: + result = client.list_components(component_type="extractor") + assert result == [] + + +class TestGetConfigDetail: + """Tests for get_config_detail().""" + + def test_get_config_detail_success(self, httpx_mock) -> None: + """get_config_detail() returns config detail from API.""" + config_data = {"id": "42", "name": "My Config", "configuration": {}} + httpx_mock.add_response( + url="https://connection.keboola.com/v2/storage/components/keboola.ex-db-snowflake/configs/42", + json=config_data, + status_code=200, + ) + + with KeboolaClient( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) as client: + result = client.get_config_detail("keboola.ex-db-snowflake", "42") + assert result["id"] == "42" + assert result["name"] == "My Config" diff --git a/tests/test_config_store.py b/tests/test_config_store.py new file mode 100644 index 00000000..b2567c01 --- /dev/null +++ b/tests/test_config_store.py @@ -0,0 +1,347 @@ +"""Tests for ConfigStore - load, save, add/remove/edit project, permissions, version check.""" + +import json +import os +import stat +from pathlib import Path + +import pytest + +from keboola_agent_cli.config_store import CURRENT_CONFIG_VERSION, ConfigStore +from keboola_agent_cli.errors import ConfigError +from keboola_agent_cli.models import AppConfig, ProjectConfig + + +class TestLoadEmptyConfig: + """Tests for loading when no config file exists.""" + + def test_load_empty_returns_default_appconfig(self, tmp_config_dir: Path) -> None: + """Loading with no config file returns an empty AppConfig.""" + store = ConfigStore(config_dir=tmp_config_dir) + config = store.load() + + assert isinstance(config, AppConfig) + assert config.version == 1 + assert config.default_project == "" + assert config.projects == {} + + def test_load_creates_no_file(self, tmp_config_dir: Path) -> None: + """Loading an empty config does not create the config file.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.load() + + assert not (tmp_config_dir / "config.json").exists() + + +class TestSaveAndLoad: + """Tests for save/load round-trip.""" + + def test_save_and_load_round_trip(self, tmp_config_dir: Path) -> None: + """Saving and then loading config preserves all data.""" + store = ConfigStore(config_dir=tmp_config_dir) + config = AppConfig( + version=1, + default_project="test", + projects={ + "test": ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-abcdef-12345678", + project_name="Test Project", + project_id=1234, + ) + }, + ) + + store.save(config) + loaded = store.load() + + assert loaded.version == 1 + assert loaded.default_project == "test" + assert "test" in loaded.projects + assert loaded.projects["test"].stack_url == "https://connection.keboola.com" + assert loaded.projects["test"].token == "901-abcdef-12345678" + assert loaded.projects["test"].project_name == "Test Project" + assert loaded.projects["test"].project_id == 1234 + + def test_save_creates_directory(self, tmp_path: Path) -> None: + """Save creates the config directory if it does not exist.""" + nested_dir = tmp_path / "nested" / "config" + store = ConfigStore(config_dir=nested_dir) + store.save(AppConfig()) + + assert nested_dir.exists() + assert (nested_dir / "config.json").exists() + + +class TestFilePermissions: + """Tests for file permission security.""" + + def test_file_permissions_0600(self, tmp_config_dir: Path) -> None: + """Config file is created with 0600 permissions (owner read/write only).""" + store = ConfigStore(config_dir=tmp_config_dir) + store.save(AppConfig()) + + config_file = tmp_config_dir / "config.json" + file_stat = os.stat(config_file) + mode = stat.S_IMODE(file_stat.st_mode) + + assert mode == 0o600 + + def test_permissions_preserved_on_resave(self, tmp_config_dir: Path) -> None: + """Permissions remain 0600 after re-saving.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.save(AppConfig()) + store.save(AppConfig(default_project="changed")) + + config_file = tmp_config_dir / "config.json" + file_stat = os.stat(config_file) + mode = stat.S_IMODE(file_stat.st_mode) + + assert mode == 0o600 + + +class TestAddProject: + """Tests for add_project().""" + + def test_add_project_success(self, tmp_config_dir: Path) -> None: + """Adding a project stores it in config with correct data.""" + store = ConfigStore(config_dir=tmp_config_dir) + project = ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-abcdef-12345678", + project_name="Test Project", + project_id=1234, + ) + + store.add_project("test", project) + + config = store.load() + assert "test" in config.projects + assert config.projects["test"].project_name == "Test Project" + + def test_add_first_project_becomes_default(self, tmp_config_dir: Path) -> None: + """The first added project becomes the default.""" + store = ConfigStore(config_dir=tmp_config_dir) + project = ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-abcdef-12345678", + ) + + store.add_project("first", project) + + config = store.load() + assert config.default_project == "first" + + def test_add_second_project_does_not_change_default(self, tmp_config_dir: Path) -> None: + """Adding a second project does not change the default.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project("first", ProjectConfig( + stack_url="https://a.com", token="901-abcdef-12345678", + )) + store.add_project("second", ProjectConfig( + stack_url="https://b.com", token="902-abcdef-12345678", + )) + + config = store.load() + assert config.default_project == "first" + + def test_add_duplicate_alias_raises_config_error(self, tmp_config_dir: Path) -> None: + """Adding a project with an existing alias raises ConfigError.""" + store = ConfigStore(config_dir=tmp_config_dir) + project = ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-abcdef-12345678", + ) + store.add_project("test", project) + + with pytest.raises(ConfigError, match="already exists"): + store.add_project("test", project) + + +class TestRemoveProject: + """Tests for remove_project().""" + + def test_remove_project_success(self, tmp_config_dir: Path) -> None: + """Removing a project deletes it from config.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project("test", ProjectConfig( + stack_url="https://a.com", token="901-abcdef-12345678", + )) + + store.remove_project("test") + + config = store.load() + assert "test" not in config.projects + + def test_remove_default_project_updates_default(self, tmp_config_dir: Path) -> None: + """Removing the default project updates the default to the next available.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project("first", ProjectConfig( + stack_url="https://a.com", token="901-abcdef-12345678", + )) + store.add_project("second", ProjectConfig( + stack_url="https://b.com", token="902-abcdef-12345678", + )) + + store.remove_project("first") + config = store.load() + + assert config.default_project == "second" + + def test_remove_last_project_clears_default(self, tmp_config_dir: Path) -> None: + """Removing the last project clears the default.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project("only", ProjectConfig( + stack_url="https://a.com", token="901-abcdef-12345678", + )) + + store.remove_project("only") + config = store.load() + + assert config.default_project == "" + assert config.projects == {} + + def test_remove_nonexistent_raises_config_error(self, tmp_config_dir: Path) -> None: + """Removing a nonexistent alias raises ConfigError.""" + store = ConfigStore(config_dir=tmp_config_dir) + + with pytest.raises(ConfigError, match="not found"): + store.remove_project("nonexistent") + + +class TestEditProject: + """Tests for edit_project().""" + + def test_edit_stack_url(self, tmp_config_dir: Path) -> None: + """Editing stack_url updates it in the config.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project("test", ProjectConfig( + stack_url="https://old.com", + token="901-abcdef-12345678", + )) + + store.edit_project("test", stack_url="https://new.com") + + project = store.get_project("test") + assert project is not None + assert project.stack_url == "https://new.com" + + def test_edit_token(self, tmp_config_dir: Path) -> None: + """Editing token updates it in the config.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project("test", ProjectConfig( + stack_url="https://a.com", + token="901-abcdef-12345678", + )) + + store.edit_project("test", token="902-newtoken-87654321") + + project = store.get_project("test") + assert project is not None + assert project.token == "902-newtoken-87654321" + + def test_edit_multiple_fields(self, tmp_config_dir: Path) -> None: + """Editing multiple fields at once works.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project("test", ProjectConfig( + stack_url="https://old.com", + token="901-abcdef-12345678", + project_name="Old Name", + )) + + store.edit_project("test", stack_url="https://new.com", project_name="New Name") + + project = store.get_project("test") + assert project is not None + assert project.stack_url == "https://new.com" + assert project.project_name == "New Name" + + def test_edit_none_values_ignored(self, tmp_config_dir: Path) -> None: + """None values in kwargs are ignored and don't overwrite existing data.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project("test", ProjectConfig( + stack_url="https://a.com", + token="901-abcdef-12345678", + )) + + store.edit_project("test", stack_url=None, token="new-token-1234abcd") + + project = store.get_project("test") + assert project is not None + assert project.stack_url == "https://a.com" # unchanged + assert project.token == "new-token-1234abcd" + + def test_edit_nonexistent_raises_config_error(self, tmp_config_dir: Path) -> None: + """Editing a nonexistent alias raises ConfigError.""" + store = ConfigStore(config_dir=tmp_config_dir) + + with pytest.raises(ConfigError, match="not found"): + store.edit_project("nonexistent", stack_url="https://new.com") + + +class TestGetProject: + """Tests for get_project().""" + + def test_get_existing_project(self, tmp_config_dir: Path) -> None: + """Getting an existing project returns it.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project("test", ProjectConfig( + stack_url="https://a.com", + token="901-abcdef-12345678", + project_name="Test", + )) + + project = store.get_project("test") + assert project is not None + assert project.project_name == "Test" + + def test_get_nonexistent_returns_none(self, tmp_config_dir: Path) -> None: + """Getting a nonexistent project returns None.""" + store = ConfigStore(config_dir=tmp_config_dir) + assert store.get_project("nonexistent") is None + + +class TestVersionCheck: + """Tests for config version validation.""" + + def test_version_1_loads_successfully(self, tmp_config_dir: Path) -> None: + """Config with version 1 loads successfully.""" + store = ConfigStore(config_dir=tmp_config_dir) + config_file = tmp_config_dir / "config.json" + config_file.write_text(json.dumps({"version": 1, "projects": {}})) + + config = store.load() + assert config.version == 1 + + def test_future_version_raises_config_error(self, tmp_config_dir: Path) -> None: + """Config with a future version raises ConfigError.""" + store = ConfigStore(config_dir=tmp_config_dir) + config_file = tmp_config_dir / "config.json" + config_file.write_text(json.dumps({ + "version": CURRENT_CONFIG_VERSION + 1, + "projects": {}, + })) + + with pytest.raises(ConfigError, match="newer than supported"): + store.load() + + def test_invalid_json_raises_config_error(self, tmp_config_dir: Path) -> None: + """Corrupted JSON in config file raises ConfigError.""" + store = ConfigStore(config_dir=tmp_config_dir) + config_file = tmp_config_dir / "config.json" + config_file.write_text("{invalid json!!") + + with pytest.raises(ConfigError, match="not valid JSON"): + store.load() + + def test_invalid_structure_raises_config_error(self, tmp_config_dir: Path) -> None: + """Config file with wrong structure raises ConfigError.""" + store = ConfigStore(config_dir=tmp_config_dir) + config_file = tmp_config_dir / "config.json" + config_file.write_text(json.dumps({ + "version": 1, + "projects": {"bad": {"not_a_valid_field_only": True}}, + })) + + with pytest.raises(ConfigError, match="invalid structure"): + store.load() diff --git a/tests/test_services.py b/tests/test_services.py new file mode 100644 index 00000000..29866d80 --- /dev/null +++ b/tests/test_services.py @@ -0,0 +1,463 @@ +"""Tests for ProjectService - add, remove, edit, list, status.""" + +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from keboola_agent_cli.config_store import ConfigStore +from keboola_agent_cli.errors import ConfigError, KeboolaApiError +from keboola_agent_cli.models import ProjectConfig, TokenVerifyResponse +from keboola_agent_cli.services.project_service import ProjectService + + +def _make_mock_client( + project_name: str = "Test Project", + project_id: int = 1234, + token_description: str = "My Token", +) -> MagicMock: + """Create a mock KeboolaClient that returns a successful verify_token response.""" + mock_client = MagicMock() + mock_client.verify_token.return_value = TokenVerifyResponse( + token_id="12345", + token_description=token_description, + project_id=project_id, + project_name=project_name, + owner_name=project_name, + ) + return mock_client + + +def _make_failing_client(error: KeboolaApiError) -> MagicMock: + """Create a mock KeboolaClient whose verify_token raises the given error.""" + mock_client = MagicMock() + mock_client.verify_token.side_effect = error + return mock_client + + +class TestAddProject: + """Tests for ProjectService.add_project().""" + + def test_add_project_success(self, tmp_config_dir: Path) -> None: + """add_project verifies token, saves to config, returns project info.""" + store = ConfigStore(config_dir=tmp_config_dir) + mock_client = _make_mock_client(project_name="Production", project_id=9999) + + service = ProjectService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + result = service.add_project( + alias="prod", + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) + + assert result["alias"] == "prod" + assert result["project_name"] == "Production" + assert result["project_id"] == 9999 + assert result["stack_url"] == "https://connection.keboola.com" + assert "901-...pt0k" in result["token"] + + # Verify it's persisted + project = store.get_project("prod") + assert project is not None + assert project.project_name == "Production" + + mock_client.verify_token.assert_called_once() + mock_client.close.assert_called_once() + + def test_add_project_invalid_token(self, tmp_config_dir: Path) -> None: + """add_project raises KeboolaApiError when token verification fails.""" + store = ConfigStore(config_dir=tmp_config_dir) + mock_client = _make_failing_client( + KeboolaApiError( + message="Invalid token", + status_code=401, + error_code="INVALID_TOKEN", + retryable=False, + ) + ) + + service = ProjectService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + with pytest.raises(KeboolaApiError) as exc_info: + service.add_project( + alias="bad", + stack_url="https://connection.keboola.com", + token="invalid-token-abcdefgh", + ) + + assert exc_info.value.error_code == "INVALID_TOKEN" + + # Project should NOT be saved on failure + assert store.get_project("bad") is None + + def test_add_project_duplicate_alias(self, tmp_config_dir: Path) -> None: + """add_project raises ConfigError when alias already exists.""" + store = ConfigStore(config_dir=tmp_config_dir) + mock_client = _make_mock_client() + + service = ProjectService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + service.add_project( + alias="test", + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) + + with pytest.raises(ConfigError, match="already exists"): + service.add_project( + alias="test", + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) + + def test_add_project_network_error(self, tmp_config_dir: Path) -> None: + """add_project raises KeboolaApiError on network timeout.""" + store = ConfigStore(config_dir=tmp_config_dir) + mock_client = _make_failing_client( + KeboolaApiError( + message="Request timed out", + status_code=0, + error_code="TIMEOUT", + retryable=True, + ) + ) + + service = ProjectService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + with pytest.raises(KeboolaApiError) as exc_info: + service.add_project( + alias="timeout", + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) + + assert exc_info.value.error_code == "TIMEOUT" + assert exc_info.value.retryable is True + + +class TestRemoveProject: + """Tests for ProjectService.remove_project().""" + + def test_remove_project_success(self, tmp_config_dir: Path) -> None: + """remove_project removes the project and returns confirmation.""" + store = ConfigStore(config_dir=tmp_config_dir) + mock_client = _make_mock_client() + service = ProjectService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + service.add_project( + alias="test", + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) + + result = service.remove_project("test") + assert result["alias"] == "test" + assert "removed" in result["message"].lower() + assert store.get_project("test") is None + + def test_remove_nonexistent_raises_error(self, tmp_config_dir: Path) -> None: + """remove_project raises ConfigError for nonexistent alias.""" + store = ConfigStore(config_dir=tmp_config_dir) + service = ProjectService(config_store=store) + + with pytest.raises(ConfigError, match="not found"): + service.remove_project("nonexistent") + + +class TestEditProject: + """Tests for ProjectService.edit_project().""" + + def test_edit_url_only(self, tmp_config_dir: Path) -> None: + """edit_project with only URL updates the stack URL without re-verifying.""" + store = ConfigStore(config_dir=tmp_config_dir) + mock_client = _make_mock_client() + + service = ProjectService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + service.add_project( + alias="test", + stack_url="https://old.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) + + # Reset mock to track new calls + mock_client.verify_token.reset_mock() + + result = service.edit_project("test", stack_url="https://new.com") + assert result["stack_url"] == "https://new.com" + + # verify_token should NOT be called when only URL changes + mock_client.verify_token.assert_not_called() + + def test_edit_token_reverifies(self, tmp_config_dir: Path) -> None: + """edit_project with new token re-verifies against the API.""" + store = ConfigStore(config_dir=tmp_config_dir) + initial_client = _make_mock_client(project_name="Old Project", project_id=1000) + new_client = _make_mock_client(project_name="New Project", project_id=2000) + + call_count = [0] + + def factory(url, token): + call_count[0] += 1 + if call_count[0] <= 1: + return initial_client + return new_client + + service = ProjectService( + config_store=store, + client_factory=factory, + ) + + service.add_project( + alias="test", + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) + + result = service.edit_project( + "test", + token="902-newtoken-ABCDEFGHIJKLMNOP", + ) + + assert result["project_name"] == "New Project" + assert result["project_id"] == 2000 + + def test_edit_no_changes_raises_error(self, tmp_config_dir: Path) -> None: + """edit_project with no changes raises ConfigError.""" + store = ConfigStore(config_dir=tmp_config_dir) + mock_client = _make_mock_client() + service = ProjectService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + service.add_project( + alias="test", + stack_url="https://a.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) + + with pytest.raises(ConfigError, match="No changes"): + service.edit_project("test") + + def test_edit_nonexistent_raises_error(self, tmp_config_dir: Path) -> None: + """edit_project for nonexistent alias raises ConfigError.""" + store = ConfigStore(config_dir=tmp_config_dir) + service = ProjectService(config_store=store) + + with pytest.raises(ConfigError, match="not found"): + service.edit_project("nonexistent", stack_url="https://new.com") + + +class TestListProjects: + """Tests for ProjectService.list_projects().""" + + def test_list_empty(self, tmp_config_dir: Path) -> None: + """list_projects with no projects returns empty list.""" + store = ConfigStore(config_dir=tmp_config_dir) + service = ProjectService(config_store=store) + + result = service.list_projects() + assert result == [] + + def test_list_multiple_projects(self, tmp_config_dir: Path) -> None: + """list_projects returns all projects with masked tokens.""" + store = ConfigStore(config_dir=tmp_config_dir) + mock_client = _make_mock_client() + service = ProjectService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + service.add_project( + alias="prod", + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) + service.add_project( + alias="dev", + stack_url="https://connection.north-europe.azure.keboola.com", + token="532-abcdef-ghijklmnop", + ) + + result = service.list_projects() + assert len(result) == 2 + + aliases = {p["alias"] for p in result} + assert aliases == {"prod", "dev"} + + # Tokens must be masked + for p in result: + assert "10493007" not in p["token"] + assert "abcdef" not in p["token"] + + # First project should be default + prod = next(p for p in result if p["alias"] == "prod") + assert prod["is_default"] is True + + def test_list_projects_token_never_fully_shown(self, tmp_config_dir: Path) -> None: + """list_projects never returns the full token.""" + store = ConfigStore(config_dir=tmp_config_dir) + full_token = "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k" + mock_client = _make_mock_client() + service = ProjectService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + service.add_project( + alias="test", + stack_url="https://connection.keboola.com", + token=full_token, + ) + + result = service.list_projects() + assert result[0]["token"] != full_token + assert "901-...pt0k" == result[0]["token"] + + +class TestGetStatus: + """Tests for ProjectService.get_status().""" + + def test_status_all_ok(self, tmp_config_dir: Path) -> None: + """get_status returns OK status with response time for healthy projects.""" + store = ConfigStore(config_dir=tmp_config_dir) + mock_client = _make_mock_client(project_name="Production", project_id=1234) + service = ProjectService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + service.add_project( + alias="prod", + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + ) + + result = service.get_status() + assert len(result) == 1 + assert result[0]["alias"] == "prod" + assert result[0]["status"] == "ok" + assert "response_time_ms" in result[0] + assert result[0]["project_name"] == "Production" + assert isinstance(result[0]["response_time_ms"], int) + + def test_status_mixed_success_failure(self, tmp_config_dir: Path) -> None: + """get_status handles mixed success/failure across projects.""" + store = ConfigStore(config_dir=tmp_config_dir) + + ok_client = _make_mock_client(project_name="OK Project") + fail_client = _make_failing_client( + KeboolaApiError( + message="Token expired", + status_code=401, + error_code="INVALID_TOKEN", + ) + ) + + call_count = [0] + + def factory(url, token): + call_count[0] += 1 + if "ok" in token: + return ok_client + return fail_client + + service = ProjectService( + config_store=store, + client_factory=factory, + ) + + store.add_project("ok-project", ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-ok-abcdefghijklmnop", + project_name="OK", + project_id=1, + )) + store.add_project("bad-project", ProjectConfig( + stack_url="https://connection.keboola.com", + token="902-bad-abcdefghijklmnop", + project_name="Bad", + project_id=2, + )) + + result = service.get_status() + assert len(result) == 2 + + ok_entry = next(r for r in result if r["alias"] == "ok-project") + bad_entry = next(r for r in result if r["alias"] == "bad-project") + + assert ok_entry["status"] == "ok" + assert ok_entry["project_name"] == "OK Project" + + assert bad_entry["status"] == "error" + assert bad_entry["error_code"] == "INVALID_TOKEN" + assert "Token expired" in bad_entry["error"] + + def test_status_specific_project(self, tmp_config_dir: Path) -> None: + """get_status with specific alias only checks that project.""" + store = ConfigStore(config_dir=tmp_config_dir) + mock_client = _make_mock_client() + service = ProjectService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + store.add_project("first", ProjectConfig( + stack_url="https://a.com", + token="901-abcdef-12345678", + )) + store.add_project("second", ProjectConfig( + stack_url="https://b.com", + token="902-abcdef-12345678", + )) + + result = service.get_status(aliases=["first"]) + assert len(result) == 1 + assert result[0]["alias"] == "first" + + def test_status_nonexistent_alias_raises_error(self, tmp_config_dir: Path) -> None: + """get_status with nonexistent alias raises ConfigError.""" + store = ConfigStore(config_dir=tmp_config_dir) + service = ProjectService(config_store=store) + + with pytest.raises(ConfigError, match="not found"): + service.get_status(aliases=["nonexistent"]) + + def test_status_token_masked(self, tmp_config_dir: Path) -> None: + """get_status always masks tokens in output.""" + store = ConfigStore(config_dir=tmp_config_dir) + full_token = "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k" + mock_client = _make_mock_client() + service = ProjectService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + store.add_project("test", ProjectConfig( + stack_url="https://connection.keboola.com", + token=full_token, + )) + + result = service.get_status() + assert result[0]["token"] != full_token + assert "901-...pt0k" == result[0]["token"]