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