"""Tests for API token endpoints.""" from datetime import UTC, datetime, timedelta from unittest.mock import patch import pytest from httpx import AsyncClient from app.models.user import User class TestApiTokenEndpoints: """Test API token management endpoints.""" @pytest.mark.asyncio async def test_generate_api_token_success( self, authenticated_client: AsyncClient, authenticated_user: User, ): """Test successful API token generation.""" request_data = {"expires_days": 30} response = await authenticated_client.post( "/api/v1/auth/api-token", json=request_data, ) assert response.status_code == 200 data = response.json() assert "api_token" in data assert "expires_at" in data assert len(data["api_token"]) > 0 # Verify token format (should be URL-safe base64) import base64 try: base64.urlsafe_b64decode(data["api_token"] + "===") # Add padding except Exception: pytest.fail("API token should be valid URL-safe base64") @pytest.mark.asyncio async def test_generate_api_token_default_expiry( self, authenticated_client: AsyncClient, ): """Test API token generation with default expiry.""" response = await authenticated_client.post("/api/v1/auth/api-token", json={}) assert response.status_code == 200 data = response.json() expires_at_str = data["expires_at"] # Handle both ISO format with/without timezone info if expires_at_str.endswith("Z"): expires_at = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00")) elif "+" in expires_at_str or expires_at_str.count("-") > 2: expires_at = datetime.fromisoformat(expires_at_str) else: # Naive datetime, assume UTC expires_at = datetime.fromisoformat(expires_at_str).replace(tzinfo=UTC) expected_expiry = datetime.now(UTC) + timedelta(days=365) # Allow 1 minute tolerance assert abs((expires_at - expected_expiry).total_seconds()) < 60 @pytest.mark.asyncio async def test_generate_api_token_custom_expiry( self, authenticated_client: AsyncClient, ): """Test API token generation with custom expiry.""" expires_days = 90 request_data = {"expires_days": expires_days} response = await authenticated_client.post( "/api/v1/auth/api-token", json=request_data, ) assert response.status_code == 200 data = response.json() expires_at_str = data["expires_at"] # Handle both ISO format with/without timezone info if expires_at_str.endswith("Z"): expires_at = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00")) elif "+" in expires_at_str or expires_at_str.count("-") > 2: expires_at = datetime.fromisoformat(expires_at_str) else: # Naive datetime, assume UTC expires_at = datetime.fromisoformat(expires_at_str).replace(tzinfo=UTC) expected_expiry = datetime.now(UTC) + timedelta(days=expires_days) # Allow 1 minute tolerance assert abs((expires_at - expected_expiry).total_seconds()) < 60 @pytest.mark.asyncio async def test_generate_api_token_validation_errors( self, authenticated_client: AsyncClient, ): """Test API token generation with validation errors.""" # Test minimum validation response = await authenticated_client.post( "/api/v1/auth/api-token", json={"expires_days": 0}, ) assert response.status_code == 422 # Test maximum validation response = await authenticated_client.post( "/api/v1/auth/api-token", json={"expires_days": 4000}, ) assert response.status_code == 422 @pytest.mark.asyncio async def test_generate_api_token_unauthenticated(self, client: AsyncClient): """Test API token generation without authentication.""" response = await client.post( "/api/v1/auth/api-token", json={"expires_days": 30}, ) assert response.status_code == 401 @pytest.mark.asyncio async def test_get_api_token_status_no_token( self, authenticated_client: AsyncClient, ): """Test getting API token status when user has no token.""" response = await authenticated_client.get("/api/v1/auth/api-token/status") assert response.status_code == 200 data = response.json() assert data["has_token"] is False assert data["expires_at"] is None assert data["is_expired"] is False @pytest.mark.asyncio async def test_get_api_token_status_with_token( self, authenticated_client: AsyncClient, ): """Test getting API token status when user has a token.""" # First generate a token await authenticated_client.post( "/api/v1/auth/api-token", json={"expires_days": 30}, ) # Then check status response = await authenticated_client.get("/api/v1/auth/api-token/status") assert response.status_code == 200 data = response.json() assert data["has_token"] is True assert data["expires_at"] is not None assert data["is_expired"] is False @pytest.mark.asyncio async def test_get_api_token_status_expired_token( self, authenticated_client: AsyncClient, authenticated_user: User, ): """Test getting API token status with expired token.""" # Mock expired token with patch("app.utils.auth.TokenUtils.is_token_expired", return_value=True): # Set a token on the user authenticated_user.api_token = "expired_token" authenticated_user.api_token_expires_at = datetime.now(UTC) - timedelta( days=1, ) response = await authenticated_client.get("/api/v1/auth/api-token/status") assert response.status_code == 200 data = response.json() assert data["has_token"] is True assert data["expires_at"] is not None assert data["is_expired"] is True @pytest.mark.asyncio async def test_get_api_token_status_unauthenticated(self, client: AsyncClient): """Test getting API token status without authentication.""" response = await client.get("/api/v1/auth/api-token/status") assert response.status_code == 401 @pytest.mark.asyncio async def test_revoke_api_token_success( self, authenticated_client: AsyncClient, ): """Test successful API token revocation.""" # First generate a token await authenticated_client.post( "/api/v1/auth/api-token", json={"expires_days": 30}, ) # Verify token exists status_response = await authenticated_client.get( "/api/v1/auth/api-token/status", ) assert status_response.json()["has_token"] is True # Revoke the token response = await authenticated_client.delete("/api/v1/auth/api-token") assert response.status_code == 200 data = response.json() assert data["message"] == "API token revoked successfully" # Verify token is gone status_response = await authenticated_client.get( "/api/v1/auth/api-token/status", ) assert status_response.json()["has_token"] is False @pytest.mark.asyncio async def test_revoke_api_token_no_token( self, authenticated_client: AsyncClient, ): """Test revoking API token when user has no token.""" response = await authenticated_client.delete("/api/v1/auth/api-token") assert response.status_code == 200 data = response.json() assert data["message"] == "API token revoked successfully" @pytest.mark.asyncio async def test_revoke_api_token_unauthenticated(self, client: AsyncClient): """Test revoking API token without authentication.""" response = await client.delete("/api/v1/auth/api-token") assert response.status_code == 401 @pytest.mark.asyncio async def test_api_token_authentication_success( self, client: AsyncClient, authenticated_client: AsyncClient, ): """Test successful authentication using API token.""" # Generate API token token_response = await authenticated_client.post( "/api/v1/auth/api-token", json={"expires_days": 30}, ) api_token = token_response.json()["api_token"] # Use API token to authenticate headers = {"API-TOKEN": api_token} response = await client.get("/api/v1/auth/me", headers=headers) assert response.status_code == 200 data = response.json() assert "id" in data assert "email" in data @pytest.mark.asyncio async def test_api_token_authentication_invalid_token(self, client: AsyncClient): """Test authentication with invalid API token.""" headers = {"API-TOKEN": "invalid_token"} response = await client.get("/api/v1/auth/me", headers=headers) assert response.status_code == 401 data = response.json() assert "Invalid API token" in data["detail"] @pytest.mark.asyncio async def test_api_token_authentication_expired_token( self, client: AsyncClient, authenticated_client: AsyncClient, ): """Test authentication with expired API token.""" # Generate API token token_response = await authenticated_client.post( "/api/v1/auth/api-token", json={"expires_days": 30}, ) api_token = token_response.json()["api_token"] # Mock expired token with patch("app.utils.auth.TokenUtils.is_token_expired", return_value=True): headers = {"API-TOKEN": api_token} response = await client.get("/api/v1/auth/me", headers=headers) assert response.status_code == 401 data = response.json() assert "API token has expired" in data["detail"] @pytest.mark.asyncio async def test_api_token_authentication_empty_token(self, client: AsyncClient): """Test authentication with empty API-TOKEN header.""" # Empty token headers = {"API-TOKEN": ""} response = await client.get("/api/v1/auth/me", headers=headers) assert response.status_code == 401 data = response.json() assert "Could not validate credentials" in data["detail"] # Whitespace only token headers = {"API-TOKEN": " "} response = await client.get("/api/v1/auth/me", headers=headers) assert response.status_code == 401 data = response.json() assert "API token required" in data["detail"] @pytest.mark.asyncio async def test_api_token_authentication_inactive_user( self, client: AsyncClient, authenticated_client: AsyncClient, authenticated_user: User, ): """Test authentication with API token for inactive user.""" # Generate API token token_response = await authenticated_client.post( "/api/v1/auth/api-token", json={"expires_days": 30}, ) api_token = token_response.json()["api_token"] # Deactivate user authenticated_user.is_active = False # Try to authenticate with API token headers = {"API-TOKEN": api_token} response = await client.get("/api/v1/auth/me", headers=headers) assert response.status_code == 401 data = response.json() assert "Account is deactivated" in data["detail"] @pytest.mark.asyncio async def test_flexible_authentication_prefers_api_token( self, client: AsyncClient, authenticated_client: AsyncClient, auth_cookies: dict[str, str], ): """Test that flexible authentication prefers API token over cookie.""" # Generate API token token_response = await authenticated_client.post( "/api/v1/auth/api-token", json={"expires_days": 30}, ) api_token = token_response.json()["api_token"] # Set both cookies and API-TOKEN header client.cookies.update(auth_cookies) headers = {"API-TOKEN": api_token} # This should use API token authentication response = await client.get("/api/v1/auth/me", headers=headers) assert response.status_code == 200 # If it used API token auth, it should work even if cookies are invalid