test: add TestOAuth suite for Google OAuth routes
6 tests covering the authorize and callback endpoints: - authorize returns URL + state, 503 when unconfigured - callback: state mismatch → 401, new user creation, existing OAuth link re-login (same user sub), email-match auto-linking to password user Provider methods (exchange_code, get_userinfo) are mocked via AsyncMock so tests run without hitting Google APIs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user