diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 71617f79..533120fe 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "kbagent", - "version": "0.47.2", + "version": "0.48.0", "source": "./plugins/kbagent", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces", "category": "development" diff --git a/CLAUDE.md b/CLAUDE.md index 0fc7dc34..5090c554 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -382,6 +382,15 @@ kbagent sharing edges [--project NAME] kbagent org setup --org-id ID --url URL [--dry-run] [--yes] [--token-description PREFIX] [--refresh] kbagent org setup --project-ids 1,2,3 --url URL [--dry-run] [--yes] [--token-description PREFIX] [--refresh] +# feature: requires a super-admin Manage API token (inline hidden prompt; never persisted; --allow-env-manage-token for CI). --project resolves the stack URL (+ project_id for project ops) from config. +kbagent feature list --project ALIAS +kbagent feature project-show --project ALIAS +kbagent feature project-add --project ALIAS --feature NAME [--dry-run] [--yes] +kbagent feature project-remove --project ALIAS --feature NAME [--dry-run] [--yes] +kbagent feature user-show --project ALIAS --email EMAIL +kbagent feature user-add --project ALIAS --email EMAIL --feature NAME [--dry-run] [--yes] +kbagent feature user-remove --project ALIAS --email EMAIL --feature NAME [--dry-run] [--yes] + kbagent tool list [--project NAME] [--branch ID] kbagent tool call TOOL_NAME [--project NAME] [--input JSON|@file|-] [--branch ID] diff --git a/Makefile b/Makefile index 78ce7491..e9194d61 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .DEFAULT_GOAL := help -.PHONY: help install install-mcp install-server sync test test-unit test-integration test-e2e test-e2e-local test-e2e-invite test-file lint lint-fix format format-check typecheck typecheck-warn skill-check skill-gen version-sync version-check changelog changelog-check check-error-codes check clean hooks web-install web-dev-backend web-dev-frontend web-build web-clean +.PHONY: help install install-mcp install-server sync test test-unit test-integration test-e2e test-e2e-local test-e2e-invite test-e2e-feature test-file lint lint-fix format format-check typecheck typecheck-warn skill-check skill-gen version-sync version-check changelog changelog-check check-error-codes check clean hooks web-install web-dev-backend web-dev-frontend web-build web-clean 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}' @@ -36,6 +36,9 @@ test-e2e-local: ## Run E2E against a project in a local config.json (CONFIG_DIR= 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-e2e-feature: ## Run feature-flag E2E (E2E_MANAGE_TOKEN super-admin + E2E_API_TOKEN + E2E_URL required) + uv run pytest tests/test_e2e.py -v -s --tb=long -k test_feature_flags_read_e2e + test-file: ## Run a specific test file (FILE=tests/test_cli.py) uv run pytest $(FILE) -v diff --git a/plugins/kbagent/.claude-plugin/plugin.json b/plugins/kbagent/.claude-plugin/plugin.json index 7bae1069..825a2a85 100644 --- a/plugins/kbagent/.claude-plugin/plugin.json +++ b/plugins/kbagent/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kbagent", - "version": "0.47.2", + "version": "0.48.0", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces", "author": { "name": "Keboola", diff --git a/plugins/kbagent/agents/keboola-expert.md b/plugins/kbagent/agents/keboola-expert.md index ad5352ab..8152f169 100644 --- a/plugins/kbagent/agents/keboola-expert.md +++ b/plugins/kbagent/agents/keboola-expert.md @@ -111,6 +111,7 @@ a critical failure. `semantic-layer search-context|get-context`, `storage create-table --if-not-exists`, `sync push|pull|diff --branch`, `sync push --no-name-drift-warnings`, fresh-CREATE writeback + KBC.* = 0.47.0+, Snowflake `workspace create` `private_key` = 0.47.1+, `sync push` fresh-CREATE variable-link resolution + `--branch ` default-tree promote = 0.47.2+, + `feature` group (stack/project/user feature flags, Manage API) = 0.48.0+, `storage retype` is a future composite), you MUST refuse the task and return a handoff message to the parent: `"Cannot proceed safely on kbagent . Missing: . @@ -150,6 +151,7 @@ a critical failure. | Ad-hoc SQL / row-count / type audit | `kbagent workspace create` + `kbagent workspace load` + `kbagent workspace query --sql "..."` | `kbagent workspace from-transformation` for existing transform debugging; `workspace list --qs-compatible` (0.42.0+, #304) for data-app reuse | querying Keboola Storage directly via Snowflake credentials outside the workspace abstraction | | Inspect dev branch | `kbagent branch list --project P`, `kbagent branch use --project P --branch ID` | `tool call get_branch` | acting on `main` when a dev branch exists | | Audit project capabilities / features | `kbagent project info --project P` (0.30.0+) -- returns project ID, name, backend, enabled features, quota limits, and metrics | `tool call verify_token` (returns less structured info; no feature list) | inspecting the UI project settings manually | +| Manage feature flags (stack catalogue / project / user) | `kbagent feature list\|project-show\|project-add\|project-remove\|user-show\|user-add\|user-remove --project P [--email E] [--feature NAME] [--dry-run] [--yes]` (0.48.0+) -- Manage API; needs a SUPER-ADMIN manage token (interactive prompt; `--allow-env-manage-token`+`KBC_MANAGE_API_TOKEN` for CI); `--project` resolves the stack URL (+project_id for `project-*`); add=admin, remove=destructive; add body is `{"feature":NAME}` | `kbagent project info` for a project's *enabled* features (read-only, no super-admin) | raw `/manage/...` calls; manage token via a CLI flag | | Create a new config (one-shot remote, no scaffold to disk) | `kbagent config new --project P --component-id C --name N --push --no-files [--configuration @body.json] [--branch ID]` (0.33.0+) -- single CLI call POSTs to `/v2/storage/components/{cid}/configs`; default body is `{}` (FIIA empty-shell pattern, validation auto-skips); explicit `--configuration` body is schema-validated by default (`--no-validate` opts out); works for ALL component types incl. `keboola.snowflake-transformation` | `kbagent config new --output-dir D` then edit + `kbagent sync push` (scaffold-then-push GitOps flow) | `tool call create_config` (refuses keboola.snowflake-transformation; raw MCP envelope, no validation) | | Create a config row | `kbagent config row-create --project P --component-id C --config-id K --name NAME` (0.30.0+) | `tool call create_config_row` | `POST /v2/storage/components/C/configs/K/rows` (raw REST) | | Update a config row | `kbagent config row-update --project P --component-id C --config-id K --row-id R [--name N] [--configuration JSON]` (0.30.0+) | `tool call update_config_row` | `PUT /v2/storage/components/C/configs/K/rows/R` (raw REST) | diff --git a/plugins/kbagent/skills/kbagent/SKILL.md b/plugins/kbagent/skills/kbagent/SKILL.md index 134d3a0f..9dd0cba7 100644 --- a/plugins/kbagent/skills/kbagent/SKILL.md +++ b/plugins/kbagent/skills/kbagent/SKILL.md @@ -41,6 +41,10 @@ description: > list members, remove member, change role, project role, bulk invite, invite from CSV, project access, member management, manage token prompt, --allow-env-manage-token, KBC_MANAGE_API_TOKEN, + feature flag, feature flags, list features, project features, user features, + enable feature, disable feature, set feature flag, add feature, remove feature, + early-adopter-preview, direct-access, pay-as-you-go, /manage/features, + super admin token, super-admin feature, stack feature catalogue, semantic-layer, semantic layer, semantic-layer model, metastore, semantic-metric, semantic-dataset, semantic-relationship, semantic-constraint, semantic-glossary, add metric, edit metric, @@ -123,6 +127,13 @@ When working inside a git repository or project directory, run `kbagent init` (o | 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 all feature flags defined on the stack | `kbagent feature list --project PROJECT` | +| Show feature flags assigned to a project | `kbagent feature project-show --project PROJECT` | +| Enable a feature flag on a project | `kbagent feature project-add --project PROJECT --feature FEATURE` | +| Disable a feature flag on a project (destructive) | `kbagent feature project-remove --project PROJECT --feature FEATURE` | +| Show feature flags assigned to a user | `kbagent feature user-show --project PROJECT --email EMAIL` | +| Enable a feature flag on a user | `kbagent feature user-add --project PROJECT --email EMAIL --feature FEATURE` | +| Disable a feature flag on a user (destructive) | `kbagent feature user-remove --project PROJECT --email EMAIL --feature FEATURE` | | List available components from connected projects | `kbagent component list` | | Show detailed information about a specific component | `kbagent component detail --component-id COMPONENT-ID` | | List configurations from connected projects | `kbagent config list` | diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index e496db1b..c19c4c1f 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -43,6 +43,16 @@ All seven commands authenticate via `KBC_MANAGE_API_TOKEN` (Manage API), not the - `org setup --org-id ID --url URL [--dry-run] [--yes]` -- bulk-onboard all projects from an org (org admin; manage token via interactive prompt by default, or `--allow-env-manage-token` + `KBC_MANAGE_API_TOKEN` for CI on 0.29.0+) - `org setup --project-ids 1,2,3 --url URL [--dry-run] [--yes]` -- onboard specific projects by ID (any project member; manage token / Personal Access Token via interactive prompt by default, or `--allow-env-manage-token` + `KBC_MANAGE_API_TOKEN` for CI on 0.29.0+) +## Feature Flags (since v0.48.0) +Requires a **super-admin** Manage API token (same kind as `org setup`). Same default-deny token policy: interactive hidden prompt by default, or `--allow-env-manage-token` + `KBC_MANAGE_API_TOKEN` for CI. `--project ALIAS` resolves the stack URL (and, for project ops, the numeric `project_id`) from config -- the alias is the only handle you pass. +- `feature list --project ALIAS` -- the stack-wide feature catalogue (`GET /manage/features`). Returns `{alias, stack_url, features: [{name, title, description, type, ...}]}`. Only `name` is a stable identifier; extra fields pass through unmodified. +- `feature project-show --project ALIAS` -- features assigned to a project, read from the project object's `features` array. Returns `{alias, project_id, project_name, features: [...]}`. +- `feature project-add --project ALIAS --feature NAME [--dry-run] [--yes]` -- enable a feature on a project (`POST /manage/projects/{id}/features`, body `{"feature": NAME}`). Permission class `admin`. +- `feature project-remove --project ALIAS --feature NAME [--dry-run] [--yes]` -- disable a feature on a project (`DELETE /manage/projects/{id}/features/{name}`). Permission class `destructive`. +- `feature user-show --project ALIAS --email EMAIL` -- features assigned to a user (`GET /manage/users/{email}`). Returns `{alias, stack_url, email, features: [...]}`. +- `feature user-add --project ALIAS --email EMAIL --feature NAME [--dry-run] [--yes]` -- enable a feature on a user (`POST /manage/users/{email}/features`). +- `feature user-remove --project ALIAS --email EMAIL --feature NAME [--dry-run] [--yes]` -- disable a feature on a user (`DELETE /manage/users/{email}/features/{name}`). + ## Component Discovery - `component list [--project NAME] [--type TYPE] [--query "text"]` -- list/search components (AI-powered with `--query`) - `component detail --component-id ID [--project NAME]` -- show component schema, docs URL, examples diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index c5671115..53f2d066 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -2202,3 +2202,44 @@ commands that never had `--hint` support. AI agents should prefer the REST surface over `--hint` for new integrations. Do not add new examples or workflows that teach `--hint`; point readers to `kbagent serve` instead. + +## `feature` command group: super-admin token, no per-project endpoint, opaque schema (since v0.48.0) + +The `feature` group manages Keboola feature flags via the **Manage API**. Five +things trip up callers: + +1. **Super-admin manage token required.** `feature list` (the stack catalogue) + and every project/user mutation need a super-admin Manage API token -- the + same kind `org setup` uses, NOT the per-project Storage token. It follows the + default-deny policy: interactive hidden prompt by default; pass top-level + `--allow-env-manage-token` + `KBC_MANAGE_API_TOKEN` for CI. Do NOT pass the + token as a CLI flag. A non-super-admin token returns 403 (exit 3). + +2. **`--project` is just a handle to the stack URL.** For `feature list` and the + `user-*` commands the alias only resolves the stack URL -- the catalogue and + user features are stack-wide, not project-scoped. For `project-*` commands it + additionally resolves the numeric `project_id` from config. The alias must be + registered (`kbagent project list`); `project-*` also requires it to carry a + `project_id`. + +3. **No dedicated "project features list" endpoint.** `feature project-show` + reads the `features` array off `GET /manage/projects/{id}`; `feature + user-show` reads it off `GET /manage/users/{email}`. There is no + `/projects/{id}/features` GET. Only the add (`POST .../features`, body + `{"feature": NAME}`) and remove (`DELETE .../features/{name}`) verbs are + per-resource. + +4. **Request body is `{"feature": NAME}`, not `{"name": NAME}`.** The add + endpoints take the feature code under the key `feature`. (Some third-party + notes claim `name` -- that is wrong for this API.) + +5. **Feature schema is opaque + shape-variable.** The Manage API publishes no + feature schema, and a `features` array may come back as a list of objects OR + a list of bare strings depending on stack/endpoint. kbagent normalises both + to `{name, title, description, type, ...}` (bare strings become + `{"name": s}`) and passes unknown keys through unmodified. Treat `name` as + the only stable field; do not depend on `title`/`type` being populated. + +To inspect a project's *enabled* features without a super-admin token, use +`kbagent project info --project P` (read-only) instead -- it returns the enabled +feature list among other project metadata. diff --git a/pyproject.toml b/pyproject.toml index 13b4bf9d..80b75973 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keboola-agent-cli" -version = "0.47.2" +version = "0.48.0" description = "AI-friendly CLI for managing Keboola projects" readme = "README.md" requires-python = ">=3.12" diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index a59ecea2..603daaf7 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -8,6 +8,9 @@ # Ordered newest-first. Each value is a list of brief one-line descriptions. CHANGELOG: dict[str, list[str]] = { + "0.48.0": [ + "New: `kbagent feature` command group for managing Keboola feature flags via the Manage API (requires a super-admin manage token, the same kind `org setup` uses). Seven subcommands: `feature list --project ALIAS` (the stack-wide feature catalogue, GET /manage/features); `feature project-show --project ALIAS` (features assigned to a project, read from the project object's `features` array); `feature project-add` / `feature project-remove --project ALIAS --feature NAME [--dry-run] [--yes]` (POST/DELETE /manage/projects/{id}/features); and `feature user-show` / `feature user-add` / `feature user-remove --project ALIAS --email EMAIL [--feature NAME]` for per-user features (GET/POST/DELETE /manage/users/{email}/features). The `--project ALIAS` resolves the stack URL (and, for project ops, the numeric project_id) from the kbagent config -- the alias is the only handle needed. The manage token follows the same default-deny policy as `org`: read from an interactive hidden prompt, never persisted, never a CLI argument; pass the top-level `--allow-env-manage-token` to read `KBC_MANAGE_API_TOKEN` from env (CI/CD). Write paths support `--dry-run` and an interactive confirm (skip with `--yes`). Permission classes: `list` / `*-show` = read, `*-add` = admin, `*-remove` = destructive. The Manage API has no published feature schema, so the new `Feature` model treats only `name` as stable and passes extras through unmodified; project/user `features` arrays returned as bare strings are normalised to `{name: ...}`. New layers: `ManageClient.list_features` / `add_project_feature` / `remove_project_feature` / `get_user` / `add_user_feature` / `remove_user_feature` (email + feature url-encoded in the path; the POST add paths tolerate a 204 No Content body), `FeatureService`, `commands/feature.py`, and a 1:1 `kbagent serve` REST router (`server/routers/feature.py`, 7 endpoints, each requiring the `X-Manage-Token` header). Human-mode tables are adaptive: the stack catalogue keeps Title/Type/Description, while project/user views (returned by the Manage API as bare strings) collapse to just Name. Tests: `tests/test_feature_service.py` (19), `tests/test_feature_cli.py` (21), `tests/test_manage_client.py` + `tests/test_models.py` extensions; read-only E2E `tests/test_e2e.py::test_feature_flags_read_e2e` (opt-in via `make test-e2e-feature`).", + ], "0.47.2": [ "Fix (`sync push`, fresh-CREATE variable binding): a transformation scaffolded alongside its sibling `keboola.variables` config + default-values row is now runnable after a single `sync push`. Three defects in the create pass are fixed together. (KFR-04) The row's `values: [...]` array was silently dropped because `local_row_to_api` only hoisted `values` into the API body when the row file already carried a `_keboola.component_id`; the two push callers now pass the known `component_id` explicitly, so a scaffold row without a `_keboola` block still hoists. (KFR-05) Rows whose parent `keboola.variables` config was created **in the same push** raised `PARENT_CONFIG_NOT_TRACKED` (or POSTed against a non-existent placeholder id): `push()` now runs in ordered phases -- configs first, then rows -- capturing each created config's placeholder id -> assigned ULID and remapping every row's `parent_config_id` to the ULID before the manifest lookup and `create_config_row(config_id=...)`. (KFR-03) The transformation's remote `configuration.variables_id` / `variables_values_id` stayed as placeholder dirnames (so `job run` failed with `Variable configuration \"\" not found`); a new Phase-C backfill resolves each placeholder to the ULID assigned this push and PUTs the corrected configuration body via `update_config` (NOT `set_variables`, which would create a second variables config), then rewrites the local `_configuration_extra` and refreshes the manifest `pull_hash` / `pull_config_hash` / `pull_extra_hashes` so a re-push is clean. When the placeholder key misses but exactly one `keboola.variables` config was created this push, it binds to that one with a warning; zero or ambiguous (>1) matches accumulate a `variable_link` error rather than writing a broken link. Downstream (FIIA) can delete its post-push `config variables-set` workaround. Tests: `tests/test_sync_config_format.py::TestLocalRowToApiComponentIdParam`, `tests/test_sync_service.py::TestFreshCreateVariableBinding` (end-to-end bindings, idempotent re-push, single-config fallback, ambiguous-config error), plus an E2E (`job run --wait` -> success) in `tests/test_e2e.py`.", "Fix (`sync push --branch `, KFR-07): pushing the local default tree to a target dev branch no longer errors with `Config file not found`. Source (where files live on disk) and target (where the API writes) are now decoupled: when no materialized `/` subtree exists for the target branch, the default tree (`main/`) is read as the source and promoted to the target branch; all API calls still target the branch id. A new `SyncService._resolve_source_branch_path` drives the local-read path in `push` / `diff` / `_push_create` / `_push_update` / `_push_row_change`; per-config tracked reads continue to use each entry's own `branch_id`. Backward-compatible: when the per-branch subtree exists (multi-branch-directory users), behaviour is unchanged. Tests: `tests/test_sync_service.py::TestFreshCreateVariableBinding::test_resolve_source_branch_path_promotes_default_tree`.", diff --git a/src/keboola_agent_cli/cli.py b/src/keboola_agent_cli/cli.py index ea49f0b3..503091db 100644 --- a/src/keboola_agent_cli/cli.py +++ b/src/keboola_agent_cli/cli.py @@ -15,6 +15,7 @@ from .commands.data_app import data_app_app from .commands.doctor import doctor_command from .commands.encrypt import encrypt_app +from .commands.feature import feature_app from .commands.flow import flow_app from .commands.http_client import http_app from .commands.init import init_command @@ -49,6 +50,7 @@ from .services.deep_lineage_service import DeepLineageService from .services.doctor_service import DoctorService from .services.encrypt_service import EncryptService +from .services.feature_service import FeatureService from .services.flow_service import FlowService from .services.http_forwarder_service import HttpForwarderService from .services.job_service import JobService @@ -91,6 +93,7 @@ _PROJ = "Project Management" app.add_typer(project_app, name="project", rich_help_panel=_PROJ) app.add_typer(org_app, name="org", rich_help_panel=_PROJ) +app.add_typer(feature_app, name="feature", rich_help_panel=_PROJ) # -- Browse & Inspect -- _BROWSE = "Browse & Inspect" @@ -338,6 +341,7 @@ def main( deep_lineage_service = DeepLineageService(config_store=config_store) org_service = OrgService(config_store=config_store) member_service = MemberService(config_store=config_store) + feature_service = FeatureService(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) @@ -398,6 +402,7 @@ def main( ctx.obj["deep_lineage_service"] = deep_lineage_service ctx.obj["org_service"] = org_service ctx.obj["member_service"] = member_service + ctx.obj["feature_service"] = feature_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 83c8eb42..9ea600c1 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -512,6 +512,26 @@ Default-deny since 0.29.0 -- closes the AI-exfiltration risk where subprocesses inherit the manage token via env. +### Feature Flags (since v0.48.0) + + Requires a SUPER-ADMIN Manage API token (same kind as `org setup`). Same + default-deny token policy: interactive hidden prompt by default; pass + top-level --allow-env-manage-token to read KBC_MANAGE_API_TOKEN from env. + --project resolves the stack URL (and, for project ops, the numeric + project_id) from config -- the alias is the only handle you pass. + + kbagent feature list --project ALIAS + Stack-wide feature catalogue (GET /manage/features). + kbagent feature project-show --project ALIAS + Features assigned to a project. + kbagent feature project-add --project ALIAS --feature NAME [--dry-run] [--yes] + kbagent feature project-remove --project ALIAS --feature NAME [--dry-run] [--yes] + Enable / disable a feature on a project. add=admin, remove=destructive. + kbagent feature user-show --project ALIAS --email EMAIL + kbagent feature user-add --project ALIAS --email EMAIL --feature NAME [--dry-run] [--yes] + kbagent feature user-remove --project ALIAS --email EMAIL --feature NAME [--dry-run] [--yes] + Per-user features (GET/POST/DELETE /manage/users/{{email}}/features). + ### Flows (Orchestrator + Conditional) kbagent flow list [--project NAME] [--branch ID] [--with-schedules] diff --git a/src/keboola_agent_cli/commands/feature.py b/src/keboola_agent_cli/commands/feature.py new file mode 100644 index 00000000..729b9d80 --- /dev/null +++ b/src/keboola_agent_cli/commands/feature.py @@ -0,0 +1,311 @@ +"""Feature-flag management commands (super-admin Manage API). + +Thin CLI layer: parses arguments, calls FeatureService, formats output. +No business logic belongs here. + +All operations require a super-admin Manage API token. It is read from an +interactive hidden prompt by default (never persisted, never a CLI argument); +pass the top-level --allow-env-manage-token to read KBC_MANAGE_API_TOKEN from +env (CI/CD). See ``resolve_manage_token`` for the full default-deny policy. +""" + +from __future__ import annotations + +from typing import Any, NoReturn + +import typer +from rich.console import Console +from rich.table import Table + +from ..errors import ConfigError, ErrorCode, KeboolaApiError +from ._helpers import ( + check_cli_permission, + get_formatter, + get_service, + map_error_to_exit_code, + resolve_manage_token, +) + +feature_app = typer.Typer(help="Feature flag management (requires super-admin Manage API token)") + + +@feature_app.callback(invoke_without_command=True) +def _feature_permission_check(ctx: typer.Context) -> None: + check_cli_permission(ctx, "feature") + + +def _handle_errors(formatter: Any, exc: Exception) -> NoReturn: + """Map a ConfigError / KeboolaApiError to a structured error + Exit.""" + if isinstance(exc, ConfigError): + formatter.error(error_code=ErrorCode.CONFIG_ERROR, message=exc.message) + raise typer.Exit(code=5) from None + if isinstance(exc, KeboolaApiError): + exit_code = map_error_to_exit_code(exc) + formatter.error(error_code=exc.error_code, message=exc.message, retryable=exc.retryable) + raise typer.Exit(code=exit_code) from None + raise exc + + +def _format_feature_catalogue(console: Console, data: dict[str, Any]) -> None: + """Render the stack feature catalogue as a Rich table.""" + features = data.get("features") or [] + title = f"Features on {data.get('stack_url', '')} ({len(features)} total)" + console.print(_feature_table(title, features)) + + +# Optional feature columns, in display order: (data key, header, Rich style). +_OPTIONAL_FEATURE_COLUMNS: tuple[tuple[str, str, str | None], ...] = ( + ("title", "Title", None), + ("type", "Type", "dim"), + ("description", "Description", "dim"), +) + + +def _present_optional_columns( + features: list[dict[str, Any]], +) -> list[tuple[str, str, str | None]]: + """Return the optional columns at least one feature actually populates. + + Project/user feature arrays come back from the Manage API as bare strings + (normalised to name-only), so Title/Type/Description would be uniformly + empty -- we drop those columns rather than render dead space. The stack + catalogue, which carries metadata, keeps whichever columns it populates. + """ + return [ + col + for col in _OPTIONAL_FEATURE_COLUMNS + if any(str(feat.get(col[0], "")).strip() for feat in features) + ] + + +def _feature_table(title: str, features: list[dict[str, Any]]) -> Table: + """Build a Rich table for a feature list, omitting empty optional columns.""" + table = Table(title=title) + table.add_column("Name", style="bold cyan") + columns = _present_optional_columns(features) + for _key, header, style in columns: + table.add_column(header, style=style) + for feat in features: + row = [feat.get("name", ""), *(str(feat.get(key, "")) for key, _, _ in columns)] + table.add_row(*row) + return table + + +def _format_assigned_features(console: Console, data: dict[str, Any]) -> None: + """Render features assigned to a project or user as a Rich table.""" + features = data.get("features") or [] + owner = ( + f"project [cyan]{data.get('alias')}[/cyan] (id={data.get('project_id')})" + if "project_id" in data + else f"user [cyan]{data.get('email')}[/cyan]" + ) + if not features: + console.print(f"No features assigned to {owner}.") + return + console.print(_feature_table(f"Features assigned to {owner} ({len(features)} total)", features)) + + +def _format_write_result(console: Console, data: dict[str, Any]) -> None: + """Render the outcome of an add/remove operation.""" + status = data.get("status", "") + feature = data.get("feature", "") + target = ( + f"project [cyan]{data.get('alias')}[/cyan] (id={data.get('project_id')})" + if "project_id" in data + else f"user [cyan]{data.get('email')}[/cyan]" + ) + if status == "dry_run": + verb = "add to" if data.get("action") == "add" else "remove from" + console.print( + f"[bold yellow]DRY RUN[/bold yellow] would {verb} {target}: feature [bold]{feature}[/bold]" + ) + elif status == "added": + console.print(f"[bold green]Added[/bold green] feature [bold]{feature}[/bold] to {target}.") + elif status == "removed": + console.print(f"[bold red]Removed[/bold red] feature [bold]{feature}[/bold] from {target}.") + + +# ── Stack catalogue ─────────────────────────────────────────────────── + + +@feature_app.command("list") +def feature_list( + ctx: typer.Context, + project: str = typer.Option( + ..., "--project", "-p", help="Project alias (used to resolve the stack URL)" + ), +) -> None: + """List all feature flags defined on the stack.""" + formatter = get_formatter(ctx) + manage_token = resolve_manage_token(allow_env=ctx.obj["allow_env_manage_token"]) + service = get_service(ctx, "feature_service") + try: + result = service.list_stack_features(manage_token=manage_token, alias=project) + except (ConfigError, KeboolaApiError) as exc: + _handle_errors(formatter, exc) + formatter.output(result, _format_feature_catalogue) + + +# ── Project features ────────────────────────────────────────────────── + + +@feature_app.command("project-show") +def feature_project_show( + ctx: typer.Context, + project: str = typer.Option(..., "--project", "-p", help="Project alias"), +) -> None: + """Show feature flags assigned to a project.""" + formatter = get_formatter(ctx) + manage_token = resolve_manage_token(allow_env=ctx.obj["allow_env_manage_token"]) + service = get_service(ctx, "feature_service") + try: + result = service.list_project_features(manage_token=manage_token, alias=project) + except (ConfigError, KeboolaApiError) as exc: + _handle_errors(formatter, exc) + formatter.output(result, _format_assigned_features) + + +@feature_app.command("project-add") +def feature_project_add( + ctx: typer.Context, + project: str = typer.Option(..., "--project", "-p", help="Project alias"), + feature: str = typer.Option(..., "--feature", "-f", help="Feature name to enable"), + dry_run: bool = typer.Option(False, "--dry-run", help="Preview without making changes"), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"), +) -> None: + """Enable a feature flag on a project.""" + formatter = get_formatter(ctx) + if ( + not dry_run + and not formatter.json_mode + and not yes + and not typer.confirm(f"Add feature '{feature}' to project {project}?") + ): + formatter.console.print("Aborted.") + raise typer.Exit(code=0) + manage_token = resolve_manage_token(allow_env=ctx.obj["allow_env_manage_token"]) + service = get_service(ctx, "feature_service") + try: + result = service.add_project_feature( + manage_token=manage_token, alias=project, feature=feature, dry_run=dry_run + ) + except (ConfigError, KeboolaApiError) as exc: + _handle_errors(formatter, exc) + formatter.output(result, _format_write_result) + + +@feature_app.command("project-remove") +def feature_project_remove( + ctx: typer.Context, + project: str = typer.Option(..., "--project", "-p", help="Project alias"), + feature: str = typer.Option(..., "--feature", "-f", help="Feature name to disable"), + dry_run: bool = typer.Option(False, "--dry-run", help="Preview without making changes"), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"), +) -> None: + """Disable a feature flag on a project (destructive).""" + formatter = get_formatter(ctx) + if ( + not dry_run + and not formatter.json_mode + and not yes + and not typer.confirm( + f"Remove feature '{feature}' from project {project}? This is destructive." + ) + ): + formatter.console.print("Aborted.") + raise typer.Exit(code=0) + manage_token = resolve_manage_token(allow_env=ctx.obj["allow_env_manage_token"]) + service = get_service(ctx, "feature_service") + try: + result = service.remove_project_feature( + manage_token=manage_token, alias=project, feature=feature, dry_run=dry_run + ) + except (ConfigError, KeboolaApiError) as exc: + _handle_errors(formatter, exc) + formatter.output(result, _format_write_result) + + +# ── User features ───────────────────────────────────────────────────── + + +@feature_app.command("user-show") +def feature_user_show( + ctx: typer.Context, + project: str = typer.Option( + ..., "--project", "-p", help="Project alias (used to resolve the stack URL)" + ), + email: str = typer.Option(..., "--email", "-e", help="User email address"), +) -> None: + """Show feature flags assigned to a user.""" + formatter = get_formatter(ctx) + manage_token = resolve_manage_token(allow_env=ctx.obj["allow_env_manage_token"]) + service = get_service(ctx, "feature_service") + try: + result = service.list_user_features(manage_token=manage_token, alias=project, email=email) + except (ConfigError, KeboolaApiError) as exc: + _handle_errors(formatter, exc) + formatter.output(result, _format_assigned_features) + + +@feature_app.command("user-add") +def feature_user_add( + ctx: typer.Context, + project: str = typer.Option( + ..., "--project", "-p", help="Project alias (used to resolve the stack URL)" + ), + email: str = typer.Option(..., "--email", "-e", help="User email address"), + feature: str = typer.Option(..., "--feature", "-f", help="Feature name to enable"), + dry_run: bool = typer.Option(False, "--dry-run", help="Preview without making changes"), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"), +) -> None: + """Enable a feature flag on a user.""" + formatter = get_formatter(ctx) + if ( + not dry_run + and not formatter.json_mode + and not yes + and not typer.confirm(f"Add feature '{feature}' to user {email}?") + ): + formatter.console.print("Aborted.") + raise typer.Exit(code=0) + manage_token = resolve_manage_token(allow_env=ctx.obj["allow_env_manage_token"]) + service = get_service(ctx, "feature_service") + try: + result = service.add_user_feature( + manage_token=manage_token, alias=project, email=email, feature=feature, dry_run=dry_run + ) + except (ConfigError, KeboolaApiError) as exc: + _handle_errors(formatter, exc) + formatter.output(result, _format_write_result) + + +@feature_app.command("user-remove") +def feature_user_remove( + ctx: typer.Context, + project: str = typer.Option( + ..., "--project", "-p", help="Project alias (used to resolve the stack URL)" + ), + email: str = typer.Option(..., "--email", "-e", help="User email address"), + feature: str = typer.Option(..., "--feature", "-f", help="Feature name to disable"), + dry_run: bool = typer.Option(False, "--dry-run", help="Preview without making changes"), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"), +) -> None: + """Disable a feature flag on a user (destructive).""" + formatter = get_formatter(ctx) + if ( + not dry_run + and not formatter.json_mode + and not yes + and not typer.confirm(f"Remove feature '{feature}' from user {email}? This is destructive.") + ): + formatter.console.print("Aborted.") + raise typer.Exit(code=0) + manage_token = resolve_manage_token(allow_env=ctx.obj["allow_env_manage_token"]) + service = get_service(ctx, "feature_service") + try: + result = service.remove_user_feature( + manage_token=manage_token, alias=project, email=email, feature=feature, dry_run=dry_run + ) + except (ConfigError, KeboolaApiError) as exc: + _handle_errors(formatter, exc) + formatter.output(result, _format_write_result) diff --git a/src/keboola_agent_cli/manage_client.py b/src/keboola_agent_cli/manage_client.py index b52faec1..f3e5ba93 100644 --- a/src/keboola_agent_cli/manage_client.py +++ b/src/keboola_agent_cli/manage_client.py @@ -8,6 +8,7 @@ """ from typing import Any +from urllib.parse import quote from .constants import DEFAULT_TIMEOUT from .http_base import BaseHttpClient @@ -226,3 +227,98 @@ def update_project_member_role( json={"role": role}, ) return response.json() + + # ------------------------------------------------------------------ + # Feature flags (super-admin manage token required). + # + # The stack-wide catalogue lives at GET /manage/features. Features + # assigned to a single project/user are NOT a dedicated endpoint -- + # they are read from the ``features`` array on the project/user object. + # Endpoint + payload shapes mirror the curl recipes verified by the + # platform team: the POST body is ``{"feature": ""}`` and the + # DELETE targets ``.../features/{name}``. + # ------------------------------------------------------------------ + + def list_features(self) -> list[dict[str, Any]]: + """List all features defined on the stack (the catalogue). + + Returns: + List of feature dicts. Field set is not contractually fixed; + callers should treat unknown keys as opaque. ``name`` is the + stable identifier used by the add/remove endpoints. + + Raises: + KeboolaApiError: On API errors (e.g. 403 without super admin). + """ + response = self._do_request("GET", "/manage/features") + return response.json() + + def add_project_feature(self, project_id: int, feature: str) -> dict[str, Any]: + """Enable a feature on a project. + + Args: + project_id: The numeric project ID. + feature: The feature name (as listed by :meth:`list_features`). + + Returns: + The API response body (project or feature payload, stack-dependent). + + Raises: + KeboolaApiError: On API errors. + """ + response = self._do_request( + "POST", f"/manage/projects/{project_id}/features", json={"feature": feature} + ) + # Most stacks return 201 with a JSON body, but some return 204 No + # Content; guard against JSONDecodeError on an empty body. + return response.json() if response.content else {} + + def remove_project_feature(self, project_id: int, feature: str) -> None: + """Disable a feature on a project. Returns 204 No Content on success.""" + self._do_request( + "DELETE", + f"/manage/projects/{project_id}/features/{quote(feature, safe='')}", + ) + + def get_user(self, email: str) -> dict[str, Any]: + """Get a user by email, including the ``features`` array. + + Args: + email: The user's email address (the public-facing key). + + Returns: + User dict with at least ``id``, ``email`` and ``features``. + + Raises: + KeboolaApiError: On API errors (e.g. 404 if the user is unknown). + """ + response = self._do_request("GET", f"/manage/users/{quote(email, safe='@')}") + return response.json() + + def add_user_feature(self, email: str, feature: str) -> dict[str, Any]: + """Enable a feature on a user. + + Args: + email: The user's email address. + feature: The feature name (as listed by :meth:`list_features`). + + Returns: + The API response body (user or feature payload, stack-dependent). + + Raises: + KeboolaApiError: On API errors. + """ + response = self._do_request( + "POST", + f"/manage/users/{quote(email, safe='@')}/features", + json={"feature": feature}, + ) + # See add_project_feature: tolerate a 204 No Content body. + return response.json() if response.content else {} + + def remove_user_feature(self, email: str, feature: str) -> None: + """Disable a feature on a user. Returns 204 No Content on success.""" + self._do_request( + "DELETE", + f"/manage/users/{quote(email, safe='@')}/features/{quote(feature, safe='')}", + ) diff --git a/src/keboola_agent_cli/models.py b/src/keboola_agent_cli/models.py index 013cc767..8375df5b 100644 --- a/src/keboola_agent_cli/models.py +++ b/src/keboola_agent_cli/models.py @@ -207,6 +207,29 @@ class ProjectMember(BaseModel): model_config = {"populate_by_name": True, "extra": "allow"} +class Feature(BaseModel): + """A Keboola feature flag, from GET /manage/features or a project/user object. + + The Manage API has no published schema for features and the field set + varies by stack version. Only ``name`` is treated as stable -- it is the + identifier passed to the add/remove endpoints. Every field defaults to a + safe empty value and extras pass through unmodified so ``--json`` output + keeps whatever the stack returned (``id``, ``projectFeature``, + ``adminFeature``, ``canBeManagedViaApi``, ...). + + Features embedded in a project/user ``features`` array may be returned as + bare strings rather than objects; the service layer normalises those to + ``{"name": }`` before validation. + """ + + name: str = Field(default="", description="Feature code -- the value used to add/remove it") + title: str = Field(default="", description="Human-readable name shown in the UI") + description: str = Field(default="") + type: str = Field(default="", description="Feature category (project | admin | global | ...)") + + model_config = {"populate_by_name": True, "extra": "allow"} + + class InvitationUser(BaseModel): """Invited user inside an Invitation object.""" diff --git a/src/keboola_agent_cli/permissions.py b/src/keboola_agent_cli/permissions.py index 51fe1a42..67c45af7 100644 --- a/src/keboola_agent_cli/permissions.py +++ b/src/keboola_agent_cli/permissions.py @@ -31,6 +31,15 @@ "project.invitation-cancel": "admin", "project.member-remove": "destructive", "project.member-set-role": "admin", + # Feature flags (super-admin manage token). Reads are safe; enabling a + # feature is an org-level decision (admin); removing one is destructive. + "feature.list": "read", + "feature.project-show": "read", + "feature.project-add": "admin", + "feature.project-remove": "destructive", + "feature.user-show": "read", + "feature.user-add": "admin", + "feature.user-remove": "destructive", # Config browsing & management "config.list": "read", "config.detail": "read", diff --git a/src/keboola_agent_cli/server/app.py b/src/keboola_agent_cli/server/app.py index 874fe42b..1a4eef66 100644 --- a/src/keboola_agent_cli/server/app.py +++ b/src/keboola_agent_cli/server/app.py @@ -39,6 +39,7 @@ configs, data_apps, encrypt, + feature, flows, health, jobs, @@ -99,6 +100,17 @@ "Mirrors `kbagent org setup|refresh`." ), }, + { + "name": "feature", + "description": ( + "**Project Management.** " + "List the stack feature-flag catalogue and enable/disable " + "features on projects and users (Manage API). Requires the " + "`X-Manage-Token` header (super-admin) on every request -- the " + "manage token is never persisted in config. " + "Mirrors `kbagent feature list|project-*|user-*`." + ), + }, # ---- Configurations ---- { "name": "configs", @@ -300,7 +312,7 @@ Sections below are grouped roughly the same way `kbagent --help` groups its command tree: -- **Project Management** -- projects, members, org +- **Project Management** -- projects, members, org, feature flags - **Configurations** -- configs, components, encrypt - **Data** -- storage, search, sharing - **Execution** -- jobs, flows, schedules, data-apps, workspaces @@ -544,6 +556,7 @@ async def _generic_handler(_request, exc: Exception): app.include_router(health.router) app.include_router(projects.router) app.include_router(members.router) + app.include_router(feature.router) app.include_router(configs.router) app.include_router(components.router) app.include_router(storage.router) @@ -727,6 +740,7 @@ def _is_ui_public(method: str, path: str) -> bool: "/search", "/semantic-layer", "/org", + "/feature", "/agents", "/members", "/health", diff --git a/src/keboola_agent_cli/server/dependencies.py b/src/keboola_agent_cli/server/dependencies.py index decc29f4..a03a4f4f 100644 --- a/src/keboola_agent_cli/server/dependencies.py +++ b/src/keboola_agent_cli/server/dependencies.py @@ -21,6 +21,7 @@ from ..services.deep_lineage_service import DeepLineageService from ..services.doctor_service import DoctorService from ..services.encrypt_service import EncryptService +from ..services.feature_service import FeatureService from ..services.flow_service import FlowService from ..services.job_service import JobService from ..services.kai_service import KaiService @@ -77,6 +78,7 @@ class ServiceRegistry: search: SearchService = field(init=False) org: OrgService = field(init=False) member: MemberService = field(init=False) + feature: FeatureService = field(init=False) sync: SyncService = field(init=False) variables: VariablesService = field(init=False) doctor: DoctorService = field(init=False) @@ -108,6 +110,7 @@ def __post_init__(self) -> None: self.search = SearchService(config_store=cs) self.org = OrgService(config_store=cs) self.member = MemberService(config_store=cs) + self.feature = FeatureService(config_store=cs) self.sync = SyncService(config_store=cs) self.variables = VariablesService(config_store=cs) self.doctor = DoctorService(config_store=cs, mcp_service=self.mcp) diff --git a/src/keboola_agent_cli/server/routers/feature.py b/src/keboola_agent_cli/server/routers/feature.py new file mode 100644 index 00000000..cb6b4d6a --- /dev/null +++ b/src/keboola_agent_cli/server/routers/feature.py @@ -0,0 +1,179 @@ +"""Feature-flag endpoints (stack catalogue / project / user) -- all require a manage token. + +1:1 mirror of the `kbagent feature` command group. Every operation hits the +Manage API and therefore needs the per-request Manage token alongside the +standard bearer token, exactly like the `members` and `org` routers. +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from ..dependencies import ServiceRegistry, get_manage_token, get_registry + +router = APIRouter(prefix="/feature", tags=["feature"]) + +# Every feature endpoint hits the Manage API, so each needs the per-request +# Manage token alongside the bearer token. Declaring the joint requirement +# here surfaces it as a separate scheme in the Swagger UI "Authorize" dialog. +_NEEDS_MANAGE_TOKEN: dict[str, Any] = {"security": [{"BearerAuth": [], "ManageToken": []}]} + + +class ProjectFeatureBody(BaseModel): + feature: str + dry_run: bool = False + + +class UserFeatureBody(BaseModel): + email: str + feature: str + dry_run: bool = False + + +def _require_manage(token: str | None) -> str: + if not token: + raise HTTPException(status_code=401, detail="Missing X-Manage-Token header.") + return token + + +@router.get("/{project}/list", summary="Stack feature catalogue", openapi_extra=_NEEDS_MANAGE_TOKEN) +def list_features( + project: str, + manage_token: str | None = Depends(get_manage_token), + registry: ServiceRegistry = Depends(get_registry), +) -> dict[str, Any]: + """Every feature defined on the stack `project` points at. The alias only + resolves the stack URL -- the catalogue is stack-wide. Mirrors + `kbagent feature list`. + """ + return registry.feature.list_stack_features( + manage_token=_require_manage(manage_token), alias=project + ) + + +@router.get( + "/{project}/project-show", + summary="Project's assigned features", + openapi_extra=_NEEDS_MANAGE_TOKEN, +) +def project_show( + project: str, + manage_token: str | None = Depends(get_manage_token), + registry: ServiceRegistry = Depends(get_registry), +) -> dict[str, Any]: + """Features assigned to `project`. Mirrors `kbagent feature project-show`.""" + return registry.feature.list_project_features( + manage_token=_require_manage(manage_token), alias=project + ) + + +@router.post( + "/{project}/project-add", + summary="Enable a feature on a project", + openapi_extra=_NEEDS_MANAGE_TOKEN, +) +def project_add( + project: str, + body: ProjectFeatureBody, + manage_token: str | None = Depends(get_manage_token), + registry: ServiceRegistry = Depends(get_registry), +) -> dict[str, Any]: + """Enable a feature on `project`. Pass `dry_run=true` to preview. Mirrors + `kbagent feature project-add`. + """ + return registry.feature.add_project_feature( + manage_token=_require_manage(manage_token), + alias=project, + feature=body.feature, + dry_run=body.dry_run, + ) + + +@router.post( + "/{project}/project-remove", + summary="Disable a feature on a project", + openapi_extra=_NEEDS_MANAGE_TOKEN, +) +def project_remove( + project: str, + body: ProjectFeatureBody, + manage_token: str | None = Depends(get_manage_token), + registry: ServiceRegistry = Depends(get_registry), +) -> dict[str, Any]: + """Disable a feature on `project` (destructive). Pass `dry_run=true` to + preview. Mirrors `kbagent feature project-remove`. + """ + return registry.feature.remove_project_feature( + manage_token=_require_manage(manage_token), + alias=project, + feature=body.feature, + dry_run=body.dry_run, + ) + + +@router.get( + "/{project}/user-show", + summary="User's assigned features", + openapi_extra=_NEEDS_MANAGE_TOKEN, +) +def user_show( + project: str, + email: str, + manage_token: str | None = Depends(get_manage_token), + registry: ServiceRegistry = Depends(get_registry), +) -> dict[str, Any]: + """Features assigned to `email` on the alias's stack. Mirrors + `kbagent feature user-show`. + """ + return registry.feature.list_user_features( + manage_token=_require_manage(manage_token), alias=project, email=email + ) + + +@router.post( + "/{project}/user-add", + summary="Enable a feature on a user", + openapi_extra=_NEEDS_MANAGE_TOKEN, +) +def user_add( + project: str, + body: UserFeatureBody, + manage_token: str | None = Depends(get_manage_token), + registry: ServiceRegistry = Depends(get_registry), +) -> dict[str, Any]: + """Enable a feature on a user. Pass `dry_run=true` to preview. Mirrors + `kbagent feature user-add`. + """ + return registry.feature.add_user_feature( + manage_token=_require_manage(manage_token), + alias=project, + email=body.email, + feature=body.feature, + dry_run=body.dry_run, + ) + + +@router.post( + "/{project}/user-remove", + summary="Disable a feature on a user", + openapi_extra=_NEEDS_MANAGE_TOKEN, +) +def user_remove( + project: str, + body: UserFeatureBody, + manage_token: str | None = Depends(get_manage_token), + registry: ServiceRegistry = Depends(get_registry), +) -> dict[str, Any]: + """Disable a feature on a user (destructive). Pass `dry_run=true` to + preview. Mirrors `kbagent feature user-remove`. + """ + return registry.feature.remove_user_feature( + manage_token=_require_manage(manage_token), + alias=project, + email=body.email, + feature=body.feature, + dry_run=body.dry_run, + ) diff --git a/src/keboola_agent_cli/services/feature_service.py b/src/keboola_agent_cli/services/feature_service.py new file mode 100644 index 00000000..e9c75858 --- /dev/null +++ b/src/keboola_agent_cli/services/feature_service.py @@ -0,0 +1,268 @@ +"""Feature-flag management service (super-admin Manage API). + +Wraps the stack feature catalogue (``GET /manage/features``) and the +project/user feature assignment endpoints behind a layer that: + +- resolves a kbagent project alias to its ``(stack_url, project_id)`` via + :class:`ConfigStore` (the alias is the only handle a caller needs -- the + numeric project ID and stack URL are looked up, never typed); +- normalises the ``features`` array on a project/user object, which the + Manage API may return either as a list of objects or a list of bare + strings, into a uniform list of :class:`Feature` dicts; +- supports ``dry_run`` previews for the write paths so an agent can show the + user exactly what would change before a super-admin token touches the stack. + +The Manage API token is never persisted -- it is passed in per call from the +interactive prompt resolved by the command layer (see ``resolve_manage_token``). +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from ..config_store import ConfigStore +from ..errors import ConfigError +from ..manage_client import ManageClient +from ..models import Feature + +logger = logging.getLogger(__name__) + +ManageClientFactory = Callable[[str, str], ManageClient] + + +@dataclass(frozen=True) +class _ResolvedAlias: + """A project alias resolved to the two handles project ops need.""" + + stack_url: str + project_id: int + + +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) + + +def _normalise_features(raw: Any) -> list[dict[str, Any]]: + """Normalise a ``features`` payload into a list of Feature dicts. + + The Manage API returns features as either a list of objects or a list of + bare strings depending on the endpoint/stack version. Bare strings are + wrapped as ``{"name": }`` so downstream rendering is uniform. + """ + if not isinstance(raw, list): + return [] + out: list[dict[str, Any]] = [] + for item in raw: + if isinstance(item, str): + out.append(Feature(name=item).model_dump(by_alias=False)) + elif isinstance(item, dict): + out.append(Feature.model_validate(item).model_dump(by_alias=False)) + return out + + +class FeatureService: + """Business logic for stack, project, and user feature flags.""" + + 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 + + # ------------------------------------------------------------------ + # Stack catalogue + # ------------------------------------------------------------------ + + def list_stack_features(self, *, manage_token: str, alias: str) -> dict[str, Any]: + """List every feature defined on the stack the alias points at. + + The alias is used only to resolve the stack URL -- the catalogue is + stack-wide, not project-scoped. + """ + stack_url = self._resolve_stack_url(alias) + manage_client = self._manage_client_factory(stack_url, manage_token) + try: + raw = manage_client.list_features() + return { + "alias": alias, + "stack_url": stack_url, + "features": _normalise_features(raw), + } + finally: + manage_client.close() + + # ------------------------------------------------------------------ + # Project features + # ------------------------------------------------------------------ + + def list_project_features(self, *, manage_token: str, alias: str) -> dict[str, Any]: + """List features assigned to the project registered under ``alias``.""" + resolved = self._resolve_alias(alias) + manage_client = self._manage_client_factory(resolved.stack_url, manage_token) + try: + project = manage_client.get_project(resolved.project_id) + return { + "alias": alias, + "project_id": resolved.project_id, + "project_name": project.get("name", ""), + "features": _normalise_features(project.get("features")), + } + finally: + manage_client.close() + + def add_project_feature( + self, *, manage_token: str, alias: str, feature: str, dry_run: bool = False + ) -> dict[str, Any]: + """Enable ``feature`` on the project registered under ``alias``.""" + resolved = self._resolve_alias(alias) + if dry_run: + return { + "status": "dry_run", + "action": "add", + "alias": alias, + "project_id": resolved.project_id, + "feature": feature, + } + manage_client = self._manage_client_factory(resolved.stack_url, manage_token) + try: + manage_client.add_project_feature(resolved.project_id, feature) + return { + "status": "added", + "alias": alias, + "project_id": resolved.project_id, + "feature": feature, + } + finally: + manage_client.close() + + def remove_project_feature( + self, *, manage_token: str, alias: str, feature: str, dry_run: bool = False + ) -> dict[str, Any]: + """Disable ``feature`` on the project registered under ``alias``.""" + resolved = self._resolve_alias(alias) + if dry_run: + return { + "status": "dry_run", + "action": "remove", + "alias": alias, + "project_id": resolved.project_id, + "feature": feature, + } + manage_client = self._manage_client_factory(resolved.stack_url, manage_token) + try: + manage_client.remove_project_feature(resolved.project_id, feature) + return { + "status": "removed", + "alias": alias, + "project_id": resolved.project_id, + "feature": feature, + } + finally: + manage_client.close() + + # ------------------------------------------------------------------ + # User features + # ------------------------------------------------------------------ + + def list_user_features(self, *, manage_token: str, alias: str, email: str) -> dict[str, Any]: + """List features assigned to ``email`` on the alias's stack.""" + stack_url = self._resolve_stack_url(alias) + manage_client = self._manage_client_factory(stack_url, manage_token) + try: + user = manage_client.get_user(email) + return { + "alias": alias, + "stack_url": stack_url, + "email": email, + "features": _normalise_features(user.get("features")), + } + finally: + manage_client.close() + + def add_user_feature( + self, *, manage_token: str, alias: str, email: str, feature: str, dry_run: bool = False + ) -> dict[str, Any]: + """Enable ``feature`` on the user ``email``.""" + stack_url = self._resolve_stack_url(alias) + if dry_run: + return { + "status": "dry_run", + "action": "add", + "alias": alias, + "email": email, + "feature": feature, + } + manage_client = self._manage_client_factory(stack_url, manage_token) + try: + manage_client.add_user_feature(email, feature) + return { + "status": "added", + "alias": alias, + "email": email, + "feature": feature, + } + finally: + manage_client.close() + + def remove_user_feature( + self, *, manage_token: str, alias: str, email: str, feature: str, dry_run: bool = False + ) -> dict[str, Any]: + """Disable ``feature`` on the user ``email``.""" + stack_url = self._resolve_stack_url(alias) + if dry_run: + return { + "status": "dry_run", + "action": "remove", + "alias": alias, + "email": email, + "feature": feature, + } + manage_client = self._manage_client_factory(stack_url, manage_token) + try: + manage_client.remove_user_feature(email, feature) + return { + "status": "removed", + "alias": alias, + "email": email, + "feature": feature, + } + finally: + manage_client.close() + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _resolve_alias(self, alias: str) -> _ResolvedAlias: + """Resolve ``alias`` to its stack URL + numeric project_id for project ops.""" + 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 _ResolvedAlias(stack_url=project.stack_url, project_id=project.project_id) + + def _resolve_stack_url(self, alias: str) -> str: + """Resolve ``alias`` to its stack URL for stack/user ops. + + Unlike :meth:`_resolve_alias`, this does not require a numeric + project_id -- the stack catalogue and user features are not + project-scoped, the alias is only a handle to the stack URL. + """ + project = self._config_store.get_project(alias) + if project is None: + raise ConfigError( + f"Project alias '{alias}' is not registered. Run `kbagent project list`." + ) + return project.stack_url diff --git a/tests/test_agent_prompt.py b/tests/test_agent_prompt.py index c88910d4..5d11df98 100644 --- a/tests/test_agent_prompt.py +++ b/tests/test_agent_prompt.py @@ -24,8 +24,11 @@ PLUGIN_JSON = PLUGIN_DIR / ".claude-plugin" / "plugin.json" # ~20k tokens ≈ 80 kB in typical English markdown (~4 chars/token). -# We target under 60 kB to leave headroom. -PROMPT_BYTE_BUDGET = 60_000 +# We target well under that to leave headroom. Bumped 60 kB -> 62 kB in +# v0.48.0 to fit the `feature` command-group matrix row; if this keeps +# creeping up, split keboola-expert into per-domain specialists rather +# than raising the ceiling again. +PROMPT_BYTE_BUDGET = 62_000 @pytest.fixture(scope="module") diff --git a/tests/test_e2e.py b/tests/test_e2e.py index e4eede92..834825c5 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -7341,6 +7341,20 @@ def test_swap_without_branch_is_rejected(self) -> None: ), ) +# Feature-flag E2E gate. Requires a SUPER-ADMIN manage token (the same kind +# `org setup` uses). Opt-in via `make test-e2e-feature` -- default-skipped in +# `make test-e2e` because the regular Storage API credentials cannot list or +# read feature flags. +skip_without_feature_credentials = pytest.mark.skipif( + not ( + os.environ.get(ENV_MANAGE_TOKEN) and os.environ.get(ENV_URL) and os.environ.get(ENV_TOKEN) + ), + reason=( + f"Requires {ENV_MANAGE_TOKEN} (super-admin), {ENV_URL}, and {ENV_TOKEN}. " + "Run via `make test-e2e-feature`." + ), +) + @pytest.mark.e2e class TestE2EDataAppLifecycle: @@ -8389,6 +8403,71 @@ def _run(*args: str) -> dict: ) +@skip_without_feature_credentials +@pytest.mark.e2e +def test_feature_flags_read_e2e(tmp_path: Path) -> None: + """Read-only feature-flag check against a real stack (since v0.48.0). + + Verifies the wiring end-to-end with a super-admin manage token: + 1. the stack catalogue (`feature list`) returns a non-empty feature set; + 2. a project's assigned features (`feature project-show`) are readable. + + Deliberately read-only -- it never enables or disables a flag, so it is + safe to run against a live project. The manage token is supplied via env + + the top-level --allow-env-manage-token opt-in (default-deny otherwise). + """ + stack_url = ( + os.environ[ENV_URL] + if os.environ[ENV_URL].startswith("https://") + else f"https://{os.environ[ENV_URL]}" + ) + config_dir = tmp_path / "kbagent-config" + config_dir.mkdir() + alias = "e2e-feature-target" + + env = { + **os.environ, + "KBC_MANAGE_API_TOKEN": os.environ[ENV_MANAGE_TOKEN], + } + + def _run(*args: str) -> Any: + return runner.invoke( + app, + ["--config-dir", str(config_dir), "--allow-env-manage-token", "--json", *args], + env=env, + ) + + # Register the project via a real Storage API token so project_id is + # populated from the token-verify response (feature project-show needs it). + add = _run( + "project", + "add", + "--project", + alias, + "--url", + stack_url, + "--token", + os.environ[ENV_TOKEN], + ) + assert add.exit_code == 0, add.output + + # 1. Stack catalogue -- the super-admin token must see the full feature set. + catalogue = _run("feature", "list", "--project", alias) + assert catalogue.exit_code == 0, catalogue.output + cat_data = json.loads(catalogue.output)["data"] + assert isinstance(cat_data["features"], list) + assert len(cat_data["features"]) > 0, "stack catalogue unexpectedly empty" + # Every catalogue entry carries a stable 'name' identifier. + assert all("name" in feat for feat in cat_data["features"]), cat_data["features"][:3] + + # 2. Project-assigned features -- readable, possibly empty, always a list. + show = _run("feature", "project-show", "--project", alias) + assert show.exit_code == 0, show.output + show_data = json.loads(show.output)["data"] + assert isinstance(show_data["features"], list) + assert show_data["project_id"] is not None + + # --------------------------------------------------------------------------- # MCP-parity commands (since v0.30.0) # --------------------------------------------------------------------------- diff --git a/tests/test_feature_cli.py b/tests/test_feature_cli.py new file mode 100644 index 00000000..7025523f --- /dev/null +++ b/tests/test_feature_cli.py @@ -0,0 +1,593 @@ +"""CLI tests for `kbagent feature` command group (super-admin Manage API). + +Exercises the thin Typer layer in ``commands/feature.py`` via ``CliRunner``. +The ``FeatureService`` is mocked by patching ``keboola_agent_cli.cli.FeatureService`` +so the instance stored in ``ctx.obj["feature_service"]`` is a ``MagicMock``. + +The manage token is sourced through ``resolve_manage_token``: passing the +top-level ``--allow-env-manage-token`` flag plus ``KBC_MANAGE_API_TOKEN`` in the +environment bypasses the interactive prompt (same pattern as test_member_cli.py). +""" + +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 ConfigError, ErrorCode, KeboolaApiError +from keboola_agent_cli.models import ProjectConfig + +STACK_URL = "https://connection.us-east4.gcp.keboola.com" +PROJECT_ID = 5725 +ALIAS = "cuesta-master" +EMAIL = "user@example.com" +FEATURE = "queue-v2" +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 + + +def _invoke(config_dir: Path, svc: MagicMock, args: list[str], input_text: str | None = None): + """Invoke the CLI with a mocked FeatureService and env-provided manage token.""" + with ( + patch("keboola_agent_cli.cli.FeatureService", return_value=svc), + patch.dict(os.environ, {"KBC_MANAGE_API_TOKEN": MANAGE_TOKEN}), + ): + return runner.invoke( + app, + [ + "--allow-env-manage-token", + "--config-dir", + str(config_dir), + *args, + ], + input=input_text, + ) + + +class TestFeatureList: + 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.list_stack_features.return_value = { + "stack_url": STACK_URL, + "features": [ + {"name": FEATURE, "title": "Queue v2", "type": "project", "description": "desc"} + ], + } + + result = _invoke(config_dir, svc, ["--json", "feature", "list", "--project", ALIAS]) + + assert result.exit_code == 0, result.output + out = json.loads(result.output) + assert out["status"] == "ok" + assert out["data"]["features"][0]["name"] == FEATURE + svc.list_stack_features.assert_called_once_with(manage_token=MANAGE_TOKEN, alias=ALIAS) + + def test_human_mode_smoke(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + svc = MagicMock() + svc.list_stack_features.return_value = { + "stack_url": STACK_URL, + "features": [ + {"name": FEATURE, "title": "Queue v2", "type": "project", "description": "desc"} + ], + } + + result = _invoke(config_dir, svc, ["feature", "list", "--project", ALIAS]) + + assert result.exit_code == 0, result.output + assert FEATURE in result.output + + def test_config_error_exits_5(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + svc = MagicMock() + svc.list_stack_features.side_effect = ConfigError("unknown alias") + + result = _invoke(config_dir, svc, ["--json", "feature", "list", "--project", ALIAS]) + + assert result.exit_code == 5, result.output + out = json.loads(result.output) + assert out["error"]["code"] == ErrorCode.CONFIG_ERROR + + def test_api_error_maps_to_exit_3(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + svc = MagicMock() + svc.list_stack_features.side_effect = KeboolaApiError( + message="Invalid or expired token", + status_code=401, + error_code=ErrorCode.INVALID_TOKEN, + ) + + result = _invoke(config_dir, svc, ["--json", "feature", "list", "--project", ALIAS]) + + assert result.exit_code == 3, result.output + + +class TestFeatureProjectShow: + 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.list_project_features.return_value = { + "alias": ALIAS, + "project_id": PROJECT_ID, + "features": [{"name": FEATURE, "title": "Queue v2", "description": "desc"}], + } + + result = _invoke(config_dir, svc, ["--json", "feature", "project-show", "--project", ALIAS]) + + assert result.exit_code == 0, result.output + out = json.loads(result.output) + assert out["data"]["features"][0]["name"] == FEATURE + svc.list_project_features.assert_called_once_with(manage_token=MANAGE_TOKEN, alias=ALIAS) + + def test_human_mode_smoke(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + svc = MagicMock() + svc.list_project_features.return_value = { + "alias": ALIAS, + "project_id": PROJECT_ID, + "features": [], + } + + result = _invoke(config_dir, svc, ["feature", "project-show", "--project", ALIAS]) + + assert result.exit_code == 0, result.output + + def test_human_mode_omits_empty_optional_columns(self, tmp_path: Path) -> None: + """Bare-string project features (name-only) drop the Title/Description columns.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + svc = MagicMock() + svc.list_project_features.return_value = { + "alias": ALIAS, + "project_id": PROJECT_ID, + # Mirrors the normalised bare-string shape: name set, rest empty. + "features": [ + {"name": "queuev2", "title": "", "description": "", "type": ""}, + {"name": "storage-types", "title": "", "description": "", "type": ""}, + ], + } + + result = _invoke(config_dir, svc, ["feature", "project-show", "--project", ALIAS]) + + assert result.exit_code == 0, result.output + assert "queuev2" in result.output + # No optional column header should be rendered when every value is empty. + assert "Title" not in result.output + assert "Description" not in result.output + + def test_human_mode_keeps_populated_optional_columns(self, tmp_path: Path) -> None: + """When a feature carries a title, the Title column is shown.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + svc = MagicMock() + svc.list_project_features.return_value = { + "alias": ALIAS, + "project_id": PROJECT_ID, + "features": [{"name": "queuev2", "title": "Queue v2", "description": "", "type": ""}], + } + + result = _invoke(config_dir, svc, ["feature", "project-show", "--project", ALIAS]) + + assert result.exit_code == 0, result.output + assert "Title" in result.output + # Description is still empty across the board, so its column stays hidden. + assert "Description" not in result.output + + +class TestFeatureProjectAdd: + def test_dry_run_skips_confirmation(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + svc = MagicMock() + svc.add_project_feature.return_value = { + "status": "dry_run", + "action": "add", + "alias": ALIAS, + "project_id": PROJECT_ID, + "feature": FEATURE, + } + + # No --yes, no --json, no input: dry-run must short-circuit the prompt. + result = _invoke( + config_dir, + svc, + ["feature", "project-add", "--project", ALIAS, "--feature", FEATURE, "--dry-run"], + ) + + assert result.exit_code == 0, result.output + assert "DRY RUN" in result.output + svc.add_project_feature.assert_called_once_with( + manage_token=MANAGE_TOKEN, alias=ALIAS, feature=FEATURE, dry_run=True + ) + + def test_confirm_abort_does_not_call_service(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + svc = MagicMock() + + result = _invoke( + config_dir, + svc, + ["feature", "project-add", "--project", ALIAS, "--feature", FEATURE], + input_text="n\n", + ) + + assert result.exit_code == 0, result.output + assert "Aborted." in result.output + svc.add_project_feature.assert_not_called() + + def test_yes_skips_confirmation_and_adds(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + svc = MagicMock() + svc.add_project_feature.return_value = { + "status": "added", + "alias": ALIAS, + "project_id": PROJECT_ID, + "feature": FEATURE, + } + + result = _invoke( + config_dir, + svc, + ["--json", "feature", "project-add", "--project", ALIAS, "--feature", FEATURE, "--yes"], + ) + + assert result.exit_code == 0, result.output + out = json.loads(result.output) + assert out["data"]["status"] == "added" + svc.add_project_feature.assert_called_once_with( + manage_token=MANAGE_TOKEN, alias=ALIAS, feature=FEATURE, dry_run=False + ) + + def test_api_error_maps_to_exit_code(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + svc = MagicMock() + svc.add_project_feature.side_effect = KeboolaApiError( + message="boom", + status_code=500, + error_code=ErrorCode.API_ERROR, + ) + + result = _invoke( + config_dir, + svc, + ["--json", "feature", "project-add", "--project", ALIAS, "--feature", FEATURE, "--yes"], + ) + + assert result.exit_code == 1, result.output + + +class TestFeatureProjectRemove: + def test_dry_run_skips_confirmation(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + svc = MagicMock() + svc.remove_project_feature.return_value = { + "status": "dry_run", + "action": "remove", + "alias": ALIAS, + "project_id": PROJECT_ID, + "feature": FEATURE, + } + + result = _invoke( + config_dir, + svc, + ["feature", "project-remove", "--project", ALIAS, "--feature", FEATURE, "--dry-run"], + ) + + assert result.exit_code == 0, result.output + assert "DRY RUN" in result.output + svc.remove_project_feature.assert_called_once_with( + manage_token=MANAGE_TOKEN, alias=ALIAS, feature=FEATURE, dry_run=True + ) + + def test_confirm_abort_does_not_call_service(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + svc = MagicMock() + + result = _invoke( + config_dir, + svc, + ["feature", "project-remove", "--project", ALIAS, "--feature", FEATURE], + input_text="n\n", + ) + + assert result.exit_code == 0, result.output + assert "Aborted." in result.output + svc.remove_project_feature.assert_not_called() + + def test_yes_skips_confirmation_and_removes(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + svc = MagicMock() + svc.remove_project_feature.return_value = { + "status": "removed", + "alias": ALIAS, + "project_id": PROJECT_ID, + "feature": FEATURE, + } + + result = _invoke( + config_dir, + svc, + [ + "--json", + "feature", + "project-remove", + "--project", + ALIAS, + "--feature", + FEATURE, + "--yes", + ], + ) + + assert result.exit_code == 0, result.output + out = json.loads(result.output) + assert out["data"]["status"] == "removed" + svc.remove_project_feature.assert_called_once_with( + manage_token=MANAGE_TOKEN, alias=ALIAS, feature=FEATURE, dry_run=False + ) + + +class TestFeatureUserShow: + 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.list_user_features.return_value = { + "email": EMAIL, + "features": [{"name": FEATURE, "title": "Queue v2", "description": "desc"}], + } + + result = _invoke( + config_dir, + svc, + ["--json", "feature", "user-show", "--project", ALIAS, "--email", EMAIL], + ) + + assert result.exit_code == 0, result.output + out = json.loads(result.output) + assert out["data"]["features"][0]["name"] == FEATURE + svc.list_user_features.assert_called_once_with( + manage_token=MANAGE_TOKEN, alias=ALIAS, email=EMAIL + ) + + def test_human_mode_smoke(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + svc = MagicMock() + svc.list_user_features.return_value = {"email": EMAIL, "features": []} + + result = _invoke( + config_dir, + svc, + ["feature", "user-show", "--project", ALIAS, "--email", EMAIL], + ) + + assert result.exit_code == 0, result.output + + +class TestFeatureUserAdd: + def test_dry_run_skips_confirmation(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + svc = MagicMock() + svc.add_user_feature.return_value = { + "status": "dry_run", + "action": "add", + "email": EMAIL, + "feature": FEATURE, + } + + result = _invoke( + config_dir, + svc, + [ + "feature", + "user-add", + "--project", + ALIAS, + "--email", + EMAIL, + "--feature", + FEATURE, + "--dry-run", + ], + ) + + assert result.exit_code == 0, result.output + assert "DRY RUN" in result.output + svc.add_user_feature.assert_called_once_with( + manage_token=MANAGE_TOKEN, alias=ALIAS, email=EMAIL, feature=FEATURE, dry_run=True + ) + + def test_confirm_abort_does_not_call_service(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + svc = MagicMock() + + result = _invoke( + config_dir, + svc, + ["feature", "user-add", "--project", ALIAS, "--email", EMAIL, "--feature", FEATURE], + input_text="n\n", + ) + + assert result.exit_code == 0, result.output + assert "Aborted." in result.output + svc.add_user_feature.assert_not_called() + + def test_yes_skips_confirmation_and_adds(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + svc = MagicMock() + svc.add_user_feature.return_value = { + "status": "added", + "email": EMAIL, + "feature": FEATURE, + } + + result = _invoke( + config_dir, + svc, + [ + "--json", + "feature", + "user-add", + "--project", + ALIAS, + "--email", + EMAIL, + "--feature", + FEATURE, + "--yes", + ], + ) + + assert result.exit_code == 0, result.output + out = json.loads(result.output) + assert out["data"]["status"] == "added" + svc.add_user_feature.assert_called_once_with( + manage_token=MANAGE_TOKEN, alias=ALIAS, email=EMAIL, feature=FEATURE, dry_run=False + ) + + +class TestFeatureUserRemove: + def test_dry_run_skips_confirmation(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + svc = MagicMock() + svc.remove_user_feature.return_value = { + "status": "dry_run", + "action": "remove", + "email": EMAIL, + "feature": FEATURE, + } + + result = _invoke( + config_dir, + svc, + [ + "feature", + "user-remove", + "--project", + ALIAS, + "--email", + EMAIL, + "--feature", + FEATURE, + "--dry-run", + ], + ) + + assert result.exit_code == 0, result.output + assert "DRY RUN" in result.output + svc.remove_user_feature.assert_called_once_with( + manage_token=MANAGE_TOKEN, alias=ALIAS, email=EMAIL, feature=FEATURE, dry_run=True + ) + + def test_confirm_abort_does_not_call_service(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + svc = MagicMock() + + result = _invoke( + config_dir, + svc, + ["feature", "user-remove", "--project", ALIAS, "--email", EMAIL, "--feature", FEATURE], + input_text="n\n", + ) + + assert result.exit_code == 0, result.output + assert "Aborted." in result.output + svc.remove_user_feature.assert_not_called() + + def test_yes_skips_confirmation_and_removes(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + _seed_store(config_dir) + svc = MagicMock() + svc.remove_user_feature.return_value = { + "status": "removed", + "email": EMAIL, + "feature": FEATURE, + } + + result = _invoke( + config_dir, + svc, + [ + "--json", + "feature", + "user-remove", + "--project", + ALIAS, + "--email", + EMAIL, + "--feature", + FEATURE, + "--yes", + ], + ) + + assert result.exit_code == 0, result.output + out = json.loads(result.output) + assert out["data"]["status"] == "removed" + svc.remove_user_feature.assert_called_once_with( + manage_token=MANAGE_TOKEN, alias=ALIAS, email=EMAIL, feature=FEATURE, dry_run=False + ) diff --git a/tests/test_feature_service.py b/tests/test_feature_service.py new file mode 100644 index 00000000..27bccb92 --- /dev/null +++ b/tests/test_feature_service.py @@ -0,0 +1,355 @@ +"""Tests for FeatureService - stack/project/user feature-flag management.""" + +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 +from keboola_agent_cli.models import ProjectConfig +from keboola_agent_cli.services.feature_service import FeatureService + +STACK_URL = "https://connection.us-east4.gcp.keboola.com" +MANAGE_TOKEN = "manage-12345-abcdefghijklmnopqrstuvwxyz0123456789" +PROJECT_ID = 5725 +ALIAS = "cuesta-master" +EMAIL = "max.ottomansky@keboola.com" + + +@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 store_without_project_id(tmp_config_dir: Path) -> ConfigStore: + """ConfigStore with an alias that has no numeric project_id.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project( + "no-id", + ProjectConfig( + stack_url=STACK_URL, + token="901-fake-storage-token-1234567890", + project_name="No ID project", + project_id=None, + ), + ) + 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 + + +# ────────────────────────────────────────────────────────────────────── +# Stack catalogue +# ────────────────────────────────────────────────────────────────────── + + +class TestListStackFeatures: + def test_normalises_dict_features(self, store_with_master, manage_client_factory) -> None: + factory, mock_client = manage_client_factory + mock_client.list_features.return_value = [ + {"name": "queue-v2", "title": "Queue v2", "type": "project"}, + {"name": "snowflake-dwh", "title": "Snowflake"}, + ] + svc = FeatureService(store_with_master, manage_client_factory=factory) + + result = svc.list_stack_features(manage_token=MANAGE_TOKEN, alias=ALIAS) + + assert result["alias"] == ALIAS + assert result["stack_url"] == STACK_URL + assert [f["name"] for f in result["features"]] == ["queue-v2", "snowflake-dwh"] + assert result["features"][0]["title"] == "Queue v2" + # Factory must be bound to the resolved stack URL + token. + factory.assert_called_once_with(STACK_URL, MANAGE_TOKEN) + mock_client.close.assert_called_once() + + def test_normalises_bare_string_features( + self, store_with_master, manage_client_factory + ) -> None: + factory, mock_client = manage_client_factory + mock_client.list_features.return_value = ["queue-v2", "snowflake-dwh"] + svc = FeatureService(store_with_master, manage_client_factory=factory) + + result = svc.list_stack_features(manage_token=MANAGE_TOKEN, alias=ALIAS) + + assert result["features"] == [ + {"name": "queue-v2", "title": "", "description": "", "type": ""}, + {"name": "snowflake-dwh", "title": "", "description": "", "type": ""}, + ] + 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 = FeatureService(store_with_master, manage_client_factory=factory) + + with pytest.raises(ConfigError, match="not registered"): + svc.list_stack_features(manage_token=MANAGE_TOKEN, alias="does-not-exist") + factory.assert_not_called() + + +# ────────────────────────────────────────────────────────────────────── +# Project features +# ────────────────────────────────────────────────────────────────────── + + +class TestListProjectFeatures: + def test_reads_features_from_project_object( + self, store_with_master, manage_client_factory + ) -> None: + factory, mock_client = manage_client_factory + mock_client.get_project.return_value = { + "name": "[Cuesta training] - Master", + "features": [{"name": "queue-v2"}, "input-mapping-default"], + } + svc = FeatureService(store_with_master, manage_client_factory=factory) + + result = svc.list_project_features(manage_token=MANAGE_TOKEN, alias=ALIAS) + + assert result["alias"] == ALIAS + assert result["project_id"] == PROJECT_ID + assert result["project_name"] == "[Cuesta training] - Master" + # Mixed dict + bare string both normalise to the uniform shape. + assert [f["name"] for f in result["features"]] == ["queue-v2", "input-mapping-default"] + mock_client.get_project.assert_called_once_with(PROJECT_ID) + mock_client.close.assert_called_once() + + def test_missing_features_key_yields_empty_list( + self, store_with_master, manage_client_factory + ) -> None: + factory, mock_client = manage_client_factory + mock_client.get_project.return_value = {"name": "X"} + svc = FeatureService(store_with_master, manage_client_factory=factory) + + result = svc.list_project_features(manage_token=MANAGE_TOKEN, alias=ALIAS) + + assert result["features"] == [] + + def test_unknown_alias_raises_config_error( + self, store_with_master, manage_client_factory + ) -> None: + factory, _ = manage_client_factory + svc = FeatureService(store_with_master, manage_client_factory=factory) + + with pytest.raises(ConfigError, match="not registered"): + svc.list_project_features(manage_token=MANAGE_TOKEN, alias="does-not-exist") + + def test_missing_project_id_raises_config_error( + self, store_without_project_id, manage_client_factory + ) -> None: + factory, _ = manage_client_factory + svc = FeatureService(store_without_project_id, manage_client_factory=factory) + + with pytest.raises(ConfigError, match="no numeric project_id"): + svc.list_project_features(manage_token=MANAGE_TOKEN, alias="no-id") + factory.assert_not_called() + + +class TestAddProjectFeature: + def test_live_call(self, store_with_master, manage_client_factory) -> None: + factory, mock_client = manage_client_factory + svc = FeatureService(store_with_master, manage_client_factory=factory) + + result = svc.add_project_feature(manage_token=MANAGE_TOKEN, alias=ALIAS, feature="queue-v2") + + assert result["status"] == "added" + assert result["project_id"] == PROJECT_ID + assert result["feature"] == "queue-v2" + mock_client.add_project_feature.assert_called_once_with(PROJECT_ID, "queue-v2") + 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 = FeatureService(store_with_master, manage_client_factory=factory) + + result = svc.add_project_feature( + manage_token=MANAGE_TOKEN, alias=ALIAS, feature="queue-v2", dry_run=True + ) + + assert result["status"] == "dry_run" + assert result["action"] == "add" + assert result["feature"] == "queue-v2" + factory.assert_not_called() + mock_client.add_project_feature.assert_not_called() + + +class TestRemoveProjectFeature: + def test_live_call(self, store_with_master, manage_client_factory) -> None: + factory, mock_client = manage_client_factory + svc = FeatureService(store_with_master, manage_client_factory=factory) + + result = svc.remove_project_feature( + manage_token=MANAGE_TOKEN, alias=ALIAS, feature="queue-v2" + ) + + assert result["status"] == "removed" + assert result["project_id"] == PROJECT_ID + mock_client.remove_project_feature.assert_called_once_with(PROJECT_ID, "queue-v2") + 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 = FeatureService(store_with_master, manage_client_factory=factory) + + result = svc.remove_project_feature( + manage_token=MANAGE_TOKEN, alias=ALIAS, feature="queue-v2", dry_run=True + ) + + assert result["status"] == "dry_run" + assert result["action"] == "remove" + factory.assert_not_called() + mock_client.remove_project_feature.assert_not_called() + + +# ────────────────────────────────────────────────────────────────────── +# User features +# ────────────────────────────────────────────────────────────────────── + + +class TestListUserFeatures: + def test_reads_features_from_user_object( + self, store_with_master, manage_client_factory + ) -> None: + factory, mock_client = manage_client_factory + mock_client.get_user.return_value = { + "email": EMAIL, + "features": ["admin-ui-beta", {"name": "early-access"}], + } + svc = FeatureService(store_with_master, manage_client_factory=factory) + + result = svc.list_user_features(manage_token=MANAGE_TOKEN, alias=ALIAS, email=EMAIL) + + assert result["alias"] == ALIAS + assert result["stack_url"] == STACK_URL + assert result["email"] == EMAIL + assert [f["name"] for f in result["features"]] == ["admin-ui-beta", "early-access"] + mock_client.get_user.assert_called_once_with(EMAIL) + # User ops resolve the stack URL only -- no numeric project_id required. + factory.assert_called_once_with(STACK_URL, MANAGE_TOKEN) + mock_client.close.assert_called_once() + + def test_works_without_project_id( + self, store_without_project_id, manage_client_factory + ) -> None: + factory, mock_client = manage_client_factory + mock_client.get_user.return_value = {"email": EMAIL, "features": []} + svc = FeatureService(store_without_project_id, manage_client_factory=factory) + + result = svc.list_user_features(manage_token=MANAGE_TOKEN, alias="no-id", email=EMAIL) + + assert result["features"] == [] + 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 = FeatureService(store_with_master, manage_client_factory=factory) + + with pytest.raises(ConfigError, match="not registered"): + svc.list_user_features(manage_token=MANAGE_TOKEN, alias="does-not-exist", email=EMAIL) + + +class TestAddUserFeature: + def test_live_call(self, store_with_master, manage_client_factory) -> None: + factory, mock_client = manage_client_factory + svc = FeatureService(store_with_master, manage_client_factory=factory) + + result = svc.add_user_feature( + manage_token=MANAGE_TOKEN, alias=ALIAS, email=EMAIL, feature="admin-ui-beta" + ) + + assert result["status"] == "added" + assert result["email"] == EMAIL + assert result["feature"] == "admin-ui-beta" + mock_client.add_user_feature.assert_called_once_with(EMAIL, "admin-ui-beta") + 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 = FeatureService(store_with_master, manage_client_factory=factory) + + result = svc.add_user_feature( + manage_token=MANAGE_TOKEN, + alias=ALIAS, + email=EMAIL, + feature="admin-ui-beta", + dry_run=True, + ) + + assert result["status"] == "dry_run" + assert result["action"] == "add" + factory.assert_not_called() + mock_client.add_user_feature.assert_not_called() + + +class TestRemoveUserFeature: + def test_live_call(self, store_with_master, manage_client_factory) -> None: + factory, mock_client = manage_client_factory + svc = FeatureService(store_with_master, manage_client_factory=factory) + + result = svc.remove_user_feature( + manage_token=MANAGE_TOKEN, alias=ALIAS, email=EMAIL, feature="admin-ui-beta" + ) + + assert result["status"] == "removed" + assert result["email"] == EMAIL + mock_client.remove_user_feature.assert_called_once_with(EMAIL, "admin-ui-beta") + 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 = FeatureService(store_with_master, manage_client_factory=factory) + + result = svc.remove_user_feature( + manage_token=MANAGE_TOKEN, + alias=ALIAS, + email=EMAIL, + feature="admin-ui-beta", + dry_run=True, + ) + + assert result["status"] == "dry_run" + assert result["action"] == "remove" + factory.assert_not_called() + mock_client.remove_user_feature.assert_not_called() + + +# ────────────────────────────────────────────────────────────────────── +# close() on error +# ────────────────────────────────────────────────────────────────────── + + +class TestCloseOnError: + def test_close_fires_even_when_client_raises( + self, store_with_master, manage_client_factory + ) -> None: + factory, mock_client = manage_client_factory + mock_client.list_features.side_effect = RuntimeError("boom") + svc = FeatureService(store_with_master, manage_client_factory=factory) + + with pytest.raises(RuntimeError, match="boom"): + svc.list_stack_features(manage_token=MANAGE_TOKEN, alias=ALIAS) + mock_client.close.assert_called_once() diff --git a/tests/test_manage_client.py b/tests/test_manage_client.py index b6d11d84..6f52fe56 100644 --- a/tests/test_manage_client.py +++ b/tests/test_manage_client.py @@ -592,3 +592,225 @@ def test_uses_PATCH_not_PUT(self, httpx_mock) -> None: assert request.method == "PATCH" assert _json.loads(request.read()) == {"role": "guest"} assert result["role"] == "guest" + + +# ────────────────────────────────────────────────────────────────────── +# Feature flags (super-admin manage token required) +# ────────────────────────────────────────────────────────────────────── + + +_FEATURES_RESPONSE = [ + { + "id": 1, + "name": "queuev2", + "title": "Queue v2", + "description": "New job queue", + "type": "project", + "canBeManagedViaApi": True, + }, + { + "id": 2, + "name": "data-apps", + "title": "Data Apps", + "type": "admin", + }, +] + + +class TestListFeatures: + def test_returns_catalogue_list(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{STACK_URL}/manage/features", + method="GET", + json=_FEATURES_RESPONSE, + status_code=200, + ) + with ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN) as client: + result = client.list_features() + assert isinstance(result, list) + assert len(result) == 2 + assert result[0]["name"] == "queuev2" + # Unknown/extra keys round-trip untouched. + assert result[0]["canBeManagedViaApi"] is True + + def test_403_without_super_admin(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{STACK_URL}/manage/features", + method="GET", + json={"error": "Super admin required"}, + status_code=403, + ) + with ( + ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN) as client, + pytest.raises(KeboolaApiError) as exc_info, + ): + client.list_features() + assert exc_info.value.error_code == "ACCESS_DENIED" + assert exc_info.value.status_code == 403 + + +class TestAddProjectFeature: + def test_success_returns_body(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{STACK_URL}/manage/projects/5725/features", + method="POST", + json={"feature": "queuev2", "added": True}, + status_code=201, + ) + with ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN) as client: + result = client.add_project_feature(5725, "queuev2") + assert result["feature"] == "queuev2" + + def test_payload_is_feature_object(self, httpx_mock) -> None: + import json as _json + + httpx_mock.add_response( + url=f"{STACK_URL}/manage/projects/5725/features", + method="POST", + json={}, + status_code=200, + ) + with ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN) as client: + client.add_project_feature(5725, "queuev2") + request = httpx_mock.get_request() + assert request.method == "POST" + assert _json.loads(request.read()) == {"feature": "queuev2"} + + def test_404_unknown_project(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{STACK_URL}/manage/projects/999/features", + method="POST", + json={"error": "Project not found"}, + status_code=404, + ) + with ( + ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN) as client, + pytest.raises(KeboolaApiError) as exc_info, + ): + client.add_project_feature(999, "queuev2") + assert exc_info.value.error_code == "NOT_FOUND" + + +class TestRemoveProjectFeature: + def test_returns_none_on_204(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{STACK_URL}/manage/projects/5725/features/queuev2", + method="DELETE", + status_code=204, + ) + with ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN) as client: + assert client.remove_project_feature(5725, "queuev2") is None + + def test_url_encodes_feature_name(self, httpx_mock) -> None: + """A feature name with reserved characters is fully percent-encoded + (quote(..., safe='')), so '/' and ' ' become %2F and %20.""" + httpx_mock.add_response( + url=f"{STACK_URL}/manage/projects/5725/features/vendor%2Ffeat%20flag", + method="DELETE", + status_code=204, + ) + with ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN) as client: + assert client.remove_project_feature(5725, "vendor/feat flag") is None + assert "vendor%2Ffeat%20flag" in str(httpx_mock.get_request().url) + + +class TestGetUser: + def test_success(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{STACK_URL}/manage/users/jane@example.com", + method="GET", + json={ + "id": 42, + "email": "jane@example.com", + "features": ["queuev2", "data-apps"], + }, + status_code=200, + ) + with ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN) as client: + result = client.get_user("jane@example.com") + assert result["id"] == 42 + assert result["email"] == "jane@example.com" + assert result["features"] == ["queuev2", "data-apps"] + + def test_url_keeps_at_and_dot_but_encodes_plus(self, httpx_mock) -> None: + """Email is quote(email, safe='@'): '@' and '.' stay literal, but + sub-address '+' is percent-encoded to %2B.""" + httpx_mock.add_response( + url=f"{STACK_URL}/manage/users/jane%2Btag@example.com", + method="GET", + json={"id": 7, "email": "jane+tag@example.com", "features": []}, + status_code=200, + ) + with ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN) as client: + result = client.get_user("jane+tag@example.com") + url = str(httpx_mock.get_request().url) + assert "jane%2Btag@example.com" in url + assert result["id"] == 7 + + def test_404_unknown_user(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{STACK_URL}/manage/users/nobody@example.com", + method="GET", + json={"error": "User not found"}, + status_code=404, + ) + with ( + ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN) as client, + pytest.raises(KeboolaApiError) as exc_info, + ): + client.get_user("nobody@example.com") + assert exc_info.value.error_code == "NOT_FOUND" + + +class TestAddUserFeature: + def test_success_returns_body(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{STACK_URL}/manage/users/jane@example.com/features", + method="POST", + json={"feature": "queuev2", "added": True}, + status_code=201, + ) + with ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN) as client: + result = client.add_user_feature("jane@example.com", "queuev2") + assert result["feature"] == "queuev2" + + def test_payload_and_encoded_url(self, httpx_mock) -> None: + import json as _json + + httpx_mock.add_response( + url=f"{STACK_URL}/manage/users/jane%2Btag@example.com/features", + method="POST", + json={}, + status_code=200, + ) + with ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN) as client: + client.add_user_feature("jane+tag@example.com", "queuev2") + request = httpx_mock.get_request() + assert request.method == "POST" + assert _json.loads(request.read()) == {"feature": "queuev2"} + assert "jane%2Btag@example.com" in str(request.url) + + +class TestRemoveUserFeature: + def test_returns_none_on_204(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{STACK_URL}/manage/users/jane@example.com/features/queuev2", + method="DELETE", + status_code=204, + ) + with ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN) as client: + assert client.remove_user_feature("jane@example.com", "queuev2") is None + + def test_encodes_both_email_and_feature(self, httpx_mock) -> None: + """Email keeps '@'/'.' (safe='@') while the feature is fully encoded + (safe='').""" + httpx_mock.add_response( + url=(f"{STACK_URL}/manage/users/jane%2Btag@example.com/features/vendor%2Fflag"), + method="DELETE", + status_code=204, + ) + with ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN) as client: + assert client.remove_user_feature("jane+tag@example.com", "vendor/flag") is None + url = str(httpx_mock.get_request().url) + assert "jane%2Btag@example.com" in url + assert "vendor%2Fflag" in url diff --git a/tests/test_models.py b/tests/test_models.py index e1690281..068a43de 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -8,6 +8,7 @@ from keboola_agent_cli.models import ( AppConfig, ErrorResponse, + Feature, ProjectConfig, SuccessResponse, TokenVerifyResponse, @@ -421,6 +422,67 @@ def test_max_workers_negative_rejected(self) -> None: AppConfig(max_parallel_workers=-5) +class TestFeature: + """Tests for the Feature model (Keboola feature flag).""" + + def test_full_object_with_extras_passes_through(self) -> None: + """A feature dict with known fields plus unknown extras validates and + keeps the extras (model_config extra='allow').""" + feature = Feature.model_validate( + { + "name": "queuev2", + "title": "Queue v2", + "description": "New job queue", + "type": "project", + "canBeManagedViaApi": True, + "id": 1, + } + ) + assert feature.name == "queuev2" + assert feature.title == "Queue v2" + assert feature.description == "New job queue" + assert feature.type == "project" + dumped = feature.model_dump() + # Extra keys survive serialization untouched. + assert dumped["canBeManagedViaApi"] is True + assert dumped["id"] == 1 + + def test_defaults_when_empty_dict(self) -> None: + """An empty dict yields safe empty-string defaults for every field.""" + feature = Feature.model_validate({}) + assert feature.name == "" + assert feature.title == "" + assert feature.description == "" + assert feature.type == "" + + def test_minimal_name_only(self) -> None: + """The common normalized shape {'name': } validates and the + other fields fall back to their defaults.""" + feature = Feature.model_validate({"name": "data-apps"}) + assert feature.name == "data-apps" + assert feature.title == "" + assert feature.type == "" + + def test_model_dump_includes_declared_and_extra_fields(self) -> None: + """model_dump emits the declared fields plus any extras.""" + feature = Feature.model_validate({"name": "x", "title": "X", "adminFeature": False}) + dumped = feature.model_dump() + assert dumped["name"] == "x" + assert dumped["title"] == "X" + assert dumped["description"] == "" + assert dumped["type"] == "" + assert dumped["adminFeature"] is False + + def test_json_round_trip_preserves_extras(self) -> None: + """Feature survives a JSON round-trip including extra keys.""" + original = Feature.model_validate( + {"name": "queuev2", "title": "Queue v2", "projectFeature": True} + ) + restored = Feature.model_validate_json(original.model_dump_json()) + assert restored.name == "queuev2" + assert restored.model_dump()["projectFeature"] is True + + class TestProjectConfigBackwardCompat: """Tests for backward compatibility of ProjectConfig with active_branch_id.""" diff --git a/tests/test_server_router_calls.py b/tests/test_server_router_calls.py index 15e102c3..617ee222 100644 --- a/tests/test_server_router_calls.py +++ b/tests/test_server_router_calls.py @@ -332,3 +332,147 @@ def test_config_variables_set_no_dry_run_kwarg(tmp_path: Path) -> None: f"Router must not pass dry_run= to set_variables, but got kwargs={kwargs}" ) assert kwargs.get("variables") == {"KEY": "val"} + + +# --------------------------------------------------------------------------- +# feature.py -- all 7 endpoints require X-Manage-Token and pass it through. +# --------------------------------------------------------------------------- + + +def test_feature_list_passes_manage_token(tmp_path: Path) -> None: + """GET /feature/{p}/list must forward manage_token to list_stack_features.""" + feature_svc = MagicMock() + feature_svc.list_stack_features.return_value = {"features": []} + registry = _mock_registry(feature=feature_svc) + app = _make_app_with_registry(tmp_path, registry) + app.dependency_overrides[get_manage_token] = lambda: "mgmt-tok" + + with TestClient(app) as client: + res = client.get(f"/feature/{PROJECT}/list", headers=AUTH) + + assert res.status_code == 200, res.text + kwargs = feature_svc.list_stack_features.call_args.kwargs + assert kwargs == {"manage_token": "mgmt-tok", "alias": PROJECT} + + +def test_feature_project_show_passes_manage_token(tmp_path: Path) -> None: + feature_svc = MagicMock() + feature_svc.list_project_features.return_value = {"features": []} + registry = _mock_registry(feature=feature_svc) + app = _make_app_with_registry(tmp_path, registry) + app.dependency_overrides[get_manage_token] = lambda: "mgmt-tok" + + with TestClient(app) as client: + res = client.get(f"/feature/{PROJECT}/project-show", headers=AUTH) + + assert res.status_code == 200, res.text + assert feature_svc.list_project_features.call_args.kwargs == { + "manage_token": "mgmt-tok", + "alias": PROJECT, + } + + +def test_feature_project_add_passes_body_and_token(tmp_path: Path) -> None: + feature_svc = MagicMock() + feature_svc.add_project_feature.return_value = {"status": "added"} + registry = _mock_registry(feature=feature_svc) + app = _make_app_with_registry(tmp_path, registry) + app.dependency_overrides[get_manage_token] = lambda: "mgmt-tok" + + with TestClient(app) as client: + res = client.post( + f"/feature/{PROJECT}/project-add", + headers=AUTH, + json={"feature": "data-streams", "dry_run": True}, + ) + + assert res.status_code == 200, res.text + assert feature_svc.add_project_feature.call_args.kwargs == { + "manage_token": "mgmt-tok", + "alias": PROJECT, + "feature": "data-streams", + "dry_run": True, + } + + +def test_feature_project_remove_passes_body_and_token(tmp_path: Path) -> None: + feature_svc = MagicMock() + feature_svc.remove_project_feature.return_value = {"status": "removed"} + registry = _mock_registry(feature=feature_svc) + app = _make_app_with_registry(tmp_path, registry) + app.dependency_overrides[get_manage_token] = lambda: "mgmt-tok" + + with TestClient(app) as client: + res = client.post( + f"/feature/{PROJECT}/project-remove", + headers=AUTH, + json={"feature": "data-streams"}, + ) + + assert res.status_code == 200, res.text + kwargs = feature_svc.remove_project_feature.call_args.kwargs + assert kwargs["manage_token"] == "mgmt-tok" + assert kwargs["feature"] == "data-streams" + assert kwargs["dry_run"] is False + + +def test_feature_user_show_passes_email_and_token(tmp_path: Path) -> None: + feature_svc = MagicMock() + feature_svc.list_user_features.return_value = {"features": []} + registry = _mock_registry(feature=feature_svc) + app = _make_app_with_registry(tmp_path, registry) + app.dependency_overrides[get_manage_token] = lambda: "mgmt-tok" + + with TestClient(app) as client: + res = client.get( + f"/feature/{PROJECT}/user-show", + headers=AUTH, + params={"email": "user@example.com"}, + ) + + assert res.status_code == 200, res.text + assert feature_svc.list_user_features.call_args.kwargs == { + "manage_token": "mgmt-tok", + "alias": PROJECT, + "email": "user@example.com", + } + + +def test_feature_user_add_passes_body_and_token(tmp_path: Path) -> None: + feature_svc = MagicMock() + feature_svc.add_user_feature.return_value = {"status": "added"} + registry = _mock_registry(feature=feature_svc) + app = _make_app_with_registry(tmp_path, registry) + app.dependency_overrides[get_manage_token] = lambda: "mgmt-tok" + + with TestClient(app) as client: + res = client.post( + f"/feature/{PROJECT}/user-add", + headers=AUTH, + json={"email": "user@example.com", "feature": "early-adopter-preview"}, + ) + + assert res.status_code == 200, res.text + assert feature_svc.add_user_feature.call_args.kwargs == { + "manage_token": "mgmt-tok", + "alias": PROJECT, + "email": "user@example.com", + "feature": "early-adopter-preview", + "dry_run": False, + } + + +def test_feature_list_missing_manage_token_returns_401(tmp_path: Path) -> None: + """No X-Manage-Token header -> 401 and the service is never called.""" + feature_svc = MagicMock() + registry = _mock_registry(feature=feature_svc) + app = _make_app_with_registry(tmp_path, registry) + + with TestClient(app) as client: + res = client.get(f"/feature/{PROJECT}/list", headers=AUTH) + + assert res.status_code == 401, res.text + body = res.json() + msg = body.get("detail") or body.get("error", {}).get("message", "") + assert "X-Manage-Token" in msg, f"Expected X-Manage-Token mention, got: {body}" + feature_svc.list_stack_features.assert_not_called() diff --git a/uv.lock b/uv.lock index ee4528dc..7d91b6a7 100644 --- a/uv.lock +++ b/uv.lock @@ -496,7 +496,7 @@ wheels = [ [[package]] name = "keboola-agent-cli" -version = "0.47.2" +version = "0.48.0" source = { editable = "." } dependencies = [ { name = "croniter" },