mantimon-tcg/backend/tests/services/oauth/test_discord.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

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