agnes-the-ai-analyst/tests/test_setup_page_roles.py
ZdenekSrotyr 92d477e422 fix(setup): default /setup to analyst, hide admin tile from non-admins
Three coupled UX fixes for the analyst-onboarding flow:

1. Dashboard "Setup a new Claude Code" CTA was rendering admin paste
   prompt for everyone (analysts couldn't actually execute the marketplace
   plugin install / skills setup steps). render_agent_prompt_banner now
   picks role based on user.is_admin — analysts get the analyst flow.

2. /setup default role changed from admin to analyst. Most visitors are
   analysts; admin layout is opt-in via the admin tile or ?role=admin.

3. Admin tile is admin-only on the role-tile nav. Non-admins see only
   the analyst tile. Server-side: non-admin requesting ?role=admin is
   silently downgraded to analyst (otherwise they'd see admin paste
   prompt despite no tile).

Tests:
- New: test_setup_page_admin_tile_hidden_for_non_admin (anonymous client
  can't see "Admin CLI" or role=admin link)
- New: test_setup_page_admin_role_downgraded_for_non_admin (anonymous
  ?role=admin → analyst layout, no marketplace step in clipboard)
- New: test_install_preview_default_role_is_analyst (admin signing in to
  bare /setup gets analyst clipboard by default)
- Renamed: test_setup_page_default_role_is_admin → ..._is_analyst
- Updated: test_setup_page_admin_clipboard_renders_admin_layout uses
  FastAPI dependency_overrides to inject admin user (admin layout is
  now admin-gated)
- Updated: test_install_preview_visible_for_signed_in_user explicitly
  passes ?role=admin to exercise admin layout
2026-05-04 20:20:37 +02:00

277 lines
12 KiB
Python

"""Tests for /setup role query-param branching.
Task 4 wires `?role=analyst|admin` through the /setup route handler so the
template can render two role tiles and the renderer can pick the right
layout (admin = full marketplace/skills/diagnose flow; analyst = trimmed
workspace-bootstrap flow). Default is `admin` to preserve existing behavior.
"""
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_default_role_is_analyst(client):
"""No `role` query param → analyst layout (most users are analysts;
admin layout is opt-in via the admin tile, which only renders to admins)."""
resp = client.get("/setup", follow_redirects=True)
assert resp.status_code == 200
text = resp.text
# Analyst tile rendered; analyst layout is what the unauthenticated /
# non-admin caller gets by default.
assert "Analyst workspace" in text
assert "role=analyst" in text
def test_setup_page_analyst_role(client):
"""`?role=analyst` → analyst tile is the active one."""
resp = client.get("/setup?role=analyst", follow_redirects=True)
assert resp.status_code == 200
text = resp.text
assert "Analyst workspace" in text
# The page must reflect the analyst selection somewhere — either via
# the active-state CSS class or the `role=analyst` link being rendered.
assert "role=analyst" in text
def test_setup_page_admin_tile_hidden_for_non_admin(client):
"""Non-admin caller (anonymous in this test) must NOT see the admin tile —
the admin paste prompt references admin-only endpoints (marketplace
registration, skills install) that a non-admin PAT can't authenticate
against, so showing it would lead to a confusing failure.
"""
resp = client.get("/setup", follow_redirects=True)
assert resp.status_code == 200
assert "Admin CLI" not in resp.text
assert "role=admin" not in resp.text
def test_setup_page_admin_role_downgraded_for_non_admin(client):
"""Non-admin requesting `?role=admin` is silently downgraded to analyst.
The page must NOT render admin instructions (no `claude plugin marketplace
add` in the rendered prompt) for someone who can't execute them."""
resp = client.get("/setup?role=admin", follow_redirects=True)
assert resp.status_code == 200
# Admin-only steps must NOT appear (would surface admin paste prompt).
assert "claude plugin marketplace add" not in resp.text
# Analyst-only step IS present (downgrade landed on analyst layout).
assert "agnes init" in resp.text
def test_install_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"]
def test_setup_page_invalid_role_falls_back(client):
"""Invalid role values must NOT 500 — either FastAPI's Literal
validation rejects with 422, or the route quietly falls back to admin.
Both are acceptable; what's not acceptable is an unhandled exception.
"""
resp = client.get("/setup?role=hacker", follow_redirects=True)
assert resp.status_code in (200, 422)
def test_setup_page_analyst_js_uses_bootstrap_scope(client):
"""Analyst tile's setupNewClaude JS must mint bootstrap-analyst PATs.
The JS PAT mint must be role-aware: analyst gets a short-TTL
bootstrap-analyst-scoped PAT (server clamps ttl ≤ 3600s), not the
historical 90-day general PAT. Asserts the wiring at the rendered
template level so we catch any regression in either the Jinja ctx
plumbing or the JS branching.
"""
resp = client.get("/setup?role=analyst", follow_redirects=True)
assert resp.status_code == 200
text = resp.text
# The role variable must be set to analyst in JS scope.
assert (
'const ROLE = "analyst"' in text
or 'ROLE = "analyst"' in text
or 'data-role="analyst"' in text
)
# The bootstrap-analyst scope must appear in the JS PAT-mint body.
assert "bootstrap-analyst" in text
assert "ttl_seconds" in text
def test_setup_page_admin_js_uses_general_scope(client):
"""Admin tile's setupNewClaude JS must keep the existing 90-day
expires_in_days behavior — byte-identical PAT mint shape so existing
admin flows don't regress.
"""
resp = client.get("/setup?role=admin", follow_redirects=True)
assert resp.status_code == 200
text = resp.text
assert "expires_in_days" in text # still present in the admin body
def test_setup_page_analyst_clipboard_renders_analyst_layout(client):
"""The clipboard text the analyst tile produces must be the analyst layout
(`agnes init` + `agnes catalog`), NOT the admin layout (`claude plugin
marketplace add` + admin-only flow).
The rendered HTML embeds the role-aware `setup_instructions_lines` text
into a JS array `SETUP_INSTRUCTIONS_TEMPLATE` (see
`_claude_setup_instructions.jinja`); `renderSetupInstructions` substitutes
`{server_url}` / `{token}` into that array at click time. So checking the
embedded array against the served HTML is sufficient — if it carries the
analyst layout, the clipboard payload will too.
Pinning to the JS array block specifically (not the whole page) avoids
false positives from chrome / preview-mode <pre> renders elsewhere.
"""
import re
resp = client.get("/setup?role=analyst", follow_redirects=True)
assert resp.status_code == 200
text = resp.text
# Locate the JS array that holds the clipboard template body. The partial
# emits `var SETUP_INSTRUCTIONS_TEMPLATE = [...].join("\n");` — match
# everything between the `[` and the matching `]` so we can scope the
# assertions to *just* what gets pasted into Claude Code, ignoring the
# preview <pre> block that also embeds the lines.
match = re.search(
r"var\s+SETUP_INSTRUCTIONS_TEMPLATE\s*=\s*\[(.*?)\]\.join\(",
text,
re.DOTALL,
)
assert match, "SETUP_INSTRUCTIONS_TEMPLATE array not found in rendered HTML"
clipboard_block = match.group(1)
# Analyst layout markers MUST be in the clipboard block.
assert "agnes init" in clipboard_block, (
"Analyst clipboard payload missing `agnes init` — is "
"compute_default_agent_prompt(role=role) being threaded into "
"setup_instructions_lines on the /setup route?"
)
assert "agnes catalog" in clipboard_block, (
"Analyst clipboard payload missing `agnes catalog` smoke verify"
)
# Admin-only markers MUST NOT be in the analyst clipboard block.
# If they are, renderSetupInstructions is producing admin layout despite
# the analyst tile being selected — the bug this test guards against.
# `agnes auth import-token` is the admin login step (analyst uses
# `agnes init` which bundles auth + workspace bootstrap).
assert "agnes auth import-token" not in clipboard_block, (
"Analyst clipboard block contains admin-only `agnes auth import-token` "
"— role plumbing is broken; analyst sees admin instructions."
)
assert "agnes skills" not in clipboard_block, (
"Analyst clipboard block contains admin-only skills setup step — "
"analyst layout should not include skills management."
)
def test_setup_page_admin_clipboard_renders_admin_layout(client, monkeypatch):
"""Counterpart to the analyst test — admin caller asking for `?role=admin`
sees the full marketplace/plugins flow.
Admin layout is now admin-gated (non-admins are silently downgraded to
analyst). To exercise the admin path, monkeypatch `get_optional_user` to
return an admin user dict. This avoids spinning up a full session-cookie
fixture for one assertion.
"""
import re
from app.web.router import get_optional_user
from fastapi import Request
async def _admin_user(request: Request): # type: ignore[no-redef]
return {"id": "admin-1", "email": "admin@example.com",
"is_admin": True, "name": "Admin"}
# Override the FastAPI dependency on the running app.
client.app.dependency_overrides[get_optional_user] = _admin_user
try:
resp = client.get("/setup?role=admin", follow_redirects=True)
finally:
client.app.dependency_overrides.pop(get_optional_user, None)
assert resp.status_code == 200
text = resp.text
match = re.search(
r"var\s+SETUP_INSTRUCTIONS_TEMPLATE\s*=\s*\[(.*?)\]\.join\(",
text,
re.DOTALL,
)
assert match, "SETUP_INSTRUCTIONS_TEMPLATE array not found in rendered HTML"
clipboard_block = match.group(1)
# Admin layout marker MUST be present.
assert "agnes auth import-token" in clipboard_block, (
"Admin clipboard payload missing `agnes auth import-token`"
)
assert "agnes skills" in clipboard_block, (
"Admin clipboard payload missing the skills setup step"
)
# Analyst-only marker MUST NOT appear in admin layout.
assert "agnes init" not in clipboard_block, (
"Admin clipboard block leaked the analyst `agnes init` step"
)
def test_setup_page_role_is_json_escaped(client):
"""The `ROLE` JS const must be injected via Jinja `tojson` (defense in
depth) — not as a bare `"{{ role }}"` string interpolation. This makes
JS string-escaping explicit and removes the dependency on Jinja
autoescape semantics for JS contexts.
"""
resp = client.get("/setup?role=analyst", follow_redirects=True)
assert resp.status_code == 200
text = resp.text
# tojson always emits double-quoted JSON: the rendered output is exactly
# `const ROLE = "analyst";` (note: no extra space inside the quotes).
assert 'const ROLE = "analyst";' in text
def test_setup_page_js_ternary_keys_bootstrap_to_analyst(client):
"""Mutation-resistant assertion: the `bootstrap-analyst` scope must sit on
the truthy branch of `ROLE === "analyst"`, not on the falsy branch.
Without this, a silent inversion (`ROLE === "analyst"` ↔ `ROLE !== "analyst"`)
would let analyst tile mint a general PAT and admin tile mint a
bootstrap-scoped PAT — with both substrings still present in served HTML.
The test passes whether the content is queried via either role URL since
the JS ternary itself is identical across role responses.
"""
import re
resp = client.get("/setup?role=admin", follow_redirects=True) # JS body is role-independent
assert resp.status_code == 200
text = resp.text
# Match the ternary `ROLE === "analyst" ? <truthy_branch> : <falsy_branch>`.
# Allow whitespace/newlines, ensure `bootstrap-analyst` is in the truthy branch.
pattern = re.compile(
r'ROLE\s*===\s*"analyst"\s*\?\s*\{[^}]*bootstrap-analyst[^}]*\}\s*:\s*\{[^}]*expires_in_days[^}]*\}',
re.DOTALL,
)
assert pattern.search(text), (
"JS PAT-mint ternary must have `bootstrap-analyst` on the analyst (truthy) "
"branch and `expires_in_days` on the admin (falsy) branch. A silent inversion "
"would let the analyst tile mint a general PAT — exactly the regression we want to catch."
)