* 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.
587 lines
28 KiB
Python
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 "<will be generated on click>" 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="") == ""
|