"""v7.56.0 — Connecteurs OAuth Google / Microsoft 365 (PKCE, tokens, refresh).""" import asyncio import time from pathlib import Path import pytest from conftest import anon_csrf from app.db import get_conn from app.services import oauth_connectors ROOT = Path(__file__).resolve().parent.parent JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js" @pytest.fixture def configured(monkeypatch): """Clients OAuth fictifs (aucune clé réelle nécessaire pour les tests).""" from app.config import settings monkeypatch.setattr(settings, "google_client_id", "gid-test") monkeypatch.setattr(settings, "google_client_secret", "gsec-test") monkeypatch.setattr(settings, "ms_client_id", "mid-test") monkeypatch.setattr(settings, "ms_client_secret", "msec-test") monkeypatch.setattr(settings, "app_base_url", "https://flowdeck.example") return settings def _begin(client, kind="google"): r = client.post(f"/api/agent/connectors/oauth/{kind}/authorize") assert r.status_code == 200, r.text return r.json() def test_authorize_url_has_pkce_scope_and_state(client, configured): data = _begin(client) url = data["url"] assert url.startswith("https://accounts.google.com/o/oauth2/v2/auth?") assert "client_id=gid-test" in url assert "code_challenge=" in url and "code_challenge_method=S256" in url assert "drive.readonly" in url and "gmail.readonly" in url and "calendar.readonly" in url assert "state=" in url assert "redirect_uri=" in url and "api%2Fagent%2Fconnectors%2Foauth%2Fgoogle%2Fcallback" in url # les cookies d'état partent avec la réponse (10 min, HttpOnly) for key in ("fd_oauth_state_google", "fd_oauth_verifier_google", "fd_oauth_next_google"): assert key in client.cookies def test_authorize_without_client_config_is_400(client): r = client.post("/api/agent/connectors/oauth/google/authorize") assert r.status_code == 400 assert "non configuré" in r.json()["detail"] def test_callback_saves_tokens_encrypted_and_redirects(client, configured, monkeypatch): _begin(client) state = client.cookies["fd_oauth_state_google"] async def fake_post(url, form=None, **kw): assert url == "https://oauth2.googleapis.com/token" assert form["grant_type"] == "authorization_code" assert form["code"] == "abc123" assert form["code_verifier"] == client.cookies["fd_oauth_verifier_google"] return {"access_token": "at-1", "refresh_token": "rt-1", "expires_in": 3600, "scope": "openid email"} monkeypatch.setattr(oauth_connectors, "_post_form", fake_post) resp = client.get( "/api/agent/connectors/oauth/google/callback", params={"code": "abc123", "state": state}, follow_redirects=False, ) assert resp.status_code == 302 assert "oauth=connected" in resp.headers["location"] with get_conn() as conn: row = conn.execute("SELECT tokens_enc FROM connector_tokens").fetchone() assert row and "at-1" not in row["tokens_enc"] # chiffré assert oauth_connectors.tokens("google", 1)["access_token"] == "at-1" status = client.get("/api/agent/connectors/oauth/google/status").json() assert status["connected"] is True and status["configured"] is True def test_callback_rejects_state_mismatch(client, configured, monkeypatch): called = [] async def fake_post(url, form=None, **kw): called.append(form) return {"access_token": "at"} monkeypatch.setattr(oauth_connectors, "_post_form", fake_post) resp = client.get( "/api/agent/connectors/oauth/google/callback", params={"code": "abc", "state": "forged"}, # != cookie follow_redirects=False, ) assert resp.status_code == 302 and "oauth_error=state" in resp.headers["location"] assert called == [] assert not oauth_connectors.is_connected("google", 1) def test_callback_without_session_redirects(client, configured, monkeypatch): _begin(client) state = client.cookies["fd_oauth_state_google"] async def fake_post(url, form=None, **kw): return {"access_token": "at"} monkeypatch.setattr(oauth_connectors, "_post_form", fake_post) # on retire la SEULE session (anon_csrf effacerait aussi le cookie d'état OAuth) client.cookies.delete("flowdeck_session") client.auth = None resp = client.get( "/api/agent/connectors/oauth/google/callback", params={"code": "abc", "state": state}, follow_redirects=False, ) assert resp.status_code == 302 and "oauth_error=session" in resp.headers["location"] def test_refresh_token_flow(client, configured, monkeypatch): oauth_connectors.save("google", 1, { "access_token": "old", "refresh_token": "rt-keep", "expires_at": int(time.time()) - 10, "scope": "openid", }) calls = [] async def fake_post(url, form=None, **kw): calls.append(form) return {"access_token": "new", "expires_in": 3600} # pas de refresh_token monkeypatch.setattr(oauth_connectors, "_post_form", fake_post) access = asyncio.run(oauth_connectors.access_token("google", 1)) assert access == "new" assert calls[0]["grant_type"] == "refresh_token" assert calls[0]["refresh_token"] == "rt-keep" stored = oauth_connectors.tokens("google", 1) assert stored["refresh_token"] == "rt-keep" # conservé si absent de la réponse assert stored["expires_at"] > time.time() def test_api_get_bearer_and_refuses_absolute_url(client, configured, monkeypatch): oauth_connectors.save("google", 1, {"access_token": "at", "refresh_token": "", "expires_at": int(time.time()) + 3600, "scope": "openid"}) seen = [] async def fake_get(url, headers=None): seen.append((url, headers)) return 200, '{"emails": 3}' monkeypatch.setattr(oauth_connectors, "_api_get", fake_get) res = asyncio.run(oauth_connectors.api_get("google", 1, "/gmail/v1/users/me/profile")) assert res["status"] == "ok" assert seen[0][0].startswith("https://www.googleapis.com/gmail/v1/") assert seen[0][1]["Authorization"] == "Bearer at" # une URL absolue dans `path` ne doit pas dévier (hôte fixe + validation) res2 = asyncio.run(oauth_connectors.api_get("google", 1, "https://evil.example/x")) assert res2["status"] == "error" assert len(seen) == 1 # aucun appel réseau def test_api_get_requires_connection(client, configured): res = asyncio.run(oauth_connectors.api_get("google", 1, "/me")) assert res["status"] == "error" and "non connecté" in res["text"] def test_disconnect_clears_tokens(client, configured): oauth_connectors.save("ms365", 1, {"access_token": "at", "expires_at": 1, "scope": ""}) assert oauth_connectors.is_connected("ms365", 1) r = client.post("/api/agent/connectors/oauth/ms365/disconnect") assert r.status_code == 200 and r.json()["status"] == "disconnected" assert not oauth_connectors.is_connected("ms365", 1) def test_catalogue_marks_oauth_connectors(client, configured): rows = client.get("/api/agent/connectors").json()["connectors"] by_kind = {r["kind"]: r for r in rows if r["builtin"]} assert "google" in by_kind and "ms365" in by_kind assert by_kind["google"]["oauth"] is True assert by_kind["google"]["status"] == "missing" # configuré mais pas connecté assert "non connecté" in by_kind["google"]["detail"] oauth_connectors.save("google", 1, {"access_token": "at", "expires_at": 9_999_999_999, "scope": "openid email drive"}) rows = client.get("/api/agent/connectors").json()["connectors"] g = {r["kind"]: r for r in rows if r["builtin"]}["google"] assert g["status"] == "ok" and "connecté" in g["detail"] def test_tool_dispatches_to_oauth_api(client, configured, monkeypatch): from app.services.tool_registry import ToolRegistry oauth_connectors.save("google", 1, {"access_token": "at", "refresh_token": "", "expires_at": int(time.time()) + 3600, "scope": "openid"}) async def fake_get(url, headers=None): assert url.startswith("https://www.googleapis.com/") assert headers["Authorization"] == "Bearer at" return 200, '{"name": "Bruno"}' monkeypatch.setattr(oauth_connectors, "_api_get", fake_get) res = asyncio.run(ToolRegistry().execute( "connector_fetch", {"connector": "google", "path": "/oauth2/v3/userinfo"}, user_id=1)) assert res.status == "success" assert "Bruno" in res.data["text"] def test_oauth_routes_require_session(client): anon_csrf(client) assert client.get("/api/agent/connectors/oauth/google/status").status_code == 401 assert client.post("/api/agent/connectors/oauth/google/authorize").status_code == 401 assert client.post("/api/agent/connectors/oauth/google/disconnect").status_code == 401 # kind inconnu → 404 même authentifié assert client.post("/api/agent/connectors/oauth/dropbox/authorize").status_code == 404 def test_oauth_menu_wired(): src = JS_PATH.read_text(encoding="utf-8") for token in ("'connector-connect'", "'connector-disconnect'", "connectorConnect()", "connectorDisconnect()", "qs.get('oauth_error')", "Connecteur connecté."): assert token in src, token assert "c.oauth" in src # le fournisseur revient avec `oauth=connected` (côté route) router = (ROOT / "app" / "routers" / "agent.py").read_text(encoding="utf-8") assert "oauth=connected" in router