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
134 lines
3.6 KiB
Python
134 lines
3.6 KiB
Python
"""Test fixtures for API endpoint tests.
|
|
|
|
Provides fixtures for testing FastAPI endpoints with mocked dependencies.
|
|
"""
|
|
|
|
from contextlib import asynccontextmanager
|
|
from datetime import UTC, datetime
|
|
from unittest.mock import MagicMock, patch
|
|
from uuid import uuid4
|
|
|
|
import fakeredis.aioredis
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api import deps as api_deps
|
|
from app.api.auth import router as auth_router
|
|
from app.api.users import router as users_router
|
|
from app.db.models import User
|
|
from app.services.jwt_service import create_access_token, create_refresh_token
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_redis():
|
|
"""Provide a fake Redis instance for testing."""
|
|
return fakeredis.aioredis.FakeRedis(decode_responses=True)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_get_redis(fake_redis):
|
|
"""Mock the get_redis context manager to use fake Redis."""
|
|
|
|
@asynccontextmanager
|
|
async def _mock_get_redis():
|
|
yield fake_redis
|
|
|
|
return _mock_get_redis
|
|
|
|
|
|
@pytest.fixture
|
|
def test_user():
|
|
"""Create a test user object.
|
|
|
|
Returns a User model instance that can be used in tests.
|
|
The user is not persisted to database.
|
|
"""
|
|
user = User(
|
|
email="test@example.com",
|
|
display_name="Test User",
|
|
avatar_url="https://example.com/avatar.jpg",
|
|
oauth_provider="google",
|
|
oauth_id="google-123",
|
|
is_premium=False,
|
|
premium_until=None,
|
|
)
|
|
# Manually set the ID since we're not using database
|
|
user.id = str(uuid4())
|
|
user.created_at = datetime.now(UTC)
|
|
user.updated_at = datetime.now(UTC)
|
|
user.last_login = None
|
|
user.linked_accounts = []
|
|
return user
|
|
|
|
|
|
@pytest.fixture
|
|
def premium_user(test_user):
|
|
"""Create a premium test user."""
|
|
from datetime import timedelta
|
|
|
|
test_user.is_premium = True
|
|
test_user.premium_until = datetime.now(UTC) + timedelta(days=30)
|
|
return test_user
|
|
|
|
|
|
@pytest.fixture
|
|
def access_token(test_user):
|
|
"""Create a valid access token for the test user."""
|
|
from uuid import UUID
|
|
|
|
user_id = UUID(test_user.id) if isinstance(test_user.id, str) else test_user.id
|
|
return create_access_token(user_id)
|
|
|
|
|
|
@pytest.fixture
|
|
def refresh_token_data(test_user):
|
|
"""Create a valid refresh token and JTI for the test user."""
|
|
from uuid import UUID
|
|
|
|
user_id = UUID(test_user.id) if isinstance(test_user.id, str) else test_user.id
|
|
token, jti = create_refresh_token(user_id)
|
|
return {"token": token, "jti": jti, "user_id": user_id}
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_db_session():
|
|
"""Create a mock database session."""
|
|
return MagicMock(spec=AsyncSession)
|
|
|
|
|
|
@pytest.fixture
|
|
def app(mock_get_redis, mock_db_session):
|
|
"""Create a test FastAPI app with mocked Redis and DB.
|
|
|
|
This creates a minimal app with just the auth and users routers,
|
|
with Redis and database mocked.
|
|
"""
|
|
# Create test app (no lifespan since we're mocking everything)
|
|
test_app = FastAPI()
|
|
test_app.include_router(auth_router, prefix="/api")
|
|
test_app.include_router(users_router, prefix="/api")
|
|
|
|
# Override get_db dependency to return mock session
|
|
async def override_get_db():
|
|
yield mock_db_session
|
|
|
|
test_app.dependency_overrides[api_deps.get_db] = override_get_db
|
|
|
|
# Patch get_redis globally for this app
|
|
with (
|
|
patch("app.api.auth.get_redis", mock_get_redis),
|
|
patch("app.services.token_store.get_redis", mock_get_redis),
|
|
):
|
|
yield test_app
|
|
|
|
# Clean up overrides
|
|
test_app.dependency_overrides.clear()
|
|
|
|
|
|
@pytest.fixture
|
|
def client(app):
|
|
"""Create a test client for the app."""
|
|
return TestClient(app)
|