from datetime import datetime, timedelta, timezone import pytest from fastapi.testclient import TestClient from app.api import auth as auth_module from app.db import get_db from app.main import app from app.models.oauth_credentials import SINGLETON_ID, OAuthCredentials from app.services import google_oauth @pytest.fixture def client(monkeypatch, db_session): def _get_db_override(): yield db_session app.dependency_overrides[get_db] = _get_db_override monkeypatch.setattr(auth_module, "_run_initial_sync", lambda: None) monkeypatch.setattr( google_oauth, "build_authorization_url", lambda: ("https://accounts.google.com/fake", "fixed-state") ) # Deliberately not using `with TestClient(app)`: that runs the app's # lifespan, which would try to reach the real MeTube instance and DB. yield TestClient(app) del app.dependency_overrides[get_db] class FakeCredentials: token = "fake-access-token" refresh_token = "fake-refresh-token" expiry = None def _start_oauth(client): response = client.get("/api/auth/google/start", follow_redirects=False) assert response.status_code in (302, 307) return "fixed-state" def test_callback_rejects_disallowed_email(client, monkeypatch): state = _start_oauth(client) monkeypatch.setattr(google_oauth, "exchange_code", lambda code, state: FakeCredentials()) monkeypatch.setattr(google_oauth, "fetch_userinfo", lambda token: {"email": "stranger@example.com"}) revoked = {} monkeypatch.setattr(google_oauth, "revoke_token", lambda token: revoked.setdefault("called", True)) response = client.get(f"/api/auth/google/callback?code=abc&state={state}", follow_redirects=False) assert response.status_code in (302, 307) assert "account_not_allowed" in response.headers["location"] assert revoked.get("called") is True status = client.get("/api/auth/status").json() assert status["authenticated"] is False assert status["connected"] is False def test_callback_accepts_allowed_email(client, monkeypatch): state = _start_oauth(client) monkeypatch.setattr(google_oauth, "exchange_code", lambda code, state: FakeCredentials()) monkeypatch.setattr(google_oauth, "fetch_userinfo", lambda token: {"email": "owner@example.com"}) response = client.get(f"/api/auth/google/callback?code=abc&state={state}", follow_redirects=False) assert response.status_code in (302, 307) assert "auth_error" not in response.headers["location"] status = client.get("/api/auth/status").json() assert status["authenticated"] is True assert status["connected"] is True assert status["email"] == "owner@example.com" def test_callback_rejects_invalid_state(client, monkeypatch): _start_oauth(client) response = client.get("/api/auth/google/callback?code=abc&state=wrong-state", follow_redirects=False) assert response.status_code in (302, 307) assert "invalid_state" in response.headers["location"] def test_status_hides_email_without_session(client, db_session): """/api/auth/status is public (the Connect page reads `authenticated`), so the connected account's email must not leak to unauthenticated requests.""" row = OAuthCredentials( id=SINGLETON_ID, google_email="owner@example.com", encrypted_refresh_token="encrypted-blob", ) db_session.add(row) db_session.commit() status = client.get("/api/auth/status").json() assert status["authenticated"] is False assert status["connected"] is True assert status["email"] is None def _seed_credentials_row(db_session, refresh_token="refresh-1"): row = OAuthCredentials( id=SINGLETON_ID, google_email="owner@example.com", encrypted_refresh_token=google_oauth.encrypt_token(refresh_token), ) db_session.add(row) db_session.commit() class FakeGoogleCredentials: """Stands in for google.oauth2.credentials.Credentials in cache tests.""" def __init__(self, **kwargs): self.refresh_token = kwargs["refresh_token"] self.token = None self.expiry = None def refresh(self, request): raise NotImplementedError def test_get_credentials_caches_and_reuses_access_token(monkeypatch, db_session): monkeypatch.setattr(google_oauth, "_cached_credentials", None) _seed_credentials_row(db_session) refreshes = [] class Creds(FakeGoogleCredentials): def refresh(self, request): refreshes.append(request) self.token = "access-token" self.expiry = datetime(2099, 1, 1, tzinfo=timezone.utc) monkeypatch.setattr(google_oauth, "Credentials", Creds) first = google_oauth.get_credentials(db_session) second = google_oauth.get_credentials(db_session) assert first is second assert first.token == "access-token" assert len(refreshes) == 1 # second call must not hit Google again def test_get_credentials_refreshes_expiring_token(monkeypatch, db_session): monkeypatch.setattr(google_oauth, "_cached_credentials", None) _seed_credentials_row(db_session) refreshes = [] class Creds(FakeGoogleCredentials): def refresh(self, request): refreshes.append(request) self.token = "access-token" # Expires in 30s -- inside the 60s refresh buffer. self.expiry = datetime.now(timezone.utc) + timedelta(seconds=30) monkeypatch.setattr(google_oauth, "Credentials", Creds) google_oauth.get_credentials(db_session) google_oauth.get_credentials(db_session) assert len(refreshes) == 2 def test_get_credentials_refresh_failure_clears_cache(monkeypatch, db_session): """A failed token refresh must not leave a broken cached object behind: the error propagates and the next call rebuilds credentials from the DB instead of reusing (and re-failing on) the stale cache entry.""" monkeypatch.setattr(google_oauth, "_cached_credentials", None) _seed_credentials_row(db_session) built = [] class Creds(FakeGoogleCredentials): def __init__(self, **kwargs): super().__init__(**kwargs) built.append(self.refresh_token) def refresh(self, request): raise RuntimeError("token endpoint down") monkeypatch.setattr(google_oauth, "Credentials", Creds) for _ in range(2): with pytest.raises(RuntimeError, match="token endpoint down"): google_oauth.get_credentials(db_session) # Both calls failed, and the broken cache entry was dropped: each call # rebuilt from the stored refresh token instead of sticking. assert len(built) == 2 assert google_oauth._cached_credentials is None def test_get_credentials_rebuilds_cache_after_reconnect(monkeypatch, db_session): monkeypatch.setattr(google_oauth, "_cached_credentials", None) _seed_credentials_row(db_session) built = [] class Creds(FakeGoogleCredentials): def __init__(self, **kwargs): super().__init__(**kwargs) built.append(self.refresh_token) self.token = "already-valid" self.expiry = datetime(2099, 1, 1, tzinfo=timezone.utc) def refresh(self, request): raise AssertionError("must not refresh a still-valid cached token") monkeypatch.setattr(google_oauth, "Credentials", Creds) google_oauth.get_credentials(db_session) assert built == ["refresh-1"] # Reconnect: the stored refresh token changes, the old cache entry must # not be reused. row = db_session.get(OAuthCredentials, SINGLETON_ID) row.encrypted_refresh_token = google_oauth.encrypt_token("refresh-2") db_session.commit() google_oauth.get_credentials(db_session) assert built == ["refresh-1", "refresh-2"]