diff --git a/tests/test_auth.py b/tests/test_auth.py index cc662ee..f64c9c2 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,4 +1,4 @@ -"""Tests for auth routes: register, login, refresh, me. +"""Tests for auth routes: register, login, refresh, me, OAuth social login. Exercises the full auth lifecycle through the FastAPI TestClient against the in-memory SQLite test database seeded by ``conftest.py``. @@ -7,9 +7,11 @@ in-memory SQLite test database seeded by ``conftest.py``. from __future__ import annotations import time +from unittest.mock import AsyncMock, patch from jose import jwt +from app.auth.oauth_providers import GoogleOAuthProvider, OAuthUserInfo from app.config.settings import settings from tests.conftest import auth_header, TEST_USER_IDS @@ -204,3 +206,138 @@ class TestMe: token = jwt.encode(payload, "wrong-secret", algorithm="HS256") resp = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"}) assert resp.status_code == 401 + + +# ── TestOAuth ───────────────────────────────────────────────────────── + + +class TestOAuth: + """GET /auth/oauth/google/authorize and POST /auth/oauth/google/callback.""" + + FAKE_PROVIDER_USER_ID = "google-sub-12345" + FAKE_EMAIL = "oauth@example.com" + FAKE_AVATAR = "https://lh3.googleusercontent.com/photo.jpg" + + def _patch_google(self, monkeypatch) -> None: + monkeypatch.setattr(settings, "GOOGLE_AUTH_CLIENT_ID", "fake-client-id") + monkeypatch.setattr(settings, "GOOGLE_AUTH_CLIENT_SECRET", "fake-client-secret") + + def _userinfo( + self, + email: str | None = None, + email_verified: bool = True, + ) -> OAuthUserInfo: + return OAuthUserInfo( + provider_user_id=self.FAKE_PROVIDER_USER_ID, + email=email or self.FAKE_EMAIL, + email_verified=email_verified, + avatar_url=self.FAKE_AVATAR, + name="OAuth User", + ) + + def _authorize(self, client) -> str: + """Call /authorize and return the fresh state token.""" + resp = client.get("/api/v1/auth/oauth/google/authorize") + assert resp.status_code == 200 + return resp.json()["state"] + + def _callback(self, client, state: str, userinfo: OAuthUserInfo): + """POST /callback with mocked provider exchange_code + get_userinfo.""" + with ( + patch.object( + GoogleOAuthProvider, + "exchange_code", + new=AsyncMock(return_value={"access_token": "google-access-tok"}), + ), + patch.object( + GoogleOAuthProvider, + "get_userinfo", + new=AsyncMock(return_value=userinfo), + ), + ): + return client.post( + "/api/v1/auth/oauth/google/callback", + json={"code": "auth-code", "state": state}, + ) + + def _decode_sub(self, access_token: str) -> str: + return jwt.decode( + access_token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM] + )["sub"] + + # -- authorize -- + + def test_authorize_returns_url_and_state(self, client, monkeypatch) -> None: + self._patch_google(monkeypatch) + resp = client.get("/api/v1/auth/oauth/google/authorize") + assert resp.status_code == 200 + data = resp.json() + assert "url" in data and "state" in data + assert "accounts.google.com" in data["url"] + assert len(data["state"]) > 0 + + def test_authorize_unconfigured_returns_503(self, client, monkeypatch) -> None: + monkeypatch.setattr(settings, "GOOGLE_AUTH_CLIENT_ID", "") + monkeypatch.setattr(settings, "GOOGLE_AUTH_CLIENT_SECRET", "") + resp = client.get("/api/v1/auth/oauth/google/authorize") + assert resp.status_code == 503 + + # -- callback -- + + def test_callback_state_mismatch_returns_401(self, client, monkeypatch) -> None: + self._patch_google(monkeypatch) + resp = client.post( + "/api/v1/auth/oauth/google/callback", + json={"code": "code", "state": "not-a-real-state"}, + ) + assert resp.status_code == 401 + + def test_callback_creates_new_user(self, client, monkeypatch) -> None: + """First-time Google login creates a new user and returns valid tokens.""" + self._patch_google(monkeypatch) + state = self._authorize(client) + resp = self._callback(client, state, self._userinfo()) + + assert resp.status_code == 200 + data = resp.json() + assert "access_token" in data and "refresh_token" in data + payload = jwt.decode( + data["access_token"], settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM] + ) + assert payload["email"] == self.FAKE_EMAIL + + def test_callback_existing_oauth_link_logs_in(self, client, monkeypatch) -> None: + """Second Google login with the same account re-uses the existing user.""" + self._patch_google(monkeypatch) + userinfo = self._userinfo() + + # First login — creates user + oauth_accounts row + resp1 = self._callback(client, self._authorize(client), userinfo) + assert resp1.status_code == 200 + sub1 = self._decode_sub(resp1.json()["access_token"]) + + # Second login — finds existing oauth_accounts row → same user + resp2 = self._callback(client, self._authorize(client), userinfo) + assert resp2.status_code == 200 + sub2 = self._decode_sub(resp2.json()["access_token"]) + + assert sub1 == sub2 + + def test_callback_email_match_links_account(self, client, monkeypatch) -> None: + """Verified Google email matching an existing password user links the accounts.""" + email = "link-target@example.com" + reg_resp = client.post( + "/api/v1/auth/register", + json={"email": email, "password": "TestPass123!"}, + ) + assert reg_resp.status_code == 201 + orig_sub = self._decode_sub(reg_resp.json()["access_token"]) + + self._patch_google(monkeypatch) + state = self._authorize(client) + resp = self._callback(client, state, self._userinfo(email=email, email_verified=True)) + + assert resp.status_code == 200 + oauth_sub = self._decode_sub(resp.json()["access_token"]) + # OAuth login must resolve to the same user as the original registration + assert orig_sub == oauth_sub