diff --git a/CLAUDE.md b/CLAUDE.md index d2c661f..af03a16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -185,10 +185,50 @@ Orchestrator ATTACHes it automatically. ### Authentication Auth providers in `app/auth/` (FastAPI-based): -- **Google**: OAuth via Google +- **Google**: OAuth via Google (Workspace group memberships pulled at sign-in — see `docs/auth-groups.md` for the GCP setup checklist + the `security` label gotcha) - **Email**: Email magic link (itsdangerous token) - **Desktop**: JWT for API +## Release & deploy workflows + +Two separate release.yml-style workflows produce GHCR images. Pick the one that matches what you're shipping. + +### `release.yml` — auto-build on every push +Runs on **every** push to **every** branch. +- Push to `main` → `:stable`, `:stable-YYYY.MM.N` (CalVer). +- Push to non-main `/` → `:dev`, `:dev-YYYY.MM.N`, `:dev-`, and (when prefix isn't a Git Flow convention) `:dev--latest` alias. + +VMs that pin to a floating tag (`:dev`, `:dev--latest`) auto-upgrade within ~5 min via the cron in `agnes-auto-upgrade.sh`. Convenient for per-developer dev VMs; **footgun for shared dev VMs** (last pusher wins, regardless of who). + +### `keboola-deploy.yml` — tag-triggered, explicit deploy only +Runs **only** on git tags matching `keboola-deploy-*`. Publishes: +- `:keboola-deploy-` — immutable, tied to the exact commit +- `:keboola-deploy-latest` — floating alias the consumer pins to + +**Operator workflow:** +```bash +git checkout +git tag keboola-deploy- +git push origin keboola-deploy- +# → workflow builds + publishes both tags +# → VM cron picks up :keboola-deploy-latest within ~5 min +# → manual cron trigger (skip the wait): sudo /usr/local/bin/agnes-auto-upgrade.sh on the VM +``` + +Use this when the consumer (e.g. a customer dev VM) needs **deploy-when-I-decide** semantics — no surprise rollouts from upstream branch pushes by other contributors. The infra repo pins `image_tag = "keboola-deploy-latest"` on the relevant VM. + +### Module versioning +The customer-instance Terraform module under `infra/modules/customer-instance/` is published as `infra-vMAJOR.MINOR.PATCH` git tags (separate from app CalVer tags). Bump on any module-API change; downstream infra repos pin to the tag in their `source = "github.com/keboola/agnes-the-ai-analyst//infra/modules/customer-instance?ref=infra-v1.X.Y"`. + +After merging a module change to `main`: +```bash +git tag infra-vX.Y.Z origin/main +git push origin infra-vX.Y.Z +``` + +### Replacing a VM after a startup-script change +Module sets `lifecycle { ignore_changes = [metadata_startup_script] }` on `google_compute_instance.vm` so normal `terraform apply` doesn't churn running VMs. To propagate a startup-script update, trigger the consumer's apply workflow manually with the VM resource address — typical workflow_dispatch input is `recreate_targets='module.agnes.google_compute_instance.vm[""]'`. + ## Key Implementation Details ### DuckDB Schema (src/db.py) diff --git a/app/auth/providers/google.py b/app/auth/providers/google.py index b91249b..b0a92ed 100644 --- a/app/auth/providers/google.py +++ b/app/auth/providers/google.py @@ -3,6 +3,7 @@ import os import logging +import httpx from authlib.integrations.starlette_client import OAuth from fastapi import APIRouter, Request from fastapi.responses import RedirectResponse @@ -21,6 +22,19 @@ oauth = OAuth() GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "") GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", "") +# Cloud Identity Groups API — requires the cloud-identity.groups.readonly scope +# AND an admin-enabled Cloud Identity / Google Workspace tenant. +# +# We use `groups/-/memberships:searchTransitiveGroups` (the "what groups does +# THIS USER belong to" endpoint), NOT `groups:search` (admin "find groups in +# org" endpoint, which requires Groups Reader admin role + 400s otherwise). +# The `-` in the path is a wildcard meaning "search across all groups in the +# caller's organization". Returns transitive memberships (incl. nested groups). +# Reference: https://cloud.google.com/identity/docs/reference/rest/v1/groups.memberships/searchTransitiveGroups +GROUPS_SEARCH_URL = ( + "https://cloudidentity.googleapis.com/v1/groups/-/memberships:searchTransitiveGroups" +) + def is_available() -> bool: return bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET) @@ -34,10 +48,75 @@ def _setup_oauth(): client_id=GOOGLE_CLIENT_ID, client_secret=GOOGLE_CLIENT_SECRET, server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", - client_kwargs={"scope": "openid email profile"}, + client_kwargs={ + "scope": ( + "openid email profile " + "https://www.googleapis.com/auth/cloud-identity.groups.readonly" + ), + }, ) +async def _fetch_google_groups(access_token: str, email: str) -> list[dict]: + """Fetch Google Workspace groups the user belongs to. + + Best-effort: returns [] on any failure (403 non-Workspace tenant, 401 expired + token, network error, etc.). Must never raise — callers rely on this to keep + the login flow working even when Cloud Identity is unavailable. + + searchTransitiveGroups query syntax (CEL) requires: + - a `labels` membership predicate scoping the group type + - `member_key_id == ''` for the user + Without `labels` Google returns 400 INVALID_ARGUMENT (silently — error + body just says "invalid argument"). + Reference: https://cloud.google.com/identity/docs/reference/rest/v1/groups.memberships/searchTransitiveGroups + + Why `security` label and not `discussion_forum`: + Empirically Keboola's Workspace lets a non-admin user read their own + group memberships ONLY for groups labelled as security groups + (`cloudidentity.googleapis.com/groups.security`). The same query with + `groups.discussion_forum` returns 403 "Insufficient permissions to + retrieve memberships" — the discussion_forum API needs admin scope. + In practice every Workspace group at Keboola carries BOTH labels, so + filtering on `security` returns the full membership list anyway. + Confirmed via scripts/debug/probe_google_groups.py. + """ + query = ( + f"member_key_id == '{email}' " + f"&& 'cloudidentity.googleapis.com/groups.security' in labels" + ) + params = {"query": query} + headers = {"Authorization": f"Bearer {access_token}"} + try: + async with httpx.AsyncClient(timeout=5.0) as client: + resp = await client.get(GROUPS_SEARCH_URL, params=params, headers=headers) + if resp.status_code >= 400: + # Log full body (not truncated) so future query-syntax / scope / + # tenant issues are diagnosable from one log line. + logger.warning( + "Google groups fetch returned %s for %s — query=%r — body=%s", + resp.status_code, email, query, resp.text, + ) + return [] + data = resp.json() + except Exception as e: + logger.warning("Google groups fetch failed for %s: %s", email, e) + return [] + + # searchTransitiveGroups returns `memberships`, not `groups`. Each membership + # carries the group identity in groupKey.id (email-shaped) + displayName. + groups = [] + for m in data.get("memberships", []) or []: + group_key = (m.get("groupKey") or {}).get("id", "") + if not group_key: + continue + groups.append({ + "id": group_key, + "name": m.get("displayName") or group_key, + }) + return groups + + _setup_oauth() @@ -102,6 +181,18 @@ async def google_callback(request: Request): finally: conn.close() + # Fetch Google Workspace groups (best-effort — must not break login). + access_token = token.get("access_token", "") + if access_token: + try: + groups = await _fetch_google_groups(access_token, email) + request.session["google_groups"] = groups + except Exception as e: + logger.warning("Failed to store google_groups in session: %s", e) + request.session["google_groups"] = [] + else: + request.session["google_groups"] = [] + # Issue JWT jwt_token = create_access_token(user["id"], user["email"], user["role"]) diff --git a/app/web/router.py b/app/web/router.py index 5d5859a..4594a9a 100644 --- a/app/web/router.py +++ b/app/web/router.py @@ -620,7 +620,17 @@ async def admin_tokens_page( return templates.TemplateResponse(request, "admin_tokens.html", ctx) -@router.get("/profile") -async def profile_redirect(request: Request): - """Back-compat: /profile (PAT CRUD) has been unified under /tokens.""" - return RedirectResponse(url="/tokens", status_code=302) +@router.get("/profile", response_class=HTMLResponse) +async def profile_page( + request: Request, + user: dict = Depends(get_current_user), +): + """User profile — shows email, name, role, and Google Workspace groups. + + Groups come from the Starlette session (populated during Google OAuth + callback); they persist for the session lifetime. Empty when the user + signed in via password/magic-link or the Cloud Identity API is unavailable. + """ + groups = request.session.get("google_groups", []) or [] + ctx = _build_context(request, user=user, groups=groups) + return templates.TemplateResponse(request, "profile.html", ctx) diff --git a/app/web/templates/_app_header.html b/app/web/templates/_app_header.html index 1d292ca..0daefe8 100644 --- a/app/web/templates/_app_header.html +++ b/app/web/templates/_app_header.html @@ -36,7 +36,8 @@
{{ session.user.role | capitalize }}
{% endif %} - My tokens + Profile + My tokens Logout diff --git a/app/web/templates/profile.html b/app/web/templates/profile.html new file mode 100644 index 0000000..dd91639 --- /dev/null +++ b/app/web/templates/profile.html @@ -0,0 +1,207 @@ +{% extends "base.html" %} +{% block title %}Profile — {{ config.INSTANCE_NAME }}{% endblock %} + +{% block content %} + + +
+
+
Your account
+

Profile

+

Account details and Google Workspace group memberships.

+
+ +
+

Account

+ + +
+ +
+

Google Workspace groups

+ {% if groups and groups | length > 0 %} +
    + {% for g in groups %} +
  • + {{ g.name or g.id }} + {{ g.id }} +
  • + {% endfor %} +
+ {% else %} +
+
No Google groups available
+
Groups are populated when you sign in with Google on a Workspace-enabled tenant. Other sign-in methods (email, password) don't expose group memberships.
+
+ {% endif %} +
+
+{% endblock %} diff --git a/docs/auth-groups.md b/docs/auth-groups.md new file mode 100644 index 0000000..8b7d0eb --- /dev/null +++ b/docs/auth-groups.md @@ -0,0 +1,56 @@ +# Google Workspace Groups in /profile + +How Agnes pulls a user's group memberships at Google sign-in and where they end up. + +## Google Cloud setup (per OAuth client / project) + +In the GCP project hosting the OAuth client (for Keboola dev: `kids-ai-data-analysis`): + +1. **Enable Cloud Identity API** — `APIs & Services → Library → "Cloud Identity API" → Enable`. +2. **OAuth consent screen → Data Access → Add or Remove Scopes** — manually add: + ``` + https://www.googleapis.com/auth/cloud-identity.groups.readonly + ``` +3. **OAuth client → Authorized redirect URIs** — must include `https:///auth/google/callback` for the deployment that uses this client. +4. **OAuth consent screen → Audience** — keep `Internal` (own Workspace tenant only). `External` triggers verification review for the sensitive Cloud Identity scope. + +That's it. No service account, no domain-wide delegation, no admin role per user. + +## The `security` label trap + +Cloud Identity exposes membership listing through `groups/-/memberships:searchTransitiveGroups`. Its `query` (CEL) **must include a label predicate**. Two label types matter: + +- `cloudidentity.googleapis.com/groups.discussion_forum` — every Workspace group has it. **Returns 403 "Insufficient permissions"** for non-admin users. +- `cloudidentity.googleapis.com/groups.security` — only security-flagged groups have it as a top-level capability, but in practice **every Keboola Workspace group also carries this label**. **Returns 200** with the full membership list. + +Agnes therefore queries with `security` (in `app/auth/providers/google.py`): + +```python +"member_key_id == '' && 'cloudidentity.googleapis.com/groups.security' in labels" +``` + +Switching to `discussion_forum` will silently break for everyone but Workspace admins. + +## Storage + use + +`app/auth/providers/google.py:google_callback` runs on every Google sign-in: + +1. Fetch via `_fetch_google_groups(access_token, email)` → list of `{"id": "", "name": ""}`. +2. Write to `request.session["google_groups"]` (Starlette signed-cookie session — per-user, not in DB). +3. Failures (403, 401, network, 4xx) are swallowed and become `[]` so login never breaks. + +Display: `app/web/templates/profile.html` reads `session.google_groups` and renders the list. Empty state explains "Groups are populated when you sign in with Google on a Workspace-enabled tenant." + +**Not in DB.** Admin views (e.g. `/admin/users`) can't see other users' groups today — adding a `users.groups` column + persisting on callback is the path forward when that's needed. + +**Refresh.** A user's stale session keeps stale groups. `Logout → sign in again` is the only refresh. + +## Debugging + +`scripts/debug/probe_google_groups.py` — stdlib, takes a Playground-issued OAuth access token + email, hits 6 candidate endpoints, prints raw response. Use this **before** changing the production query — saves a deploy cycle per attempt. + +```bash +python3 scripts/debug/probe_google_groups.py "ya29.…" user@keboola.com +``` + +Token via [OAuth 2.0 Playground](https://developers.google.com/oauthplayground/) → gear icon → own credentials → request the three scopes (`cloud-identity.groups.readonly`, `cloud-identity.groups`, `admin.directory.group.readonly`) → exchange code → copy access token. diff --git a/infra/modules/customer-instance/startup-script.sh.tpl b/infra/modules/customer-instance/startup-script.sh.tpl index fb9e6c3..71b61fe 100644 --- a/infra/modules/customer-instance/startup-script.sh.tpl +++ b/infra/modules/customer-instance/startup-script.sh.tpl @@ -92,10 +92,15 @@ GOOGLE_CLIENT_SECRET=$(gcloud secrets versions access latest --secret=google-oau # instance — leave it "none" and let the corp-PKI rotate scripts handle certs. CADDY_TLS_LINE="" if [ "$TLS_MODE" = "caddy" ] && [ -n "$DOMAIN" ]; then + # Value MUST be quoted in the .env file: agnes-auto-upgrade.sh sources + # /opt/agnes/.env via `set -a; . .env; set +a`, and bash interprets an + # unquoted `KEY=value with spaces` as `KEY=value` followed by trying to + # exec `with`/`spaces` as commands → boot succeeds but every cron tick + # logs ": command not found". if [ -n "$ACME_EMAIL" ]; then - CADDY_TLS_LINE="CADDY_TLS=tls $ACME_EMAIL" + CADDY_TLS_LINE="CADDY_TLS=\"tls $ACME_EMAIL\"" else - CADDY_TLS_LINE="CADDY_TLS=tls internal" + CADDY_TLS_LINE="CADDY_TLS=\"tls internal\"" fi fi diff --git a/scripts/debug/probe_google_groups.py b/scripts/debug/probe_google_groups.py new file mode 100755 index 0000000..f80ba52 --- /dev/null +++ b/scripts/debug/probe_google_groups.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Probe Google Cloud Identity / Admin Directory APIs for "list groups of THIS user". + +Run locally with a fresh user OAuth access token to figure out which endpoint ++ scope combo actually works for your Workspace tenant — without a deploy cycle. + +Stdlib only — no pip install needed. + +Why this exists: + Zdeněk's first attempt used `cloudidentity.googleapis.com/v1/groups:search` + with `cloud-identity.groups.readonly` scope. Returns 400 INVALID_ARGUMENT + in Keboola's Workspace because that endpoint requires admin permission + despite the scope name suggesting otherwise. + +How to get an access token (Easiest path): + + Google's OAuth 2.0 Playground (https://developers.google.com/oauthplayground/) + 1. Click the gear icon (top right) → tick "Use your own OAuth credentials" + 2. Paste your Client ID + Secret (from kids-ai-data-analysis project, + same OAuth client agnes-dev uses) + 3. Step 1: pick scopes. For comparison test all of: + https://www.googleapis.com/auth/cloud-identity.groups.readonly + https://www.googleapis.com/auth/cloud-identity.groups + https://www.googleapis.com/auth/admin.directory.group.readonly + openid + email + profile + 4. Authorize APIs → sign in as your Workspace user + 5. Step 2: Exchange authorization code for tokens + 6. Copy the "Access token" string (starts with `ya29.`) + +Usage: + python3 scripts/debug/probe_google_groups.py + +Example: + python3 scripts/debug/probe_google_groups.py ya29.a0AfH6S... petr@keboola.com +""" + +from __future__ import annotations + +import json +import sys +import urllib.error +import urllib.parse +import urllib.request + + +def _section(title: str) -> None: + print() + print("=" * 78) + print(f" {title}") + print("=" * 78) + + +def _probe(name: str, url: str, params: dict | None = None, + headers: dict | None = None) -> None: + print(f"\n--- {name} ---") + full_url = url + if params: + full_url = f"{url}?{urllib.parse.urlencode(params)}" + print(f" GET {url}") + if params: + for k, v in params.items(): + print(f" {k}={v}") + + req = urllib.request.Request(full_url, headers=headers or {}) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + status = resp.status + body_bytes = resp.read() + except urllib.error.HTTPError as e: + status = e.code + body_bytes = e.read() + except Exception as e: + print(f" EXCEPTION: {type(e).__name__}: {e}") + return + + print(f" HTTP {status}") + body = body_bytes.decode("utf-8", errors="replace") + try: + body = json.dumps(json.loads(body), indent=2) + except Exception: + body = body[:600] + print(" body:") + for line in body.splitlines(): + print(f" {line}") + + +def main() -> int: + if len(sys.argv) != 3: + print(__doc__) + return 1 + access_token, email = sys.argv[1], sys.argv[2] + auth = {"Authorization": f"Bearer {access_token}"} + + _section("0. Token introspection — what scopes does this token actually have?") + _probe( + "tokeninfo", + "https://oauth2.googleapis.com/tokeninfo", + params={"access_token": access_token}, + ) + + _section("1. OpenID userinfo — verify token identifies the right user") + _probe( + "userinfo", + "https://openidconnect.googleapis.com/v1/userinfo", + headers=auth, + ) + + _section("2. Cloud Identity — searchTransitiveGroups (user perspective)") + for label_kind in ("discussion_forum", "security"): + _probe( + f"with labels = '{label_kind}'", + "https://cloudidentity.googleapis.com/v1/groups/-/memberships:searchTransitiveGroups", + params={ + "query": ( + f"member_key_id == '{email}' && " + f"'cloudidentity.googleapis.com/groups.{label_kind}' in labels" + ), + }, + headers=auth, + ) + + _section("3. Cloud Identity — searchDirectGroups (no transitive)") + _probe( + "direct only with discussion_forum label", + "https://cloudidentity.googleapis.com/v1/groups/-/memberships:searchDirectGroups", + params={ + "query": ( + f"member_key_id == '{email}' && " + "'cloudidentity.googleapis.com/groups.discussion_forum' in labels" + ), + }, + headers=auth, + ) + + _section("4. Cloud Identity — groups:search (admin endpoint, expected to fail)") + _probe( + "admin search with parent + member_key_id", + "https://cloudidentity.googleapis.com/v1/groups:search", + params={ + "query": ( + "parent == 'customers/my_customer' && " + f"member_key_id == '{email}' && " + "'cloudidentity.googleapis.com/groups.discussion_forum' in labels" + ), + "view": "BASIC", + }, + headers=auth, + ) + + _section("5. Admin SDK Directory — legacy groups?userKey (admin scope required)") + _probe( + "directory list groups for user", + "https://admin.googleapis.com/admin/directory/v1/groups", + params={"userKey": email}, + headers=auth, + ) + + print() + print("=" * 78) + print("Interpretation guide:") + print("=" * 78) + print(""" + HTTP 200 + groups list → that's the working endpoint, use it in google.py + HTTP 200 + empty list → endpoint works but user has no matching groups + HTTP 400 INVALID_ARG → query syntax wrong OR permission issue Google + silently disguises as 400 (common for non-admin) + HTTP 403 PERMISSION → token lacks scope or admin role + HTTP 401 UNAUTHENTICATED→ token expired (re-fetch from playground) + HTTP 404 NOT FOUND → API not enabled, or wrong URL + + If ALL Cloud Identity endpoints return 400/403 for a non-admin user, the + conclusion is: Cloud Identity Groups API requires admin permission for + user-perspective queries, regardless of OAuth scope. Switch to one of: + (a) Service Account + Domain-Wide Delegation (Vojta's v3 design) + (b) Workspace OIDC groups claim (admin enables in Workspace Console) + (c) Grant 'Groups Reader' role to every user (admin overhead) +""") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_admin_tokens_ui.py b/tests/test_admin_tokens_ui.py index 90f3c57..90f7ea8 100644 --- a/tests/test_admin_tokens_ui.py +++ b/tests/test_admin_tokens_ui.py @@ -261,17 +261,10 @@ def test_admin_tokens_deeplink_preserves_user_query(fresh_db): assert 'id="flt-user"' in resp.text -# ── Back-compat redirects ───────────────────────────────────────────────── - -def test_profile_redirects_to_tokens(fresh_db): - """/profile no longer renders — it 302-redirects to /tokens.""" - from fastapi.testclient import TestClient - from app.main import app - - client = TestClient(app) - resp = client.get("/profile", follow_redirects=False) - assert resp.status_code == 302 - assert resp.headers["location"] == "/tokens" +# NOTE: test_profile_redirects_to_tokens removed — /profile no longer +# redirects to /tokens; it renders a real profile page including Google +# Workspace groups (cherry-pick of Zdeněk's 4f7e4cd). Current /profile +# behaviour is covered by tests/test_auth_providers.py. # ── Admin list API — expanded fields ─────────────────────────────────────── diff --git a/tests/test_auth_providers.py b/tests/test_auth_providers.py index 4be4775..d7d36c0 100644 --- a/tests/test_auth_providers.py +++ b/tests/test_auth_providers.py @@ -144,6 +144,97 @@ class TestGoogleOAuth: assert "error" in resp.headers.get("location", "") +class TestGoogleGroupsFetch: + """Unit tests for _fetch_google_groups — the helper must be tolerant of + every realistic failure mode (non-Workspace tenants return 403, expired + tokens return 401, network errors bubble from httpx) and never raise.""" + + def test_parses_groups_from_success_response(self, monkeypatch): + import asyncio + from app.auth.providers import google as gp + + + # searchTransitiveGroups returns {"memberships": [...]}, not {"groups": [...]}. + # Each item carries the group identity in groupKey.id + displayName, + # matching the actual API response shape. + fake_payload = { + "memberships": [ + { + "group": "groups/abc123", + "groupKey": {"id": "team-eng@example.com"}, + "displayName": "Engineering", + }, + { + "group": "groups/def456", + "groupKey": {"id": "everyone@example.com"}, + # No displayName — falls back to id + }, + ], + } + + class _Resp: + status_code = 200 + text = "" + def json(self): + return fake_payload + + class _FakeClient: + def __init__(self, *a, **kw): + pass + async def __aenter__(self): + return self + async def __aexit__(self, *a): + return False + async def get(self, url, params=None, headers=None): + return _Resp() + + monkeypatch.setattr(gp.httpx, "AsyncClient", _FakeClient) + + groups = asyncio.run(gp._fetch_google_groups("fake-token", "user@example.com")) + assert groups == [ + {"id": "team-eng@example.com", "name": "Engineering"}, + {"id": "everyone@example.com", "name": "everyone@example.com"}, + ] + + def test_returns_empty_on_403(self, monkeypatch): + """Cloud Identity not enabled (non-Workspace tenant) → 403 → [] + warning.""" + import asyncio + from app.auth.providers import google as gp + + class _Resp: + status_code = 403 + text = "Cloud Identity API has not been enabled" + + class _FakeClient: + def __init__(self, *a, **kw): pass + async def __aenter__(self): return self + async def __aexit__(self, *a): return False + async def get(self, url, params=None, headers=None): + return _Resp() + + monkeypatch.setattr(gp.httpx, "AsyncClient", _FakeClient) + + groups = asyncio.run(gp._fetch_google_groups("fake-token", "user@example.com")) + assert groups == [] + + def test_returns_empty_on_exception(self, monkeypatch): + """Network error inside httpx must be swallowed, not propagated.""" + import asyncio + from app.auth.providers import google as gp + + class _FakeClient: + def __init__(self, *a, **kw): pass + async def __aenter__(self): return self + async def __aexit__(self, *a): return False + async def get(self, *a, **kw): + raise RuntimeError("boom") + + monkeypatch.setattr(gp.httpx, "AsyncClient", _FakeClient) + + groups = asyncio.run(gp._fetch_google_groups("fake-token", "user@example.com")) + assert groups == [] + + class TestCookieAuth: def test_web_ui_with_cookie(self, client): """Test that web UI routes accept JWT from cookie.""" diff --git a/tests/test_pat.py b/tests/test_pat.py index 692da84..8d5664e 100644 --- a/tests/test_pat.py +++ b/tests/test_pat.py @@ -277,40 +277,11 @@ def test_pat_cannot_create_pat(fresh_db): assert resp.status_code == 403 -def test_profile_page_redirects_to_tokens(fresh_db): - """/profile was unified under /tokens in feat/unify-tokens-fullwidth; - the route now 302-redirects to /tokens.""" - from fastapi.testclient import TestClient - import uuid - from src.db import get_system_db, close_system_db - from src.repositories.users import UserRepository - from app.auth.jwt import create_access_token - from app.main import app - - conn = get_system_db() - try: - uid = str(uuid.uuid4()) - UserRepository(conn).create(id=uid, email="u@t", name="U", role="analyst") - token = create_access_token(user_id=uid, email="u@t", role="analyst") - finally: - conn.close() - close_system_db() - - client = TestClient(app) - # Redirect is unauthenticated (no auth guard on the redirect itself) - resp = client.get("/profile", follow_redirects=False) - assert resp.status_code == 302 - assert resp.headers["location"] == "/tokens" - - # Following the redirect with a valid session lands on the unified page. - resp = client.get( - "/tokens", - headers={"Accept": "text/html"}, - cookies={"access_token": token}, - ) - assert resp.status_code == 200 - assert "My tokens" in resp.text # non-admin title - assert 'id="new-token-btn"' in resp.text # non-admin CTA +# NOTE: test_profile_page_redirects_to_tokens removed — /profile no longer +# redirects to /tokens; it renders a real profile page including Google +# Workspace groups (cherry-pick of Zdeněk's 4f7e4cd). The /tokens render +# checks (My tokens title, new-token-btn) survive in the test_admin_tokens_ui +# suite. def test_pat_first_use_from_new_ip_audits(fresh_db): diff --git a/tests/test_web_ui.py b/tests/test_web_ui.py index c8f7dba..2111bbf 100644 --- a/tests/test_web_ui.py +++ b/tests/test_web_ui.py @@ -100,9 +100,11 @@ class TestWebUISmoke: assert "app-header" in body # Nav after split: "Tokens" (own) for every signed-in user + # admin-only "All tokens" link pointing at /admin/tokens. + # Profile link added with the Google-Workspace-groups feature + # (cherry-pick of zs/google-groups-display + dropdown wiring). assert 'href="/tokens"' in body assert 'href="/admin/tokens"' in body - assert 'href="/profile"' not in body + assert 'href="/profile"' in body assert 'href="/admin/users"' in body # New modern UI markers assert 'class="users-page"' in body @@ -111,7 +113,7 @@ class TestWebUISmoke: assert 'id="confirm-modal"' in body def test_nav_shows_tokens_link_for_non_admin(self, web_client, analyst_cookie): - """Non-admins see the 'My tokens' user-menu link — no 'All tokens' link, no /profile.""" + """Non-admins see 'My tokens' + 'Profile' user-menu links — no 'All tokens'.""" resp = web_client.get("/dashboard", cookies=analyst_cookie) assert resp.status_code in (200, 302) if resp.status_code == 302: @@ -119,9 +121,9 @@ class TestWebUISmoke: resp = web_client.get(resp.headers["location"], cookies=analyst_cookie) body = resp.text assert 'href="/tokens"' in body - assert 'href="/profile"' not in body + assert 'href="/profile"' in body assert ">My tokens<" in body - assert ">Profile<" not in body + assert ">Profile<" in body # Non-admins must NOT see the admin "All tokens" link. assert 'href="/admin/tokens"' not in body assert ">All tokens<" not in body @@ -138,11 +140,23 @@ class TestWebUISmoke: assert ">My tokens<" in body assert ">All tokens<" in body - def test_profile_redirects_to_tokens(self, web_client, admin_cookie): - """Back-compat: /profile 302-redirects to /tokens.""" - resp = web_client.get("/profile", cookies=admin_cookie, follow_redirects=False) - assert resp.status_code == 302 - assert resp.headers["location"] == "/tokens" + def test_profile_renders_account_details(self, web_client, admin_cookie): + """/profile renders a real profile page with email, name, role.""" + resp = web_client.get("/profile", cookies=admin_cookie) + assert resp.status_code == 200 + body = resp.text + assert "admin@test.com" in body + # Role pill + link to /tokens for PAT management + assert 'class="role-pill"' in body + assert 'href="/tokens"' in body + # Empty-state copy when no Google groups in session + assert "No Google groups available" 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) class TestClaudeSetupPreview: