agnes-the-ai-analyst/tests/test_home_route_resolution.py
Vojtech a46b9dc928
/home install-hero polish: license link contrast, auto-mode reorder, Shift+Tab guidance (#243)
* Make /home install-hero links readable against blue background

The Claude license-options link added in the previous commit inherited
the default `<a>` style (`var(--hp-primary)` blue), which renders as
blue-on-blue and is unreadable inside the blue install-hero. Add a
scoped `.install-hero a` rule that uses white with an underline
(matching the existing lead-paragraph contrast pattern) so any link
nested in the hero stays legible.

* Reorder /home install flow: auto-mode is now Step 2, Agnes install becomes Step 3

Step 3 (was Step 2) pastes a ~20-command bash bootstrap into a fresh
Claude Code session. Without auto-mode enabled first, each Bash/edit
command needs a manual approve click — bad UX for first-time users.

Move auto-mode from the outside-hero `<details>` reference block into
the install-hero as a real Step 2, between "install Claude Code" and
"install Agnes". Content is the persistent `acceptEdits` snippet
(write to ~/.claude/settings.json) plus a one-liner pointing at
Shift+Tab for users who are already inside a running Claude Code
session. YOLO mode for full Bash auto-approve stays on
/setup-advanced behind the existing link.

The outside-hero `setup-collapsible[data-section="step3"]` block is
dropped — auto-mode is no longer reference content, it's a real
install step, and duplicating it would just diverge over time.
Onboarded users no longer see the auto-mode block at all (consistent
with Steps 1 + 3 also hiding post-onboarding).

Completion banner copy updated: "Step 1, 2 & 3 done — Claude Code
installed, auto-mode set, Agnes ready". Dashboard CTA partial and
other templates don't reference step numbers for this flow, so no
adaptation needed there.

* Simplify /home Step 2 to Shift+Tab only — drop the JSON snippet

Operator pointed out two issues with the prior Step 2:

1. The settings.json snippet is redundant. Claude Code's first
   Shift+Tab cycle to auto-accept mode already prompts the user
   whether to persist it as default — Claude writes the config
   itself, no manual file edit needed.

2. The snippet only showed the POSIX path `~/.claude/settings.json`,
   which doesn't translate to native Windows.

Replace the snippet + copy button with a plain Shift+Tab instruction,
explicitly call out the first-time "make this the default?" prompt,
and note that Claude handles the config write itself — same flow on
macOS / Linux / WSL / Windows. Adds a fallback line for users who
already closed the post-OAuth session.

* Tighten /home Step 2 install-note to two paragraphs

Operator: drop the 'Claude writes the setting itself, so this works
the same on macOS / Linux / WSL / Windows...' line plus the
'auto-approves file edits going forward; Bash commands stay gated
— that's the safe default' line. Both were filler — the make-default
prompt already implies persistence, and gated Bash is the obvious
default users won't be surprised by.

Result: paragraph 1 carries Shift+Tab + first-time make-default
say-yes + closed-session fallback in one breath; paragraph 2 keeps
the verbatim YOLO link. Same affordances, less vertical space.
2026-05-11 16:46:58 +00:00

326 lines
12 KiB
Python

"""``get_home_route`` and the ``/`` redirect chain.
Resolution order is env > yaml > default ``/dashboard``. The env path is
the Terraform-overrideable knob — operators set ``AGNES_HOME_ROUTE`` on
the VM without forking instance.yaml. Bad values fall through to the
default rather than producing an external-host redirect.
"""
from __future__ import annotations
import tempfile
import uuid
import pytest
@pytest.fixture
def fresh_db(monkeypatch):
with tempfile.TemporaryDirectory() as tmp:
monkeypatch.setenv("DATA_DIR", tmp)
monkeypatch.setenv("TESTING", "1")
monkeypatch.setenv("JWT_SECRET_KEY", "test-jwt-secret-key-minimum-32-chars!!")
# Ensure the env-var override is unset between tests.
monkeypatch.delenv("AGNES_HOME_ROUTE", raising=False)
yield tmp
def _make_user_and_session(conn, email="u@example.com"):
from src.repositories.users import UserRepository
from app.auth.jwt import create_access_token
uid = str(uuid.uuid4())
UserRepository(conn).create(id=uid, email=email, name=email.split("@")[0])
return uid, create_access_token(user_id=uid, email=email)
def _client():
from fastapi.testclient import TestClient
from app.main import app
return TestClient(app, follow_redirects=False)
def test_default_home_route_is_dashboard(fresh_db, monkeypatch):
monkeypatch.delenv("AGNES_HOME_ROUTE", raising=False)
from app.instance_config import get_home_route
assert get_home_route() == "/dashboard"
def test_env_overrides_default(fresh_db, monkeypatch):
monkeypatch.setenv("AGNES_HOME_ROUTE", "/home")
from app.instance_config import get_home_route
assert get_home_route() == "/home"
def test_env_rejects_external_redirect(fresh_db, monkeypatch):
"""An attacker controlling the env var (or a typo) must not pivot
the root redirect to ``//evil.com`` or ``https://evil.com``."""
monkeypatch.setenv("AGNES_HOME_ROUTE", "//evil.com/path")
from app.instance_config import get_home_route
assert get_home_route() == "/dashboard"
monkeypatch.setenv("AGNES_HOME_ROUTE", "https://evil.com")
assert get_home_route() == "/dashboard"
def test_root_redirect_authed_user_uses_home_route(fresh_db, monkeypatch):
"""``GET /`` for an authenticated user redirects to the configured
home route, not the hard-coded ``/dashboard``."""
monkeypatch.setenv("AGNES_HOME_ROUTE", "/home")
from src.db import get_system_db, close_system_db
conn = get_system_db()
try:
_, sess = _make_user_and_session(conn)
finally:
conn.close()
close_system_db()
c = _client()
resp = c.get("/", cookies={"access_token": sess})
assert resp.status_code == 302
assert resp.headers["location"] == "/home"
def test_root_redirect_unauthed_goes_to_login(fresh_db):
c = _client()
resp = c.get("/")
assert resp.status_code == 302
assert resp.headers["location"] == "/login"
def test_instance_admin_email_default_empty(fresh_db, monkeypatch):
"""Unset env + unset YAML → empty string. Template branches on
truthiness so empty hides the GWS Email-admin button cleanly."""
monkeypatch.delenv("AGNES_INSTANCE_ADMIN_EMAIL", raising=False)
from app.instance_config import get_instance_admin_email
assert get_instance_admin_email() == ""
def test_instance_admin_email_env_overrides(fresh_db, monkeypatch):
"""env var takes precedence over YAML / default."""
monkeypatch.setenv("AGNES_INSTANCE_ADMIN_EMAIL", "ops@example.com")
from app.instance_config import get_instance_admin_email
assert get_instance_admin_email() == "ops@example.com"
def test_instance_admin_email_strips_whitespace(fresh_db, monkeypatch):
"""Operator quoting habits ("` ops@example.com `") shouldn't break the
mailto link — strip surrounding whitespace at the resolver."""
monkeypatch.setenv("AGNES_INSTANCE_ADMIN_EMAIL", " ops@example.com ")
from app.instance_config import get_instance_admin_email
assert get_instance_admin_email() == "ops@example.com"
def test_instance_admin_email_empty_env_treated_as_unset(fresh_db, monkeypatch):
"""Empty-string env var is intentional opt-out, not garbage."""
monkeypatch.setenv("AGNES_INSTANCE_ADMIN_EMAIL", "")
from app.instance_config import get_instance_admin_email
assert get_instance_admin_email() == ""
def test_gws_oauth_default_unset(fresh_db, monkeypatch):
monkeypatch.delenv("AGNES_GWS_CLIENT_ID", raising=False)
monkeypatch.delenv("AGNES_GWS_CLIENT_SECRET", raising=False)
from app.instance_config import get_gws_oauth_credentials
creds = get_gws_oauth_credentials()
assert creds["configured"] is False
assert creds["client_id"] == ""
assert creds["client_secret"] == ""
# OAUTHLIB_INSECURE_TRANSPORT defaults to "1" (gws CLI uses HTTP loopback)
assert creds["oauthlib_insecure_transport"] == "1"
def test_gws_oauth_env_overrides(fresh_db, monkeypatch):
monkeypatch.setenv("AGNES_GWS_CLIENT_ID", "abc.apps.googleusercontent.com")
monkeypatch.setenv("AGNES_GWS_CLIENT_SECRET", "GOCSPX-secret")
from app.instance_config import get_gws_oauth_credentials
creds = get_gws_oauth_credentials()
assert creds["configured"] is True
assert creds["client_id"] == "abc.apps.googleusercontent.com"
assert creds["client_secret"] == "GOCSPX-secret"
def test_gws_oauth_project_id_derived_from_client_id(fresh_db, monkeypatch):
"""Numeric project_id is the prefix of the client_id before the first '-'.
Required by the gws CLI's client_secret.json schema (non-Option in Rust)."""
monkeypatch.setenv(
"AGNES_GWS_CLIENT_ID", "123456789012-abcd5678efgh.apps.googleusercontent.com"
)
monkeypatch.setenv("AGNES_GWS_CLIENT_SECRET", "GOCSPX-x")
monkeypatch.delenv("AGNES_GWS_PROJECT_ID", raising=False)
from app.instance_config import get_gws_oauth_credentials
assert get_gws_oauth_credentials()["project_id"] == "123456789012"
def test_gws_oauth_project_id_explicit_override(fresh_db, monkeypatch):
"""Explicit AGNES_GWS_PROJECT_ID wins over the derived value — covers
edge cases where the client_id doesn't contain a numeric prefix."""
monkeypatch.setenv(
"AGNES_GWS_CLIENT_ID", "abc-x.apps.googleusercontent.com"
)
monkeypatch.setenv("AGNES_GWS_CLIENT_SECRET", "GOCSPX-x")
monkeypatch.setenv("AGNES_GWS_PROJECT_ID", "explicit-id")
from app.instance_config import get_gws_oauth_credentials
assert get_gws_oauth_credentials()["project_id"] == "explicit-id"
def test_gws_oauth_half_configured_falls_back(fresh_db, monkeypatch):
"""Only client_id, no secret → not configured. Half-configuration must
not engage the shortcut branch."""
monkeypatch.setenv("AGNES_GWS_CLIENT_ID", "abc.apps.googleusercontent.com")
monkeypatch.delenv("AGNES_GWS_CLIENT_SECRET", raising=False)
from app.instance_config import get_gws_oauth_credentials
assert get_gws_oauth_credentials()["configured"] is False
def test_home_renders_configured_gws_branch(fresh_db, monkeypatch):
"""Configured branch writes ~/.config/gws/client_secret.json directly
instead of exporting env vars. Claude Code's security layer redacts
env vars whose name contains 'SECRET', so the file-write path is the
only reliable way to seed the OAuth app credentials.
The gws prompt body now flows through Jinja's autoescape (the template
moved from inline `<code>` text to a `{{ connector_prompts.gws }}`
expression after the connector-prompts extraction). That means `"`
characters render as `&quot;` in the served HTML — the browser
un-escapes them on read, but the raw response body has the entity-
encoded form. So the test un-escapes before substring-matching."""
import html as _html
monkeypatch.setenv(
"AGNES_GWS_CLIENT_ID", "123456789012-abcd5678efgh.apps.googleusercontent.com"
)
monkeypatch.setenv("AGNES_GWS_CLIENT_SECRET", "GOCSPX-secret-xyz")
from src.db import get_system_db, close_system_db
conn = get_system_db()
try:
_, sess = _make_user_and_session(conn)
finally:
conn.close()
close_system_db()
c = _client()
resp = c.get("/home", cookies={"access_token": sess})
assert resp.status_code == 200
body = _html.unescape(resp.text)
# Configured branch — JSON file path
assert "~/.config/gws/client_secret.json" in body
assert '"client_id": "123456789012-abcd5678efgh.apps.googleusercontent.com"' in body
assert '"client_secret": "GOCSPX-secret-xyz"' in body
# Project ID derived from client_id prefix
assert '"project_id": "123456789012"' in body
# Full read+write scopes — no --readonly flag (Agnes needs Drive/Gmail
# write so the agent can create, edit, and send on the user's behalf).
assert "gws auth login --readonly" not in body
assert "OAUTHLIB_INSECURE_TRANSPORT=1 gws auth login" in body
# Manual-setup walkthrough should NOT appear in the configured branch
assert "Run `gws auth setup` for me" not in body
# Old env-var approach should not leak back in
assert "export GOOGLE_WORKSPACE_CLI_CLIENT_SECRET=" not in body
def test_home_renders_manual_gws_branch_when_unset(fresh_db, monkeypatch):
monkeypatch.delenv("AGNES_GWS_CLIENT_ID", raising=False)
monkeypatch.delenv("AGNES_GWS_CLIENT_SECRET", raising=False)
from src.db import get_system_db, close_system_db
conn = get_system_db()
try:
_, sess = _make_user_and_session(conn)
finally:
conn.close()
close_system_db()
c = _client()
resp = c.get("/home", cookies={"access_token": sess})
assert resp.status_code == 200
body = resp.text
# Manual setup walkthrough renders
assert "Run `gws auth setup` for me" in body
# No leaked client_id placeholder
assert "GOOGLE_WORKSPACE_CLI_CLIENT_ID=" not in body
def test_home_automode_default_show(fresh_db, monkeypatch):
monkeypatch.delenv("AGNES_HOME_SHOW_AUTOMODE", raising=False)
from app.instance_config import get_home_automode_visibility
assert get_home_automode_visibility() is True
def test_home_automode_env_can_hide(fresh_db, monkeypatch):
monkeypatch.setenv("AGNES_HOME_SHOW_AUTOMODE", "0")
from app.instance_config import get_home_automode_visibility
assert get_home_automode_visibility() is False
def test_home_renders_automode_block_by_default(fresh_db, monkeypatch):
"""The auto-mode step renders by default for the not-onboarded /home
view. The block is now Step 2 (the install-flow reorder put auto-mode
BEFORE the Agnes install so users have auto-accept on for Step 3's
~20 commands), so its label is "Step 2 — turn on auto-mode"."""
monkeypatch.delenv("AGNES_HOME_SHOW_AUTOMODE", raising=False)
from src.db import get_system_db, close_system_db
conn = get_system_db()
try:
_, sess = _make_user_and_session(conn)
finally:
conn.close()
close_system_db()
c = _client()
body = c.get("/home", cookies={"access_token": sess}).text
assert "Step 2 — turn on auto-mode" in body
# The auto-mode step now lives inside the install-hero as an
# install-block (peer with Step 1 + Step 3), not as a separate
# automode-card. Look for the label + the keystroke prompt.
assert "Shift + Tab" in body
def test_home_hides_automode_block_when_env_off(fresh_db, monkeypatch):
monkeypatch.setenv("AGNES_HOME_SHOW_AUTOMODE", "0")
from src.db import get_system_db, close_system_db
conn = get_system_db()
try:
_, sess = _make_user_and_session(conn)
finally:
conn.close()
close_system_db()
c = _client()
body = c.get("/home", cookies={"access_token": sess}).text
assert "Step 2 — turn on auto-mode" not in body
def test_navbar_home_link_uses_home_route(fresh_db, monkeypatch):
"""The shared navbar's primary "Home" link respects
``AGNES_HOME_ROUTE`` so a single env flip routes it to /home or
/dashboard. Tested by rendering an authed page and grepping the
rendered HTML — keeps the assertion close to what users see."""
monkeypatch.setenv("AGNES_HOME_ROUTE", "/home")
from src.db import get_system_db, close_system_db
conn = get_system_db()
try:
_, sess = _make_user_and_session(conn)
finally:
conn.close()
close_system_db()
c = _client()
# /home page itself renders the shared header.
resp = c.get("/home", cookies={"access_token": sess})
assert resp.status_code == 200
# Navbar link href reflects the resolved home_route, not hard-coded /dashboard.
# Label is "Home" (was "Dashboard" before the nav reorg).
assert 'href="/home">Home' in resp.text