Add comprehensive test suite for image processing and related services
- Implement tests for database generator to ensure proper session handling. - Create tests for EXIF extraction and conversion functions. - Add tests for image-related endpoints, ensuring proper data retrieval and isolation between clients. - Develop tests for OCR functionality, including language detection and text extraction. - Introduce tests for the image processing pipeline, covering success and failure scenarios. - Validate rate limiting functionality and ensure independent counters for different clients. - Implement scraper tests to verify HTML content fetching and error handling. - Add unit tests for various services, including storage and filename generation. - Establish worker entry point for ARQ to handle background image processing tasks.
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
"""
|
||||
Tests d'authentification — API Keys + scopes.
|
||||
"""
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.dependencies.auth import hash_api_key
|
||||
from app.models.client import APIClient
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 401 — Pas de clé / clé invalide
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_no_auth_returns_401(async_client: AsyncClient):
|
||||
"""Requête sans header Authorization → HTTP 401."""
|
||||
response = await async_client.get("/images")
|
||||
assert response.status_code == 422 or response.status_code == 401
|
||||
|
||||
|
||||
async def test_invalid_key_returns_401(async_client: AsyncClient):
|
||||
"""Requête avec une clé invalide → HTTP 401."""
|
||||
response = await async_client.get(
|
||||
"/images",
|
||||
headers={"Authorization": "Bearer invalid-key-that-does-not-exist"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert "Authentification requise" in response.json()["detail"]
|
||||
|
||||
|
||||
async def test_no_bearer_prefix_returns_401(async_client: AsyncClient):
|
||||
"""Requête avec un header Authorization sans 'Bearer ' → HTTP 401."""
|
||||
response = await async_client.get(
|
||||
"/images",
|
||||
headers={"Authorization": "Basic some-key"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_empty_bearer_returns_401(async_client: AsyncClient):
|
||||
"""Requête avec 'Bearer ' mais sans clé → HTTP 401."""
|
||||
response = await async_client.get(
|
||||
"/images",
|
||||
headers={"Authorization": "Bearer "},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 200 — Clé valide
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_valid_key_returns_200(
|
||||
async_client: AsyncClient,
|
||||
client_a: APIClient,
|
||||
auth_headers_a: dict,
|
||||
):
|
||||
"""Requête avec une clé valide → HTTP 200."""
|
||||
response = await async_client.get("/images", headers=auth_headers_a)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 401 — Client inactif
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_inactive_client_returns_401(
|
||||
async_client: AsyncClient,
|
||||
client_a: APIClient,
|
||||
auth_headers_a: dict,
|
||||
db_session,
|
||||
):
|
||||
"""Client désactivé → HTTP 401 même avec une clé valide."""
|
||||
# Désactiver le client
|
||||
client_a.is_active = False
|
||||
db_session.add(client_a)
|
||||
await db_session.commit()
|
||||
|
||||
response = await async_client.get("/images", headers=auth_headers_a)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 403 — Scope manquant
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_missing_scope_returns_403(
|
||||
async_client: AsyncClient,
|
||||
db_session,
|
||||
):
|
||||
"""Client sans le bon scope → HTTP 403."""
|
||||
# Créer un client avec seulement images:read (pas images:write)
|
||||
key = "test-readonly-key-123"
|
||||
client = APIClient(
|
||||
name="Read Only Client",
|
||||
api_key_hash=hash_api_key(key),
|
||||
scopes=["images:read"], # pas images:write ni admin
|
||||
plan="free",
|
||||
)
|
||||
db_session.add(client)
|
||||
await db_session.commit()
|
||||
|
||||
# Tenter d'accéder aux endpoints admin
|
||||
response = await async_client.get(
|
||||
"/auth/clients",
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert "Permission insuffisante" in response.json()["detail"]
|
||||
|
||||
|
||||
async def test_scope_images_read_allowed(
|
||||
async_client: AsyncClient,
|
||||
client_a: APIClient,
|
||||
auth_headers_a: dict,
|
||||
):
|
||||
"""Client avec scope images:read peut lister les images."""
|
||||
response = await async_client.get("/images", headers=auth_headers_a)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Rotation de clé
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_key_rotation(
|
||||
async_client: AsyncClient,
|
||||
admin_client: APIClient,
|
||||
admin_headers: dict,
|
||||
client_a: APIClient,
|
||||
auth_headers_a: dict,
|
||||
):
|
||||
"""Après rotation, l'ancienne clé est invalide et la nouvelle fonctionne."""
|
||||
# Vérifier que la clé actuelle fonctionne
|
||||
response = await async_client.get("/images", headers=auth_headers_a)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Rotation de la clé
|
||||
response = await async_client.post(
|
||||
f"/auth/clients/{client_a.id}/rotate-key",
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
new_key = response.json()["api_key"]
|
||||
assert new_key # La nouvelle clé est retournée
|
||||
|
||||
# L'ancienne clé ne fonctionne plus
|
||||
response = await async_client.get("/images", headers=auth_headers_a)
|
||||
assert response.status_code == 401
|
||||
|
||||
# La nouvelle clé fonctionne
|
||||
response = await async_client.get(
|
||||
"/images",
|
||||
headers={"Authorization": f"Bearer {new_key}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Création de client
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_create_client_returns_key_once(
|
||||
async_client: AsyncClient,
|
||||
admin_client: APIClient,
|
||||
admin_headers: dict,
|
||||
):
|
||||
"""La clé API est retournée une seule fois à la création."""
|
||||
response = await async_client.post(
|
||||
"/auth/clients",
|
||||
json={
|
||||
"name": "New Test Client",
|
||||
"scopes": ["images:read"],
|
||||
"plan": "free",
|
||||
},
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert "api_key" in data
|
||||
assert len(data["api_key"]) > 20 # clé suffisamment longue
|
||||
assert data["name"] == "New Test Client"
|
||||
assert data["scopes"] == ["images:read"]
|
||||
|
||||
# La clé n'apparaît pas dans les réponses GET
|
||||
client_id = data["id"]
|
||||
response = await async_client.get(
|
||||
f"/auth/clients/{client_id}",
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "api_key" not in response.json()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# CRUD clients — admin only
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_list_clients_admin_only(
|
||||
async_client: AsyncClient,
|
||||
client_a: APIClient,
|
||||
auth_headers_a: dict,
|
||||
admin_client: APIClient,
|
||||
admin_headers: dict,
|
||||
):
|
||||
"""Seul un admin peut lister les clients."""
|
||||
# Non-admin → 403
|
||||
response = await async_client.get("/auth/clients", headers=auth_headers_a)
|
||||
assert response.status_code == 403
|
||||
|
||||
# Admin → 200
|
||||
response = await async_client.get("/auth/clients", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
async def test_update_client(
|
||||
async_client: AsyncClient,
|
||||
admin_client: APIClient,
|
||||
admin_headers: dict,
|
||||
client_a: APIClient,
|
||||
):
|
||||
"""L'admin peut modifier les scopes et le plan d'un client."""
|
||||
response = await async_client.patch(
|
||||
f"/auth/clients/{client_a.id}",
|
||||
json={"plan": "premium", "scopes": ["images:read"]},
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["plan"] == "premium"
|
||||
assert response.json()["scopes"] == ["images:read"]
|
||||
|
||||
|
||||
async def test_soft_delete_client(
|
||||
async_client: AsyncClient,
|
||||
admin_client: APIClient,
|
||||
admin_headers: dict,
|
||||
client_a: APIClient,
|
||||
auth_headers_a: dict,
|
||||
):
|
||||
"""Soft delete désactive le client — les requêtes suivantes retournent 401."""
|
||||
response = await async_client.delete(
|
||||
f"/auth/clients/{client_a.id}",
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["is_active"] is False
|
||||
|
||||
# Le client désactivé ne peut plus s'authentifier
|
||||
response = await async_client.get("/images", headers=auth_headers_a)
|
||||
assert response.status_code == 401
|
||||
Reference in New Issue
Block a user