Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion src/keboola_agent_cli/commands/org.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -150,6 +154,12 @@ def org_setup(
"--token-description",
help="Description prefix for created Storage API tokens",
),
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:
"""Set up all projects from a Keboola organization.

Expand All @@ -176,6 +186,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)
Expand All @@ -201,6 +212,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)
Expand Down
6 changes: 5 additions & 1 deletion src/keboola_agent_cli/manage_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -87,17 +88,20 @@ 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).

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()
6 changes: 6 additions & 0 deletions src/keboola_agent_cli/services/org_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -199,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(
Expand All @@ -210,6 +213,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.

Expand All @@ -221,6 +225,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

Expand All @@ -237,6 +242,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()
Expand Down
44 changes: 44 additions & 0 deletions tests/test_manage_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
68 changes: 68 additions & 0 deletions tests/test_org_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,74 @@ 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.kwargs
assert call_kwargs["expires_in"] == 3600

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"}]

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,
)

# 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


class TestExistingProjectIdNone:
"""Tests for existing projects with project_id=None not polluting the set."""

Expand Down