* 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.
409 lines
17 KiB
Python
409 lines
17 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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Atlassian base URL — operator-provisioned site root, Terraform-overrideable
|
|
# via AGNES_ATLASSIAN_BASE_URL.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_atlassian_base_url_default_empty(fresh_db, monkeypatch):
|
|
"""Unset env + unset YAML → empty string. Connector prompt falls
|
|
back to asking the user for the site URL (the existing flow)."""
|
|
monkeypatch.delenv("AGNES_ATLASSIAN_BASE_URL", raising=False)
|
|
from app.instance_config import get_atlassian_base_url
|
|
assert get_atlassian_base_url() == ""
|
|
|
|
|
|
def test_atlassian_base_url_env_overrides(fresh_db, monkeypatch):
|
|
"""Env var takes precedence over YAML / default."""
|
|
monkeypatch.setenv("AGNES_ATLASSIAN_BASE_URL", "https://acme.atlassian.net")
|
|
from app.instance_config import get_atlassian_base_url
|
|
assert get_atlassian_base_url() == "https://acme.atlassian.net"
|
|
|
|
|
|
def test_atlassian_base_url_strips_trailing_slash(fresh_db, monkeypatch):
|
|
"""`https://acme.atlassian.net/` → `https://acme.atlassian.net`.
|
|
Matches the per-user helper script's normalization at storage time
|
|
(atlassian_prompt step 4 guard 2). Without this, $BASE_URL/rest/...
|
|
becomes $BASE_URL//rest/... which some CDN paths reject."""
|
|
monkeypatch.setenv("AGNES_ATLASSIAN_BASE_URL", "https://acme.atlassian.net/")
|
|
from app.instance_config import get_atlassian_base_url
|
|
assert get_atlassian_base_url() == "https://acme.atlassian.net"
|
|
|
|
|
|
def test_atlassian_base_url_strips_trailing_wiki(fresh_db, monkeypatch):
|
|
"""`https://acme.atlassian.net/wiki` (the Confluence path) →
|
|
`https://acme.atlassian.net` (bare site root). The connector
|
|
prompt's verify step probes both Jira (root) and Confluence
|
|
(root + /wiki), so the canonical stored value is the root."""
|
|
monkeypatch.setenv("AGNES_ATLASSIAN_BASE_URL", "https://acme.atlassian.net/wiki")
|
|
from app.instance_config import get_atlassian_base_url
|
|
assert get_atlassian_base_url() == "https://acme.atlassian.net"
|
|
|
|
monkeypatch.setenv("AGNES_ATLASSIAN_BASE_URL", "https://acme.atlassian.net/wiki/")
|
|
assert get_atlassian_base_url() == "https://acme.atlassian.net"
|
|
|
|
|
|
def test_atlassian_prompt_uses_base_url_when_set():
|
|
"""The atlassian connector prompt bakes the operator's base URL into
|
|
the helper script instead of asking the user. Saves a chat round-
|
|
trip and avoids the "guess your org's Atlassian URL" footgun."""
|
|
from app.web.connector_prompts import atlassian_prompt
|
|
|
|
p = atlassian_prompt(base_url="https://acme.atlassian.net")
|
|
# The literal URL is baked into the prompt body.
|
|
assert "https://acme.atlassian.net" in p
|
|
# The "ask me for the site URL" step disappears.
|
|
assert "Ask me for my Atlassian Cloud site URL" not in p
|
|
# The placeholder in step 4's helper-script body is replaced with the literal.
|
|
assert "<the site URL I gave you>" not in p
|
|
# The new "operator baked it in" wording appears in step 1.
|
|
assert "already provisioned by the Agnes operator" in p
|
|
|
|
|
|
def test_atlassian_prompt_asks_user_when_base_url_empty():
|
|
"""When no operator override is set, prompt falls back to the
|
|
existing "ask me for the site URL" flow — no regression for OSS
|
|
instances that don't set the env var."""
|
|
from app.web.connector_prompts import atlassian_prompt
|
|
|
|
p = atlassian_prompt(base_url="")
|
|
assert "Ask me for my Atlassian Cloud site URL" in p
|
|
assert "<the site URL I gave you>" in p
|
|
assert "already provisioned by the Agnes operator" not in p
|
|
|
|
|
|
def test_all_connector_prompts_threads_atlassian_base_url():
|
|
"""all_connector_prompts() must forward the atlassian_base_url
|
|
kwarg to atlassian_prompt — otherwise the operator's Terraform
|
|
override never reaches the rendered text."""
|
|
from app.web.connector_prompts import all_connector_prompts
|
|
|
|
out = all_connector_prompts(atlassian_base_url="https://acme.atlassian.net")
|
|
assert "https://acme.atlassian.net" in out["atlassian"]
|
|
assert "Ask me for my Atlassian Cloud site URL" not in out["atlassian"]
|