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
242 lines
8.3 KiB
Python
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
|