Files
OpenJarvis/tests/connectors/test_oauth_flow.py
T
f9d1bc8c27 fix(connectors): complete Google OAuth and register Drive in Data Sources (fixes #512) (#548)
Pasting a Google Client ID / Secret never completed OAuth: Drive (and its
Google siblings) accepted the credentials, showed no error, opened no browser,
and never appeared in Data Sources. Root cause is three coupled defects, all
reproduced at the unit level against main with a FastAPI TestClient (no Google
creds, network-free):

(A/B) POST /connect routed a `client_id:client_secret` pair into the
  connector's handle_callback, which spawned a daemon thread that popped a
  browser and ran its own localhost:8789 callback server. That thread fails
  silently in the bundled desktop context (`except Exception: pass`), so the
  connector never gained an access_token; /connect returned status "pending"
  and the UI's 20x2s poll timed out with no error.
  Fix: in POST /connect, an OAuth `client_id:client_secret` pair now persists
  the client credentials to every Google credential file and returns an
  `oauth_required` directive pointing at the in-process server flow, instead of
  the silent background thread. The Google connectors' handle_callback no longer
  spawns the browser thread for the pair case — it only persists the creds; the
  server's /oauth/start -> /oauth/callback owns the consent round-trip.

(C) The would-be-correct server flow was itself broken: under
  `from __future__ import annotations` plus a `Request` import local to the
  router factory, FastAPI could not resolve the stringized `request: Request`
  annotation. /oauth/start returned HTTP 422 (request mis-bound as a query
  param) and /oauth/callback injected None -> AttributeError on
  `request.base_url`. Fix: import `Request` at module scope and make the
  callback's `request` a required injected dependency.

A malformed/blank client pair now raises HTTP 400 with the provider setup URL
instead of a perpetual silent "pending" (REVIEW.md silent-failure discipline).

Frontend: DataSourcesPage now opens the server OAuth window when /connect
returns `oauth_required`, then polls until connected; connect errors surface the
backend detail; the Drive setup steps document the "Web application" OAuth
client + server-callback redirect URI the in-process flow requires.

Tests (run on the main venv, hermetic — no ~/.openjarvis pollution):
- test_oauth_flow.py: the three handle_callback tests now assert NO browser is
  opened and only client creds are persisted (was: assert background flow ran).
- test_connectors_router_oauth.py (new): reproduces + fixes all three defects via
  TestClient with mocked token exchange; parametrized over gdrive/gcalendar/
  gcontacts/gmail/google_tasks to prove the shared OAuth path is fixed for every
  sibling and that a single consent writes the access_token to all six Google
  credential files and flips is_connected() to True.
Full tests/connectors suite: 355 passed.

Relationship to PR #510: #510 rewrites all of these files (account-scoped
retrieval) but still carries all three defects. This fix is intentionally scoped
to the OAuth path and does not modify oauth.py, to minimize collision. A
maintainer can either merge this and rebase #510 on top, or port these changes
into #510. See PR body for details.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:55:22 -07:00

161 lines
5.7 KiB
Python

"""Tests for OAuth token exchange and Google connector integration."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
def test_exchange_google_token_calls_endpoint() -> None:
from openjarvis.connectors.oauth import exchange_google_token
mock_resp = MagicMock()
mock_resp.json.return_value = {
"access_token": "ya29.test",
"refresh_token": "1//test",
"token_type": "Bearer",
"expires_in": 3600,
}
mock_resp.raise_for_status = MagicMock()
with patch("httpx.post", return_value=mock_resp) as mock_post:
tokens = exchange_google_token(
code="4/test-code",
client_id="test-id.apps.googleusercontent.com",
client_secret="test-secret",
)
assert tokens["access_token"] == "ya29.test"
assert tokens["refresh_token"] == "1//test"
mock_post.assert_called_once()
def test_gdrive_handle_callback_persists_creds_no_background_flow(
tmp_path: Path,
) -> None:
"""A pasted client pair persists creds ONLY — no silent browser thread.
Regression for issue #512: the previous implementation spawned a daemon
thread that popped a browser and ran its own localhost:8789 callback
server. That thread failed silently in the bundled desktop context, so the
connector never gained an access token. ``handle_callback`` must now only
save the client_id/secret; the in-process server flow owns the consent
round-trip. We assert ``open_browser`` is never invoked.
"""
from openjarvis.connectors.gdrive import GDriveConnector
from openjarvis.connectors.oauth import load_tokens
creds = str(tmp_path / "gdrive.json")
conn = GDriveConnector(credentials_path=creds)
with patch("openjarvis.core.open_browser") as mock_browser:
conn.handle_callback("test-id.apps.googleusercontent.com:test-secret")
mock_browser.assert_not_called()
tokens = load_tokens(creds)
assert tokens is not None
assert tokens["client_id"] == "test-id.apps.googleusercontent.com"
assert tokens["client_secret"] == "test-secret"
# No access token yet — that arrives via /oauth/callback.
assert not tokens.get("access_token")
assert conn.is_connected() is False
def test_gdrive_is_connected_requires_access_token(tmp_path: Path) -> None:
from openjarvis.connectors.gdrive import GDriveConnector
from openjarvis.connectors.oauth import save_tokens
creds = str(tmp_path / "gdrive.json")
conn = GDriveConnector(credentials_path=creds)
# Just client_id is not "connected"
save_tokens(creds, {"client_id": "test-id"})
assert conn.is_connected() is False
# With access_token IS connected
save_tokens(creds, {"access_token": "ya29.test", "client_id": "test-id"})
assert conn.is_connected() is True
def test_gcalendar_handle_callback_persists_creds_no_background_flow(
tmp_path: Path,
) -> None:
"""Sibling connector shares the fix: creds saved, no browser thread (#512)."""
from openjarvis.connectors.gcalendar import GCalendarConnector
from openjarvis.connectors.oauth import load_tokens
creds = str(tmp_path / "gcalendar.json")
conn = GCalendarConnector(credentials_path=creds)
with patch("openjarvis.core.open_browser") as mock_browser:
conn.handle_callback("test-id.apps.googleusercontent.com:test-secret")
mock_browser.assert_not_called()
tokens = load_tokens(creds)
assert tokens is not None
assert tokens["client_id"] == "test-id.apps.googleusercontent.com"
assert tokens["client_secret"] == "test-secret"
assert conn.is_connected() is False
def test_gcontacts_handle_callback_persists_creds_no_background_flow(
tmp_path: Path,
) -> None:
"""Sibling connector shares the fix: creds saved, no browser thread (#512)."""
from openjarvis.connectors.gcontacts import GContactsConnector
from openjarvis.connectors.oauth import load_tokens
creds = str(tmp_path / "gcontacts.json")
conn = GContactsConnector(credentials_path=creds)
with patch("openjarvis.core.open_browser") as mock_browser:
conn.handle_callback("test-id.apps.googleusercontent.com:test-secret")
mock_browser.assert_not_called()
tokens = load_tokens(creds)
assert tokens is not None
assert tokens["client_id"] == "test-id.apps.googleusercontent.com"
assert tokens["client_secret"] == "test-secret"
assert conn.is_connected() is False
def test_gdrive_handle_callback_raw_token(tmp_path: Path) -> None:
from openjarvis.connectors.gdrive import GDriveConnector
from openjarvis.connectors.oauth import load_tokens
creds = str(tmp_path / "gdrive.json")
conn = GDriveConnector(credentials_path=creds)
conn.handle_callback("some-raw-token-value")
tokens = load_tokens(creds)
assert tokens is not None
assert tokens["token"] == "some-raw-token-value"
def test_gdrive_auth_url_returns_credentials_page_without_client_id(
tmp_path: Path,
) -> None:
from openjarvis.connectors.gdrive import GDriveConnector
creds = str(tmp_path / "gdrive.json")
conn = GDriveConnector(credentials_path=creds)
url = conn.auth_url()
assert url == "https://console.cloud.google.com/apis/credentials"
def test_gdrive_auth_url_returns_consent_url_with_client_id(
tmp_path: Path,
) -> None:
from openjarvis.connectors.gdrive import GDriveConnector
from openjarvis.connectors.oauth import save_tokens
creds = str(tmp_path / "gdrive.json")
conn = GDriveConnector(credentials_path=creds)
save_tokens(creds, {"client_id": "test-id.apps.googleusercontent.com"})
url = conn.auth_url()
assert "accounts.google.com" in url
assert "test-id.apps.googleusercontent.com" in url