mantimon-tcg/backend/tests/api/test_auth.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

261 lines
9.4 KiB
Python

"""Tests for auth API endpoints.
Tests the authentication endpoints including OAuth redirects,
token refresh, and logout.
"""
from unittest.mock import AsyncMock, patch
from uuid import UUID
from fastapi import status
from fastapi.testclient import TestClient
class TestGoogleAuthRedirect:
"""Tests for GET /api/auth/google endpoint."""
def test_returns_501_when_not_configured(self, client: TestClient):
"""Test that endpoint returns 501 when Google OAuth is not configured.
Without client credentials, OAuth flow cannot proceed.
"""
with patch("app.api.auth.google_oauth") as mock_oauth:
mock_oauth.is_configured.return_value = False
response = client.get(
"/api/auth/google",
params={"redirect_uri": "http://localhost/callback"},
follow_redirects=False,
)
assert response.status_code == status.HTTP_501_NOT_IMPLEMENTED
assert "not configured" in response.json()["detail"]
class TestDiscordAuthRedirect:
"""Tests for GET /api/auth/discord endpoint."""
def test_returns_501_when_not_configured(self, client: TestClient):
"""Test that endpoint returns 501 when Discord OAuth is not configured."""
with patch("app.api.auth.discord_oauth") as mock_oauth:
mock_oauth.is_configured.return_value = False
response = client.get(
"/api/auth/discord",
params={"redirect_uri": "http://localhost/callback"},
follow_redirects=False,
)
assert response.status_code == status.HTTP_501_NOT_IMPLEMENTED
assert "not configured" in response.json()["detail"]
class TestRefreshTokens:
"""Tests for POST /api/auth/refresh endpoint."""
def test_returns_new_access_token(
self, client: TestClient, test_user, refresh_token_data, mock_get_redis
):
"""Test that refresh endpoint returns new access token for valid refresh token.
A valid, non-revoked refresh token should yield a new access token.
"""
# Store the refresh token in fake Redis
import asyncio
async def setup_token():
async with mock_get_redis() as redis:
key = f"refresh_token:{refresh_token_data['user_id']}:{refresh_token_data['jti']}"
await redis.setex(key, 86400, "1")
asyncio.get_event_loop().run_until_complete(setup_token())
# Mock user service to return our test user
with patch("app.api.auth.user_service") as mock_user_service:
mock_user_service.get_by_id = AsyncMock(return_value=test_user)
with (
patch("app.api.auth.get_redis", mock_get_redis),
patch("app.services.token_store.get_redis", mock_get_redis),
):
response = client.post(
"/api/auth/refresh",
json={"refresh_token": refresh_token_data["token"]},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert "access_token" in data
assert data["refresh_token"] == refresh_token_data["token"]
assert data["token_type"] == "bearer"
assert "expires_in" in data
def test_returns_401_for_invalid_token(self, client: TestClient):
"""Test that refresh endpoint returns 401 for invalid refresh token."""
response = client.post(
"/api/auth/refresh",
json={"refresh_token": "invalid.token.here"},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_returns_401_for_revoked_token(
self, client: TestClient, refresh_token_data, mock_get_redis
):
"""Test that refresh endpoint returns 401 for revoked token.
A refresh token not in Redis (revoked/expired) should be rejected.
"""
# Don't store the token in Redis - simulating revocation
with (
patch("app.api.auth.get_redis", mock_get_redis),
patch("app.services.token_store.get_redis", mock_get_redis),
):
response = client.post(
"/api/auth/refresh",
json={"refresh_token": refresh_token_data["token"]},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert "revoked" in response.json()["detail"]
def test_returns_401_for_deleted_user(
self, client: TestClient, refresh_token_data, mock_get_redis
):
"""Test that refresh endpoint returns 401 if user no longer exists."""
# Store the token
import asyncio
async def setup_token():
async with mock_get_redis() as redis:
key = f"refresh_token:{refresh_token_data['user_id']}:{refresh_token_data['jti']}"
await redis.setex(key, 86400, "1")
asyncio.get_event_loop().run_until_complete(setup_token())
# Mock user service to return None (user deleted)
with patch("app.api.auth.user_service") as mock_user_service:
mock_user_service.get_by_id = AsyncMock(return_value=None)
with (
patch("app.api.auth.get_redis", mock_get_redis),
patch("app.services.token_store.get_redis", mock_get_redis),
):
response = client.post(
"/api/auth/refresh",
json={"refresh_token": refresh_token_data["token"]},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert "User not found" in response.json()["detail"]
class TestLogout:
"""Tests for POST /api/auth/logout endpoint."""
def test_revokes_token(self, client: TestClient, refresh_token_data, mock_get_redis):
"""Test that logout revokes the refresh token.
After logout, the token should no longer be in Redis.
"""
# Store the token first
import asyncio
async def setup_and_check():
async with mock_get_redis() as redis:
key = f"refresh_token:{refresh_token_data['user_id']}:{refresh_token_data['jti']}"
await redis.setex(key, 86400, "1")
return key
key = asyncio.get_event_loop().run_until_complete(setup_and_check())
# Logout
with (
patch("app.api.auth.get_redis", mock_get_redis),
patch("app.services.token_store.get_redis", mock_get_redis),
):
response = client.post(
"/api/auth/logout",
json={"refresh_token": refresh_token_data["token"]},
)
assert response.status_code == status.HTTP_204_NO_CONTENT
# Verify token is gone
async def verify_deleted():
async with mock_get_redis() as redis:
return await redis.exists(key)
exists = asyncio.get_event_loop().run_until_complete(verify_deleted())
assert exists == 0
def test_succeeds_for_invalid_token(self, client: TestClient):
"""Test that logout succeeds even for invalid tokens.
Invalid tokens are effectively "already logged out", so no error.
"""
response = client.post(
"/api/auth/logout",
json={"refresh_token": "invalid.token.here"},
)
assert response.status_code == status.HTTP_204_NO_CONTENT
class TestLogoutAll:
"""Tests for POST /api/auth/logout-all endpoint."""
def test_requires_authentication(self, client: TestClient):
"""Test that logout-all requires a valid access token.
Without authentication, endpoint should return 401.
"""
response = client.post("/api/auth/logout-all")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_revokes_all_tokens(self, client: TestClient, test_user, access_token, mock_get_redis):
"""Test that logout-all revokes all refresh tokens for user.
Should delete all tokens matching the user's ID pattern.
"""
user_id = UUID(test_user.id) if isinstance(test_user.id, str) else test_user.id
# Store multiple tokens
import asyncio
async def setup_tokens():
async with mock_get_redis() as redis:
await redis.setex(f"refresh_token:{user_id}:jti-1", 86400, "1")
await redis.setex(f"refresh_token:{user_id}:jti-2", 86400, "1")
await redis.setex(f"refresh_token:{user_id}:jti-3", 86400, "1")
asyncio.get_event_loop().run_until_complete(setup_tokens())
# Mock dependencies
with patch("app.api.deps.user_service") as mock_user_service:
mock_user_service.get_by_id = AsyncMock(return_value=test_user)
with (
patch("app.api.auth.get_redis", mock_get_redis),
patch("app.services.token_store.get_redis", mock_get_redis),
):
response = client.post(
"/api/auth/logout-all",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_204_NO_CONTENT
# Verify all tokens are gone
async def count_remaining():
async with mock_get_redis() as redis:
count = 0
async for _ in redis.scan_iter(match=f"refresh_token:{user_id}:*"):
count += 1
return count
remaining = asyncio.get_event_loop().run_until_complete(count_remaining())
assert remaining == 0