mantimon-tcg/backend/tests/services/oauth/test_google.py
Cal Corum 996c43fbd9 Implement Phase 2: Authentication system
Complete OAuth-based authentication with JWT session management:

Core Services:
- JWT service for access/refresh token creation and verification
- Token store with Redis-backed refresh token revocation
- User service for CRUD operations and OAuth-based creation
- Google and Discord OAuth services with full flow support

API Endpoints:
- GET /api/auth/{google,discord} - Start OAuth flows
- GET /api/auth/{google,discord}/callback - Handle OAuth callbacks
- POST /api/auth/refresh - Exchange refresh token for new access token
- POST /api/auth/logout - Revoke single refresh token
- POST /api/auth/logout-all - Revoke all user sessions
- GET/PATCH /api/users/me - User profile management
- GET /api/users/me/linked-accounts - List OAuth providers
- GET /api/users/me/sessions - Count active sessions

Infrastructure:
- Pydantic schemas for auth/user request/response models
- FastAPI dependencies (get_current_user, get_current_premium_user)
- OAuthLinkedAccount model for multi-provider support
- Alembic migration for oauth_linked_accounts table

Dependencies added: email-validator, fakeredis (dev), respx (dev)

84 new tests, 1058 total passing
2026-01-27 21:49:59 -06:00

242 lines
8.3 KiB
Python

"""Tests for Google OAuth service.
Tests the Google OAuth flow with mocked HTTP responses using respx.
"""
from unittest.mock import patch
import pytest
import respx
from httpx import Response
from app.services.oauth.google import GoogleOAuth, GoogleOAuthError
pytestmark = pytest.mark.asyncio
class TestGetAuthorizationUrl:
"""Tests for get_authorization_url method."""
def test_raises_when_not_configured(self):
"""Test that get_authorization_url raises when Google OAuth is not configured.
Without client ID, the method should raise GoogleOAuthError.
"""
oauth = GoogleOAuth()
with patch("app.services.oauth.google.settings") as mock_settings:
mock_settings.google_client_id = None
with pytest.raises(GoogleOAuthError, match="not configured"):
oauth.get_authorization_url("http://localhost/callback", "state123")
def test_returns_valid_url_when_configured(self):
"""Test that get_authorization_url returns properly formatted URL.
The URL should include client_id, redirect_uri, state, and scopes.
"""
oauth = GoogleOAuth()
with patch("app.services.oauth.google.settings") as mock_settings:
mock_settings.google_client_id = "test-client-id"
mock_settings.google_client_secret = "test-secret"
url = oauth.get_authorization_url("http://localhost/callback", "state123")
assert "accounts.google.com/o/oauth2/v2/auth" in url
assert "client_id=test-client-id" in url
assert "redirect_uri=http" in url
assert "state=state123" in url
assert "scope=" in url
assert "response_type=code" in url
class TestExchangeCodeForTokens:
"""Tests for exchange_code_for_tokens method."""
@respx.mock
async def test_returns_tokens_on_success(self):
"""Test that exchange_code_for_tokens returns tokens on success.
Mocks Google's token endpoint to return valid tokens.
"""
oauth = GoogleOAuth()
respx.post("https://oauth2.googleapis.com/token").mock(
return_value=Response(
200,
json={
"access_token": "test-access-token",
"id_token": "test-id-token",
"expires_in": 3600,
"token_type": "Bearer",
},
)
)
with patch("app.services.oauth.google.settings") as mock_settings:
mock_settings.google_client_id = "test-client-id"
mock_settings.google_client_secret.get_secret_value.return_value = "test-secret"
tokens = await oauth.exchange_code_for_tokens("auth-code", "http://localhost/callback")
assert tokens["access_token"] == "test-access-token"
@respx.mock
async def test_raises_on_error_response(self):
"""Test that exchange_code_for_tokens raises on error from Google.
If Google returns an error, GoogleOAuthError should be raised.
"""
oauth = GoogleOAuth()
respx.post("https://oauth2.googleapis.com/token").mock(
return_value=Response(
400,
json={
"error": "invalid_grant",
"error_description": "Code has expired",
},
)
)
with patch("app.services.oauth.google.settings") as mock_settings:
mock_settings.google_client_id = "test-client-id"
mock_settings.google_client_secret.get_secret_value.return_value = "test-secret"
with pytest.raises(GoogleOAuthError, match="Token exchange failed"):
await oauth.exchange_code_for_tokens("expired-code", "http://localhost/callback")
class TestFetchUserInfo:
"""Tests for fetch_user_info method."""
@respx.mock
async def test_returns_user_info_on_success(self):
"""Test that fetch_user_info returns user data from Google.
Mocks Google's userinfo endpoint.
"""
oauth = GoogleOAuth()
respx.get("https://www.googleapis.com/oauth2/v2/userinfo").mock(
return_value=Response(
200,
json={
"id": "google-user-123",
"email": "user@gmail.com",
"name": "Test User",
"picture": "https://google.com/avatar.jpg",
},
)
)
user_info = await oauth.fetch_user_info("test-access-token")
assert user_info["id"] == "google-user-123"
assert user_info["email"] == "user@gmail.com"
@respx.mock
async def test_raises_on_error_response(self):
"""Test that fetch_user_info raises on error from Google."""
oauth = GoogleOAuth()
respx.get("https://www.googleapis.com/oauth2/v2/userinfo").mock(
return_value=Response(401, json={"error": "Invalid token"})
)
with pytest.raises(GoogleOAuthError, match="Failed to fetch user info"):
await oauth.fetch_user_info("invalid-token")
class TestGetUserInfo:
"""Tests for get_user_info method (full flow)."""
@respx.mock
async def test_returns_oauth_user_info_on_success(self):
"""Test that get_user_info completes full OAuth flow.
This tests the combined token exchange + user info fetch.
"""
oauth = GoogleOAuth()
# Mock token exchange
respx.post("https://oauth2.googleapis.com/token").mock(
return_value=Response(
200,
json={
"access_token": "test-access-token",
"id_token": "test-id-token",
"expires_in": 3600,
},
)
)
# Mock user info
respx.get("https://www.googleapis.com/oauth2/v2/userinfo").mock(
return_value=Response(
200,
json={
"id": "google-user-456",
"email": "fullflow@gmail.com",
"name": "Full Flow User",
"picture": "https://google.com/fullflow.jpg",
},
)
)
with patch("app.services.oauth.google.settings") as mock_settings:
mock_settings.google_client_id = "test-client-id"
mock_settings.google_client_secret.get_secret_value.return_value = "test-secret"
result = await oauth.get_user_info("auth-code", "http://localhost/callback")
assert result.provider == "google"
assert result.oauth_id == "google-user-456"
assert result.email == "fullflow@gmail.com"
assert result.name == "Full Flow User"
assert result.avatar_url == "https://google.com/fullflow.jpg"
@respx.mock
async def test_raises_when_no_access_token(self):
"""Test that get_user_info raises when token response lacks access_token."""
oauth = GoogleOAuth()
respx.post("https://oauth2.googleapis.com/token").mock(
return_value=Response(
200,
json={"id_token": "only-id-token"}, # No access_token
)
)
with patch("app.services.oauth.google.settings") as mock_settings:
mock_settings.google_client_id = "test-client-id"
mock_settings.google_client_secret.get_secret_value.return_value = "test-secret"
with pytest.raises(GoogleOAuthError, match="No access token"):
await oauth.get_user_info("auth-code", "http://localhost/callback")
class TestIsConfigured:
"""Tests for is_configured method."""
def test_returns_false_when_not_configured(self):
"""Test that is_configured returns False without credentials."""
oauth = GoogleOAuth()
with patch("app.services.oauth.google.settings") as mock_settings:
mock_settings.google_client_id = None
mock_settings.google_client_secret = None
assert oauth.is_configured() is False
def test_returns_true_when_configured(self):
"""Test that is_configured returns True with credentials."""
oauth = GoogleOAuth()
with patch("app.services.oauth.google.settings") as mock_settings:
mock_settings.google_client_id = "client-id"
mock_settings.google_client_secret = "client-secret"
assert oauth.is_configured() is True