Files
handler/tests/test_api_web_management.py
T
Claude 4f05d09c2b feat(web): fully web-managed control plane via a DB command queue
Make credentials/hosts, projects, agents, and approvals manageable from the
dashboard. The API and control layer are separate containers, so the API can't
run control actions directly (no git/tmux/claude, doesn't own the tmux
sessions). Instead the API enqueues a command and a worker in the control
container executes it and writes the result back.

Data model (migration 0003):
- `commands` queue/audit table; `forge_hosts` registry; `approvals` gains a
  nullable approver id + `actor` so operator verdicts are first-class.

Control worker:
- `control/worker.py` claims commands and dispatches to the existing control
  functions (spawn/kill/resume/record_approval/write_skills/poller.sweep),
  plus a periodic CI sweep. New `handler worker` CLI subcommand; it becomes the
  control image's default command (subsumes `poll-ci --watch`).

API:
- `require_admin` gate + `ADMIN_TOKEN`; project GET/PATCH/DELETE; agent
  spawn/kill/delete; resume now enqueues (fixes a cross-container bug where the
  API tried to send tmux keys to a session in the control container); new
  approvals/commands/hosts routes; forge-init and poll-ci enqueue endpoints.

Credentials/hosts:
- host->token-env lookup consults the `forge_hosts` registry first (built-in
  map is the fallback); `resolve()` refactored to a scheme dispatch reserving
  `db:` for a future encrypted store. Web input restricts credential_ref to
  env:/file:/db: (cmd: stays CLI-only — it would run arbitrary commands).

Dashboard:
- New tabs for projects, agents (spawn/kill with live command-status polling),
  approvals, hosts, and an activity/audit view; shared context is now writable.

Tests: +33 (queue atomicity, worker dispatch, CRUD, hosts, admin gating,
cmd: rejection, host-aware credentials, and an API->queue->worker->spawn
end-to-end). README gains a Web management section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CrhrBToauu4L2qG6jdnuFP
2026-07-10 16:32:45 +00:00

156 lines
5.9 KiB
Python

"""Web-management API surface: project/host CRUD, enqueue endpoints, and admin gating.
The conftest sets AUTH_TOKEN=test-token and SHARED_CONTEXT_WRITE_TOKEN=shared-token with no
ADMIN_TOKEN, so the effective admin token is the global test-token. The shared-token is a
valid-but-not-admin bearer: it passes require_auth (reads) but not require_admin (writes),
which is exactly what we use to prove the gate."""
from __future__ import annotations
import pytest
@pytest.fixture
def lowpriv(env):
"""A valid bearer that is NOT the admin token (the shared-context write token)."""
return {"Authorization": f"Bearer {env['shared_token']}"}
def _mk_project(client, auth, pid="proj"):
return client.post("/projects", json={"id": pid, "root_dir": "/tmp/proj"}, headers=auth)
# --- project CRUD ---------------------------------------------------------------------
def test_get_update_delete_project(client, auth):
_mk_project(client, auth)
assert client.get("/projects/proj", headers=auth).json()["id"] == "proj"
remote = "https://github.com/me/p.git"
r = client.patch("/projects/proj", json={"git_remote": remote}, headers=auth)
assert r.status_code == 200 and r.json()["git_remote"] == remote
assert client.delete("/projects/proj", headers=auth).status_code == 200
assert client.get("/projects/proj", headers=auth).status_code == 404
def test_credential_ref_cmd_scheme_rejected(client, auth):
r = client.post(
"/projects",
json={"id": "x", "root_dir": "/tmp/x", "credential_ref": "cmd:cat /etc/passwd"},
headers=auth,
)
assert r.status_code == 422
# env:/file:/db: are accepted.
ok = client.post(
"/projects",
json={"id": "y", "root_dir": "/tmp/y", "credential_ref": "env:TOK"},
headers=auth,
)
assert ok.status_code == 201
def test_patch_project_cmd_scheme_rejected(client, auth):
_mk_project(client, auth)
r = client.patch("/projects/proj", json={"credential_ref": "cmd:whoami"}, headers=auth)
assert r.status_code == 422
# --- admin gating ---------------------------------------------------------------------
def test_reads_allowed_but_writes_need_admin(client, auth, lowpriv):
_mk_project(client, auth)
# low-priv token can read...
assert client.get("/projects", headers=lowpriv).status_code == 200
assert client.get("/hosts", headers=lowpriv).status_code == 200
# ...but not perform admin actions.
patch = client.patch("/projects/proj", json={"root_dir": "/x"}, headers=lowpriv)
assert patch.status_code == 403
assert client.delete("/projects/proj", headers=lowpriv).status_code == 403
host = client.post("/hosts", json={"hostname": "h", "forge_type": "gitea"}, headers=lowpriv)
assert host.status_code == 403
spawn = client.post("/projects/proj/agents/spawn", json={"name": "j"}, headers=lowpriv)
assert spawn.status_code == 403
# --- enqueue endpoints ----------------------------------------------------------------
def test_spawn_enqueues_command(client, auth):
_mk_project(client, auth)
r = client.post(
"/projects/proj/agents/spawn",
json={"name": "junior", "role": "junior", "worktree": "feat/x", "task": "do it"},
headers=auth,
)
assert r.status_code == 202
body = r.json()
assert body["type"] == "spawn" and body["status"] == "queued"
assert body["agent_name"] == "junior"
assert body["payload"]["role"] == "junior" and body["payload"]["worktree"] == "feat/x"
# visible on the commands feed
assert any(c["id"] == body["id"] for c in client.get("/commands", headers=auth).json())
def test_kill_enqueues_command(client, auth):
_mk_project(client, auth)
client.post(
"/projects/proj/agents",
json={"name": "api", "working_dir": "/tmp/proj/api"},
headers=auth,
)
r = client.post("/projects/proj/agents/api/kill", headers=auth)
assert r.status_code == 202 and r.json()["type"] == "kill"
def test_approval_enqueues_correct_command_type(client, auth):
_mk_project(client, auth)
r = client.post(
"/projects/proj/approvals",
json={"branch": "feat/x", "status": "rejected", "note": "nit"},
headers=auth,
)
assert r.status_code == 202
# verdict 'rejected' maps to command type 'reject'
assert r.json()["type"] == "reject"
assert r.json()["payload"]["branch"] == "feat/x"
def test_forge_init_and_poll_ci_enqueue(client, auth):
_mk_project(client, auth)
assert client.post("/projects/proj/forge-init", headers=auth).json()["type"] == "forge_init"
assert client.post("/projects/proj/poll-ci", headers=auth).json()["type"] == "poll_ci"
assert client.post("/poll-ci", headers=auth).json()["project_id"] is None
def test_command_status_polling(client, auth):
_mk_project(client, auth)
cmd = client.post("/projects/proj/poll-ci", headers=auth).json()
got = client.get(f"/commands/{cmd['id']}", headers=auth)
assert got.status_code == 200 and got.json()["id"] == cmd["id"]
assert client.get("/commands/999999", headers=auth).status_code == 404
# --- hosts ----------------------------------------------------------------------------
def test_host_crud(client, auth):
r = client.post(
"/hosts",
json={"hostname": "git.corp", "forge_type": "gitea", "token_env_var": "GITEA_TOKEN"},
headers=auth,
)
assert r.status_code == 201
assert client.get("/hosts/git.corp", headers=auth).json()["token_env_var"] == "GITEA_TOKEN"
patch = client.patch("/hosts/git.corp", json={"base_url": "https://git.corp"}, headers=auth)
assert patch.status_code == 200
assert client.delete("/hosts/git.corp", headers=auth).status_code == 200
assert client.get("/hosts/git.corp", headers=auth).status_code == 404
def test_host_bad_forge_type_422(client, auth):
r = client.post("/hosts", json={"hostname": "h", "forge_type": "svn"}, headers=auth)
assert r.status_code == 422