"""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