Files
sdb2-backend/tests/api/v1/test_api_token_endpoints.py
2025-08-01 09:30:15 +02:00

369 lines
13 KiB
Python

"""Tests for API token endpoints."""
# ruff: noqa: ARG002, PLR2004, PLC0415, BLE001, E501
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