Files
sdb2-backend/tests/api/v1/test_api_token_endpoints.py

368 lines
13 KiB
Python

"""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,
) -> None:
"""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,
) -> None:
"""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") or "+" 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,
) -> None:
"""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") or "+" 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,
) -> None:
"""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) -> None:
"""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,
) -> None:
"""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,
) -> None:
"""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,
) -> None:
"""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) -> None:
"""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,
) -> None:
"""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,
) -> None:
"""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) -> None:
"""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,
) -> None:
"""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) -> None:
"""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,
) -> None:
"""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) -> None:
"""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,
) -> None:
"""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],
) -> None:
"""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