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
322 lines
11 KiB
Python
322 lines
11 KiB
Python
"""Tests for Discord OAuth service.
|
|
|
|
Tests the Discord 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.discord import DiscordOAuth, DiscordOAuthError
|
|
|
|
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 Discord OAuth is not configured.
|
|
|
|
Without client ID, the method should raise DiscordOAuthError.
|
|
"""
|
|
oauth = DiscordOAuth()
|
|
|
|
with patch("app.services.oauth.discord.settings") as mock_settings:
|
|
mock_settings.discord_client_id = None
|
|
|
|
with pytest.raises(DiscordOAuthError, 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 = DiscordOAuth()
|
|
|
|
with patch("app.services.oauth.discord.settings") as mock_settings:
|
|
mock_settings.discord_client_id = "test-client-id"
|
|
mock_settings.discord_client_secret = "test-secret"
|
|
|
|
url = oauth.get_authorization_url("http://localhost/callback", "state123")
|
|
|
|
assert "discord.com/api/oauth2/authorize" 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 Discord's token endpoint to return valid tokens.
|
|
"""
|
|
oauth = DiscordOAuth()
|
|
|
|
respx.post("https://discord.com/api/oauth2/token").mock(
|
|
return_value=Response(
|
|
200,
|
|
json={
|
|
"access_token": "test-access-token",
|
|
"refresh_token": "test-refresh-token",
|
|
"expires_in": 604800,
|
|
"token_type": "Bearer",
|
|
},
|
|
)
|
|
)
|
|
|
|
with patch("app.services.oauth.discord.settings") as mock_settings:
|
|
mock_settings.discord_client_id = "test-client-id"
|
|
mock_settings.discord_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 Discord.
|
|
|
|
If Discord returns an error, DiscordOAuthError should be raised.
|
|
"""
|
|
oauth = DiscordOAuth()
|
|
|
|
respx.post("https://discord.com/api/oauth2/token").mock(
|
|
return_value=Response(
|
|
400,
|
|
json={
|
|
"error": "invalid_grant",
|
|
"error_description": "Invalid code",
|
|
},
|
|
)
|
|
)
|
|
|
|
with patch("app.services.oauth.discord.settings") as mock_settings:
|
|
mock_settings.discord_client_id = "test-client-id"
|
|
mock_settings.discord_client_secret.get_secret_value.return_value = "test-secret"
|
|
|
|
with pytest.raises(DiscordOAuthError, match="Token exchange failed"):
|
|
await oauth.exchange_code_for_tokens("invalid-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 Discord.
|
|
|
|
Mocks Discord's users/@me endpoint.
|
|
"""
|
|
oauth = DiscordOAuth()
|
|
|
|
respx.get("https://discord.com/api/users/@me").mock(
|
|
return_value=Response(
|
|
200,
|
|
json={
|
|
"id": "discord-user-123",
|
|
"username": "testuser",
|
|
"global_name": "Test User",
|
|
"email": "user@discord.com",
|
|
"avatar": "abc123",
|
|
},
|
|
)
|
|
)
|
|
|
|
user_info = await oauth.fetch_user_info("test-access-token")
|
|
|
|
assert user_info["id"] == "discord-user-123"
|
|
assert user_info["email"] == "user@discord.com"
|
|
|
|
@respx.mock
|
|
async def test_raises_on_error_response(self):
|
|
"""Test that fetch_user_info raises on error from Discord."""
|
|
oauth = DiscordOAuth()
|
|
|
|
respx.get("https://discord.com/api/users/@me").mock(
|
|
return_value=Response(401, json={"message": "401: Unauthorized"})
|
|
)
|
|
|
|
with pytest.raises(DiscordOAuthError, match="Failed to fetch user info"):
|
|
await oauth.fetch_user_info("invalid-token")
|
|
|
|
|
|
class TestBuildAvatarUrl:
|
|
"""Tests for _build_avatar_url method."""
|
|
|
|
def test_returns_none_for_no_avatar(self):
|
|
"""Test that _build_avatar_url returns None when avatar is None."""
|
|
oauth = DiscordOAuth()
|
|
result = oauth._build_avatar_url("123456", None)
|
|
assert result is None
|
|
|
|
def test_builds_png_url_for_static_avatar(self):
|
|
"""Test that _build_avatar_url builds PNG URL for static avatars."""
|
|
oauth = DiscordOAuth()
|
|
result = oauth._build_avatar_url("123456", "abcdef123")
|
|
|
|
assert result == "https://cdn.discordapp.com/avatars/123456/abcdef123.png"
|
|
|
|
def test_builds_gif_url_for_animated_avatar(self):
|
|
"""Test that _build_avatar_url builds GIF URL for animated avatars.
|
|
|
|
Animated avatars start with 'a_' prefix.
|
|
"""
|
|
oauth = DiscordOAuth()
|
|
result = oauth._build_avatar_url("123456", "a_animated123")
|
|
|
|
assert result == "https://cdn.discordapp.com/avatars/123456/a_animated123.gif"
|
|
|
|
|
|
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 = DiscordOAuth()
|
|
|
|
# Mock token exchange
|
|
respx.post("https://discord.com/api/oauth2/token").mock(
|
|
return_value=Response(
|
|
200,
|
|
json={
|
|
"access_token": "test-access-token",
|
|
"refresh_token": "test-refresh",
|
|
"expires_in": 604800,
|
|
},
|
|
)
|
|
)
|
|
|
|
# Mock user info
|
|
respx.get("https://discord.com/api/users/@me").mock(
|
|
return_value=Response(
|
|
200,
|
|
json={
|
|
"id": "discord-user-456",
|
|
"username": "fullflowuser",
|
|
"global_name": "Full Flow User",
|
|
"email": "fullflow@discord.com",
|
|
"avatar": "avatar123",
|
|
},
|
|
)
|
|
)
|
|
|
|
with patch("app.services.oauth.discord.settings") as mock_settings:
|
|
mock_settings.discord_client_id = "test-client-id"
|
|
mock_settings.discord_client_secret.get_secret_value.return_value = "test-secret"
|
|
|
|
result = await oauth.get_user_info("auth-code", "http://localhost/callback")
|
|
|
|
assert result.provider == "discord"
|
|
assert result.oauth_id == "discord-user-456"
|
|
assert result.email == "fullflow@discord.com"
|
|
assert result.name == "Full Flow User"
|
|
assert "avatar123.png" in result.avatar_url
|
|
|
|
@respx.mock
|
|
async def test_uses_username_when_no_global_name(self):
|
|
"""Test that get_user_info falls back to username for display name.
|
|
|
|
Discord users may not have global_name set.
|
|
"""
|
|
oauth = DiscordOAuth()
|
|
|
|
respx.post("https://discord.com/api/oauth2/token").mock(
|
|
return_value=Response(
|
|
200,
|
|
json={"access_token": "test-access-token"},
|
|
)
|
|
)
|
|
|
|
respx.get("https://discord.com/api/users/@me").mock(
|
|
return_value=Response(
|
|
200,
|
|
json={
|
|
"id": "discord-user-789",
|
|
"username": "legacyuser",
|
|
"global_name": None, # No global name
|
|
"email": "legacy@discord.com",
|
|
"avatar": None,
|
|
},
|
|
)
|
|
)
|
|
|
|
with patch("app.services.oauth.discord.settings") as mock_settings:
|
|
mock_settings.discord_client_id = "test-client-id"
|
|
mock_settings.discord_client_secret.get_secret_value.return_value = "test-secret"
|
|
|
|
result = await oauth.get_user_info("auth-code", "http://localhost/callback")
|
|
|
|
assert result.name == "legacyuser"
|
|
assert result.avatar_url is None
|
|
|
|
@respx.mock
|
|
async def test_raises_when_no_email(self):
|
|
"""Test that get_user_info raises when Discord user has no email.
|
|
|
|
Email is required for account creation.
|
|
"""
|
|
oauth = DiscordOAuth()
|
|
|
|
respx.post("https://discord.com/api/oauth2/token").mock(
|
|
return_value=Response(
|
|
200,
|
|
json={"access_token": "test-access-token"},
|
|
)
|
|
)
|
|
|
|
respx.get("https://discord.com/api/users/@me").mock(
|
|
return_value=Response(
|
|
200,
|
|
json={
|
|
"id": "discord-user-noemail",
|
|
"username": "noemailuser",
|
|
"email": None, # No verified email
|
|
},
|
|
)
|
|
)
|
|
|
|
with patch("app.services.oauth.discord.settings") as mock_settings:
|
|
mock_settings.discord_client_id = "test-client-id"
|
|
mock_settings.discord_client_secret.get_secret_value.return_value = "test-secret"
|
|
|
|
with pytest.raises(DiscordOAuthError, match="verified email"):
|
|
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 = DiscordOAuth()
|
|
|
|
with patch("app.services.oauth.discord.settings") as mock_settings:
|
|
mock_settings.discord_client_id = None
|
|
mock_settings.discord_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 = DiscordOAuth()
|
|
|
|
with patch("app.services.oauth.discord.settings") as mock_settings:
|
|
mock_settings.discord_client_id = "client-id"
|
|
mock_settings.discord_client_secret = "client-secret"
|
|
|
|
assert oauth.is_configured() is True
|