agnes-the-ai-analyst/tests/test_setup_page_unified.py
Minas Arustamyan 50e0463501 feat(marketplace): clone-based plugin setup + auto-refresh SessionStart hook
Adds end-to-end flow for installing and keeping the per-user filtered
Claude Code marketplace in sync with the user's Agnes stack
(admin RBAC grants \ MyAIStack opt-outs U /store installs).

Setup (one-liner in install prompt step 5):
  `agnes refresh-marketplace --bootstrap` clones the per-user marketplace
  bare repo to ~/.agnes/marketplace, strips PAT from the cloned origin
  URL, registers the local path with Claude Code, and installs every
  plugin in the served manifest at --scope project. Replaces a 15-line
  inline shell sequence that tripped Claude Code's agent-driven `rm -rf`
  permission gate.

Auto-refresh (SessionStart hook installed by `agnes init`):
  `agnes refresh-marketplace --quiet` runs every Claude Code session,
  fetches+resets the clone (server rebuilds as orphan commits, so
  pull --ff-only is impossible), and version-aware reconciles:
    - missing in workspace -> claude plugin install <name>@agnes --scope project
    - version differs       -> claude plugin update <name>@agnes
    - matches               -> skip
  Don't auto-uninstall plugins that disappeared from the manifest --
  a transient empty manifest from the server would wipe the stack.

Hook output: when --quiet AND something actually changed, emits Claude
Code hook JSON on stdout -- `systemMessage` (transient toast) and
`hookSpecificOutput.additionalContext` (model-side system reminder),
both carrying the change summary plus a "/exit + restart Claude Code"
instruction (Claude only scans plugins at session start).

Windows hook compatibility: the refresh-marketplace hook command is
wrapped in `bash -c "..."` because Claude Code on Windows runs hook
commands directly without invoking a shell, so `2>/dev/null || true`
would otherwise be passed as literal argv tokens.

Cross-cutting:
  - cli/lib/marketplace.py: shared CLONE_DIR + MARKETPLACE_NAME constants.
  - cli/lib/hooks.py: SessionStart now has two independent entries
    (pull + refresh-marketplace) so a failure in one doesn't suppress
    the other; legacy `da sync` and prior single-pull layouts upgrade
    cleanly on re-init.
  - PAT injection on every git fetch via per-invocation credential
    helper (token in \$AGNES_TOKEN env, never in argv or .git/config).
  - Pre-snapshot of installed plugins captured BEFORE
    `claude plugin marketplace update` so silent auto-applied version
    bumps still fire notifications.
  - scripts/dev/agnes-client-reset.sh: cleans ~/.claude/plugins/marketplaces/agnes,
    ~/.claude/plugins/cache/agnes, drops uv build cache, documents
    workspace-scoped residue that can't be enumerated from the script.
  - app/web/setup_instructions.py: legacy AGNES_DEBUG_AUTH path also
    uses clone (direct HTTPS marketplace add is broken end-to-end on
    every Claude Code distribution -- stores response as single file,
    plugin source paths then 404).

28 new tests (test_cli_refresh_marketplace.py) + extended hook + setup
template tests cover bootstrap, fetch+reset ordering, version-aware
reconcile, project-path filtering, hook JSON shape, and the bash-c
Windows wrapper invariant.
2026-05-07 06:59:13 +02:00

117 lines
4.7 KiB
Python

"""Tests for the unified `/setup` route.
The previous `?role=analyst|admin` query parameter is gone. The route
renders a single layout for everyone — admin-vs-analyst is no longer a
branch. The marketplace + plugins block is gated by per-user
`resource_grants` resolved inside `compute_default_agent_prompt`.
"""
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def client(tmp_path, monkeypatch):
"""TestClient against a freshly-built FastAPI app rooted at tmp_path.
Mirrors the `web_client` fixture in tests/test_web_ui.py — we re-create
the app so the DuckDB singleton picks up the per-test DATA_DIR rather
than leaking state across tests on the same xdist worker.
"""
monkeypatch.setenv("DATA_DIR", str(tmp_path))
monkeypatch.setenv("TESTING", "1")
monkeypatch.setenv("JWT_SECRET_KEY", "test-secret-key-min-32-characters!!")
(tmp_path / "state").mkdir()
(tmp_path / "analytics").mkdir()
(tmp_path / "extracts").mkdir()
from src.db import close_system_db
close_system_db()
from app.main import create_app
app = create_app()
yield TestClient(app)
close_system_db()
def test_setup_page_renders_unified_layout(client):
"""Bare `/setup` (no query param) renders the unified flow:
- `agnes init` is mandatory (subsumes the old admin-only
`agnes auth import-token` + `agnes auth whoami` pair).
- Anonymous visitors with no plugin grants get the no-marketplace
layout (Confirm = step 6).
"""
resp = client.get("/setup", follow_redirects=True)
assert resp.status_code == 200
text = resp.text
# Unified flow markers.
assert "agnes init" in text
# Legacy admin-only login verbs are gone from the rendered prompt.
assert "agnes auth import-token" not in text
# No-marketplace layout: Confirm = step 6.
assert "6) Confirm:" in text
def test_setup_page_ignores_role_query_param(client):
"""`?role=...` is no longer accepted by the route signature. FastAPI
ignores unknown query params silently — `/setup?role=admin` still
serves the unified layout. No 422, no redirect, no behavior delta
vs. bare `/setup`."""
bare = client.get("/setup", follow_redirects=True)
with_role = client.get("/setup?role=admin", follow_redirects=True)
assert bare.status_code == 200
assert with_role.status_code == 200
# Both responses contain the unified-flow marker.
assert "agnes init" in bare.text
assert "agnes init" in with_role.text
# Legacy admin-only login verbs are gone from both.
assert "agnes auth import-token" not in bare.text
assert "agnes auth import-token" not in with_role.text
def test_setup_page_renders_marketplace_for_user_with_grants(client, monkeypatch):
"""When the caller has plugin grants in `resource_grants`, the
unified flow inserts the marketplace + plugins block (step 5) and
Confirm shifts to step 8.
Stub `marketplace_filter.resolve_allowed_plugins` to return a
plugin so we don't have to seed the full marketplace plumbing in
this test — we're verifying the layout switch, not the RBAC
resolver itself (covered by `test_marketplace_filter`)."""
from app.web.router import get_optional_user
from fastapi import Request
from src import marketplace_filter
async def _admin_user(request: Request): # type: ignore[no-redef]
return {"id": "admin-1", "email": "admin@example.com",
"is_admin": True, "name": "Admin", "groups": ["Admin"]}
monkeypatch.setattr(
marketplace_filter,
"resolve_allowed_plugins",
lambda conn, user: [{"manifest_name": "demo-plugin"}],
)
client.app.dependency_overrides[get_optional_user] = _admin_user
try:
resp = client.get("/setup", follow_redirects=True)
finally:
client.app.dependency_overrides.pop(get_optional_user, None)
assert resp.status_code == 200
text = resp.text
# Marketplace block marker. The per-plugin install lines moved inside
# `agnes refresh-marketplace --bootstrap`, so we check the section
# header + the one-liner instead of `claude plugin install <name>@agnes`.
assert "Register the Agnes Claude Code marketplace" in text
assert "agnes refresh-marketplace --bootstrap" in text
# Layout shift: Confirm is now step 8 (was 6 without marketplace).
assert "8) Confirm:" in text
# Pre-flight is in the rendered prompt at step 4.
assert "Make sure git and claude are installed" in text
def test_install_legacy_path_redirects_to_setup(client):
"""`/install` legacy path keeps redirecting to `/setup` (302/307)."""
resp = client.get("/install", follow_redirects=False)
assert resp.status_code in (302, 307)
assert "/setup" in resp.headers["location"]