* 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.
326 lines
12 KiB
Python
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 `"` 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
|