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
261 lines
9.4 KiB
Python
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
|