Add admin panel, WebSocket support, and API versioning
CI / Lint & Format (push) Failing after 28s
CI / Tests (push) Has been skipped
CI / Security Scan (push) Failing after 8s
CI / Docker Build (push) Has been skipped

Introduce an admin portal (React + Nginx), WebSocket routing, and
API versioning middleware with `/api/v1/` prefix deprecation.
Add master API key authentication, new Prometheus metrics for AI
token consumption and active WebSockets, and extend S3 config
with a public endpoint URL. Update test paths and fixtures to
align with the new routing structure.
This commit is contained in:
2026-06-22 11:25:22 -04:00
parent d68deb9c74
commit 2f2a5e9b4a
158 changed files with 64811 additions and 503 deletions
+19 -19
View File
@@ -18,14 +18,14 @@ pytestmark = pytest.mark.asyncio
async def test_no_auth_returns_401(async_client: AsyncClient):
"""Requête sans header Authorization → HTTP 401."""
response = await async_client.get("/images")
response = await async_client.get("/api/v1/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",
"/api/v1/images",
headers={"Authorization": "Bearer invalid-key-that-does-not-exist"},
)
assert response.status_code == 401
@@ -35,7 +35,7 @@ async def test_invalid_key_returns_401(async_client: AsyncClient):
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",
"/api/v1/images",
headers={"Authorization": "Basic some-key"},
)
assert response.status_code == 401
@@ -44,7 +44,7 @@ async def test_no_bearer_prefix_returns_401(async_client: AsyncClient):
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",
"/api/v1/images",
headers={"Authorization": "Bearer "},
)
assert response.status_code == 401
@@ -60,7 +60,7 @@ async def test_valid_key_returns_200(
auth_headers_a: dict,
):
"""Requête avec une clé valide → HTTP 200."""
response = await async_client.get("/images", headers=auth_headers_a)
response = await async_client.get("/api/v1/images", headers=auth_headers_a)
assert response.status_code == 200
@@ -80,7 +80,7 @@ async def test_inactive_client_returns_401(
db_session.add(client_a)
await db_session.commit()
response = await async_client.get("/images", headers=auth_headers_a)
response = await async_client.get("/api/v1/images", headers=auth_headers_a)
assert response.status_code == 401
@@ -106,7 +106,7 @@ async def test_missing_scope_returns_403(
# Tenter d'accéder aux endpoints admin
response = await async_client.get(
"/auth/clients",
"/api/v1/auth/clients",
headers={"Authorization": f"Bearer {key}"},
)
assert response.status_code == 403
@@ -119,7 +119,7 @@ async def test_scope_images_read_allowed(
auth_headers_a: dict,
):
"""Client avec scope images:read peut lister les images."""
response = await async_client.get("/images", headers=auth_headers_a)
response = await async_client.get("/api/v1/images", headers=auth_headers_a)
assert response.status_code == 200
@@ -136,12 +136,12 @@ async def test_key_rotation(
):
"""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)
response = await async_client.get("/api/v1/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",
f"/api/v1/auth/clients/{client_a.id}/rotate-key",
headers=admin_headers,
)
assert response.status_code == 200
@@ -149,12 +149,12 @@ async def test_key_rotation(
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)
response = await async_client.get("/api/v1/images", headers=auth_headers_a)
assert response.status_code == 401
# La nouvelle clé fonctionne
response = await async_client.get(
"/images",
"/api/v1/images",
headers={"Authorization": f"Bearer {new_key}"},
)
assert response.status_code == 200
@@ -171,7 +171,7 @@ async def test_create_client_returns_key_once(
):
"""La clé API est retournée une seule fois à la création."""
response = await async_client.post(
"/auth/clients",
"/api/v1/auth/clients",
json={
"name": "New Test Client",
"scopes": ["images:read"],
@@ -189,7 +189,7 @@ async def test_create_client_returns_key_once(
# 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}",
f"/api/v1/auth/clients/{client_id}",
headers=admin_headers,
)
assert response.status_code == 200
@@ -209,11 +209,11 @@ async def test_list_clients_admin_only(
):
"""Seul un admin peut lister les clients."""
# Non-admin → 403
response = await async_client.get("/auth/clients", headers=auth_headers_a)
response = await async_client.get("/api/v1/auth/clients", headers=auth_headers_a)
assert response.status_code == 403
# Admin → 200
response = await async_client.get("/auth/clients", headers=admin_headers)
response = await async_client.get("/api/v1/auth/clients", headers=admin_headers)
assert response.status_code == 200
@@ -225,7 +225,7 @@ async def test_update_client(
):
"""L'admin peut modifier les scopes et le plan d'un client."""
response = await async_client.patch(
f"/auth/clients/{client_a.id}",
f"/api/v1/auth/clients/{client_a.id}",
json={"plan": "premium", "scopes": ["images:read"]},
headers=admin_headers,
)
@@ -243,12 +243,12 @@ async def test_soft_delete_client(
):
"""Soft delete désactive le client — les requêtes suivantes retournent 401."""
response = await async_client.delete(
f"/auth/clients/{client_a.id}",
f"/api/v1/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)
response = await async_client.get("/api/v1/images", headers=auth_headers_a)
assert response.status_code == 401