From 9bb214b5d1b6fe4cf2dd691433558705fb96cdbd Mon Sep 17 00:00:00 2001 From: Matejkys <12933713+Matejkys@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:29:20 +0100 Subject: [PATCH 1/3] feat: add --token-expires-in option to org setup command Allow setting token lifetime when onboarding organization projects, so tokens created by CI/CD automation auto-expire instead of accumulating. --- src/keboola_agent_cli/commands/org.py | 7 ++ src/keboola_agent_cli/manage_client.py | 6 +- src/keboola_agent_cli/services/org_service.py | 5 ++ tests/test_org_service.py | 67 +++++++++++++++++++ 4 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/keboola_agent_cli/commands/org.py b/src/keboola_agent_cli/commands/org.py index 33a97954..218b13a0 100644 --- a/src/keboola_agent_cli/commands/org.py +++ b/src/keboola_agent_cli/commands/org.py @@ -150,6 +150,11 @@ def org_setup( "--token-description", help="Description prefix for created Storage API tokens", ), + token_expires_in: int | None = typer.Option( + None, + "--token-expires-in", + help="Token lifetime in seconds (e.g. 3600 for 1 hour). If not set, tokens never expire.", + ), ) -> None: """Set up all projects from a Keboola organization. @@ -176,6 +181,7 @@ def org_setup( org_id=org_id, token_description=token_description, dry_run=True, + token_expires_in=token_expires_in, ) except KeboolaApiError as exc: _handle_api_error(formatter, exc) @@ -201,6 +207,7 @@ def org_setup( org_id=org_id, token_description=token_description, dry_run=dry_run, + token_expires_in=token_expires_in, ) except KeboolaApiError as exc: _handle_api_error(formatter, exc) diff --git a/src/keboola_agent_cli/manage_client.py b/src/keboola_agent_cli/manage_client.py index 1b4a8f12..13f1a4fe 100644 --- a/src/keboola_agent_cli/manage_client.py +++ b/src/keboola_agent_cli/manage_client.py @@ -79,6 +79,7 @@ def create_project_token( description: str, can_manage_buckets: bool = True, can_read_all_file_uploads: bool = True, + expires_in: int | None = None, ) -> dict[str, Any]: """Create a new Storage API token for a project. @@ -87,6 +88,7 @@ def create_project_token( description: Token description. can_manage_buckets: Whether the token can manage buckets. can_read_all_file_uploads: Whether the token can read all file uploads. + expires_in: Token lifetime in seconds. None means the token never expires. Returns: Token dict including the 'token' field (shown only once). @@ -94,10 +96,12 @@ def create_project_token( Raises: KeboolaApiError: On API errors. """ - payload = { + payload: dict[str, Any] = { "description": description, "canManageBuckets": can_manage_buckets, "canReadAllFileUploads": can_read_all_file_uploads, } + if expires_in is not None: + payload["expiresIn"] = expires_in response = self._do_request("POST", f"/manage/projects/{project_id}/tokens", json=payload) return response.json() diff --git a/src/keboola_agent_cli/services/org_service.py b/src/keboola_agent_cli/services/org_service.py index 0569e72f..0a1d0be7 100644 --- a/src/keboola_agent_cli/services/org_service.py +++ b/src/keboola_agent_cli/services/org_service.py @@ -76,6 +76,7 @@ def setup_organization( org_id: int, token_description: str = DEFAULT_TOKEN_DESCRIPTION, dry_run: bool = False, + token_expires_in: int | None = None, ) -> dict[str, Any]: """Set up all projects from a Keboola organization. @@ -160,6 +161,7 @@ def setup_organization( alias=alias, token_description=token_description, owner_name=owner_name, + token_expires_in=token_expires_in, ) # Re-read to get masked token registered = self._config_store.get_project(alias) @@ -210,6 +212,7 @@ def _setup_single_project( alias: str, token_description: str, owner_name: str = "", + token_expires_in: int | None = None, ) -> None: """Create a token for a single project, verify it, and register it. @@ -221,6 +224,7 @@ def _setup_single_project( alias: The alias to register the project under. token_description: Description for the created token. owner_name: Email/name of the manage token owner (for unique identification). + token_expires_in: Token lifetime in seconds. None means no expiration. """ description = f"{token_description} [{owner_name}]" if owner_name else token_description @@ -237,6 +241,7 @@ def _setup_single_project( token_data = manage_client.create_project_token( project_id=project_id, description=description, + expires_in=token_expires_in, ) finally: manage_client.close() diff --git a/tests/test_org_service.py b/tests/test_org_service.py index 1704ffe5..e8251fe8 100644 --- a/tests/test_org_service.py +++ b/tests/test_org_service.py @@ -385,6 +385,73 @@ def test_custom_token_description(self, tmp_path: Path) -> None: ) or "my-custom-prefix" in str(call_args) +class TestTokenExpiration: + """Tests for token expiration (expiresIn) parameter.""" + + def test_expires_in_passed_to_manage_client(self, tmp_path: Path) -> None: + """When token_expires_in is set, it is forwarded to create_project_token.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + store = ConfigStore(config_dir=config_dir) + + projects = [{"id": 100, "name": "Alpha"}] + + manage_mock = MagicMock() + manage_mock.list_organization_projects.return_value = projects + manage_mock.create_project_token.return_value = { + "id": "tok-1", + "token": "901-99999-generatedToken1234567890ab", + "description": "kbagent-cli", + } + + service = OrgService( + config_store=store, + manage_client_factory=lambda url, token: manage_mock, + storage_client_factory=_make_storage_client(), + ) + + service.setup_organization( + stack_url="https://connection.keboola.com", + manage_token="manage-token-123456789012345678", + org_id=42, + token_expires_in=3600, + ) + + call_kwargs = manage_mock.create_project_token.call_args[1] + assert call_kwargs["expires_in"] == 3600 + + def test_expires_in_none_by_default(self, tmp_path: Path) -> None: + """When token_expires_in is not set, expires_in is None.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + store = ConfigStore(config_dir=config_dir) + + projects = [{"id": 100, "name": "Alpha"}] + + manage_mock = MagicMock() + manage_mock.list_organization_projects.return_value = projects + manage_mock.create_project_token.return_value = { + "id": "tok-1", + "token": "901-99999-generatedToken1234567890ab", + "description": "kbagent-cli", + } + + service = OrgService( + config_store=store, + manage_client_factory=lambda url, token: manage_mock, + storage_client_factory=_make_storage_client(), + ) + + service.setup_organization( + stack_url="https://connection.keboola.com", + manage_token="manage-token-123456789012345678", + org_id=42, + ) + + call_kwargs = manage_mock.create_project_token.call_args[1] + assert call_kwargs["expires_in"] is None + + class TestExistingProjectIdNone: """Tests for existing projects with project_id=None not polluting the set.""" From 5320997afcdadd6d2cd57d6ab50bb5ef2030c638 Mon Sep 17 00:00:00 2001 From: Matejkys <12933713+Matejkys@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:30:21 +0100 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?validation,=20test=20fixes,=20dry-run=20expiration=20display?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add min=1 constraint to --token-expires-in to reject zero/negative values - Fix test accessor: call_args[1] → call_args.kwargs for keyword arguments - Add payload-level tests in test_manage_client.py verifying expiresIn presence/absence - Show token expiration in dry-run output header - Include token_expires_in in setup_organization result dict --- src/keboola_agent_cli/commands/org.py | 7 ++- src/keboola_agent_cli/services/org_service.py | 1 + tests/test_manage_client.py | 44 +++++++++++++++++++ tests/test_org_service.py | 13 ++++-- 4 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/keboola_agent_cli/commands/org.py b/src/keboola_agent_cli/commands/org.py index 218b13a0..03e17ae2 100644 --- a/src/keboola_agent_cli/commands/org.py +++ b/src/keboola_agent_cli/commands/org.py @@ -58,10 +58,14 @@ def _format_setup_result(console: Console, data: dict) -> None: skipped = data.get("projects_skipped", []) failed = data.get("projects_failed", []) + token_expires_in = data.get("token_expires_in") mode_label = "[bold yellow]DRY RUN[/bold yellow] " if dry_run else "" + expiry_label = ( + f", token expiration: [bold]{token_expires_in}s[/bold]" if token_expires_in else "" + ) console.print( f"\n{mode_label}Organization [bold]{org_id}[/bold] on {stack_url} " - f"-- {projects_found} project(s) found\n" + f"-- {projects_found} project(s) found{expiry_label}\n" ) # Added / would-add table @@ -153,6 +157,7 @@ def org_setup( token_expires_in: int | None = typer.Option( None, "--token-expires-in", + min=1, help="Token lifetime in seconds (e.g. 3600 for 1 hour). If not set, tokens never expire.", ), ) -> None: diff --git a/src/keboola_agent_cli/services/org_service.py b/src/keboola_agent_cli/services/org_service.py index 0a1d0be7..f954fb28 100644 --- a/src/keboola_agent_cli/services/org_service.py +++ b/src/keboola_agent_cli/services/org_service.py @@ -201,6 +201,7 @@ def setup_organization( "projects_skipped": skipped, "projects_failed": failed, "dry_run": dry_run, + "token_expires_in": token_expires_in, } def _setup_single_project( diff --git a/tests/test_manage_client.py b/tests/test_manage_client.py index 75755877..ea579a16 100644 --- a/tests/test_manage_client.py +++ b/tests/test_manage_client.py @@ -226,6 +226,50 @@ def test_custom_description_in_payload(self, httpx_mock) -> None: client.close() + def test_expires_in_included_in_payload(self, httpx_mock) -> None: + """When expires_in is set, expiresIn is sent in the request payload.""" + httpx_mock.add_response( + url=f"{STACK_URL}/manage/projects/100/tokens", + json=TOKEN_RESPONSE, + status_code=201, + ) + + client = ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN) + client.create_project_token( + project_id=100, + description="kbagent-cli", + expires_in=3600, + ) + + request = httpx_mock.get_request() + import json + + body = json.loads(request.content) + assert body["expiresIn"] == 3600 + client.close() + + def test_expires_in_none_excluded_from_payload(self, httpx_mock) -> None: + """When expires_in is None, expiresIn key is absent from the request payload.""" + httpx_mock.add_response( + url=f"{STACK_URL}/manage/projects/100/tokens", + json=TOKEN_RESPONSE, + status_code=201, + ) + + client = ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN) + client.create_project_token( + project_id=100, + description="kbagent-cli", + ) + + request = httpx_mock.get_request() + import json + + body = json.loads(request.content) + assert "expiresIn" not in body + client.close() + + class TestManageClientContextManager: """Test context manager protocol.""" diff --git a/tests/test_org_service.py b/tests/test_org_service.py index e8251fe8..e6277c3e 100644 --- a/tests/test_org_service.py +++ b/tests/test_org_service.py @@ -417,17 +417,21 @@ def test_expires_in_passed_to_manage_client(self, tmp_path: Path) -> None: token_expires_in=3600, ) - call_kwargs = manage_mock.create_project_token.call_args[1] + call_kwargs = manage_mock.create_project_token.call_args.kwargs assert call_kwargs["expires_in"] == 3600 - def test_expires_in_none_by_default(self, tmp_path: Path) -> None: - """When token_expires_in is not set, expires_in is None.""" + def test_expires_in_none_not_in_payload(self, tmp_path: Path) -> None: + """When token_expires_in is not set, expiresIn is absent from API payload.""" config_dir = tmp_path / "config" config_dir.mkdir() store = ConfigStore(config_dir=config_dir) projects = [{"id": 100, "name": "Alpha"}] + # Use a real-ish mock that captures the payload + payloads_sent: list[dict] = [] + original_create = None + manage_mock = MagicMock() manage_mock.list_organization_projects.return_value = projects manage_mock.create_project_token.return_value = { @@ -448,7 +452,8 @@ def test_expires_in_none_by_default(self, tmp_path: Path) -> None: org_id=42, ) - call_kwargs = manage_mock.create_project_token.call_args[1] + # Verify expires_in=None was passed (ManageClient will skip expiresIn from payload) + call_kwargs = manage_mock.create_project_token.call_args.kwargs assert call_kwargs["expires_in"] is None From f5f410dcabec84eb7147051ef7c8225ff6458ef7 Mon Sep 17 00:00:00 2001 From: Petr Date: Thu, 26 Mar 2026 12:39:29 +0100 Subject: [PATCH 3/3] fix: remove unused variables in test_org_service.py Remove leftover payloads_sent and original_create variables that were causing ruff F841 lint failures in CI. --- tests/test_org_service.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/test_org_service.py b/tests/test_org_service.py index e6277c3e..8742e28f 100644 --- a/tests/test_org_service.py +++ b/tests/test_org_service.py @@ -428,10 +428,6 @@ def test_expires_in_none_not_in_payload(self, tmp_path: Path) -> None: projects = [{"id": 100, "name": "Alpha"}] - # Use a real-ish mock that captures the payload - payloads_sent: list[dict] = [] - original_create = None - manage_mock = MagicMock() manage_mock.list_organization_projects.return_value = projects manage_mock.create_project_token.return_value = {