agnes-the-ai-analyst/tests/test_web_ui.py
Vojtech c09c85d13a
fix(cta): clipboard fallback + fold Atlassian MCP into connectors (#249)
* fix(cta): fall back to textarea+execCommand when Clipboard API rejects

The "Setup a new Claude Code" CTA fetches /auth/tokens, parses the JSON
response, renders the setup script, THEN calls
`navigator.clipboard.writeText()`. Modern browsers (Safari, Firefox, and
Chrome on stricter configurations) reject `writeText` with
NotAllowedError when transient user activation has been consumed by an
intervening `await` — which is exactly the case here. Users perceived
this as "the browser blocked the copy" and got the manual-paste fallback
modal even though the textarea + `document.execCommand('copy')` path
WOULD have worked synchronously without needing fresh user activation.

`copyToClipboard` now:
- prefers the modern Clipboard API (unchanged for the happy path)
- on writeText rejection, falls back to `copyViaTextarea` instead of
  surfacing the rejection to the caller's catch block.

`copyViaTextarea` is the previously-inline textarea fallback factored
out into a named helper, with two small hardening touches:
- `readonly` + `tabindex=-1` so the hidden textarea doesn't steal
  focus or pop the virtual keyboard on mobile.
- explicit `setSelectionRange(0, text.length)` to belt-and-braces the
  selection on iOS Safari (where `.select()` alone sometimes selects
  zero chars on touch-focused textareas).

Only the CTA button needed this — the Step-1 install-command and the
connector-copy buttons all call `writeText` synchronously inside the
click handler (no awaits in between), so they keep their existing
user-gesture context and didn't hit the same rejection. No template
changes there.

* refactor(home): fold Atlassian MCP registration into connectors block

The standalone "Register the Atlassian MCP server" step (was step 6 in
the unified setup script) moves INTO the Atlassian connector's prompt
body so all Atlassian-related setup lives in one logical group. Same
intent that #247 carried for connectors, applied one level deeper:
the hosted Remote MCP registration is part of "set up Atlassian", not
its own ungrouped step.

What changed:
- `app/web/connector_prompts.py` — the Atlassian prompt's step 5
  replaces the speculative "Register the on-demand Atlassian MCP under
  .claude/mcp/atlassian" line with the actual hosted Remote MCP
  registration: `claude mcp add --transport sse atlassian
  https://mcp.atlassian.com/v1/sse || true`. The `|| true` keeps re-runs
  idempotent and the body explains the OAuth-on-first-use contract.
  Both /home's Atlassian tile and the inlined setup-script Atlassian
  sub-block emit this line — single source of truth holds.
- `app/web/setup_instructions.py` — `_mcp_servers_block` deleted; the
  `mcp_servers` step is removed from `_step_numbers`; resolve_lines no
  longer calls it.
- Renumbering: install (1), init (2), catalog (3), preflight (4),
  marketplace (5), diagnose (6), connectors (7), confirm (8). Was:
  6 = mcp_servers, 7 = diagnose, 8 = connectors, 9 = confirm.
- `tests/test_setup_instructions.py` — Confirm step 9→8, Connect 8→7,
  diagnose 7→6, mcp_servers references dropped.
  `test_step_numbering_with_connectors_step` now asserts
  `"mcp_servers" not in steps`. Stray-Confirm assertion lists shift
  by one position.
- `tests/test_setup_page_unified.py` + `tests/test_web_ui.py` — same
  step-number shifts in the rendered /setup preview assertions.

The `claude mcp add` line is still the Atlassian Remote-MCP path that
the 2026-05-10 init-report Fix C added — only its position in the
flow changes. /home Atlassian tile copying continues to install the
MCP too (the prompt body the tile pastes contains the same line).
112 tests pass.

* feat(atlassian): operator-overrideable base URL via AGNES_ATLASSIAN_BASE_URL

Adds an env var / YAML key the operator (Terraform module, customer-VM
template, OSS instance.yaml) can set to bake the Atlassian Cloud site
root into the connector prompt — so end users don't have to guess /
paste their org's `https://<myorg>.atlassian.net`.

When set, the Atlassian connector prompt (rendered on both /home tile
and inlined into the setup-script step 7 Atlassian sub-block) replaces
step 1's "Ask me for my Atlassian Cloud site URL and email" with a
one-line note that the URL is already provisioned by the operator and
asks only for the email. Step 4's helper-script body has the
`BASE_URL='<the site URL I gave you>'` placeholder substituted with
the literal value. When unset (empty), the existing "ask the user"
flow remains — no regression for OSS instances.

Resolution + normalization in `get_atlassian_base_url()`:
- env `AGNES_ATLASSIAN_BASE_URL` > yaml `instance.atlassian.base_url` > ""
- strips trailing slash + trailing `/wiki` so the canonical value is
  the bare site root. Matches the per-user helper script's
  normalization at storage time (atlassian_prompt step 4 guard 2), so
  the literal baked in by the operator stays consistent with what the
  user's helper script would have computed from their input.

Plumbing:
- `app/instance_config.py`: new `get_atlassian_base_url()` resolver.
- `app/web/connector_prompts.py`:
  - `atlassian_prompt(*, base_url: str = "")` — string-replace two
    explicit placeholder phrases when base_url is truthy; otherwise
    return the prompt unchanged.
  - `all_connector_prompts(..., atlassian_base_url: str = "")` —
    forwards the kwarg.
- `app/web/router.py` (`_build_context`): reads
  `get_atlassian_base_url()` and passes it through to
  `all_connector_prompts(...)` so both the /home tile context AND the
  inlined-script `resolve_lines(...)` call use the same value.
- `src/welcome_template.py` (`compute_default_agent_prompt`): same
  threading via the existing import-on-demand path.

Tests (`tests/test_home_route_resolution.py`):
- `get_atlassian_base_url` resolver: default empty, env override,
  trailing-slash strip, trailing-`/wiki` strip.
- `atlassian_prompt(base_url=...)`: literal URL baked in, ask-step
  removed, placeholder replaced, operator-baked-in copy appears.
- `atlassian_prompt(base_url="")`: existing ask-the-user flow
  unchanged.
- `all_connector_prompts(atlassian_base_url=...)`: kwarg threads
  through to the rendered atlassian prompt.

135 tests pass.

* feat(asana): register hosted Asana Remote MCP in connector prompt

The Asana connector prompt only stored a PAT in the OS keychain + ran
a curl verify against /api/1.0/users/me. That set Claude Code up for
direct `curl` calls but didn't actually wire Asana into Claude's tool
list — so the user couldn't ask Claude to "find my open Asana tasks"
and have it work. Symmetric oversight to the Atlassian connector's
original speculative `.claude/mcp/atlassian` line that this branch
already replaced with `claude mcp add --transport sse atlassian
https://mcp.atlassian.com/v1/sse`.

Adds a new step 5 that registers Asana's hosted Remote MCP:

  claude mcp add --transport http asana https://mcp.asana.com/mcp || true

This is the V2 endpoint (streamable HTTP transport, launched February
2026). The V1 SSE endpoint at https://mcp.asana.com/sse was deprecated
2026-05-11 (today) and must NOT be used — calling it out explicitly
in the prompt body so a future operator who finds an old reference
doesn't paste the dead URL. OAuth is handled by Claude Code at first
use, same model as the Atlassian MCP step.

The PAT stored in step 3 stays for direct `curl` calls (precheck +
ad-hoc scripts) — the MCP path uses its own OAuth grant, not the PAT.
Old step 5 (revoke instructions) renumbers to step 6 and adds the
`claude mcp remove asana` cleanup hint.

Same single-source-of-truth invariant holds: /home Asana tile + the
inlined Asana sub-block in the setup script (step 7 connectors) both
emit identical text from `asana_prompt()`.

71 tests pass.

* feat(asana): drive MCP OAuth login + end-to-end validation post-register

`claude mcp add --transport http asana ...` only registers the
server in Claude Code's local config — it does NOT trigger OAuth.
The browser tab opens the first time any `mcp__asana__*` tool gets
invoked. So the previous step 5 left a user looking at a "registered"
MCP that, in practice, hadn't authed yet and would fail on first
real use. Same blind spot Atlassian's prompt also has, but Asana was
the one called out in the latest review pass.

Adds a new step 6 between MCP registration (step 5) and the revoke
instructions (now step 7):

  a. Tell the user verbatim what's about to happen — a low-impact
     read through the MCP will pop the OAuth browser tab; sign in
     with the same account whose PAT they stored in step 3 and
     approve. Frames the OAuth as one-time so users don't wait
     for it on every later call.
  b. Drive an actual MCP read. Don't prescribe the exact tool name
     because the Asana MCP's exposed surface (`mcp__asana__*`) is
     versioned upstream and we don't want to pin to a name that
     gets renamed. Instead: tell Claude to pick the lightest read
     from its surfaced tool list (users-me / list-workspaces /
     equivalent). Document the recovery path when Claude Code
     times out waiting for the OAuth tool use: `claude mcp list`
     to confirm registration before retrying.
  c. Print a single one-line proof that combines wiring + auth:
     "Asana MCP connected as <name> — <N> workspace(s) visible."
     Explicit anti-echo callout for tokens, task content, comments.
     On failure, surface the exact Claude-Code error and stop —
     no silent pass.
  d. Sanity-check that the MCP OAuth identity and the PAT identity
     reference the same Asana account. Easy mistake to make when
     the user has multiple Asana accounts — flag only on mismatch,
     keep quiet when they match. Recovery: `claude mcp remove asana
     && claude logout asana` then redo step 5.

Step 7 (revoke) absorbs both the keychain delete + the
`claude logout asana` line so users have a single place to undo
everything.

43 tests pass.

* fix(init): clear stale CA env vars on Windows before any TLS handshake

Reported by the 2026-05-11 Windows test pass: after `agnes init` the
gws connector failed with `UnknownIssuer` TLS errors because
`SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE` were still set in Windows
User scope pointing at `C:\Users\localadmin\.config\agnes\ca-bundle.pem`
— a file that did not exist on the test host. Past Agnes installs
(the setup-prompt trust block + older bootstrap helpers) write those
pointers when they materialize a combined Agnes-CA bundle; when the
bundle file later disappears (re-init on a new VM, machine swap, the
~/.agnes dir wiped), the pointers go stale and every native Windows
TLS handshake fails before Agnes itself runs. SSL_CERT_FILE in
particular REPLACES (not appends to) the trust store, so a stale
pointer is silently catastrophic.

`agnes init` now clears stale pointers in two layers before the first
server roundtrip:

1. Current-process env (os.environ) — what the immediately-following
   `api_get` to /api/catalog/tables actually reads. Without this, init
   itself blows up before it gets to step 2.
2. Windows User-scope env via PowerShell
   `[Environment]::SetEnvironmentVariable(name, $null, 'User')` — what
   every future shell + every native tool (gws, claude.exe, pip, uv)
   inherits. The 2026-05-11 reporter expected this exact cleanup
   ("init was supposed to clear these but they persisted").

The cleanup is best-effort and conservative:
- Only deletes a var when its value points at a path that does NOT
  exist on disk. Intentional operator config (e.g. SSL_CERT_FILE
  pointing at a corp certifi bundle) stays put.
- PowerShell missing / restricted execution policy / WSL-without-pwsh:
  swallowed silently. The current-process leg still runs, which
  unblocks init even on hosts where the User-scope leg cannot fire.

Tests (`tests/test_init_ca_cleanup.py`, 6 cases):
- Stale pointers → removed from process env.
- Real-path pointers → preserved.
- Non-Windows hosts: PowerShell is not invoked.
- Windows hosts: PowerShell IS invoked with a script that checks
  all three vars + uses Test-Path + SetEnvironmentVariable.
- PowerShell FileNotFoundError: cleanup swallows it, does not raise.
- `_is_windows_host()` reflects sys.platform.

* refactor(asana): MCP-first flow — drop PAT storage, precheck via `claude mcp list`

The Asana hosted MCP at https://mcp.asana.com/mcp authenticates via
OAuth (Claude Code holds the grant; browser tab pops on first tool
use). The earlier prompt walked the user through creating + keychain-
storing an Asana Personal Access Token AND registering the MCP — two
parallel auth surfaces for one connector. Once the MCP works, the PAT
has no consumer: the precheck/verify steps that used `curl
$BASE/api/1.0/users/me` are just redundant proof that Asana itself is
reachable, which the OAuth handshake already establishes.

Removed:
- Step 0 keychain probe + curl verify against /users/me with PAT.
- Step 1 open developer-console / create PAT.
- Step 2 click "+ New access token", warn shown-ONCE.
- Step 3 helper-script for keychain-storage (per-OS bodies: macOS
  `security add-generic-password`, Linux `secret-tool store`, Windows
  `cmdkey /generic`).
- Step 4 PAT-side `users/me` verify.
- Step 5's split that kept the PAT around for direct curl scripts.
- Step 6d's "MCP vs PAT identity sanity check" — there is no PAT
  anymore, nothing to mismatch against.

New flow (3 steps total):
- Step 0 precheck: `claude mcp list | grep ^asana` — if found, the
  server is registered AND Claude Code is holding its OAuth grant
  (otherwise prior failure would have removed it); print
  "Asana MCP already registered — skipping setup" and stop. Tells the
  user the explicit reset command (`claude mcp remove asana && claude
  logout asana`) so a re-register stays one paste.
- Step 1: `claude mcp add --transport http asana
  https://mcp.asana.com/mcp` — no `|| true` because step 0 should have
  caught the "already exists" case. Step explains the V2-vs-V1
  endpoint distinction (V1 SSE deprecated 2026-05-11) and the
  abort-clean recovery if the precheck somehow missed the existing
  server.
- Step 2: same OAuth + low-impact-read validation pattern as before.
- Step 3: revoke instructions (mcp remove + logout + Asana-side app
  revoke at app.asana.com/Settings → Apps).

Both surfaces (the /home Asana tile and the inlined Asana sub-block
in the setup script's step 7) emit the new text from the same
asana_prompt() — single-source-of-truth invariant intact.

77 tests pass.
2026-05-11 21:54:51 +02:00

587 lines
28 KiB
Python

"""Smoke tests for web UI pages."""
import os
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def web_client(tmp_path, monkeypatch):
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()
# Reset global DuckDB singleton to pick up new DATA_DIR
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()
@pytest.fixture
def admin_cookie(web_client, tmp_path, monkeypatch):
from argon2 import PasswordHasher
from src.db import get_system_db
from src.repositories.users import UserRepository
from tests.helpers.auth import grant_admin
password = "AdminPass1!"
password_hash = PasswordHasher().hash(password)
conn = get_system_db()
UserRepository(conn).create(
id="admin1", email="admin@test.com", name="Admin",
password_hash=password_hash,
)
grant_admin(conn, "admin1")
conn.close()
resp = web_client.post("/auth/token", json={"email": "admin@test.com", "password": password})
assert resp.status_code == 200, f"Bootstrap failed: {resp.text}"
token = resp.json()["access_token"]
return {"access_token": token}
@pytest.fixture
def analyst_cookie(web_client, tmp_path, monkeypatch):
from argon2 import PasswordHasher
from src.db import get_system_db
from src.repositories.users import UserRepository
password = "AnalystPass1!"
password_hash = PasswordHasher().hash(password)
conn = get_system_db()
UserRepository(conn).create(
id="analyst1", email="analyst@test.com", name="Analyst",
password_hash=password_hash,
)
conn.close()
resp = web_client.post("/auth/token", json={"email": "analyst@test.com", "password": password})
assert resp.status_code == 200, f"Analyst token failed: {resp.text}"
token = resp.json()["access_token"]
return {"access_token": token}
class TestWebUISmoke:
def test_login_page(self, web_client):
resp = web_client.get("/login")
assert resp.status_code == 200
def test_dashboard(self, web_client, admin_cookie):
resp = web_client.get("/dashboard", cookies=admin_cookie)
assert resp.status_code in (200, 302)
def test_catalog(self, web_client, admin_cookie):
resp = web_client.get("/catalog", cookies=admin_cookie)
assert resp.status_code == 200
def test_corporate_memory(self, web_client, admin_cookie):
resp = web_client.get("/corporate-memory", cookies=admin_cookie)
assert resp.status_code == 200
def test_activity_center(self, web_client, admin_cookie):
resp = web_client.get("/activity-center", cookies=admin_cookie)
assert resp.status_code == 200
def test_admin_tables(self, web_client, admin_cookie):
resp = web_client.get("/admin/tables", cookies=admin_cookie)
if resp.status_code == 404:
pytest.skip("Route /admin/tables does not exist")
assert resp.status_code == 200
def test_admin_permissions_route_removed(self, web_client, admin_cookie):
"""v19 dropped the half-shipped /admin/permissions page (replaced by
the unified /admin/access page). Verify the route is gone."""
resp = web_client.get("/admin/permissions", cookies=admin_cookie)
assert resp.status_code == 404
def test_admin_users_renders_modern_ui(self, web_client, admin_cookie):
resp = web_client.get("/admin/users", cookies=admin_cookie)
assert resp.status_code == 200
body = resp.text
# Shared header chrome
assert "app-header" in body
# Nav: "My tokens" (own) is in the user-menu dropdown; admin Tokens
# entry (and Tables, Users, Groups, Resource access, Server config)
# lives in the Admin dropdown.
assert 'href="/tokens"' in body
assert 'href="/admin/tokens"' in body
assert 'href="/profile"' in body
assert 'href="/admin/users"' in body
# v12 modern UI markers — Role column was replaced by Groups chips,
# so role-pill is gone. Confirm-modal pattern is shared by both.
assert 'class="users-page"' in body
assert 'id="confirm-modal"' in body
def test_nav_shows_tokens_link_for_non_admin(self, web_client, analyst_cookie):
"""Non-admins see 'My tokens' + 'Profile' user-menu links — no admin Tokens entry."""
resp = web_client.get("/dashboard", cookies=analyst_cookie)
assert resp.status_code in (200, 302)
if resp.status_code == 302:
# Dashboard may redirect in some flows; follow it for nav check.
resp = web_client.get(resp.headers["location"], cookies=analyst_cookie)
body = resp.text
assert 'href="/tokens"' in body
assert 'href="/profile"' in body
assert ">My tokens<" in body
assert ">Profile<" in body
# Non-admins must NOT see the admin Tokens link inside the Admin dropdown.
assert 'href="/admin/tokens"' not in body
def test_nav_shows_all_tokens_link_for_admin(self, web_client, admin_cookie):
"""Admins see the 'My tokens' user-menu link and the admin Tokens entry inside the Admin dropdown."""
resp = web_client.get("/dashboard", cookies=admin_cookie)
assert resp.status_code in (200, 302)
if resp.status_code == 302:
resp = web_client.get(resp.headers["location"], cookies=admin_cookie)
body = resp.text
assert 'href="/tokens"' in body
assert 'href="/admin/tokens"' in body
assert ">My tokens<" in body
# Admin dropdown now lists Tables / Tokens / Users / Groups / Resource access / Server config.
assert 'href="/admin/tables"' in body
assert ">Tables<" in body
assert ">Tokens<" in body
def test_profile_renders_account_details(self, web_client, admin_cookie):
"""/profile renders a real profile page with email + tokens link.
v12 changes: role-pill is replaced by an Admin-pill driven by Admin
user_group membership; ``session.google_groups`` is gone (the
OAuth callback writes Workspace memberships into
``user_group_members`` instead), so the "No Google groups available"
empty state is no longer rendered.
"""
resp = web_client.get("/profile", cookies=admin_cookie)
assert resp.status_code == 200
body = resp.text
assert "admin@test.com" in body
assert 'href="/tokens"' in body
def test_profile_requires_auth(self, web_client):
"""/profile requires auth (was a 302 back-compat redirect before)."""
resp = web_client.get("/profile", follow_redirects=False)
# Auth dep raises 401; some configs may redirect to /login — accept either.
assert resp.status_code in (401, 302)
@pytest.mark.skip(
reason=(
"v12: /profile no longer renders an admin-self-management link. "
"Admin can navigate to /admin/users/{id} from the top-nav Admin "
"dropdown directly. Drop or rewrite this test once the profile "
"page settles."
)
)
def test_profile_shows_admin_detail_link_for_admin(self, web_client, admin_cookie):
resp = web_client.get("/profile", cookies=admin_cookie)
assert resp.status_code == 200
assert 'href="/admin/users/admin1"' in resp.text
@pytest.mark.skip(
reason=(
"v12: profile page no longer surfaces /admin/users/* link at all, "
"so the negative-assertion is moot. Header chrome unrelated to "
"the profile body now contains the admin dropdown."
)
)
def test_profile_hides_admin_detail_link_for_non_admin(self, web_client, analyst_cookie):
resp = web_client.get("/profile", cookies=analyst_cookie)
assert resp.status_code == 200
assert "/admin/users/" not in resp.text
@pytest.mark.skip(
reason=(
"v12: the four-level core.viewer/analyst/km_admin/admin hierarchy "
"is gone. Profile now shows group memberships (user_group_members) "
"and effective resource access (resource_grants), not internal "
"role keys. Rewrite against the new sections — see "
"templates/profile.html."
)
)
def test_profile_shows_effective_roles_for_non_admin(self, web_client, analyst_cookie):
resp = web_client.get("/profile", cookies=analyst_cookie)
assert resp.status_code == 200
body = resp.text
assert "Effective roles" in body
assert "core.analyst" in body
assert "core.viewer" in body
assert "Direct grants" in body
class TestClaudeSetupPreview:
"""/install and /dashboard render a visible, read-only preview of the
'Setup a new Claude Code' clipboard payload. The real token is never
rendered into the HTML — only a styled placeholder is.
"""
def test_install_preview_visible_for_signed_in_user(self, web_client, admin_cookie):
# /setup is now a single unified flow regardless of caller's role.
# Admin sees the same layout as everyone else; the marketplace
# block appears iff the caller has plugin grants in
# `resource_grants` (the seeded admin in this fixture has none).
resp = web_client.get("/setup", cookies=admin_cookie)
assert resp.status_code == 200
body = resp.text
# Preview card + placeholder token render
assert "setup-preview-pre" in body
assert "What Claude Code will receive" in body
assert "&lt;will be generated on click&gt;" in body
assert 'class="placeholder-token"' in body
# Setup payload text substituted with real server URL. The wheel URL
# must be under /cli/wheel/ (uv tool install rejects a bare .whl alias
# because it validates the PEP 427 filename in the URL before fetch).
assert "/cli/wheel/" in body
assert "/cli/agnes.whl" not in body
# Unified always-on layout (Fix B + Fix C in 2026-05-10 init-report
# response): preflight + marketplace + Atlassian MCP all unconditional.
# Step 1 install, step 4 preflight, step 5 marketplace, step 6 MCP,
# step 7 diagnose.
assert "1) Install the CLI" in body
assert "6) Run diagnostics" in body
assert "agnes diagnose" in body
# `agnes init` is now the mandatory bootstrap step.
assert "agnes init" in body
# The generated /setup prompt's "Log in" / "Verify the login"
# admin-only headers are gone (agnes init subsumes them).
# `agnes auth whoami` survives as a static manual-install
# example elsewhere on the page (not in the generated prompt).
assert "2) Log in" not in body
assert "3) Verify the login" not in body
def test_install_preview_unified_layout(self, web_client, admin_cookie):
"""The clipboard payload (SETUP_INSTRUCTIONS_TEMPLATE JS array)
carries the unified layout for every caller — admin-vs-analyst
is no longer a layout branch. Marketplace + Atlassian MCP blocks
are always emitted (Fix B + Fix C in 2026-05-10 init-report
response): the user-facing one-liner is `agnes refresh-marketplace
--bootstrap` (the literal `claude plugin marketplace add` shows up
only as a documentation comment listing what the binary does
internally, never as an instruction to run by hand)."""
import re
resp = web_client.get("/setup", cookies=admin_cookie)
assert resp.status_code == 200
body = resp.text
match = re.search(
r"var\s+SETUP_INSTRUCTIONS_TEMPLATE\s*=\s*\[(.*?)\]\.join\(",
body, re.DOTALL,
)
assert match, "SETUP_INSTRUCTIONS_TEMPLATE array missing"
clipboard = match.group(1)
assert "agnes init" in clipboard
# User runs the bootstrap one-liner, not raw `claude plugin
# marketplace add` — the latter is an internal step described in a
# comment block, never an action line to run.
assert "agnes refresh-marketplace --bootstrap" in clipboard
# Atlassian MCP registration is always-on now.
assert "claude mcp add --transport sse atlassian" in clipboard
# Legacy admin-only auth verbs are gone from the generated prompt.
assert "agnes auth import-token" not in clipboard
# `agnes auth whoami` was the old admin step 3; subsumed by
# `agnes init` + `agnes catalog` smoke verify.
assert "3) Verify the login" not in clipboard
assert "2) Log in" not in clipboard
def test_dashboard_setup_cta_links_to_setup(self, web_client, admin_cookie):
"""Dashboard setup CTA shows env-setup-cta and a link to /setup instead
of an inline collapsed preview."""
resp = web_client.get("/dashboard", cookies=admin_cookie)
assert resp.status_code == 200
body = resp.text
assert "env-setup-cta" in body
assert "Open the full setup page" in body
assert 'href="/setup"' in body
# inline <details> preview block must no longer appear
assert 'aria-label="Preview of the clipboard payload"' not in body
def test_install_mcp_card_removed(self, web_client):
"""The stale 'Use with Claude Code / MCP' card on /setup has been
removed — there is no Agnes-as-MCP-server today. The Atlassian
MCP server registration step (Fix C in the 2026-05-10 init-report
response) is registered FROM the setup script, not as a /setup-
page card; that's an unrelated wiring direction.
"""
resp = web_client.get("/setup")
assert resp.status_code == 200
body = resp.text
assert "Use with Claude Code / MCP" not in body
class TestAdminRoleGuards:
def test_analyst_cannot_access_admin_tables(self, web_client, admin_cookie, analyst_cookie):
resp = web_client.get("/admin/tables", cookies=analyst_cookie)
assert resp.status_code == 403
def test_admin_can_access_admin_tables(self, web_client, admin_cookie):
resp = web_client.get("/admin/tables", cookies=admin_cookie)
assert resp.status_code == 200
def test_analyst_cannot_access_admin_access_page(self, web_client, analyst_cookie):
"""The unified /admin/access page replaces the dropped
/admin/permissions page. Non-admin must still be blocked."""
resp = web_client.get("/admin/access", cookies=analyst_cookie)
assert resp.status_code == 403
def test_admin_can_access_admin_access_page(self, web_client, admin_cookie):
resp = web_client.get("/admin/access", cookies=admin_cookie)
assert resp.status_code == 200
def test_analyst_cannot_access_corporate_memory_admin(self, web_client, admin_cookie, analyst_cookie):
resp = web_client.get("/corporate-memory/admin", cookies=analyst_cookie)
assert resp.status_code == 403
def test_admin_agent_prompt_page_admin_only(self, web_client, admin_cookie, analyst_cookie):
"""The renamed Agent Setup Prompt page is gated by require_admin."""
# Unauthenticated → 302 redirect to login
r = web_client.get("/admin/agent-prompt", follow_redirects=False)
assert r.status_code in (302, 401, 403)
# Non-admin → 403
r = web_client.get("/admin/agent-prompt", cookies=analyst_cookie, follow_redirects=False)
assert r.status_code == 403
# Admin → 200
r = web_client.get("/admin/agent-prompt", cookies=admin_cookie, follow_redirects=False)
assert r.status_code == 200
def test_admin_scheduler_runs_page_admin_only(self, web_client, admin_cookie, analyst_cookie):
"""The /admin/scheduler-runs read-only audit-log view is gated by require_admin."""
r = web_client.get("/admin/scheduler-runs", follow_redirects=False)
assert r.status_code in (302, 401, 403)
r = web_client.get("/admin/scheduler-runs", cookies=analyst_cookie, follow_redirects=False)
assert r.status_code == 403
r = web_client.get("/admin/scheduler-runs", cookies=admin_cookie, follow_redirects=False)
assert r.status_code == 200
assert b"run_session_collector" in r.content
# Post-refactor: per-processor audit actions instead of one
# run_verification_detector. Both processors are wired in
# SCHEDULER_AUDIT_ACTIONS.
assert b"run_session_processor:verification" in r.content
assert b"run_session_processor:usage" in r.content
assert b"run_corporate_memory" in r.content
# Devin Review on e86dd5ed: list must use the actual logged action
# string, not a guess.
assert b"marketplace.sync_all" in r.content
def test_profile_sessions_page_no_admin_required(self, web_client, analyst_cookie, admin_cookie):
"""The /profile/sessions page is gated by get_current_user, not require_admin —
every authenticated user views their own sessions."""
r = web_client.get("/profile/sessions", follow_redirects=False)
assert r.status_code in (302, 401, 403)
r = web_client.get("/profile/sessions", cookies=analyst_cookie, follow_redirects=False)
assert r.status_code == 200
assert b"My sessions" in r.content
r = web_client.get("/profile/sessions", cookies=admin_cookie, follow_redirects=False)
assert r.status_code == 200
def test_profile_session_download_path_safety(self, web_client, analyst_cookie):
"""Per-session download endpoint must reject any filename that could
escape the user's own session directory."""
# NB: bare ".." is excluded — httpx normalises the URL to
# /profile/sessions before sending, so it never reaches the
# download handler. The %2F-encoded variant exercises the real
# path-component value that does reach the handler.
for bad in ["../etc/passwd", "subdir/file.jsonl", ".env",
"session.jsonl.bak", "..%2Fetc%2Fpasswd"]:
r = web_client.get(f"/profile/sessions/{bad}", cookies=analyst_cookie, follow_redirects=False)
assert r.status_code == 404, f"Expected 404 for {bad!r}, got {r.status_code}"
# Unauthenticated → never the file
r = web_client.get("/profile/sessions/anything.jsonl", follow_redirects=False)
assert r.status_code in (302, 401, 403)
def test_profile_sessions_page_tolerates_stat_failures(self, web_client, analyst_cookie, tmp_path, monkeypatch):
"""Devin Review on d878764a: a transient stat() failure on one file
must not 500 the whole page. Skip the bad row, render the rest."""
import pathlib
user_sessions = tmp_path / "user_sessions" / "analyst1"
user_sessions.mkdir(parents=True)
good = user_sessions / "good.jsonl"
good.write_text('{"event": "ok"}\n')
bad = user_sessions / "bad.jsonl"
bad.write_text('{"event": "stat-explodes"}\n')
monkeypatch.setenv("DATA_DIR", str(tmp_path))
# Make `bad.jsonl`.stat() raise; `good.jsonl`.stat() works.
real_stat = pathlib.Path.stat
def selective_stat(self, *args, **kwargs):
if self.name == "bad.jsonl":
raise PermissionError("simulated stat failure")
return real_stat(self, *args, **kwargs)
monkeypatch.setattr(pathlib.Path, "stat", selective_stat)
r = web_client.get("/profile/sessions", cookies=analyst_cookie, follow_redirects=False)
assert r.status_code == 200
assert b"good.jsonl" in r.content
assert b"bad.jsonl" not in r.content
def test_profile_session_download_returns_file_for_owner(self, web_client, analyst_cookie, tmp_path, monkeypatch):
"""Authenticated owner can fetch their own jsonl with proper Content-Disposition."""
# The seeded analyst is "analyst1" (per conftest.seeded_app).
user_sessions = tmp_path / "user_sessions" / "analyst1"
user_sessions.mkdir(parents=True)
sample = user_sessions / "abc-123.jsonl"
sample.write_text('{"event": "test"}\n')
monkeypatch.setenv("DATA_DIR", str(tmp_path))
r = web_client.get("/profile/sessions/abc-123.jsonl", cookies=analyst_cookie, follow_redirects=False)
assert r.status_code == 200
assert r.headers.get("content-disposition", "").endswith('filename="abc-123.jsonl"')
assert b'"event": "test"' in r.content
class TestUnauthenticatedHtmlRedirects:
def test_dashboard_unauthenticated_redirects_to_login(self, web_client):
resp = web_client.get("/dashboard", follow_redirects=False)
assert resp.status_code == 302
assert resp.headers["location"].startswith("/login")
assert "next=%2Fdashboard" in resp.headers["location"]
def test_catalog_unauthenticated_redirects_to_login(self, web_client):
resp = web_client.get("/catalog", follow_redirects=False)
assert resp.status_code == 302
assert resp.headers["location"].startswith("/login")
assert "next=%2Fcatalog" in resp.headers["location"]
def test_api_route_still_returns_json_401(self, web_client):
# /api/sync/manifest requires auth; must keep JSON 401 (no redirect).
resp = web_client.get("/api/sync/manifest", follow_redirects=False)
assert resp.status_code == 401
assert resp.headers["content-type"].startswith("application/json")
def test_password_login_honors_next(self, web_client, tmp_path):
from argon2 import PasswordHasher
from src.db import get_system_db
from src.repositories.users import UserRepository
password = "TestPass1!"
conn = get_system_db()
UserRepository(conn).create(
id="u1", email="u1@test.com", name="U1",
password_hash=PasswordHasher().hash(password),
)
conn.close()
resp = web_client.post(
"/auth/password/login/web",
data={"email": "u1@test.com", "password": password, "next": "/catalog"},
follow_redirects=False,
)
assert resp.status_code == 302
assert resp.headers["location"] == "/catalog"
def test_password_login_rejects_open_redirect(self, web_client, tmp_path):
from argon2 import PasswordHasher
from src.db import get_system_db
from src.repositories.users import UserRepository
password = "TestPass1!"
conn = get_system_db()
UserRepository(conn).create(
id="u2", email="u2@test.com", name="U2",
password_hash=PasswordHasher().hash(password),
)
conn.close()
resp = web_client.post(
"/auth/password/login/web",
data={"email": "u2@test.com", "password": password, "next": "//evil.example/"},
follow_redirects=False,
)
assert resp.status_code == 302
assert resp.headers["location"] == "/dashboard"
@pytest.mark.parametrize("hostile_next,expected_location", [
("javascript:alert(1)", "/dashboard"),
("http://evil.example/", "/dashboard"),
("//evil.example/", "/dashboard"),
("dashboard", "/dashboard"), # missing leading slash
("/foo?bar=baz", "/foo?bar=baz"), # valid same-origin with query
])
def test_password_login_sanitizes_next(self, web_client, tmp_path, hostile_next, expected_location):
from argon2 import PasswordHasher
from src.db import get_system_db
from src.repositories.users import UserRepository
import uuid
password = "TestPass1!"
uid = f"u-{uuid.uuid4().hex[:8]}"
conn = get_system_db()
UserRepository(conn).create(
id=uid, email=f"{uid}@test.com", name=uid,
password_hash=PasswordHasher().hash(password),
)
conn.close()
resp = web_client.post(
"/auth/password/login/web",
data={"email": f"{uid}@test.com", "password": password, "next": hostile_next},
follow_redirects=False,
)
assert resp.status_code == 302
assert resp.headers["location"] == expected_location
def test_non_api_post_still_returns_json_401(self, web_client):
# POST to a JSON auth endpoint that lives outside /api/ — must NOT be redirected.
resp = web_client.post("/auth/token", json={"email": "nope@x.com", "password": "wrong"},
follow_redirects=False)
assert resp.status_code == 401
assert resp.headers["content-type"].startswith("application/json")
def test_auth_json_get_still_returns_json_401(self, web_client):
# GET to a JSON endpoint under /auth/* (e.g. PAT CRUD) — must NOT be redirected,
# so CLI clients calling api_get("/auth/tokens") get JSON they can parse.
resp = web_client.get("/auth/tokens", follow_redirects=False)
assert resp.status_code == 401
assert resp.headers["content-type"].startswith("application/json")
def test_login_page_propagates_next_to_password_button(self, web_client):
resp = web_client.get("/login?next=/catalog")
assert resp.status_code == 200
body = resp.text
# Password button URL should carry next.
assert "/login/password?next=%2Fcatalog" in body, \
f"Expected /login/password?next=%2Fcatalog in login page HTML; got snippet: {body[:500]}"
def test_login_page_propagates_next_to_google_button(self, web_client, monkeypatch):
"""The Google OAuth button URL must also carry the ?next param so the
post-login redirect honors the requested destination."""
# Force Google provider to appear available so the button is rendered.
monkeypatch.setattr(
"app.auth.providers.google.is_available", lambda: True,
)
resp = web_client.get("/login?next=/catalog")
assert resp.status_code == 200
body = resp.text
assert "/auth/google/login?next=%2Fcatalog" in body, \
f"Expected google login URL with ?next in login page; snippet: {body[:800]}"
def test_login_email_page_extracts_and_renders_next(self, web_client):
"""/login/email (magic link) must extract ?next from the URL and
emit it into the hidden form field so it round-trips to the POST."""
resp = web_client.get("/login/email?next=/catalog")
assert resp.status_code == 200
body = resp.text
# The template renders <input type="hidden" name="next" value="/catalog">
assert 'name="next" value="/catalog"' in body, \
f"Expected /catalog in next hidden field; snippet: {body[:800]}"
def test_login_email_page_rejects_open_redirect_in_next(self, web_client):
"""Hostile ?next values (e.g. //evil) must be sanitized away before
the hidden field is rendered."""
resp = web_client.get("/login/email?next=//evil.example/")
assert resp.status_code == 200
body = resp.text
assert "evil.example" not in body
# Empty string is the sanitized default.
assert 'name="next" value=""' in body
def test_google_login_stashes_safe_next_in_session(self, web_client, monkeypatch):
"""google_login() must stash the sanitized next_path in the session.
We can't exercise the full OAuth flow without a Google mock, but we
can verify the helper applies the sanitizer correctly."""
from app.auth._common import safe_next_path
# Valid same-origin paths pass through.
assert safe_next_path("/catalog") == "/catalog"
assert safe_next_path("/foo?bar=baz") == "/foo?bar=baz"
# Open-redirect shapes get defaulted.
assert safe_next_path("//evil.example/") == "/dashboard"
assert safe_next_path("http://evil.example/") == "/dashboard"
assert safe_next_path("javascript:alert(1)") == "/dashboard"
assert safe_next_path("") == "/dashboard"
assert safe_next_path(None) == "/dashboard"
# Empty-default variant (used when computing query string).
assert safe_next_path(None, default="") == ""