diff --git a/CLAUDE.md b/CLAUDE.md index 56081a6a..92d60ed9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -264,6 +264,13 @@ kbagent project description-get --project NAME kbagent project description-set --project NAME [--text STR | --file PATH | --stdin] kbagent project use ALIAS kbagent project current +kbagent project invite --project ALIAS --email EMAIL --role admin|guest|readOnly|share [--reason TEXT] [--dry-run] +kbagent project invite --from-csv FILE [--default-role ROLE] [--workers N] [--dry-run] +kbagent project member-list --project ALIAS [--include-pending] +kbagent project invitation-list --project ALIAS +kbagent project invitation-cancel --project ALIAS --email EMAIL [--invitation-id ID] [--yes] +kbagent project member-remove --project ALIAS --email EMAIL [--yes] +kbagent project member-set-role --project ALIAS --email EMAIL --role admin|guest|readOnly|share kbagent config list [--project NAME] [--component-type TYPE] [--component-id ID] [--branch ID] [--include-rows] kbagent config detail --project NAME [--project NAME ...] --component-id ID [--config-id ID] [--branch ID] [--with-state] diff --git a/Makefile b/Makefile index 10c880da..f50f42ff 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .DEFAULT_GOAL := help -.PHONY: help install install-mcp sync test test-unit test-integration test-e2e test-file lint lint-fix format format-check skill-check skill-gen version-sync version-check changelog changelog-check check-error-codes check clean hooks +.PHONY: help install install-mcp sync test test-unit test-integration test-e2e test-e2e-invite test-file lint lint-fix format format-check skill-check skill-gen version-sync version-check changelog changelog-check check-error-codes check clean hooks help: ## Show this help message @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' @@ -26,6 +26,9 @@ test-integration: ## Run integration tests only test-e2e: ## Run E2E tests (E2E_API_TOKEN and E2E_URL required) uv run pytest tests/test_e2e.py -v -s --tb=long +test-e2e-invite: ## Run project invite E2E (E2E_MANAGE_TOKEN + E2E_INVITE_PROJECT_ID required) + uv run pytest tests/test_e2e.py -v -s --tb=long -m e2e_invite + test-file: ## Run a specific test file (FILE=tests/test_cli.py) uv run pytest $(FILE) -v diff --git a/plugins/kbagent/agents/keboola-expert.md b/plugins/kbagent/agents/keboola-expert.md index 42b4a632..50d7ba16 100644 --- a/plugins/kbagent/agents/keboola-expert.md +++ b/plugins/kbagent/agents/keboola-expert.md @@ -112,6 +112,13 @@ a critical failure. | Pause a running data app | `kbagent data-app stop --project P --app-id N` (0.27.0+) | -- | `kbagent data-app delete` (irreversible; cascades to Storage config) | | Read the simpleAuth password for a password-gated app | `kbagent data-app password --project P --app-id N` (0.27.0+) -- needs Manage API token (interactive prompt by default; `--allow-env-manage-token` + `KBC_MANAGE_API_TOKEN` for CI on 0.28.0+) | -- | trying to "rotate" the password (not supported by the API; delete + recreate to mint a new one) | | Tear down a data app | `kbagent data-app delete --project P --app-id N` (0.27.0+) -- cascades to Storage config; URL retired | -- | manually `tool call delete_config keboola.data-apps` while leaving the deployment record orphaned | +| Invite a user to a project (single) | `kbagent project invite --project P --email E --role admin\|guest\|readOnly\|share` (0.26.1+) | raw `requests.post(/manage/projects/{id}/invitations)` only if version-gated out | `kbagent project invite` without `KBC_MANAGE_API_TOKEN` set; passing manage token via CLI flag | +| Invite many users (bulk) | `kbagent project invite --from-csv FILE [--default-role guest] [--workers N] [--dry-run]` (0.26.1+) | `--hint client` to generate a parallel script using `ManageClient` | per-row shell loop calling the CLI -- defeats the parallelism + idempotency the service already does | +| List active project members | `kbagent project member-list --project P [--include-pending]` (0.26.1+) | `tool call run_sync_action` against the Manage API | reading `.kbagent/config.json` to infer membership (it only stores the local user's token) | +| List pending invitations | `kbagent project invitation-list --project P` (0.26.1+) | -- | -- | +| Cancel a pending invitation | `kbagent project invitation-cancel --project P --email E --yes` (0.26.1+) | `--invitation-id ID` if email lookup is ambiguous | DELETE via raw HTTP without going through the service layer | +| Remove an active member | `kbagent project member-remove --project P --email E --yes` (0.26.1+, **destructive**) | `--hint client` for a script that removes by user_id directly | calling `member-remove` without `--yes` in non-interactive contexts (it will prompt and hang) | +| Change a member's role | `kbagent project member-set-role --project P --email E --role admin\|guest\|readOnly\|share` (0.26.1+) | -- | `PUT /manage/projects/{id}/users/{userId}` -- the API rejects PUT with 404, the kbagent client correctly uses **PATCH** | If the table does not cover the user's task, **ask clarifying questions** instead of guessing. Returning a targeted question is a @@ -187,6 +194,29 @@ success, not a failure. verification payload but do not treat it as a failure signal. Production writes never materialize anything. +- **`project invite` "already invited / already member" is a no-op, not a failure** (0.26.1+): + Re-inviting a user the project already knows returns HTTP 400 from the + Manage API. kbagent normalises both "...already been invited..." and + "...already a member..." to `status="noop"` with a `note` field, exit 0. + **Do not retry on 400 from these commands** -- the user is already + on the project (or already pending). For bulk runs, `noop` rows count + toward `noop`, not `failed`, in the summary; surface that distinction + to the user when reporting bulk results. + +- **`project invite --from-csv` ordering is non-deterministic** (0.26.1+): + Bulk invitation parallelises via `ThreadPoolExecutor` (default 8 workers). + The `rows[]` array in the JSON result is in completion order, not CSV + order. When reporting per-row outcomes to the user, **match by `email`, + not by index**. Partial-success exits 0 with `failed > 0` reflected in + the JSON -- treat that as a soft failure that needs review, not a + catastrophe. + +- **`project member-set-role` uses PATCH, not PUT** (0.26.1+): The Manage + API endpoint is `PATCH /manage/projects/{id}/users/{userId}` with + `{"role": "..."}`. PUT returns 404 even on real members. kbagent's + `ManageClient.update_project_member_role` emits PATCH; if you write a + `--hint client` script that hits the endpoint directly, do the same. + - **`legacy_branch_storage: true` on `--branch` writes** (0.25.2+): Projects without the `storage-branches` feature flag (legacy fake-branch projects) accept `--branch X` writes at the API level, but the diff --git a/plugins/kbagent/skills/kbagent/SKILL.md b/plugins/kbagent/skills/kbagent/SKILL.md index 27cb91bb..c0d98874 100644 --- a/plugins/kbagent/skills/kbagent/SKILL.md +++ b/plugins/kbagent/skills/kbagent/SKILL.md @@ -31,6 +31,10 @@ description: > app proxy, simpleAuth, app auto-suspend, configVersion, redeploy contract, Data Science API, /apps endpoint, app password, KBC::Project ciphertext, local workspace, project directory, kbagent init. + local workspace, project directory, kbagent init, + invite user, invite member, project invitation, manage members, + list members, remove member, change role, project role, + bulk invite, invite from CSV, project access, member management. --- # kbagent -- Keboola Agent CLI @@ -92,6 +96,12 @@ When working inside a git repository or project directory, run `kbagent init` (o | Show the effective default project | `kbagent project current` | | Get the Keboola dashboard project description | `kbagent project description-get --project PROJECT` | | Set the Keboola dashboard project description (markdown) | `kbagent project description-set --project PROJECT` | +| Invite a user (or many users via CSV) to one or more projects | `kbagent project invite` | +| List active members of a project (and optionally pending invitations) | `kbagent project member-list --project PROJECT` | +| List pending project invitations | `kbagent project invitation-list --project PROJECT` | +| Cancel a pending invitation | `kbagent project invitation-cancel --project PROJECT --email EMAIL` | +| Remove an active member from a project (destructive) | `kbagent project member-remove --project PROJECT --email EMAIL` | +| Change an existing member's role (PATCH) | `kbagent project member-set-role --project PROJECT --email EMAIL --role ROLE` | | Set up projects and register them in the kbagent config | `kbagent org setup --url URL` | | List available components from connected projects | `kbagent component list` | | Show detailed information about a specific component | `kbagent component detail --component-id COMPONENT-ID` | @@ -247,6 +257,7 @@ For detailed response parsing rules and common pitfalls, see [gotchas](reference | **Storage column types** (native types, NOT NULL, DEFAULT, branch materialize) | [storage-types-workflow](references/storage-types-workflow.md) | | **Typify a typeless table** (profile -> CTAS -> swap-tables -> validate -> handoff) | [typify-table-workflow](references/typify-table-workflow.md) | | Bucket sharing & linking | [sharing-workflow](references/sharing-workflow.md) | +| **Project members & invitations** (single + bulk via CSV, role change, remove) | [member-workflow](references/member-workflow.md) | | Dev branches | [branch-workflow](references/branch-workflow.md) | | Encrypting secrets for MCP tools | [encrypt-workflow](references/encrypt-workflow.md) | | Sync & Git-branching (GitOps) | [sync-workflow](references/sync-workflow.md) | diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index 71836b9a..d58a31d9 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -21,6 +21,18 @@ All commands support `--json` for structured output. Multi-project flags (`--pro - `project use ALIAS` -- pin `ALIAS` as the persistent default project. Stored as `default_project` in config.json. Overridden at runtime by `KBAGENT_PROJECT=ALIAS` (env, beats pin) and by `--project ALIAS` (CLI flag, beats both) - `project current` -- print the effective default project and its source (`env` / `pin` / `none`). Reports both the env override AND the persisted pin so misconfigurations are visible. Returns `{"alias": null, "source": "none"}` when neither is set +## Project Members & Invitations (since v0.26.1) + +All seven commands authenticate via `KBC_MANAGE_API_TOKEN` (Manage API), not the project's Storage token. Allowed roles are exactly `admin`, `guest`, `readOnly`, `share` -- the API self-reports this list in its 400 validation error and `constants.PROJECT_ROLES` mirrors it. + +- `project invite --project ALIAS --email EMAIL --role admin|guest|readOnly|share [--reason TEXT] [--dry-run]` -- single-shot invitation. Returns `{"status": "ok", "invitation_id": ..., ...}`. Re-inviting an already-invited or already-member email returns `{"status": "noop", "note": "already_invited" | "already_member"}` (HTTP 400 from the Manage API, normalised to a no-op). +- `project invite --from-csv FILE [--default-role ROLE] [--workers N] [--dry-run]` -- bulk invitation. CSV header required; columns: `email`, `project` (alias) or `project_id` (numeric), `role` (optional with `--default-role`), `reason` (optional). Parallelised via `ThreadPoolExecutor` (default 8 workers). Single-stack-URL invariant per file: rows referencing different stacks raise `ConfigError` upfront. Result is `{"total","succeeded","noop","failed","rows":[...]}`; `rows[]` order is *not deterministic*. Exit 0 even with `failed > 0` -- inspect the JSON. +- `project member-list --project ALIAS [--include-pending]` -- list active members. Each member dict carries `id`, `email`, `name`, `role`, `status`, `mfa_enabled`. With `--include-pending`, the response also includes `pending_invitations: [...]`. +- `project invitation-list --project ALIAS` -- list pending (unaccepted) invitations only. +- `project invitation-cancel --project ALIAS --email EMAIL [--invitation-id ID] [--yes]` -- cancel a pending invitation. Without `--invitation-id`, the service resolves it by listing pending invitations and matching `--email` (case-insensitive). 204 No Content on success; `KeboolaApiError(NOT_FOUND)` if the email has no pending invitation. +- `project member-remove --project ALIAS --email EMAIL [--yes]` -- destructive: remove an active member. The service resolves `--email` to the numeric `user_id` (case-insensitive) and DELETEs `/manage/projects/{id}/users/{userId}`. Re-add the user via `project invite`. +- `project member-set-role --project ALIAS --email EMAIL --role admin|guest|readOnly|share` -- change an existing member's role. Uses **PATCH** `/manage/projects/{id}/users/{userId}` with `{"role": "..."}`. PUT does *not* work on this endpoint -- pre-v0.26.1 implementations that tried PUT got a misleading 404. + ## Permission flags (top-level, session-only) - `--deny-writes` -- block all write/destructive/admin operations for this single invocation. Merges with any persisted permission policy; never written to config.json. Exit code 6 (PERMISSION_DENIED) on blocked operations - `--deny-destructive` -- block only destructive operations (delete-table, delete-bucket, terminate-job, etc.) for this invocation. Pure-write ops like create-table stay allowed. Use this when you want to keep build-up capabilities but lock out tear-downs diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index 0ac8e63d..c2898e6c 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -104,6 +104,36 @@ container after `autoSuspendAfterSeconds` of inactivity. Hit the URL to wake it (auto-restart triggers a 30-60s cold boot) or run `kbagent data-app start --app-id N`. +## `project invite` "already invited / already member" returns HTTP 400, not 422 (since v0.26.1) + +- Re-inviting a user the project already knows about returns HTTP **400** with + one of two error strings: + - `"This user has already been invited to this project."` (pending invitation) + - `"This user is already a member of this project."` (active member) +- `MemberService.invite()` translates both cases to `status="noop"` with + `note="already_invited"` / `"already_member"` -- they are *not* exit-1 + failures. Bulk runs (`--from-csv`) count them as `noop` in the summary, not + `failed`. +- The 422 heuristic in pre-v0.26.1 orchestrator scripts (`invite_participants.py:25`) + is **wrong** for this API. If you write a parallel implementation, key off + status_code 400 + the substring marker, not 422. + +## `project member-set-role` is PATCH, not PUT (since v0.26.1) + +- The Manage API role-change endpoint is `PATCH /manage/projects/{id}/users/{userId}` + with body `{"role": "..."}`. **PUT returns 404** ("resource not found") even + on a real, currently-active member -- the endpoint shape is PATCH-only. +- The kbagent `ManageClient.update_project_member_role` method emits PATCH; + any custom code re-implementing the call must do the same. + +## `project invite --from-csv` order is not deterministic (since v0.26.1) + +- Bulk invitation parallelises via `ThreadPoolExecutor` (default 8 workers). + The `rows[]` array in the result is in completion order, not CSV order. +- Per-row parsing of `failed_rows` should match by `email`, not by index. +- A failed row never aborts the run -- the executor accumulates results and + the command exits 0 with `failed > 0` reflected in the JSON summary. Mirror + the `org setup` partial-success exit semantics. ## `default_bucket` is per-config and only an output prefix (since 0.26.0) diff --git a/plugins/kbagent/skills/kbagent/references/member-workflow.md b/plugins/kbagent/skills/kbagent/references/member-workflow.md new file mode 100644 index 00000000..218172bf --- /dev/null +++ b/plugins/kbagent/skills/kbagent/references/member-workflow.md @@ -0,0 +1,171 @@ +# Project Member & Invitation Workflow (since v0.26.1) + +Closes the long-standing Manage API gap that forced every Keboola-internal +automation (most recently the Cuesta-training orchestrator) to bypass kbagent +and POST raw HTTP at `/manage/projects/{id}/invitations`. + +## Auth + +All seven commands use the **Manage API**, not the Storage API. Provide the +manage token via `KBC_MANAGE_API_TOKEN` (env var or interactive prompt). The +manage token is *never* persisted to config.json, *never* accepted as a CLI +argument, *never* logged. + +```bash +export KBC_MANAGE_API_TOKEN= +``` + +## Roles (whitelist) + +The Manage API accepts exactly four role values: + +| Role | Use case | +|------|----------| +| `admin` | Full project control (create/delete tokens, manage members, all data ops) | +| `share` | Read-only with sharing rights to other projects in the org | +| `readOnly` | Read-only | +| `guest` | Lowest blast radius; useful for temporary access (and for the e2e test) | + +Both Typer (`click.Choice`) and `MemberService._validate_role()` enforce this +list. The whitelist is defined in `constants.PROJECT_ROLES`. + +## Single invite + +```bash +kbagent project invite --project prod --email a@b.com --role admin --reason "On-call rotation" +``` + +Returns: +```json +{ + "status": "ok", + "invitation_id": 1741, + "alias": "prod", + "project_id": 5725, + "email": "a@b.com", + "role": "admin" +} +``` + +If the user is already invited or already a member, the API returns HTTP 400 +and kbagent translates it to `{"status": "noop", "note": "already_invited" | "already_member"}` -- this is **not** an error and exit code stays 0. + +## Bulk invite from CSV (the headline use case) + +CSV header required. Recognised columns (case-insensitive): `email` (required), +`project` (alias) **or** `project_id` (numeric integer), `role` +(optional if `--default-role` is set), `reason` (optional). Extra columns are +ignored. Each row may pick a different project as long as **all rows resolve +to the same stack URL** (rows referencing multiple stacks raise upfront +before any HTTP call). + +```csv +email,project,role,reason +ann@example.com,prod,admin,On-call +ben@example.com,staging,guest,Read-only access for QA +chen@example.com,5725,share,Shared bucket consumer +``` + +```bash +kbagent project invite --from-csv participants.csv --default-role guest --workers 8 +``` + +Result schema: +```json +{ + "total": 3, + "succeeded": 2, + "noop": 1, + "failed": 0, + "rows": [ + {"email": "ann@example.com", "project": "prod", "role": "admin", "status": "ok", "invitation_id": 1741, ...}, + {"email": "ben@example.com", "project": "staging", "role": "guest", "status": "noop", "note": "already_invited", ...}, + {"email": "chen@example.com", "project": "5725", "project_id": 5725, "role": "share", "status": "ok", "invitation_id": 1742, ...} + ], + "dry_run": false +} +``` + +The `rows[]` array is in **completion order**, not CSV order (parallel +workers). Match by `email`, not by index. Partial-success exits 0 with +`failed > 0` reflected in the JSON; this mirrors `org setup`. + +`--dry-run` resolves every row and reports what *would* happen without +sending invitations. Use it before any large CSV. + +## Audit who is on a project + +```bash +kbagent project member-list --project prod --include-pending +``` + +Returns active members + pending invitations in one shot: +```json +{ + "alias": "prod", + "project_id": 5725, + "members": [ + {"id": 216, "email": "max.ottomansky@keboola.com", "role": "admin", "status": "active", "mfa_enabled": true, ...}, + {"id": 4241, "email": "mfiser@cuestapartners.com", "role": "guest", "status": "active", "mfa_enabled": true, ...} + ], + "pending_invitations": [ + {"id": 1515, "user": {"email": "marcusscwong@gmail.com"}, "role": "admin", "reason": "", ...} + ] +} +``` + +For the pending-only view: `kbagent project invitation-list --project prod`. + +## Change a member's role + +Uses HTTP **PATCH** under the hood (PUT returns 404 even on real members -- +that's the Manage API's quirk, not a kbagent bug). + +```bash +kbagent project member-set-role --project prod --email a@b.com --role guest +``` + +The service resolves `--email` to the numeric user_id by listing project +members and matching case-insensitively. The PATCH response includes the +updated user dict. + +## Cancel a pending invitation + +```bash +kbagent project invitation-cancel --project prod --email a@b.com --yes +``` + +Without `--invitation-id`, the service resolves the ID by listing pending +invitations and matching `--email`. With `--invitation-id ID`, it skips the +lookup. The DELETE returns 204 No Content on success; if the invitation has +already been deleted the API returns 404 with "Invitation not found". + +## Remove an active member (destructive) + +```bash +kbagent project member-remove --project prod --email a@b.com --yes +``` + +Resolves `--email` to user_id, then DELETEs `/manage/projects/{id}/users/{userId}`. +Permission category: `destructive` (re-adding requires sending a fresh invite). + +## Idempotency cheat-sheet + +| API response | kbagent translation | Exit code | +|--------------|--------------------|-----------| +| HTTP 201 invitation created | `status="ok"` | 0 | +| HTTP 400 "...already been invited..." | `status="noop"`, `note="already_invited"` | 0 | +| HTTP 400 "...already a member..." | `status="noop"`, `note="already_member"` | 0 | +| HTTP 400 "Role X is not valid..." | Re-raised; `--role` should be on the whitelist | 1 | +| HTTP 401 invalid manage token | `KeboolaApiError(INVALID_TOKEN)` | 3 | +| HTTP 403 manage token lacks org-admin | `KeboolaApiError(ACCESS_DENIED)` | 1 | +| HTTP 404 project / invitation not found | `KeboolaApiError(NOT_FOUND)` | 1 | + +## When to use the Manage API direct-add (not in v0.26.1) + +The Manage API also exposes `POST /manage/projects/{id}/users` with body +`{"email": "...", "role": "..."}`. This **directly creates a member without +sending an email** -- useful for org-internal automation, dangerous for +public-facing flows. v0.26.1 deliberately does NOT expose this path because +its semantics differ from `invite`. If you need it, talk to the maintainers +about a future `member-add-direct` command. diff --git a/pyproject.toml b/pyproject.toml index dfca5d5a..a4ff6b89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ pythonpath = ["src", "tests"] markers = [ "integration: marks tests as integration tests requiring real API credentials (deselect with '-m \"not integration\"')", "e2e: marks tests as end-to-end tests requiring real API credentials (deselect with '-m \"not e2e\"')", + "e2e_invite: project invite E2E -- requires E2E_MANAGE_TOKEN + E2E_INVITE_PROJECT_ID; opt-in via 'make test-e2e-invite'", ] [tool.ruff] diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index 5430db77..bfaa7d67 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -31,6 +31,14 @@ "Tests: 30 service-level tests in `tests/test_data_app_service.py` (validation, dry-run, happy-path orchestration, cleanup-in-finally, encryption-failure-aborts-loud, poll-loop semantics including the transient-stopped invariant), 10 CLI tests in `tests/test_data_app_cli.py` (mutual-exclusion validation, dual JSON+human output, `--yes` for delete, manage-token forwarding for password without leaking the token to stdout/stderr).", "Plugin: new `data-app-workflow.md` reference + two `(since v0.27.0)` gotcha entries (the §9 redeploy contract; cross-project KMS ciphertext mismatch). `keboola-expert.md` matrix gains five rows (`create`, `deploy`, `start`, `stop`, `delete`).", ], + "0.26.1": [ + "New: project member & invitation lifecycle. Closes the long-standing Manage API gap that forced every Keboola-internal automation (most recently `17_CuestaDemo/scripts/replicate_master.py` and `invite_participants.py`) to bypass kbagent and POST raw HTTP at `/manage/projects/{id}/invitations`. Seven new commands under `kbagent project`: `invite` (single-shot or `--from-csv` bulk with `ThreadPoolExecutor` parallelism, default 8 workers), `member-list` (active members, `--include-pending` adds pending invitations), `invitation-list`, `invitation-cancel` (resolves invitation_id by email lookup so callers don't have to), `member-remove` (destructive; resolves user_id by email), `member-set-role` (PATCH `/manage/projects/{id}/users/{userId}` with `{role}`). All seven require `KBC_MANAGE_API_TOKEN`; the manage token is never logged, never persisted, never accepted on the CLI line. Permission registry: `member-remove` is `destructive`, `member-list` / `invitation-list` are `read`, the rest are `admin`.", + "New: role whitelist `PROJECT_ROLES = ('admin', 'guest', 'readOnly', 'share')` in `constants.py`, lifted verbatim from the Manage API's own validation error message (verified empirically on 2026-05-01 against `connection.us-east4.gcp.keboola.com`). Typer enforces the whitelist via `click.Choice` at the command layer; `MemberService` double-checks for defence-in-depth. Invalid role values now fail-fast with `Role 'X' is not valid. Allowed roles are: admin, guest, readOnly, share` instead of letting the API return an opaque 400.", + "New: `MemberService` (`src/keboola_agent_cli/services/member_service.py`) wrapping six new `ManageClient` methods (`create_project_invitation`, `list_project_invitations`, `cancel_project_invitation`, `list_project_members`, `remove_project_member`, `update_project_member_role`). Resolves project alias -> (stack_url, project_id) via `ConfigStore`; resolves email -> numeric user_id / invitation_id by listing + matching case-insensitively. Treats the Manage API's HTTP 400 'already been invited' / 'already a member' responses as `status=noop` rather than errors (the heuristic the orchestrator scripts had to do via substring matching, now typed to `status_code == 400` AND message-substring marker constants). `--from-csv` enforces a single-stack-URL invariant per file (rows referencing multiple stacks raise `ConfigError` upfront).", + "New: hint definitions (`hints/definitions/member.py`) for all seven commands. Both `--hint client` (direct `ManageClient` calls) and `--hint service` (`MemberService` calls) generate runnable Python.", + "New: e2e marker `e2e_invite` (registered in `pyproject.toml`). `make test-e2e-invite` runs `tests/test_e2e.py::test_project_invite_e2e` against a real Manage API; gated on `E2E_MANAGE_TOKEN` + `E2E_INVITE_PROJECT_ID` (skips cleanly when missing). The test invites `ottomansky.max@gmail.com` (override via `E2E_INVITE_EMAIL`) as `guest`, asserts the invitation appears in `invitation-list`, then cancels it -- the same run that proves the system can send confirms it can clean up.", + "Docs: new `references/member-workflow.md` (golden paths for single invite, bulk invite, audit, role change, remove). `gotchas.md` gains three `(since v0.26.1)` entries -- 'already invited / already member' returns HTTP 400 not 422; role-change is PATCH not PUT (PUT returns 404 even on real members); bulk-invite ordering is not deterministic (parallel workers). `keboola-expert.md` adds seven matrix rows under 'Project administration' plus a Rule 6 VERSION GATE entry. `commands-reference.md` adds a 'Project members & invitations' section.", + ], "0.26.0": [ "New: `kbagent config set-default-bucket --bucket BUCKET_ID | --clear [--dry-run] [--branch ID]` -- discoverable wrapper around the raw-mode `storage.output.default_bucket` workaround documented at https://keboola.atlassian.net/wiki/spaces/SUP/pages/3770155030/ (epic KBCP-108). Read-modify-write that preserves all sibling keys under `storage.output` and the rest of the configuration. Same-value writes short-circuit with `{\"changed\": false}` (no API call, no version bump). `--clear` removes only the `default_bucket` key, leaving an empty `storage.output: {}` if no other siblings live there (intentional -- mirrors `set_nested_value`'s parent-creation semantics; Storage API treats `output: {}` and missing `output` identically as 'use the auto-derived bucket'). Live-validated end-to-end on three component types -- row-based GCS extractor, root-only `keboola.ex-cnb-exchange-rates`, and `ex-generic-v2` with multiple jobs -- output tables routed to the configured bucket at job runtime in every case. The per-table `destination` override (the second method shown in the support article) keeps using the existing `kbagent config update --set 'storage.output.tables=[...]'` -- no new wrapper there because per-table mappings have many fields that don't fit a single-purpose flag.", "Fix: `kbagent sync pull --with-samples` no longer crashes with `TypeError: '>' not supported between instances of 'NoneType' and 'int'` when one or more tables in the project return `rowsCount: null` from the Storage API (typical for newly-created or empty tables on some backends, reproduced live against `kosik-sales`). `dict.get(\"rowsCount\", 0)` returns the default `0` only when the key is **missing** -- if the key is present with a `null` value, `.get()` returns `None`, and the `> 0` comparison crashed Python 3 before any sample was fetched. The filter and sort key in `SyncService._fetch_samples()` now coerce `None` to `0` via a small `_rows()` helper used in both places (`t.get(\"rowsCount\") or 0`), so empty/null-rowcount tables are gracefully skipped exactly like `rowsCount: 0` ones. Closes #233.", diff --git a/src/keboola_agent_cli/cli.py b/src/keboola_agent_cli/cli.py index e97b8b54..d9d7974c 100644 --- a/src/keboola_agent_cli/cli.py +++ b/src/keboola_agent_cli/cli.py @@ -48,6 +48,7 @@ from .services.kai_service import KaiService from .services.lineage_service import LineageService from .services.mcp_service import McpService +from .services.member_service import MemberService from .services.org_service import OrgService from .services.project_service import ProjectService from .services.schedule_service import ScheduleService @@ -302,6 +303,7 @@ def main( lineage_service = LineageService(config_store=config_store) deep_lineage_service = DeepLineageService(config_store=config_store) org_service = OrgService(config_store=config_store) + member_service = MemberService(config_store=config_store) mcp_service = McpService(config_store=config_store) branch_service = BranchService(config_store=config_store) sharing_service = SharingService(config_store=config_store) @@ -356,6 +358,7 @@ def main( ctx.obj["lineage_service"] = lineage_service ctx.obj["deep_lineage_service"] = deep_lineage_service ctx.obj["org_service"] = org_service + ctx.obj["member_service"] = member_service ctx.obj["mcp_service"] = mcp_service ctx.obj["branch_service"] = branch_service ctx.obj["sharing_service"] = sharing_service diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index 434283ef..3921487a 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -94,6 +94,37 @@ Print the effective default project and its source (env / pin / none). Resolution order for single-project operations: --project > KBAGENT_PROJECT > pin. +### Project Members & Invitations (since v0.26.1) + + Requires KBC_MANAGE_API_TOKEN (Manage API auth). Allowed roles: admin, guest, readOnly, share. + + kbagent project invite --project ALIAS --email EMAIL --role ROLE [--reason TEXT] [--dry-run] + Send an invitation email. Re-inviting an existing invitee or member is a no-op + (HTTP 400 from the Manage API; the service returns status="noop" with note + "already_invited" / "already_member"). + + kbagent project invite --from-csv FILE [--default-role ROLE] [--workers N] [--dry-run] + Bulk invite. CSV must have a header row with columns: email, project (alias or + numeric ID), role (optional if --default-role is given), reason (optional). + Parallelised with ThreadPoolExecutor (default 8 workers). Per-row results in + `rows[]` with status=ok|noop|failed; `failed_rows` ordering is not deterministic. + + kbagent project member-list --project ALIAS [--include-pending] + List active project members. --include-pending also fetches pending invitations. + + kbagent project invitation-list --project ALIAS + List pending (unaccepted) invitations only. + + kbagent project invitation-cancel --project ALIAS --email EMAIL [--invitation-id ID] [--yes] + Cancel a pending invitation. Without --invitation-id, the service resolves the + ID by listing pending invitations and matching --email (case-insensitive). + + kbagent project member-remove --project ALIAS --email EMAIL [--yes] + Remove an active member (destructive). Service resolves --email to user_id. + + kbagent project member-set-role --project ALIAS --email EMAIL --role ROLE + Change an existing member's role via PATCH /manage/projects/{{id}}/users/{{userId}}. + ### Component Discovery kbagent component list [--project NAME] [--type TYPE] [--query "search"] diff --git a/src/keboola_agent_cli/commands/project.py b/src/keboola_agent_cli/commands/project.py index ccb41a76..f202d2a8 100644 --- a/src/keboola_agent_cli/commands/project.py +++ b/src/keboola_agent_cli/commands/project.py @@ -8,15 +8,18 @@ from pathlib import Path from typing import Any +import click import typer from rich.console import Console from rich.table import Table from ..constants import ( + DEFAULT_INVITE_WORKERS, DEFAULT_STACK_URL, DEFAULT_TOKEN_DESCRIPTION, ENV_KBC_STORAGE_API_URL, ENV_KBC_TOKEN, + PROJECT_ROLES, ) from ..errors import ConfigError, ErrorCode, KeboolaApiError from ._helpers import ( @@ -654,3 +657,495 @@ def project_description_set( except ConfigError as exc: formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) raise typer.Exit(code=5) from None + + +# ── Project members & invitations (since v0.26.1) ───────────────────── + + +def _format_invite_result(console: Console, data: dict[str, Any]) -> None: + """Single-shot invite result.""" + status = data.get("status", "") + if status == "ok": + console.print( + f"[bold green]Invited[/bold green] {data['email']} to " + f"[cyan]{data['alias']}[/cyan] as [yellow]{data['role']}[/yellow] " + f"(invitation_id={data.get('invitation_id')})." + ) + elif status == "noop": + console.print( + f"[yellow]No-op[/yellow]: {data['email']} on [cyan]{data['alias']}[/cyan] " + f"-- {data.get('note', '')}." + ) + elif status == "dry_run": + console.print( + f"[dim]Would invite[/dim] {data['email']} to [cyan]{data['alias']}[/cyan] " + f"as [yellow]{data['role']}[/yellow]." + ) + else: + console.print(f"[bold red]Unexpected status[/bold red]: {data!r}") + + +def _format_bulk_invite_result(console: Console, data: dict[str, Any]) -> None: + """Render the bulk-invite summary table.""" + console.print( + f"\n[bold]Bulk invite:[/bold] total={data['total']} " + f"succeeded={data['succeeded']} noop={data['noop']} failed={data['failed']}" + + (" [dim](dry-run)[/dim]" if data.get("dry_run") else "") + ) + rows = data.get("rows") or [] + if not rows: + return + table = Table(title="Per-row results") + table.add_column("Status", style="bold") + table.add_column("Email") + table.add_column("Project") + table.add_column("Role") + table.add_column("Note") + status_style = {"ok": "green", "noop": "yellow", "failed": "red"} + for row in rows: + status = row.get("status", "") + style = status_style.get(status, "white") + table.add_row( + f"[{style}]{status}[/{style}]", + row.get("email", ""), + row.get("project", ""), + row.get("role", ""), + row.get("note", ""), + ) + console.print(table) + + +def _format_member_list(console: Console, data: dict[str, Any]) -> None: + members = data.get("members") or [] + table = Table(title=f"Members of {data.get('alias')} (project_id={data.get('project_id')})") + table.add_column("ID", justify="right", style="dim") + table.add_column("Email") + table.add_column("Role", style="yellow") + table.add_column("Status") + table.add_column("MFA", justify="center") + for m in members: + table.add_row( + str(m.get("id", "")), + m.get("email", ""), + m.get("role", ""), + m.get("status", ""), + "yes" if m.get("mfa_enabled") else "no", + ) + console.print(table) + pending = data.get("pending_invitations") + if pending: + ptable = Table(title="Pending invitations") + ptable.add_column("ID", justify="right", style="dim") + ptable.add_column("Email") + ptable.add_column("Role", style="yellow") + ptable.add_column("Reason") + for p in pending: + ptable.add_row( + str(p.get("id", "")), + p.get("user", {}).get("email", ""), + p.get("role", ""), + p.get("reason", ""), + ) + console.print(ptable) + + +def _format_invitation_list(console: Console, data: dict[str, Any]) -> None: + invitations = data.get("invitations") or [] + if not invitations: + console.print(f"No pending invitations for [cyan]{data.get('alias')}[/cyan].") + return + table = Table( + title=f"Pending invitations for {data.get('alias')} (project_id={data.get('project_id')})" + ) + table.add_column("ID", justify="right", style="dim") + table.add_column("Email") + table.add_column("Role", style="yellow") + table.add_column("Reason") + for inv in invitations: + table.add_row( + str(inv.get("id", "")), + inv.get("user", {}).get("email", ""), + inv.get("role", ""), + inv.get("reason", ""), + ) + console.print(table) + + +@project_app.command("invite") +def project_invite( + ctx: typer.Context, + project: str | None = typer.Option( + None, "--project", "-p", help="Project alias to invite the user to (single-shot mode)" + ), + email: str | None = typer.Option( + None, "--email", "-e", help="Email address of the user to invite" + ), + role: str | None = typer.Option( + None, + "--role", + "-r", + click_type=click.Choice(list(PROJECT_ROLES)), + help="Role to grant: " + " | ".join(PROJECT_ROLES), + ), + reason: str | None = typer.Option( + None, "--reason", help="Optional human-readable reason attached to the invitation" + ), + from_csv: Path | None = typer.Option( + None, + "--from-csv", + help="CSV file with columns email, project (alias or numeric ID), role[, reason]", + ), + default_role: str | None = typer.Option( + None, + "--default-role", + click_type=click.Choice(list(PROJECT_ROLES)), + help="Role to apply when a CSV row has no role column", + ), + workers: int = typer.Option( + DEFAULT_INVITE_WORKERS, + "--workers", + min=1, + max=32, + help="Parallel workers for --from-csv (default 8)", + ), + dry_run: bool = typer.Option(False, "--dry-run", help="Preview without sending invitations"), +) -> None: + """Invite a user (or many users via CSV) to one or more projects. + + \b + Single-shot: + kbagent project invite --project prod --email a@b.com --role admin + + \b + Bulk (one row per email; CSV header required): + kbagent project invite --from-csv participants.csv --default-role guest + """ + formatter = get_formatter(ctx) + + if from_csv and (project or email): + formatter.error( + message="--from-csv is mutually exclusive with --project / --email", + error_code=ErrorCode.USAGE_ERROR, + ) + raise typer.Exit(code=2) + if not from_csv and not (project and email and role): + formatter.error( + message="Provide --project, --email, and --role for single-shot invite " + "(or use --from-csv for bulk).", + error_code=ErrorCode.USAGE_ERROR, + ) + raise typer.Exit(code=2) + + if should_hint(ctx): + if from_csv: + formatter.error( + message=( + "--hint is not available for `project invite --from-csv`. " + "Use --hint client/service on a single-shot invite " + "(--project + --email + --role) instead, or open the " + "MemberService source for the bulk pattern." + ), + error_code=ErrorCode.USAGE_ERROR, + ) + raise typer.Exit(code=2) + emit_hint( + ctx, + "project.invite", + project=project, + project_id="", + email=email, + role=role, + reason=reason or "", + ) + return + + manage_token = resolve_manage_token() + service = get_service(ctx, "member_service") + + try: + if from_csv: + result = service.invite_bulk( + manage_token=manage_token, + csv_path=from_csv, + default_role=default_role, + workers=workers, + dry_run=dry_run, + ) + payload = result.model_dump() + formatter.output(payload, _format_bulk_invite_result) + return + + result = service.invite( + manage_token=manage_token, + alias=project, + email=email, + role=role, + reason=reason, + dry_run=dry_run, + ) + except ConfigError as exc: + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) + raise typer.Exit(code=5) from None + except ValueError as exc: + formatter.error(message=str(exc), error_code=ErrorCode.VALIDATION_ERROR) + raise typer.Exit(code=2) from None + except KeboolaApiError as exc: + exit_code = map_error_to_exit_code(exc) + formatter.error( + message=exc.message, + error_code=exc.error_code, + retryable=exc.retryable, + ) + raise typer.Exit(code=exit_code) from None + + formatter.output(result, _format_invite_result) + + +@project_app.command("member-list") +def project_member_list( + ctx: typer.Context, + project: str = typer.Option(..., "--project", "-p", help="Project alias to list members for"), + include_pending: bool = typer.Option( + False, "--include-pending", help="Also list pending (unaccepted) invitations" + ), +) -> None: + """List active members of a project (and optionally pending invitations).""" + formatter = get_formatter(ctx) + + if should_hint(ctx): + emit_hint( + ctx, + "project.member-list", + project=project, + project_id="", + include_pending=str(include_pending), + ) + return + + manage_token = resolve_manage_token() + service = get_service(ctx, "member_service") + try: + result = service.list_members( + manage_token=manage_token, + alias=project, + include_pending=include_pending, + ) + except ConfigError as exc: + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) + raise typer.Exit(code=5) from None + except KeboolaApiError as exc: + exit_code = map_error_to_exit_code(exc) + formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) + raise typer.Exit(code=exit_code) from None + + formatter.output(result, _format_member_list) + + +@project_app.command("invitation-list") +def project_invitation_list( + ctx: typer.Context, + project: str = typer.Option( + ..., "--project", "-p", help="Project alias to list pending invitations for" + ), +) -> None: + """List pending project invitations.""" + formatter = get_formatter(ctx) + + if should_hint(ctx): + emit_hint( + ctx, + "project.invitation-list", + project=project, + project_id="", + ) + return + + manage_token = resolve_manage_token() + service = get_service(ctx, "member_service") + try: + result = service.list_invitations(manage_token=manage_token, alias=project) + except ConfigError as exc: + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) + raise typer.Exit(code=5) from None + except KeboolaApiError as exc: + exit_code = map_error_to_exit_code(exc) + formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) + raise typer.Exit(code=exit_code) from None + + formatter.output(result, _format_invitation_list) + + +@project_app.command("invitation-cancel") +def project_invitation_cancel( + ctx: typer.Context, + project: str = typer.Option(..., "--project", "-p", help="Project alias"), + email: str = typer.Option( + ..., + "--email", + "-e", + help="Invitee's email address (used to look up the invitation if --invitation-id is omitted)", + ), + invitation_id: int | None = typer.Option( + None, + "--invitation-id", + help="Numeric invitation ID; bypass the email lookup", + ), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"), +) -> None: + """Cancel a pending invitation.""" + formatter = get_formatter(ctx) + + if should_hint(ctx): + emit_hint( + ctx, + "project.invitation-cancel", + project=project, + project_id="", + email=email, + invitation_id=str(invitation_id) if invitation_id is not None else "None", + ) + return + + if ( + not formatter.json_mode + and not yes + and not typer.confirm(f"Cancel pending invitation for {email} on {project}?") + ): + formatter.console.print("Aborted.") + raise typer.Exit(code=0) + + manage_token = resolve_manage_token() + service = get_service(ctx, "member_service") + try: + result = service.cancel_invitation( + manage_token=manage_token, + alias=project, + email=email, + invitation_id=invitation_id, + ) + except ConfigError as exc: + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) + raise typer.Exit(code=5) from None + except KeboolaApiError as exc: + exit_code = map_error_to_exit_code(exc) + formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) + raise typer.Exit(code=exit_code) from None + + formatter.output( + result, + lambda c, d: c.print( + f"[bold green]Cancelled[/bold green] invitation_id={d.get('invitation_id')} " + f"for {d.get('email')} on [cyan]{d.get('alias')}[/cyan]." + ), + ) + + +@project_app.command("member-remove") +def project_member_remove( + ctx: typer.Context, + project: str = typer.Option(..., "--project", "-p", help="Project alias"), + email: str = typer.Option(..., "--email", "-e", help="Email of the member to remove"), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"), +) -> None: + """Remove an active member from a project (destructive).""" + formatter = get_formatter(ctx) + + if should_hint(ctx): + emit_hint( + ctx, + "project.member-remove", + project=project, + project_id="", + user_id="", + email=email, + ) + return + + if ( + not formatter.json_mode + and not yes + and not typer.confirm(f"Remove member {email} from project {project}? This is destructive.") + ): + formatter.console.print("Aborted.") + raise typer.Exit(code=0) + + manage_token = resolve_manage_token() + service = get_service(ctx, "member_service") + try: + result = service.remove_member( + manage_token=manage_token, + alias=project, + email=email, + ) + except ConfigError as exc: + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) + raise typer.Exit(code=5) from None + except KeboolaApiError as exc: + exit_code = map_error_to_exit_code(exc) + formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) + raise typer.Exit(code=exit_code) from None + + formatter.output( + result, + lambda c, d: c.print( + f"[bold red]Removed[/bold red] {d.get('email')} (user_id={d.get('user_id')}) " + f"from [cyan]{d.get('alias')}[/cyan]." + ), + ) + + +@project_app.command("member-set-role") +def project_member_set_role( + ctx: typer.Context, + project: str = typer.Option(..., "--project", "-p", help="Project alias"), + email: str = typer.Option(..., "--email", "-e", help="Email of the member to update"), + role: str = typer.Option( + ..., + "--role", + "-r", + click_type=click.Choice(list(PROJECT_ROLES)), + help="New role: " + " | ".join(PROJECT_ROLES), + ), +) -> None: + """Change an existing member's role (PATCH).""" + formatter = get_formatter(ctx) + + if should_hint(ctx): + emit_hint( + ctx, + "project.member-set-role", + project=project, + project_id="", + user_id="", + email=email, + role=role, + ) + return + + manage_token = resolve_manage_token() + service = get_service(ctx, "member_service") + try: + result = service.set_member_role( + manage_token=manage_token, + alias=project, + email=email, + role=role, + ) + except ConfigError as exc: + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) + raise typer.Exit(code=5) from None + except ValueError as exc: + formatter.error(message=str(exc), error_code=ErrorCode.VALIDATION_ERROR) + raise typer.Exit(code=2) from None + except KeboolaApiError as exc: + exit_code = map_error_to_exit_code(exc) + formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) + raise typer.Exit(code=exit_code) from None + + formatter.output( + result, + lambda c, d: c.print( + f"[bold green]Updated[/bold green] {d.get('email')} role on " + f"[cyan]{d.get('alias')}[/cyan] -> [yellow]{d.get('role')}[/yellow]." + ), + ) diff --git a/src/keboola_agent_cli/constants.py b/src/keboola_agent_cli/constants.py index d2a170d8..799a9bb8 100644 --- a/src/keboola_agent_cli/constants.py +++ b/src/keboola_agent_cli/constants.py @@ -35,6 +35,18 @@ # --- Token Description --- DEFAULT_TOKEN_DESCRIPTION: str = "kbagent-cli" +# --- Project Member Roles --- +# Allowed values for project membership / invitation `role` field. Lifted from +# the Manage API's own validation error: `Role "X" is not valid. Allowed roles +# are: admin, guest, readOnly, share`. Verified empirically 2026-05-01 against +# connection.us-east4.gcp.keboola.com. If the API ever extends the list, the +# fix is to extend this tuple -- the engine already returns the new options in +# its validation error message. +PROJECT_ROLES: tuple[str, ...] = ("admin", "guest", "readOnly", "share") + +# --- Bulk Invite Defaults --- +DEFAULT_INVITE_WORKERS: int = 8 + # --- Job Limits --- DEFAULT_JOB_LIMIT: int = 50 DEFAULT_JOBS_PER_CONFIG: int = 5 diff --git a/src/keboola_agent_cli/hints/definitions/__init__.py b/src/keboola_agent_cli/hints/definitions/__init__.py index eb9cac56..a5ac1558 100644 --- a/src/keboola_agent_cli/hints/definitions/__init__.py +++ b/src/keboola_agent_cli/hints/definitions/__init__.py @@ -10,6 +10,7 @@ job, # noqa: F401 kai, # noqa: F401 lineage, # noqa: F401 + member, # noqa: F401 org, # noqa: F401 project, # noqa: F401 schedule, # noqa: F401 diff --git a/src/keboola_agent_cli/hints/definitions/member.py b/src/keboola_agent_cli/hints/definitions/member.py new file mode 100644 index 00000000..3dd6e30d --- /dev/null +++ b/src/keboola_agent_cli/hints/definitions/member.py @@ -0,0 +1,218 @@ +"""Hint definitions for project member & invitation commands (since v0.26.1).""" + +from .. import HintRegistry +from ..models import ClientCall, CommandHint, HintStep, ServiceCall + +# ── project invite ──────────────────────────────────────────────── + +HintRegistry.register( + CommandHint( + cli_command="project.invite", + description="Invite a user (by email) to a project with a given role", + steps=[ + HintStep( + comment="POST /manage/projects/{id}/invitations", + client=ClientCall( + method="create_project_invitation", + args={ + "project_id": "{project_id}", + "email": "{email}", + "role": "{role}", + "reason": "{reason}", + }, + client_type="manage", + result_var="invitation", + result_hint="dict", + ), + service=ServiceCall( + service_class="MemberService", + service_module="member_service", + method="invite", + args={ + "alias": "{project}", + "email": "{email}", + "role": "{role}", + "reason": "{reason}", + }, + ), + ), + ], + notes=[ + "Uses Manage API + KBC_MANAGE_API_TOKEN (not the Storage token).", + "Allowed roles: admin, guest, readOnly, share.", + "Re-inviting an existing invitee or member returns HTTP 400; the service " + "treats it as a no-op with a 'note' field.", + ], + ) +) + +# ── project member-list ─────────────────────────────────────────── + +HintRegistry.register( + CommandHint( + cli_command="project.member-list", + description="List active members (and optionally pending invitations)", + steps=[ + HintStep( + comment="GET /manage/projects/{id}/users", + client=ClientCall( + method="list_project_members", + args={"project_id": "{project_id}"}, + client_type="manage", + result_var="members", + result_hint="list[dict]", + ), + service=ServiceCall( + service_class="MemberService", + service_module="member_service", + method="list_members", + args={ + "alias": "{project}", + "include_pending": "{include_pending}", + }, + ), + ), + ], + ) +) + +# ── project invitation-list ────────────────────────────────────── + +HintRegistry.register( + CommandHint( + cli_command="project.invitation-list", + description="List pending project invitations", + steps=[ + HintStep( + comment="GET /manage/projects/{id}/invitations", + client=ClientCall( + method="list_project_invitations", + args={"project_id": "{project_id}"}, + client_type="manage", + result_var="invitations", + result_hint="list[dict]", + ), + service=ServiceCall( + service_class="MemberService", + service_module="member_service", + method="list_invitations", + args={"alias": "{project}"}, + ), + ), + ], + ) +) + +# ── project invitation-cancel ──────────────────────────────────── + +HintRegistry.register( + CommandHint( + cli_command="project.invitation-cancel", + description="Cancel a pending invitation", + steps=[ + HintStep( + comment="DELETE /manage/projects/{id}/invitations/{invitationId}", + client=ClientCall( + method="cancel_project_invitation", + args={ + "project_id": "{project_id}", + "invitation_id": "{invitation_id}", + }, + client_type="manage", + result_var="_", + result_hint="None", + ), + service=ServiceCall( + service_class="MemberService", + service_module="member_service", + method="cancel_invitation", + args={ + "alias": "{project}", + "email": "{email}", + "invitation_id": "{invitation_id}", + }, + ), + ), + ], + notes=[ + "If --invitation-id is omitted, the service resolves it by listing " + "pending invitations and matching --email (case-insensitive).", + ], + ) +) + +# ── project member-remove ──────────────────────────────────────── + +HintRegistry.register( + CommandHint( + cli_command="project.member-remove", + description="Remove an active member from a project", + steps=[ + HintStep( + comment="DELETE /manage/projects/{id}/users/{userId}", + client=ClientCall( + method="remove_project_member", + args={ + "project_id": "{project_id}", + "user_id": "{user_id}", + }, + client_type="manage", + result_var="_", + result_hint="None", + ), + service=ServiceCall( + service_class="MemberService", + service_module="member_service", + method="remove_member", + args={ + "alias": "{project}", + "email": "{email}", + }, + ), + ), + ], + notes=[ + "Destructive: revokes project access. Re-add via `kbagent project invite`.", + "The service resolves --email to the numeric user_id automatically.", + ], + ) +) + +# ── project member-set-role ────────────────────────────────────── + +HintRegistry.register( + CommandHint( + cli_command="project.member-set-role", + description="Change an existing member's role", + steps=[ + HintStep( + comment="PATCH /manage/projects/{id}/users/{userId}", + client=ClientCall( + method="update_project_member_role", + args={ + "project_id": "{project_id}", + "user_id": "{user_id}", + "role": "{role}", + }, + client_type="manage", + result_var="updated", + result_hint="dict", + ), + service=ServiceCall( + service_class="MemberService", + service_module="member_service", + method="set_member_role", + args={ + "alias": "{project}", + "email": "{email}", + "role": "{role}", + }, + ), + ), + ], + notes=[ + "Uses HTTP PATCH (not PUT — PUT returns 404 even on real members).", + "Allowed roles: admin, guest, readOnly, share.", + ], + ) +) diff --git a/src/keboola_agent_cli/manage_client.py b/src/keboola_agent_cli/manage_client.py index a2b62574..fb391a97 100644 --- a/src/keboola_agent_cli/manage_client.py +++ b/src/keboola_agent_cli/manage_client.py @@ -133,3 +133,80 @@ def create_project_token( payload["expiresIn"] = expires_in response = self._do_request("POST", f"/manage/projects/{project_id}/tokens", json=payload) return response.json() + + # ------------------------------------------------------------------ + # Project members & invitations (verified 2026-05-01 against the + # us-east4.gcp.keboola.com Manage API; see plan-of-record §"Verifications"). + # ------------------------------------------------------------------ + + def create_project_invitation( + self, + project_id: int, + email: str, + role: str, + reason: str | None = None, + ) -> dict[str, Any]: + """Send an invitation email to add ``email`` as a project member. + + Returns the invitation object on success (HTTP 201). On HTTP 400 with + the error message ``"This user has already been invited..."`` or + ``"...is already a member..."`` the caller should treat the call as a + no-op rather than an error -- the higher layer encodes that policy. + + Args: + project_id: Numeric project ID. + email: Email of the user to invite. + role: One of ``admin``, ``guest``, ``readOnly``, ``share``. + reason: Optional human-readable note attached to the invitation. + + Returns: + Invitation dict: ``{id, created, expires, reason, role, user, creator}``. + """ + payload: dict[str, Any] = {"email": email, "role": role} + if reason: + payload["reason"] = reason + response = self._do_request( + "POST", f"/manage/projects/{project_id}/invitations", json=payload + ) + return response.json() + + def list_project_invitations(self, project_id: int) -> list[dict[str, Any]]: + """List pending (not-yet-accepted) invitations for a project. + + Returns a plain list. Each item has shape + ``{id, created, expires, reason, role, user: {id, name, email}, creator: {...}}``. + """ + response = self._do_request("GET", f"/manage/projects/{project_id}/invitations") + return response.json() + + def cancel_project_invitation(self, project_id: int, invitation_id: int) -> None: + """Cancel a pending invitation by ID. Returns 204 No Content on success.""" + self._do_request("DELETE", f"/manage/projects/{project_id}/invitations/{invitation_id}") + + def list_project_members(self, project_id: int) -> list[dict[str, Any]]: + """List active project members. + + Returns a plain list. Each user dict carries the project role at the + top level (``role`` field) -- not nested under a ``user`` key. + """ + response = self._do_request("GET", f"/manage/projects/{project_id}/users") + return response.json() + + def remove_project_member(self, project_id: int, user_id: int) -> None: + """Remove a member from a project. Returns 204 No Content on success.""" + self._do_request("DELETE", f"/manage/projects/{project_id}/users/{user_id}") + + def update_project_member_role( + self, project_id: int, user_id: int, role: str + ) -> dict[str, Any]: + """Change an existing member's role. + + The Manage API uses **PATCH** here -- ``PUT`` returns 404 even on real + members. Returns the updated user dict on success (HTTP 200). + """ + response = self._do_request( + "PATCH", + f"/manage/projects/{project_id}/users/{user_id}", + json={"role": role}, + ) + return response.json() diff --git a/src/keboola_agent_cli/models.py b/src/keboola_agent_cli/models.py index 84c746cd..c5693674 100644 --- a/src/keboola_agent_cli/models.py +++ b/src/keboola_agent_cli/models.py @@ -168,3 +168,73 @@ class SuccessResponse(BaseModel): status: str = Field(default="ok", description="Always 'ok' for success responses") data: Any = Field(default=None, description="Response payload") + + +class ProjectMember(BaseModel): + """Active project member as returned by GET /manage/projects/{id}/users. + + The Manage API returns audit-relevant fields beyond what kbagent renames + explicitly: ``created``, ``expires``, ``invitor``, ``approver``, ``features``, + ``canAccessLogs``, ``isSuperAdmin``, ``canApproveMergeRequests``. We allow + extras through unmodified so admins inspecting `--json` output get the full + audit trail (who invited whom, when, status flags), not a narrow whitelist. + """ + + id: int = Field(description="Numeric Keboola user ID") + email: str = Field(description="Member email address") + name: str = Field(default="", description="Display name (may be empty for stub accounts)") + role: str = Field(description="Project role: admin | guest | readOnly | share") + status: str = Field(default="active", description="Membership status") + mfa_enabled: bool = Field(default=False, alias="mfaEnabled") + + model_config = {"populate_by_name": True, "extra": "allow"} + + +class InvitationUser(BaseModel): + """Invited user inside an Invitation object.""" + + id: int | None = Field(default=None) + email: str + name: str = Field(default="") + + +class ProjectInvitation(BaseModel): + """Pending project invitation as returned by GET /manage/projects/{id}/invitations. + + Extras (``created``, ``expires``, ``creator``) pass through unmodified so + callers can audit when invitations were created and by whom. + """ + + id: int = Field(description="Invitation ID -- pass to DELETE to cancel") + role: str = Field(description="Role offered to the invitee") + reason: str = Field(default="") + user: InvitationUser = Field(description="The invited user (email + resolved id)") + + model_config = {"populate_by_name": True, "extra": "allow"} + + +class MemberInviteRow(BaseModel): + """Per-row outcome of a bulk-invite operation. + + `status` = 'ok' (created), 'noop' (already invited or already a member), + 'failed' (any other error). `note` carries the human-readable explanation. + """ + + email: str + project: str = Field(description="Alias or numeric ID as it appeared in the source CSV row") + project_id: int | None = Field(default=None, description="Resolved numeric project ID") + role: str = Field(default="") + status: str = Field(description="ok | noop | failed") + note: str = Field(default="") + invitation_id: int | None = Field(default=None) + + +class BulkInviteResult(BaseModel): + """Aggregate result of `kbagent project invite --from-csv`.""" + + total: int + succeeded: int + noop: int + failed: int + rows: list[MemberInviteRow] = Field(default_factory=list) + dry_run: bool = Field(default=False) diff --git a/src/keboola_agent_cli/permissions.py b/src/keboola_agent_cli/permissions.py index 97ec7033..e3f34b76 100644 --- a/src/keboola_agent_cli/permissions.py +++ b/src/keboola_agent_cli/permissions.py @@ -24,6 +24,12 @@ "project.description-set": "write", "project.use": "write", "project.current": "read", + "project.invite": "admin", + "project.member-list": "read", + "project.invitation-list": "read", + "project.invitation-cancel": "admin", + "project.member-remove": "destructive", + "project.member-set-role": "admin", # Config browsing & management "config.list": "read", "config.detail": "read", diff --git a/src/keboola_agent_cli/services/member_service.py b/src/keboola_agent_cli/services/member_service.py new file mode 100644 index 00000000..fb5f4a95 --- /dev/null +++ b/src/keboola_agent_cli/services/member_service.py @@ -0,0 +1,593 @@ +"""Project membership and invitation lifecycle service. + +Wraps the Manage API endpoints under ``/manage/projects/{id}/{users,invitations}`` +behind a layer that: + +- resolves a project alias to its numeric ID via ``ConfigStore``; +- looks up members + invitations by email (the public-facing key) so callers + never need to deal with raw user/invitation IDs; +- treats the Manage API's "already invited / already a member" 400 response as + a no-op rather than an error (mirrors the heuristic from the orchestrator + scripts, but typed to status_code + message substring rather than guessed + HTTP code); +- parallelises bulk invitation via :class:`ThreadPoolExecutor`, accumulating + per-row results so one bad row never aborts the rest. + +Endpoints + payload shapes were verified empirically on 2026-05-01 against +``connection.us-east4.gcp.keboola.com``; see the plan-of-record for the full +verification log. +""" + +from __future__ import annotations + +import csv +import logging +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path +from typing import Any + +from ..config_store import ConfigStore +from ..constants import DEFAULT_INVITE_WORKERS, PROJECT_ROLES +from ..errors import ConfigError, ErrorCode, KeboolaApiError +from ..manage_client import ManageClient +from ..models import ( + BulkInviteResult, + MemberInviteRow, + ProjectInvitation, + ProjectMember, +) + +logger = logging.getLogger(__name__) + +ManageClientFactory = Callable[[str, str], ManageClient] + + +def default_manage_client_factory(stack_url: str, manage_token: str) -> ManageClient: + """Construct a :class:`ManageClient` bound to ``stack_url``.""" + return ManageClient(stack_url=stack_url, manage_token=manage_token) + + +# The Manage API returns HTTP 400 (not 422) with one of these substrings when +# a duplicate invitation/member is created. Treated as success-with-note in +# the service layer so bulk imports don't fail on idempotent re-runs. +_ALREADY_INVITED_MARKER = "already been invited" +_ALREADY_MEMBER_MARKER = "already a member" + + +class MemberService: + """Business logic for project members and invitations.""" + + def __init__( + self, + config_store: ConfigStore, + manage_client_factory: ManageClientFactory | None = None, + ) -> None: + self._config_store = config_store + self._manage_client_factory = manage_client_factory or default_manage_client_factory + + # ------------------------------------------------------------------ + # Public API: single-shot operations + # ------------------------------------------------------------------ + + def invite( + self, + *, + manage_token: str, + alias: str, + email: str, + role: str, + reason: str | None = None, + dry_run: bool = False, + ) -> dict[str, Any]: + """Invite ``email`` to the project registered under ``alias``.""" + self._validate_role(role) + stack_url, project_id = self._resolve_alias(alias) + + if dry_run: + return { + "status": "dry_run", + "alias": alias, + "project_id": project_id, + "email": email, + "role": role, + "reason": reason or "", + } + + manage_client = self._manage_client_factory(stack_url, manage_token) + try: + return self._invite_one(manage_client, alias, project_id, email, role, reason) + finally: + manage_client.close() + + def invite_bulk( + self, + *, + manage_token: str, + csv_path: Path, + default_role: str | None = None, + workers: int = DEFAULT_INVITE_WORKERS, + dry_run: bool = False, + ) -> BulkInviteResult: + """Invite every row of ``csv_path`` in parallel. + + CSV must have a header row. Recognised columns (case-insensitive): + ``email`` (required), ``project`` or ``project_id`` (one required), + ``role`` (optional if ``default_role`` is given), ``reason`` (optional). + Extra columns are ignored. ``project`` values that are all-digits are + resolved as numeric project IDs without an alias lookup. + """ + if default_role is not None: + self._validate_role(default_role) + rows = self._parse_invite_csv(csv_path, default_role) + if not rows: + return BulkInviteResult(total=0, succeeded=0, noop=0, failed=0, dry_run=dry_run) + + if dry_run: + return self._bulk_dry_run(rows) + + # Resolve every row's (stack_url, project_id) up front. A row that + # fails resolution (unknown alias, unregistered project_id) becomes a + # per-row "failed" entry; the rest of the batch still runs. Mirrors + # the partial-success contract enforced by `OrgService.refresh_tokens`. + resolved: list[tuple[dict[str, Any], str, int]] = [] + upfront_failures: list[MemberInviteRow] = [] + for row in rows: + try: + stack_url, project_id = self._stack_for_row(row) + resolved.append((row, stack_url, project_id)) + except ConfigError as exc: + upfront_failures.append( + MemberInviteRow( + email=row["email"], + project=str(row["project"]), + role=row["role"], + status="failed", + note=str(getattr(exc, "message", exc)), + ) + ) + + if not resolved: + return BulkInviteResult( + total=len(upfront_failures), + succeeded=0, + noop=0, + failed=len(upfront_failures), + rows=upfront_failures, + dry_run=False, + ) + + # All resolved rows must share a single stack URL; sending invitations + # for project A on stack X via a client bound to stack Y is a security + # bug, not a "partial-success" path. + resolved_stacks = {t[1] for t in resolved} + if len(resolved_stacks) != 1: + raise ConfigError( + f"CSV references multiple stack URLs ({sorted(resolved_stacks)}); " + "split the file by stack and run --from-csv per stack." + ) + stack_url = resolved_stacks.pop() + + manage_client = self._manage_client_factory(stack_url, manage_token) + results: list[MemberInviteRow] = list(upfront_failures) + try: + worker_count = max(1, min(workers, len(resolved))) + with ThreadPoolExecutor(max_workers=worker_count) as pool: + futures = [ + pool.submit(self._invoke_resolved_row, manage_client, row, project_id) + for row, _, project_id in resolved + ] + for fut in as_completed(futures): + results.append(fut.result()) + finally: + manage_client.close() + + return BulkInviteResult( + total=len(results), + succeeded=sum(1 for r in results if r.status == "ok"), + noop=sum(1 for r in results if r.status == "noop"), + failed=sum(1 for r in results if r.status == "failed"), + rows=results, + dry_run=False, + ) + + def list_members( + self, + *, + manage_token: str, + alias: str, + include_pending: bool = False, + ) -> dict[str, Any]: + """Return active members (and, optionally, pending invitations).""" + stack_url, project_id = self._resolve_alias(alias) + manage_client = self._manage_client_factory(stack_url, manage_token) + try: + members_raw = manage_client.list_project_members(project_id) + members = [ProjectMember.model_validate(m) for m in members_raw] + payload: dict[str, Any] = { + "alias": alias, + "project_id": project_id, + "members": [m.model_dump(by_alias=False) for m in members], + } + if include_pending: + inv_raw = manage_client.list_project_invitations(project_id) + payload["pending_invitations"] = [ + ProjectInvitation.model_validate(i).model_dump(by_alias=False) for i in inv_raw + ] + return payload + finally: + manage_client.close() + + def list_invitations( + self, + *, + manage_token: str, + alias: str, + ) -> dict[str, Any]: + """Return pending invitations for ``alias``.""" + stack_url, project_id = self._resolve_alias(alias) + manage_client = self._manage_client_factory(stack_url, manage_token) + try: + raw = manage_client.list_project_invitations(project_id) + return { + "alias": alias, + "project_id": project_id, + "invitations": [ + ProjectInvitation.model_validate(i).model_dump(by_alias=False) for i in raw + ], + } + finally: + manage_client.close() + + def cancel_invitation( + self, + *, + manage_token: str, + alias: str, + email: str, + invitation_id: int | None = None, + ) -> dict[str, Any]: + """Cancel a pending invitation. Resolves by email if no ID is supplied.""" + stack_url, project_id = self._resolve_alias(alias) + manage_client = self._manage_client_factory(stack_url, manage_token) + try: + if invitation_id is None: + invitation_id = self._resolve_invitation_id(manage_client, project_id, email) + manage_client.cancel_project_invitation(project_id, invitation_id) + return { + "status": "cancelled", + "alias": alias, + "project_id": project_id, + "email": email, + "invitation_id": invitation_id, + } + finally: + manage_client.close() + + def remove_member( + self, + *, + manage_token: str, + alias: str, + email: str, + ) -> dict[str, Any]: + """Remove an active member from a project.""" + stack_url, project_id = self._resolve_alias(alias) + manage_client = self._manage_client_factory(stack_url, manage_token) + try: + user_id = self._resolve_member_id(manage_client, project_id, email) + manage_client.remove_project_member(project_id, user_id) + return { + "status": "removed", + "alias": alias, + "project_id": project_id, + "email": email, + "user_id": user_id, + } + finally: + manage_client.close() + + def set_member_role( + self, + *, + manage_token: str, + alias: str, + email: str, + role: str, + ) -> dict[str, Any]: + """Change an existing member's role via PATCH.""" + self._validate_role(role) + stack_url, project_id = self._resolve_alias(alias) + manage_client = self._manage_client_factory(stack_url, manage_token) + try: + user_id = self._resolve_member_id(manage_client, project_id, email) + updated = manage_client.update_project_member_role(project_id, user_id, role) + return { + "status": "updated", + "alias": alias, + "project_id": project_id, + "email": email, + "user_id": user_id, + "role": updated.get("role", role), + } + finally: + manage_client.close() + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + @staticmethod + def _validate_role(role: str) -> None: + """Defence-in-depth: command layer enforces the same whitelist via Choice.""" + if role not in PROJECT_ROLES: + raise ValueError( + f"Invalid role {role!r}. Allowed roles are: {', '.join(PROJECT_ROLES)}." + ) + + def _resolve_alias(self, alias: str) -> tuple[str, int]: + """Look up ``alias`` in the config store and return ``(stack_url, project_id)``.""" + project = self._config_store.get_project(alias) + if project is None: + raise ConfigError( + f"Project alias '{alias}' is not registered. Run `kbagent project list`." + ) + if project.project_id is None: + raise ConfigError( + f"Project alias '{alias}' has no numeric project_id; " + "re-add it via `kbagent project add` to populate it." + ) + return project.stack_url, project.project_id + + def _stack_for_row(self, row: dict[str, Any]) -> tuple[str, int]: + """Resolve a CSV row's project field to ``(stack_url, project_id)``.""" + project_field = str(row["project"]).strip() + if project_field.isdigit(): + # Numeric project_id rows still need a stack_url; we infer from the + # currently-registered projects sharing that ID, falling back to + # any default. + project_id = int(project_field) + for cfg in self._config_store.load().projects.values(): + if cfg.project_id == project_id: + return cfg.stack_url, project_id + raise ConfigError( + f"CSV row references project_id={project_id}, which is not registered " + "in this kbagent config; add it via `kbagent project add` so we know " + "which stack URL to use." + ) + return self._resolve_alias(project_field) + + @staticmethod + def _resolve_member_id(manage_client: ManageClient, project_id: int, email: str) -> int: + """Find an active member's numeric ID by email (case-insensitive match).""" + members = manage_client.list_project_members(project_id) + normalised = email.casefold() + for member in members: + if str(member.get("email", "")).casefold() == normalised: + return int(member["id"]) + raise KeboolaApiError( + message=f"No active member with email {email!r} on project {project_id}.", + status_code=404, + error_code=ErrorCode.NOT_FOUND, + retryable=False, + ) + + @staticmethod + def _resolve_invitation_id(manage_client: ManageClient, project_id: int, email: str) -> int: + """Find a pending invitation by email.""" + invitations = manage_client.list_project_invitations(project_id) + normalised = email.casefold() + for inv in invitations: + if str(inv.get("user", {}).get("email", "")).casefold() == normalised: + return int(inv["id"]) + raise KeboolaApiError( + message=f"No pending invitation for email {email!r} on project {project_id}.", + status_code=404, + error_code=ErrorCode.NOT_FOUND, + retryable=False, + ) + + def _invite_one( + self, + manage_client: ManageClient, + project_label: str, + project_id: int, + email: str, + role: str, + reason: str | None, + ) -> dict[str, Any]: + """Single-row invitation logic shared by ``invite`` and ``invite_bulk``. + + ``project_label`` is the human-readable project identifier surfaced in + the result dict's ``alias`` field. Single-shot mode passes the + registered alias; bulk mode passes the raw CSV ``project`` cell, which + may be either an alias or a numeric project ID string -- whatever the + user wrote. + """ + try: + invitation = manage_client.create_project_invitation( + project_id=project_id, + email=email, + role=role, + reason=reason, + ) + return { + "status": "ok", + "alias": project_label, + "project_id": project_id, + "email": email, + "role": role, + "invitation_id": invitation.get("id"), + } + except KeboolaApiError as exc: + note = self._noop_note_for(exc) + if note is None: + raise + return { + "status": "noop", + "alias": project_label, + "project_id": project_id, + "email": email, + "role": role, + "note": note, + } + + def _invoke_resolved_row( + self, + manage_client: ManageClient, + row: dict[str, Any], + project_id: int, + ) -> MemberInviteRow: + """Execute one CSV row inside the bulk-invite executor. + + Called only on rows whose (stack_url, project_id) was already resolved + by ``invite_bulk`` -- so the only failure path here is the API call + itself (e.g. invalid email, network error, role rejection). + """ + email = row["email"] + role = row["role"] + reason = row.get("reason") + project_field = str(row["project"]).strip() + try: + outcome = self._invite_one( + manage_client, project_field, project_id, email, role, reason + ) + return MemberInviteRow( + email=email, + project=project_field, + project_id=project_id, + role=role, + status=outcome["status"], + note=outcome.get("note", ""), + invitation_id=outcome.get("invitation_id"), + ) + except KeboolaApiError as exc: + return MemberInviteRow( + email=email, + project=project_field, + project_id=project_id, + role=role, + status="failed", + note=str(getattr(exc, "message", exc)), + ) + + @staticmethod + def _noop_note_for(exc: KeboolaApiError) -> str | None: + """Return a noop reason if ``exc`` is the "already invited / member" 400.""" + if exc.status_code != 400: + return None + message = exc.message or "" + if _ALREADY_INVITED_MARKER in message: + return "already_invited" + if _ALREADY_MEMBER_MARKER in message: + return "already_member" + return None + + def _parse_invite_csv(self, csv_path: Path, default_role: str | None) -> list[dict[str, Any]]: + """Parse + validate a bulk-invite CSV. Returns a list of normalised dicts.""" + if not csv_path.exists(): + raise ConfigError(f"CSV file not found: {csv_path}") + + # `utf-8-sig` strips a leading BOM if Excel produced the CSV (otherwise + # the first header reads as `email`, which fails the email-column + # check with a misleading message). + with csv_path.open("r", newline="", encoding="utf-8-sig") as fh: + reader = csv.DictReader(fh) + if reader.fieldnames is None: + raise ConfigError(f"CSV file {csv_path} has no header row.") + headers = {h.strip().lower(): h for h in reader.fieldnames if h} + if "email" not in headers: + raise ConfigError( + f"CSV file {csv_path} is missing an 'email' column. " + f"Found columns: {list(reader.fieldnames)}." + ) + project_key = ( + "project" + if "project" in headers + else ("project_id" if "project_id" in headers else None) + ) + if project_key is None: + raise ConfigError( + f"CSV file {csv_path} must have a 'project' or 'project_id' column. " + f"Found columns: {list(reader.fieldnames)}." + ) + has_role = "role" in headers + if not has_role and default_role is None: + raise ConfigError( + f"CSV file {csv_path} has no 'role' column and --default-role was not given." + ) + + rows: list[dict[str, Any]] = [] + for line_no, raw in enumerate(reader, start=2): # header is line 1 + email = (raw.get(headers["email"]) or "").strip() + project = (raw.get(headers[project_key]) or "").strip() + role = (raw.get(headers["role"]) if has_role else None) or default_role or "" + role = role.strip() + reason = (raw.get(headers["reason"]) or "").strip() if "reason" in headers else "" + if not email or not project: + raise ConfigError( + f"CSV {csv_path} line {line_no}: 'email' and '{project_key}' are both required." + ) + if not role: + raise ConfigError( + f"CSV {csv_path} line {line_no}: missing role and no --default-role." + ) + self._validate_role(role) + rows.append( + { + "email": email, + "project": project, + "role": role, + "reason": reason or None, + } + ) + return rows + + def _bulk_dry_run(self, rows: list[dict[str, Any]]) -> BulkInviteResult: + """Render a dry-run result without hitting the network. + + Mirrors the live path: per-row resolution failures become per-row + failed entries; multi-stack-URL CSVs raise (matches the real-run + invariant so users don't get a "preview said ok, real run aborted" + surprise). + """ + previewed: list[MemberInviteRow] = [] + resolved_stacks: set[str] = set() + for row in rows: + try: + stack_url, project_id = self._stack_for_row(row) + resolved_stacks.add(stack_url) + except ConfigError as exc: + previewed.append( + MemberInviteRow( + email=row["email"], + project=str(row["project"]), + role=row["role"], + status="failed", + note=str(getattr(exc, "message", exc)), + ) + ) + continue + previewed.append( + MemberInviteRow( + email=row["email"], + project=str(row["project"]), + project_id=project_id, + role=row["role"], + status="ok", + note="dry_run", + ) + ) + if len(resolved_stacks) > 1: + raise ConfigError( + f"CSV references multiple stack URLs ({sorted(resolved_stacks)}); " + "split the file by stack and run --from-csv per stack." + ) + return BulkInviteResult( + total=len(previewed), + succeeded=sum(1 for r in previewed if r.status == "ok"), + noop=0, + failed=sum(1 for r in previewed if r.status == "failed"), + rows=previewed, + dry_run=True, + ) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index cf6cd001..b18fc679 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -6139,6 +6139,29 @@ def test_swap_without_branch_is_rejected(self) -> None: reason=( f"requires {ENV_TOKEN} + {ENV_DATA_APP_GIT_REPO_PRIVATE} + " f"{ENV_DATA_APP_GIT_USER} + {ENV_DATA_APP_GIT_PAT}" +# ────────────────────────────────────────────────────────────────────── +# Project invite E2E (since v0.26.1) +# +# Opt-in via `make test-e2e-invite`. Default-skipped in `make test-e2e` because +# (a) it sends a real invitation email and (b) it depends on a separate manage +# token / project ID that the regular E2E credentials don't carry. +# ────────────────────────────────────────────────────────────────────── + + +ENV_MANAGE_TOKEN = "E2E_MANAGE_TOKEN" +ENV_INVITE_PROJECT_ID = "E2E_INVITE_PROJECT_ID" +ENV_INVITE_EMAIL = "E2E_INVITE_EMAIL" +DEFAULT_INVITE_EMAIL = "ottomansky.max@gmail.com" + +skip_without_invite_credentials = pytest.mark.skipif( + not ( + os.environ.get(ENV_MANAGE_TOKEN) + and os.environ.get(ENV_INVITE_PROJECT_ID) + and os.environ.get(ENV_URL) + ), + reason=( + f"Requires {ENV_MANAGE_TOKEN}, {ENV_INVITE_PROJECT_ID}, and {ENV_URL}. " + "Run via `make test-e2e-invite`." ), ) @@ -6663,3 +6686,114 @@ def test_config_update_auto_normalizes_script_array(self, tmp_path: Path) -> Non assert "Expected" not in rendered or "script" not in rendered, ( f"job envelope still mentions the script type-mismatch failure: {rendered}" ) +@pytest.mark.e2e_invite +@skip_without_invite_credentials +def test_project_invite_e2e(tmp_path: Path) -> None: + """Real invite to the master cuesta project: send -> list -> cancel -> verify gone. + + Uses role=guest (lowest blast radius). The cancel step in the same run + invalidates the invitation link before the inbox sees it, so this is a + "the system can send + clean up" check, not a "join my project" check. + """ + from keboola_agent_cli.config_store import ConfigStore as _Store + from keboola_agent_cli.models import ProjectConfig as _Project + + invite_email = os.environ.get(ENV_INVITE_EMAIL, DEFAULT_INVITE_EMAIL) + project_id = int(os.environ[ENV_INVITE_PROJECT_ID]) + stack_url = ( + os.environ[ENV_URL] + if os.environ[ENV_URL].startswith("https://") + else f"https://{os.environ[ENV_URL]}" + ) + alias = f"e2e-invite-target-{project_id}" + + # Bypass `kbagent project add` (which would verify a Storage API token). + # MemberService only needs (stack_url, project_id) -- the storage token + # field is unused. Write a minimal config.json with a placeholder token. + config_dir = tmp_path / "kbagent-config" + config_dir.mkdir() + store = _Store(config_dir=config_dir) + store.add_project( + alias, + _Project( + stack_url=stack_url, + token="901-e2e-placeholder-not-used-by-member-commands-xxxxxxxxxx", + project_id=project_id, + project_name="E2E invite target", + ), + ) + + env = { + **os.environ, + "KBC_MANAGE_API_TOKEN": os.environ[ENV_MANAGE_TOKEN], + } + + def _run(*args: str) -> dict: + result = runner.invoke( + app, + ["--config-dir", str(config_dir), "--json", *args], + env=env, + ) + assert result.exit_code == 0, ( + f"{' '.join(args)} failed (exit {result.exit_code}):\n{result.output}" + ) + return json.loads(result.output) + + # 1. Defensive cleanup: if a stale invitation exists from a prior aborted + # run, cancel it first so we start from a known state. + initial = _run("project", "invitation-list", "--project", alias)["data"]["invitations"] + for inv in initial: + if inv.get("user", {}).get("email", "").casefold() == invite_email.casefold(): + _run( + "project", + "invitation-cancel", + "--project", + alias, + "--email", + invite_email, + "--yes", + ) + + # 2. Send the invitation. + sent = _run( + "project", + "invite", + "--project", + alias, + "--email", + invite_email, + "--role", + "guest", + "--reason", + "kbagent v0.26.1 e2e", + )["data"] + assert sent["status"] == "ok" + assert sent["invitation_id"] is not None + invitation_id = sent["invitation_id"] + + try: + # 3. Confirm it shows up in invitation-list. + listed = _run("project", "invitation-list", "--project", alias)["data"]["invitations"] + emails = {row["user"]["email"].casefold() for row in listed} + assert invite_email.casefold() in emails, f"{invite_email} did not appear in {emails}" + finally: + # 4. Cancel (always, even if the assertion above fails -- never leave + # a real-email invitation around for a flaky test). + _run( + "project", + "invitation-cancel", + "--project", + alias, + "--email", + invite_email, + "--invitation-id", + str(invitation_id), + "--yes", + ) + + # 5. Verify the invitation is gone. + final = _run("project", "invitation-list", "--project", alias)["data"]["invitations"] + final_emails = {row["user"]["email"].casefold() for row in final} + assert invite_email.casefold() not in final_emails, ( + f"{invite_email} still pending after cancel: {final_emails}" + ) diff --git a/tests/test_manage_client.py b/tests/test_manage_client.py index 953f5366..0e50350f 100644 --- a/tests/test_manage_client.py +++ b/tests/test_manage_client.py @@ -361,3 +361,215 @@ def test_context_manager(self, httpx_mock) -> None: with ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN) as client: result = client.list_organization_projects(1) assert result == [] + + +# ────────────────────────────────────────────────────────────────────── +# Project members & invitations (since v0.26.1) +# ────────────────────────────────────────────────────────────────────── + + +_INVITATION_RESPONSE = { + "id": 1741, + "created": "2026-05-01T19:04:35+0200", + "expires": None, + "reason": "v0.26.1 verification", + "role": "guest", + "user": {"id": 1325, "email": "ottomansky.max@gmail.com", "name": ""}, + "creator": {"id": 216, "email": "max.ottomansky@keboola.com", "name": "Max"}, +} + +_MEMBER_LIST_RESPONSE = [ + { + "id": 216, + "name": "Max", + "email": "max.ottomansky@keboola.com", + "role": "admin", + "status": "active", + "mfaEnabled": True, + "features": ["power-user"], + "canAccessLogs": False, + }, + { + "id": 4241, + "name": "Marcel", + "email": "mfiser@cuestapartners.com", + "role": "guest", + "status": "active", + "mfaEnabled": True, + "features": [], + "canAccessLogs": False, + }, +] + + +class TestCreateProjectInvitation: + def test_success(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{STACK_URL}/manage/projects/5725/invitations", + method="POST", + json=_INVITATION_RESPONSE, + status_code=201, + ) + with ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN) as client: + result = client.create_project_invitation( + project_id=5725, + email="ottomansky.max@gmail.com", + role="guest", + reason="v0.26.1 verification", + ) + assert result["id"] == 1741 + assert result["role"] == "guest" + assert result["user"]["email"] == "ottomansky.max@gmail.com" + + def test_payload_contains_email_role_reason(self, httpx_mock) -> None: + import json as _json + + httpx_mock.add_response( + url=f"{STACK_URL}/manage/projects/5725/invitations", + method="POST", + json=_INVITATION_RESPONSE, + status_code=201, + ) + with ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN) as client: + client.create_project_invitation( + project_id=5725, + email="ottomansky.max@gmail.com", + role="guest", + reason="v0.26.1 verification", + ) + body = _json.loads(httpx_mock.get_request().read()) + assert body == { + "email": "ottomansky.max@gmail.com", + "role": "guest", + "reason": "v0.26.1 verification", + } + + def test_omits_reason_when_none(self, httpx_mock) -> None: + import json as _json + + httpx_mock.add_response( + url=f"{STACK_URL}/manage/projects/5725/invitations", + method="POST", + json=_INVITATION_RESPONSE, + status_code=201, + ) + with ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN) as client: + client.create_project_invitation(project_id=5725, email="x@y.com", role="admin") + body = _json.loads(httpx_mock.get_request().read()) + assert body == {"email": "x@y.com", "role": "admin"} + + def test_400_already_invited_surfaces_message(self, httpx_mock) -> None: + """The 'already invited' 400 must round-trip the API's error text so + the service layer can match its substring marker.""" + httpx_mock.add_response( + url=f"{STACK_URL}/manage/projects/5725/invitations", + method="POST", + json={"error": "This user has already been invited to this project."}, + status_code=400, + ) + with ( + ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN) as client, + pytest.raises(KeboolaApiError) as exc_info, + ): + client.create_project_invitation(project_id=5725, email="x@y.com", role="admin") + assert exc_info.value.status_code == 400 + assert "already been invited" in exc_info.value.message + + +class TestListProjectInvitations: + def test_returns_plain_list(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{STACK_URL}/manage/projects/5725/invitations", + json=[_INVITATION_RESPONSE], + status_code=200, + ) + with ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN) as client: + result = client.list_project_invitations(5725) + assert isinstance(result, list) + assert result[0]["user"]["email"] == "ottomansky.max@gmail.com" + + +class TestCancelProjectInvitation: + def test_returns_none_on_204(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{STACK_URL}/manage/projects/5725/invitations/1741", + method="DELETE", + status_code=204, + ) + with ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN) as client: + assert client.cancel_project_invitation(5725, 1741) is None + + def test_404_after_already_deleted(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{STACK_URL}/manage/projects/5725/invitations/1741", + method="DELETE", + json={"error": "Invitation not found"}, + status_code=404, + ) + with ( + ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN) as client, + pytest.raises(KeboolaApiError) as exc_info, + ): + client.cancel_project_invitation(5725, 1741) + assert exc_info.value.error_code == "NOT_FOUND" + + +class TestListProjectMembers: + def test_returns_top_level_user_dicts(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{STACK_URL}/manage/projects/5725/users", + json=_MEMBER_LIST_RESPONSE, + status_code=200, + ) + with ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN) as client: + result = client.list_project_members(5725) + assert len(result) == 2 + assert result[0]["email"] == "max.ottomansky@keboola.com" + # Role lives at the top level (not nested under a "user" key). + assert result[0]["role"] == "admin" + assert result[1]["role"] == "guest" + + +class TestRemoveProjectMember: + def test_returns_none_on_204(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{STACK_URL}/manage/projects/5725/users/216", + method="DELETE", + status_code=204, + ) + with ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN) as client: + assert client.remove_project_member(5725, 216) is None + + def test_400_administrator_not_found(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{STACK_URL}/manage/projects/5725/users/999", + method="DELETE", + json={"error": "Administrator not found"}, + status_code=400, + ) + with ( + ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN) as client, + pytest.raises(KeboolaApiError) as exc_info, + ): + client.remove_project_member(5725, 999) + assert exc_info.value.status_code == 400 + + +class TestUpdateProjectMemberRole: + def test_uses_PATCH_not_PUT(self, httpx_mock) -> None: + """Regression: PUT returns 404 even on real members; the client must + emit PATCH.""" + import json as _json + + httpx_mock.add_response( + url=f"{STACK_URL}/manage/projects/5725/users/216", + method="PATCH", + json={"id": 216, "email": "max.ottomansky@keboola.com", "role": "guest"}, + status_code=200, + ) + with ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN) as client: + result = client.update_project_member_role(5725, 216, "guest") + request = httpx_mock.get_request() + assert request.method == "PATCH" + assert _json.loads(request.read()) == {"role": "guest"} + assert result["role"] == "guest" diff --git a/tests/test_member_cli.py b/tests/test_member_cli.py new file mode 100644 index 00000000..7a2eaea8 --- /dev/null +++ b/tests/test_member_cli.py @@ -0,0 +1,564 @@ +"""CLI tests for `kbagent project invite / member-* / invitation-*` (since v0.26.1).""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +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 ErrorCode, KeboolaApiError +from keboola_agent_cli.models import BulkInviteResult, MemberInviteRow, ProjectConfig + +STACK_URL = "https://connection.us-east4.gcp.keboola.com" +PROJECT_ID = 5725 +ALIAS = "cuesta-master" +MANAGE_TOKEN = "manage-12345-abcdefghijklmnopqrstuvwxyz0123456789" + + +runner = CliRunner() + + +def _seed_store(config_dir: Path) -> ConfigStore: + store = ConfigStore(config_dir=config_dir) + store.add_project( + ALIAS, + ProjectConfig( + stack_url=STACK_URL, + token="901-fake-storage-token-1234567890", + project_name="[Cuesta training] - Master", + project_id=PROJECT_ID, + ), + ) + return store + + +class TestProjectInviteSingle: + def test_json_happy_path(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + + svc = MagicMock() + svc.invite.return_value = { + "status": "ok", + "alias": ALIAS, + "project_id": PROJECT_ID, + "email": "ottomansky.max@gmail.com", + "role": "guest", + "invitation_id": 1741, + } + + with ( + patch("keboola_agent_cli.cli.MemberService", return_value=svc), + patch.dict(os.environ, {"KBC_MANAGE_API_TOKEN": MANAGE_TOKEN}), + ): + result = runner.invoke( + app, + [ + "--config-dir", + str(config_dir), + "--json", + "project", + "invite", + "--project", + ALIAS, + "--email", + "ottomansky.max@gmail.com", + "--role", + "guest", + ], + ) + + assert result.exit_code == 0, result.output + out = json.loads(result.output) + assert out["status"] == "ok" + assert out["data"]["invitation_id"] == 1741 + svc.invite.assert_called_once() + + def test_missing_required_args_exits_2(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + + with patch.dict(os.environ, {"KBC_MANAGE_API_TOKEN": MANAGE_TOKEN}): + result = runner.invoke( + app, + [ + "--config-dir", + str(config_dir), + "--json", + "project", + "invite", + "--project", + ALIAS, + # --email + --role missing + ], + ) + assert result.exit_code == 2 + + def test_invalid_role_blocked_by_choice(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + + with patch.dict(os.environ, {"KBC_MANAGE_API_TOKEN": MANAGE_TOKEN}): + result = runner.invoke( + app, + [ + "--config-dir", + str(config_dir), + "--json", + "project", + "invite", + "--project", + ALIAS, + "--email", + "x@y.com", + "--role", + "developer", # not on whitelist -> Click rejects with exit 2 + ], + ) + assert result.exit_code == 2 + + def test_dry_run_short_circuits(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + svc = MagicMock() + svc.invite.return_value = { + "status": "dry_run", + "alias": ALIAS, + "project_id": PROJECT_ID, + "email": "x@y.com", + "role": "guest", + } + + with ( + patch("keboola_agent_cli.cli.MemberService", return_value=svc), + patch.dict(os.environ, {"KBC_MANAGE_API_TOKEN": MANAGE_TOKEN}), + ): + result = runner.invoke( + app, + [ + "--config-dir", + str(config_dir), + "--json", + "project", + "invite", + "--project", + ALIAS, + "--email", + "x@y.com", + "--role", + "guest", + "--dry-run", + ], + ) + assert result.exit_code == 0 + assert json.loads(result.output)["data"]["status"] == "dry_run" + + def test_invalid_token_maps_to_exit_3(self, tmp_path: Path) -> None: + """`map_error_to_exit_code` exclusively maps INVALID_TOKEN -> 3.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + svc = MagicMock() + svc.invite.side_effect = KeboolaApiError( + message="Invalid or expired token", + status_code=401, + error_code=ErrorCode.INVALID_TOKEN, + ) + + with ( + patch("keboola_agent_cli.cli.MemberService", return_value=svc), + patch.dict(os.environ, {"KBC_MANAGE_API_TOKEN": MANAGE_TOKEN}), + ): + result = runner.invoke( + app, + [ + "--config-dir", + str(config_dir), + "--json", + "project", + "invite", + "--project", + ALIAS, + "--email", + "x@y.com", + "--role", + "admin", + ], + ) + assert result.exit_code == 3 + + +class TestProjectInviteBulk: + def test_json_bulk_summary(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + svc = MagicMock() + svc.invite_bulk.return_value = BulkInviteResult( + total=2, + succeeded=1, + noop=1, + failed=0, + rows=[ + MemberInviteRow( + email="a@b.com", + project=ALIAS, + project_id=PROJECT_ID, + role="guest", + status="ok", + invitation_id=1, + ), + MemberInviteRow( + email="c@d.com", + project=ALIAS, + project_id=PROJECT_ID, + role="guest", + status="noop", + note="already_invited", + ), + ], + ) + csv_path = tmp_path / "bulk.csv" + csv_path.write_text( + "email,project,role\na@b.com,cuesta-master,guest\nc@d.com,cuesta-master,guest\n" + ) + + with ( + patch("keboola_agent_cli.cli.MemberService", return_value=svc), + patch.dict(os.environ, {"KBC_MANAGE_API_TOKEN": MANAGE_TOKEN}), + ): + result = runner.invoke( + app, + [ + "--config-dir", + str(config_dir), + "--json", + "project", + "invite", + "--from-csv", + str(csv_path), + ], + ) + assert result.exit_code == 0, result.output + out = json.loads(result.output) + assert out["status"] == "ok" + assert out["data"]["total"] == 2 + assert out["data"]["succeeded"] == 1 + assert out["data"]["noop"] == 1 + + def test_from_csv_mutually_exclusive_with_project(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + csv_path = tmp_path / "bulk.csv" + csv_path.write_text("email,project,role\na@b.com,cuesta-master,guest\n") + + with patch.dict(os.environ, {"KBC_MANAGE_API_TOKEN": MANAGE_TOKEN}): + result = runner.invoke( + app, + [ + "--config-dir", + str(config_dir), + "--json", + "project", + "invite", + "--from-csv", + str(csv_path), + "--project", + ALIAS, + ], + ) + assert result.exit_code == 2 + + +class TestMemberList: + def test_json_output(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + svc = MagicMock() + svc.list_members.return_value = { + "alias": ALIAS, + "project_id": PROJECT_ID, + "members": [ + { + "id": 216, + "email": "max.ottomansky@keboola.com", + "name": "Max", + "role": "admin", + "status": "active", + "mfa_enabled": True, + } + ], + } + + with ( + patch("keboola_agent_cli.cli.MemberService", return_value=svc), + patch.dict(os.environ, {"KBC_MANAGE_API_TOKEN": MANAGE_TOKEN}), + ): + result = runner.invoke( + app, + [ + "--config-dir", + str(config_dir), + "--json", + "project", + "member-list", + "--project", + ALIAS, + ], + ) + assert result.exit_code == 0, result.output + out = json.loads(result.output) + assert out["data"]["members"][0]["role"] == "admin" + svc.list_members.assert_called_once_with( + manage_token=MANAGE_TOKEN, alias=ALIAS, include_pending=False + ) + + def test_include_pending_flag_propagates(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + svc = MagicMock() + svc.list_members.return_value = { + "alias": ALIAS, + "project_id": PROJECT_ID, + "members": [], + "pending_invitations": [], + } + + with ( + patch("keboola_agent_cli.cli.MemberService", return_value=svc), + patch.dict(os.environ, {"KBC_MANAGE_API_TOKEN": MANAGE_TOKEN}), + ): + result = runner.invoke( + app, + [ + "--config-dir", + str(config_dir), + "--json", + "project", + "member-list", + "--project", + ALIAS, + "--include-pending", + ], + ) + assert result.exit_code == 0 + svc.list_members.assert_called_once_with( + manage_token=MANAGE_TOKEN, alias=ALIAS, include_pending=True + ) + + +class TestInvitationCancel: + def test_yes_skips_confirmation(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + svc = MagicMock() + svc.cancel_invitation.return_value = { + "status": "cancelled", + "alias": ALIAS, + "project_id": PROJECT_ID, + "email": "x@y.com", + "invitation_id": 99, + } + + with ( + patch("keboola_agent_cli.cli.MemberService", return_value=svc), + patch.dict(os.environ, {"KBC_MANAGE_API_TOKEN": MANAGE_TOKEN}), + ): + result = runner.invoke( + app, + [ + "--config-dir", + str(config_dir), + "--json", + "project", + "invitation-cancel", + "--project", + ALIAS, + "--email", + "x@y.com", + "--yes", + ], + ) + assert result.exit_code == 0 + svc.cancel_invitation.assert_called_once() + + +class TestMemberRemove: + def test_destructive_yes(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + svc = MagicMock() + svc.remove_member.return_value = { + "status": "removed", + "alias": ALIAS, + "project_id": PROJECT_ID, + "email": "ghost@example.com", + "user_id": 999, + } + + with ( + patch("keboola_agent_cli.cli.MemberService", return_value=svc), + patch.dict(os.environ, {"KBC_MANAGE_API_TOKEN": MANAGE_TOKEN}), + ): + result = runner.invoke( + app, + [ + "--config-dir", + str(config_dir), + "--json", + "project", + "member-remove", + "--project", + ALIAS, + "--email", + "ghost@example.com", + "--yes", + ], + ) + assert result.exit_code == 0 + svc.remove_member.assert_called_once() + + +class TestMemberSetRole: + def test_propagates_role(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + svc = MagicMock() + svc.set_member_role.return_value = { + "status": "updated", + "alias": ALIAS, + "project_id": PROJECT_ID, + "email": "x@y.com", + "user_id": 216, + "role": "guest", + } + + with ( + patch("keboola_agent_cli.cli.MemberService", return_value=svc), + patch.dict(os.environ, {"KBC_MANAGE_API_TOKEN": MANAGE_TOKEN}), + ): + result = runner.invoke( + app, + [ + "--config-dir", + str(config_dir), + "--json", + "project", + "member-set-role", + "--project", + ALIAS, + "--email", + "x@y.com", + "--role", + "guest", + ], + ) + assert result.exit_code == 0, result.output + svc.set_member_role.assert_called_once_with( + manage_token=MANAGE_TOKEN, alias=ALIAS, email="x@y.com", role="guest" + ) + + +class TestRegressions: + """Iteration-2 reviewer findings encoded as CLI regression tests.""" + + def test_hint_with_from_csv_emits_clear_error_not_silent_skip(self, tmp_path: Path) -> None: + """Pre-fix: --hint + --from-csv silently fell through to the live + path and prompted for the manage token. Now it exits 2 with a usage + error explaining hints are single-shot only.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + csv_path = tmp_path / "bulk.csv" + csv_path.write_text("email,project,role\na@b.com,cuesta-master,guest\n") + + with patch.dict(os.environ, {"KBC_MANAGE_API_TOKEN": MANAGE_TOKEN}): + result = runner.invoke( + app, + [ + "--config-dir", + str(config_dir), + "--json", + "--hint", + "client", + "project", + "invite", + "--from-csv", + str(csv_path), + ], + ) + assert result.exit_code == 2, result.output + # The error envelope is JSON; check the message content. + out = json.loads(result.output) + assert "from-csv" in out["error"]["message"].lower() + + +class TestHints: + def test_invite_hint_client_renders(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + + with patch.dict(os.environ, {"KBC_MANAGE_API_TOKEN": MANAGE_TOKEN}): + result = runner.invoke( + app, + [ + "--config-dir", + str(config_dir), + "--hint", + "client", + "project", + "invite", + "--project", + ALIAS, + "--email", + "x@y.com", + "--role", + "admin", + ], + ) + assert result.exit_code == 0 + assert "ManageClient" in result.output + assert "create_project_invitation" in result.output + + def test_invite_hint_service_renders(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + + with patch.dict(os.environ, {"KBC_MANAGE_API_TOKEN": MANAGE_TOKEN}): + result = runner.invoke( + app, + [ + "--config-dir", + str(config_dir), + "--hint", + "service", + "project", + "invite", + "--project", + ALIAS, + "--email", + "x@y.com", + "--role", + "admin", + ], + ) + assert result.exit_code == 0 + assert "MemberService" in result.output + assert "invite" in result.output diff --git a/tests/test_member_service.py b/tests/test_member_service.py new file mode 100644 index 00000000..f8fb8f62 --- /dev/null +++ b/tests/test_member_service.py @@ -0,0 +1,585 @@ +"""Tests for MemberService - project member & invitation lifecycle (since v0.26.1).""" + +from __future__ import annotations + +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, ErrorCode, KeboolaApiError +from keboola_agent_cli.models import ProjectConfig +from keboola_agent_cli.services.member_service import MemberService + +STACK_URL = "https://connection.us-east4.gcp.keboola.com" +MANAGE_TOKEN = "manage-12345-abcdefghijklmnopqrstuvwxyz0123456789" +PROJECT_ID = 5725 +ALIAS = "cuesta-master" + + +def _make_member(uid: int, email: str, role: str = "admin") -> dict: + return { + "id": uid, + "email": email, + "name": email.split("@")[0], + "role": role, + "status": "active", + "mfaEnabled": False, + "features": [], + } + + +def _make_invitation(inv_id: int, email: str, role: str = "guest") -> dict: + return { + "id": inv_id, + "created": "2026-05-01T19:04:35+0200", + "expires": None, + "reason": "", + "role": role, + "user": {"id": None, "email": email, "name": ""}, + "creator": {"id": 216, "email": "max.ottomansky@keboola.com", "name": "Max"}, + } + + +@pytest.fixture +def store_with_master(tmp_config_dir: Path) -> ConfigStore: + """ConfigStore with the master cuesta project pre-registered.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project( + ALIAS, + ProjectConfig( + stack_url=STACK_URL, + token="901-fake-storage-token-1234567890", + project_name="[Cuesta training] - Master", + project_id=PROJECT_ID, + ), + ) + return store + + +@pytest.fixture +def manage_client_factory(): + """Factory returning a single shared MagicMock manage client.""" + mock = MagicMock() + mock._stack_url = STACK_URL + factory = MagicMock(return_value=mock) + return factory, mock + + +# ────────────────────────────────────────────────────────────────────── +# invite (single) +# ────────────────────────────────────────────────────────────────────── + + +class TestInviteSingle: + def test_happy_path(self, store_with_master, manage_client_factory) -> None: + factory, mock_client = manage_client_factory + mock_client.create_project_invitation.return_value = _make_invitation( + 1741, "ottomansky.max@gmail.com" + ) + svc = MemberService(store_with_master, manage_client_factory=factory) + + result = svc.invite( + manage_token=MANAGE_TOKEN, + alias=ALIAS, + email="ottomansky.max@gmail.com", + role="guest", + reason="hi", + ) + + assert result["status"] == "ok" + assert result["invitation_id"] == 1741 + mock_client.create_project_invitation.assert_called_once_with( + project_id=PROJECT_ID, + email="ottomansky.max@gmail.com", + role="guest", + reason="hi", + ) + mock_client.close.assert_called_once() + + def test_dry_run_makes_no_client_call(self, store_with_master, manage_client_factory) -> None: + factory, mock_client = manage_client_factory + svc = MemberService(store_with_master, manage_client_factory=factory) + + result = svc.invite( + manage_token=MANAGE_TOKEN, + alias=ALIAS, + email="x@y.com", + role="admin", + dry_run=True, + ) + + assert result["status"] == "dry_run" + factory.assert_not_called() + mock_client.create_project_invitation.assert_not_called() + + def test_already_invited_returns_noop(self, store_with_master, manage_client_factory) -> None: + factory, mock_client = manage_client_factory + mock_client.create_project_invitation.side_effect = KeboolaApiError( + message="API error 400 from ...: This user has already been invited to this project.", + status_code=400, + error_code=ErrorCode.API_ERROR, + ) + svc = MemberService(store_with_master, manage_client_factory=factory) + + result = svc.invite(manage_token=MANAGE_TOKEN, alias=ALIAS, email="x@y.com", role="admin") + + assert result["status"] == "noop" + assert result["note"] == "already_invited" + mock_client.close.assert_called_once() + + def test_already_member_returns_noop(self, store_with_master, manage_client_factory) -> None: + factory, mock_client = manage_client_factory + mock_client.create_project_invitation.side_effect = KeboolaApiError( + message="API error 400 from ...: This user is already a member of this project.", + status_code=400, + error_code=ErrorCode.API_ERROR, + ) + svc = MemberService(store_with_master, manage_client_factory=factory) + + result = svc.invite(manage_token=MANAGE_TOKEN, alias=ALIAS, email="x@y.com", role="admin") + + assert result["status"] == "noop" + assert result["note"] == "already_member" + + def test_other_400_re_raises(self, store_with_master, manage_client_factory) -> None: + factory, mock_client = manage_client_factory + mock_client.create_project_invitation.side_effect = KeboolaApiError( + message="API error 400 from ...: completely unrelated rejection", + status_code=400, + error_code=ErrorCode.API_ERROR, + ) + svc = MemberService(store_with_master, manage_client_factory=factory) + + with pytest.raises(KeboolaApiError): + svc.invite(manage_token=MANAGE_TOKEN, alias=ALIAS, email="x@y.com", role="admin") + # close() must still fire even on raise + mock_client.close.assert_called_once() + + def test_unknown_alias_raises_config_error( + self, store_with_master, manage_client_factory + ) -> None: + factory, _ = manage_client_factory + svc = MemberService(store_with_master, manage_client_factory=factory) + + with pytest.raises(ConfigError, match="not registered"): + svc.invite( + manage_token=MANAGE_TOKEN, + alias="does-not-exist", + email="x@y.com", + role="admin", + ) + + def test_invalid_role_raises_value_error( + self, store_with_master, manage_client_factory + ) -> None: + factory, _ = manage_client_factory + svc = MemberService(store_with_master, manage_client_factory=factory) + + with pytest.raises(ValueError, match="Invalid role"): + svc.invite( + manage_token=MANAGE_TOKEN, + alias=ALIAS, + email="x@y.com", + role="developer", # not on the whitelist + ) + + +# ────────────────────────────────────────────────────────────────────── +# invite (bulk via --from-csv) +# ────────────────────────────────────────────────────────────────────── + + +def _write_csv(path: Path, content: str) -> Path: + path.write_text(content, encoding="utf-8") + return path + + +class TestInviteBulk: + def test_partial_success( + self, + tmp_path: Path, + store_with_master: ConfigStore, + manage_client_factory, + ) -> None: + factory, mock_client = manage_client_factory + + def _create_invitation(*, project_id, email, role, reason): + if email == "fail@example.com": + raise KeboolaApiError( + message="API error 400: nope", + status_code=400, + error_code=ErrorCode.API_ERROR, + ) + if email == "dup@example.com": + raise KeboolaApiError( + message="API error 400: This user has already been invited to this project.", + status_code=400, + error_code=ErrorCode.API_ERROR, + ) + return _make_invitation(1700 + len(email), email, role) + + mock_client.create_project_invitation.side_effect = _create_invitation + + csv_path = _write_csv( + tmp_path / "bulk.csv", + "email,project,role\n" + "ok@example.com,cuesta-master,guest\n" + "dup@example.com,cuesta-master,guest\n" + "fail@example.com,cuesta-master,guest\n", + ) + + svc = MemberService(store_with_master, manage_client_factory=factory) + result = svc.invite_bulk(manage_token=MANAGE_TOKEN, csv_path=csv_path, workers=1) + + assert result.total == 3 + assert result.succeeded == 1 + assert result.noop == 1 + assert result.failed == 1 + assert {r.email for r in result.rows} == { + "ok@example.com", + "dup@example.com", + "fail@example.com", + } + + def test_dry_run_makes_no_client_call( + self, + tmp_path: Path, + store_with_master: ConfigStore, + manage_client_factory, + ) -> None: + factory, mock_client = manage_client_factory + csv_path = _write_csv( + tmp_path / "bulk.csv", + "email,project,role\nok@example.com,cuesta-master,guest\n", + ) + svc = MemberService(store_with_master, manage_client_factory=factory) + + result = svc.invite_bulk(manage_token=MANAGE_TOKEN, csv_path=csv_path, dry_run=True) + + assert result.dry_run is True + assert result.total == 1 + assert result.succeeded == 1 + factory.assert_not_called() + mock_client.create_project_invitation.assert_not_called() + + def test_default_role_fills_missing_column( + self, + tmp_path: Path, + store_with_master: ConfigStore, + manage_client_factory, + ) -> None: + factory, mock_client = manage_client_factory + mock_client.create_project_invitation.return_value = _make_invitation(1, "x@y.com", "admin") + csv_path = _write_csv( + tmp_path / "bulk.csv", + "email,project\nx@y.com,cuesta-master\n", + ) + svc = MemberService(store_with_master, manage_client_factory=factory) + + result = svc.invite_bulk( + manage_token=MANAGE_TOKEN, + csv_path=csv_path, + default_role="admin", + workers=1, + ) + + assert result.succeeded == 1 + assert result.rows[0].role == "admin" + + def test_no_role_column_no_default_role_raises( + self, + tmp_path: Path, + store_with_master: ConfigStore, + manage_client_factory, + ) -> None: + factory, _ = manage_client_factory + csv_path = _write_csv( + tmp_path / "bulk.csv", + "email,project\nx@y.com,cuesta-master\n", + ) + svc = MemberService(store_with_master, manage_client_factory=factory) + + with pytest.raises(ConfigError, match="role"): + svc.invite_bulk(manage_token=MANAGE_TOKEN, csv_path=csv_path) + + def test_missing_email_column_raises( + self, + tmp_path: Path, + store_with_master: ConfigStore, + manage_client_factory, + ) -> None: + factory, _ = manage_client_factory + csv_path = _write_csv( + tmp_path / "bulk.csv", + "user,project,role\nx@y.com,cuesta-master,admin\n", + ) + svc = MemberService(store_with_master, manage_client_factory=factory) + + with pytest.raises(ConfigError, match="missing an 'email' column"): + svc.invite_bulk(manage_token=MANAGE_TOKEN, csv_path=csv_path) + + def test_missing_file_raises( + self, + tmp_path: Path, + store_with_master: ConfigStore, + manage_client_factory, + ) -> None: + factory, _ = manage_client_factory + svc = MemberService(store_with_master, manage_client_factory=factory) + + with pytest.raises(ConfigError, match="not found"): + svc.invite_bulk(manage_token=MANAGE_TOKEN, csv_path=tmp_path / "missing.csv") + + def test_unknown_alias_in_csv_row_is_per_row_failure_not_global_abort( + self, + tmp_path: Path, + store_with_master: ConfigStore, + manage_client_factory, + ) -> None: + """One bad row never aborts the rest -- mirror OrgService.refresh_tokens. + + Regression: pre-fix, the upfront `_stack_for_row` set comprehension + would raise ConfigError on the first unregistered alias and the entire + bulk batch would abort. Now the bad row appears as `status="failed"` + and the remaining rows still execute. + """ + factory, mock_client = manage_client_factory + mock_client.create_project_invitation.return_value = _make_invitation(42, "ok@example.com") + csv_path = _write_csv( + tmp_path / "bulk.csv", + "email,project,role\n" + "ok@example.com,cuesta-master,guest\n" + "bad@example.com,unknown-alias,guest\n", + ) + + svc = MemberService(store_with_master, manage_client_factory=factory) + result = svc.invite_bulk(manage_token=MANAGE_TOKEN, csv_path=csv_path, workers=1) + + assert result.total == 2 + assert result.succeeded == 1 + assert result.failed == 1 + by_email = {r.email: r for r in result.rows} + assert by_email["ok@example.com"].status == "ok" + assert by_email["bad@example.com"].status == "failed" + assert "unknown-alias" in by_email["bad@example.com"].note + # The good row still hit the API + mock_client.create_project_invitation.assert_called_once() + + def test_numeric_project_id_resolves( + self, + tmp_path: Path, + store_with_master: ConfigStore, + manage_client_factory, + ) -> None: + factory, mock_client = manage_client_factory + mock_client.create_project_invitation.return_value = _make_invitation(1, "x@y.com", "guest") + csv_path = _write_csv( + tmp_path / "bulk.csv", + f"email,project_id,role\nx@y.com,{PROJECT_ID},guest\n", + ) + svc = MemberService(store_with_master, manage_client_factory=factory) + + result = svc.invite_bulk(manage_token=MANAGE_TOKEN, csv_path=csv_path, workers=1) + assert result.succeeded == 1 + assert result.rows[0].project_id == PROJECT_ID + + +# ────────────────────────────────────────────────────────────────────── +# member-list, invitation-list, invitation-cancel +# ────────────────────────────────────────────────────────────────────── + + +class TestBulkRegressions: + """Iteration-2 reviewer findings encoded as regression tests.""" + + def test_dry_run_rejects_multi_stack_csv( + self, + tmp_path: Path, + store_with_master: ConfigStore, + manage_client_factory, + ) -> None: + """Dry-run preview must enforce the same single-stack invariant the + live path enforces -- otherwise users get a 'preview said ok' surprise + on the real run.""" + factory, _ = manage_client_factory + store_with_master.add_project( + "other-stack", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-fake-other-stack-token", + project_id=1, + project_name="Other", + ), + ) + csv_path = _write_csv( + tmp_path / "bulk.csv", + "email,project,role\na@b.com,cuesta-master,guest\nc@d.com,other-stack,guest\n", + ) + svc = MemberService(store_with_master, manage_client_factory=factory) + + with pytest.raises(ConfigError, match="multiple stack URLs"): + svc.invite_bulk(manage_token=MANAGE_TOKEN, csv_path=csv_path, dry_run=True) + + def test_csv_with_utf8_bom_parses( + self, + tmp_path: Path, + store_with_master: ConfigStore, + manage_client_factory, + ) -> None: + """Excel-exported CSVs prepend a UTF-8 BOM; the parser must strip it + so the first header reads as 'email', not 'email'.""" + factory, mock_client = manage_client_factory + mock_client.create_project_invitation.return_value = _make_invitation(1, "x@y.com") + csv_path = tmp_path / "bom.csv" + #  = UTF-8 BOM + csv_path.write_text( + "email,project,role\nx@y.com,cuesta-master,guest\n", + encoding="utf-8", + ) + svc = MemberService(store_with_master, manage_client_factory=factory) + + result = svc.invite_bulk(manage_token=MANAGE_TOKEN, csv_path=csv_path, workers=1) + assert result.succeeded == 1 + + +class TestListMembers: + def test_active_only(self, store_with_master, manage_client_factory) -> None: + factory, mock_client = manage_client_factory + mock_client.list_project_members.return_value = [ + _make_member(216, "max.ottomansky@keboola.com", "admin"), + ] + svc = MemberService(store_with_master, manage_client_factory=factory) + + result = svc.list_members(manage_token=MANAGE_TOKEN, alias=ALIAS) + + assert result["alias"] == ALIAS + assert result["project_id"] == PROJECT_ID + assert result["members"][0]["email"] == "max.ottomansky@keboola.com" + assert "pending_invitations" not in result + mock_client.list_project_invitations.assert_not_called() + + def test_include_pending(self, store_with_master, manage_client_factory) -> None: + factory, mock_client = manage_client_factory + mock_client.list_project_members.return_value = [ + _make_member(216, "max.ottomansky@keboola.com") + ] + mock_client.list_project_invitations.return_value = [ + _make_invitation(1515, "marcusscwong@gmail.com", "admin") + ] + svc = MemberService(store_with_master, manage_client_factory=factory) + + result = svc.list_members(manage_token=MANAGE_TOKEN, alias=ALIAS, include_pending=True) + + assert len(result["pending_invitations"]) == 1 + assert result["pending_invitations"][0]["user"]["email"] == "marcusscwong@gmail.com" + + +class TestCancelInvitation: + def test_resolves_id_from_email(self, store_with_master, manage_client_factory) -> None: + factory, mock_client = manage_client_factory + mock_client.list_project_invitations.return_value = [ + _make_invitation(1515, "marcusscwong@gmail.com") + ] + svc = MemberService(store_with_master, manage_client_factory=factory) + + result = svc.cancel_invitation( + manage_token=MANAGE_TOKEN, alias=ALIAS, email="marcusscwong@gmail.com" + ) + + assert result["invitation_id"] == 1515 + mock_client.cancel_project_invitation.assert_called_once_with(PROJECT_ID, 1515) + + def test_explicit_id_skips_lookup(self, store_with_master, manage_client_factory) -> None: + factory, mock_client = manage_client_factory + svc = MemberService(store_with_master, manage_client_factory=factory) + + result = svc.cancel_invitation( + manage_token=MANAGE_TOKEN, + alias=ALIAS, + email="x@y.com", + invitation_id=9999, + ) + + assert result["invitation_id"] == 9999 + mock_client.list_project_invitations.assert_not_called() + mock_client.cancel_project_invitation.assert_called_once_with(PROJECT_ID, 9999) + + def test_email_not_found_raises_404(self, store_with_master, manage_client_factory) -> None: + factory, mock_client = manage_client_factory + mock_client.list_project_invitations.return_value = [ + _make_invitation(1, "someone-else@example.com") + ] + svc = MemberService(store_with_master, manage_client_factory=factory) + + with pytest.raises(KeboolaApiError) as exc_info: + svc.cancel_invitation( + manage_token=MANAGE_TOKEN, alias=ALIAS, email="missing@example.com" + ) + assert exc_info.value.status_code == 404 + + +class TestRemoveMember: + def test_resolves_user_id_from_email(self, store_with_master, manage_client_factory) -> None: + factory, mock_client = manage_client_factory + mock_client.list_project_members.return_value = [ + _make_member(4241, "mfiser@cuestapartners.com", "admin") + ] + svc = MemberService(store_with_master, manage_client_factory=factory) + + result = svc.remove_member( + manage_token=MANAGE_TOKEN, + alias=ALIAS, + email="MFiser@CuestaPartners.com", # case-insensitive + ) + + assert result["user_id"] == 4241 + mock_client.remove_project_member.assert_called_once_with(PROJECT_ID, 4241) + + def test_email_not_found_raises_404(self, store_with_master, manage_client_factory) -> None: + factory, mock_client = manage_client_factory + mock_client.list_project_members.return_value = [] + svc = MemberService(store_with_master, manage_client_factory=factory) + + with pytest.raises(KeboolaApiError) as exc_info: + svc.remove_member(manage_token=MANAGE_TOKEN, alias=ALIAS, email="ghost@example.com") + assert exc_info.value.status_code == 404 + mock_client.remove_project_member.assert_not_called() + + +class TestSetMemberRole: + def test_propagates_role_via_patch(self, store_with_master, manage_client_factory) -> None: + factory, mock_client = manage_client_factory + mock_client.list_project_members.return_value = [ + _make_member(216, "max.ottomansky@keboola.com", "admin") + ] + mock_client.update_project_member_role.return_value = { + "id": 216, + "email": "max.ottomansky@keboola.com", + "role": "guest", + } + svc = MemberService(store_with_master, manage_client_factory=factory) + + result = svc.set_member_role( + manage_token=MANAGE_TOKEN, + alias=ALIAS, + email="max.ottomansky@keboola.com", + role="guest", + ) + + assert result["role"] == "guest" + mock_client.update_project_member_role.assert_called_once_with(PROJECT_ID, 216, "guest") + + def test_invalid_role_raises_value_error( + self, store_with_master, manage_client_factory + ) -> None: + factory, _ = manage_client_factory + svc = MemberService(store_with_master, manage_client_factory=factory) + with pytest.raises(ValueError, match="Invalid role"): + svc.set_member_role( + manage_token=MANAGE_TOKEN, + alias=ALIAS, + email="x@y.com", + role="developer", + )