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.
160 lines
5.9 KiB
Python
160 lines
5.9 KiB
Python
"""
|
|
Tests WebSocket — suivi pipeline temps réel.
|
|
|
|
Utilise le TestClient de Starlette pour les WebSockets.
|
|
"""
|
|
import pytest
|
|
from unittest.mock import patch
|
|
|
|
from starlette.testclient import TestClient
|
|
|
|
from app.main import app
|
|
from app.models.image import Image, ProcessingStatus
|
|
from app.models.client import APIClient
|
|
|
|
|
|
# Client synchrone pour les WebSockets (Starlette TestClient)
|
|
# On mocke complètement la DB pour éviter les soucis d'event loop (greenlet_spawn)
|
|
sync_client = TestClient(app)
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Tests
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
class TestWSPipelineNoToken:
|
|
"""Connexion sans token → refus immédiat."""
|
|
|
|
def test_no_token_rejected(self):
|
|
"""WS sans token reçoit un close 4001."""
|
|
with pytest.raises(Exception):
|
|
with sync_client.websocket_connect("/ws/pipeline/1"):
|
|
pass
|
|
|
|
|
|
@patch("app.routers.websocket._get_image")
|
|
@patch("app.routers.websocket._authenticate_ws")
|
|
class TestWSPipelineAuth:
|
|
"""Tests d'authentification WebSocket appliqués avec un mock de DB."""
|
|
|
|
def test_invalid_token_rejected(self, mock_auth, mock_get_image):
|
|
"""WS avec un token invalide reçoit un close."""
|
|
mock_auth.return_value = None
|
|
with pytest.raises(Exception):
|
|
with sync_client.websocket_connect("/ws/pipeline/1?token=invalid-key"):
|
|
pass
|
|
|
|
def test_valid_token_accepted(self, mock_auth, mock_get_image):
|
|
"""WS avec token valide d'un client qui possède l'image → accepté."""
|
|
# Mock du client authentifié
|
|
client_mock = APIClient(id="client-a", scopes=[])
|
|
client_mock.has_scope = lambda x: False
|
|
mock_auth.return_value = client_mock
|
|
|
|
# Mock de l'image (DONE -> envoie message synthétique)
|
|
image_mock = Image()
|
|
image_mock.id = 1
|
|
image_mock.client_id = "client-a"
|
|
image_mock.processing_status = ProcessingStatus.DONE
|
|
mock_get_image.return_value = image_mock
|
|
|
|
with sync_client.websocket_connect(
|
|
"/ws/pipeline/1?token=valid-key"
|
|
) as ws:
|
|
data = ws.receive_json()
|
|
assert data["event"] == "pipeline.done"
|
|
assert data["synthetic"] is True
|
|
|
|
def test_wrong_owner_rejected(self, mock_auth, mock_get_image):
|
|
"""WS avec token d'un client B sur image de client A → refus 4003."""
|
|
# Mock du client authentifié (client B)
|
|
client_mock = APIClient(id="client-b", scopes=[])
|
|
client_mock.has_scope = lambda x: False
|
|
mock_auth.return_value = client_mock
|
|
|
|
# Mock de l'image appartenant au client A
|
|
image_mock = Image()
|
|
image_mock.id = 1
|
|
image_mock.client_id = "client-a"
|
|
image_mock.processing_status = ProcessingStatus.PENDING
|
|
mock_get_image.return_value = image_mock
|
|
|
|
with pytest.raises(Exception):
|
|
with sync_client.websocket_connect(
|
|
"/ws/pipeline/1?token=valid-key"
|
|
):
|
|
pass
|
|
|
|
|
|
@patch("app.routers.websocket._get_image")
|
|
@patch("app.routers.websocket._authenticate_ws")
|
|
class TestWSPipelineSynthetic:
|
|
"""Tests des messages synthétiques."""
|
|
|
|
def test_done_image_sends_synthetic(self, mock_auth, mock_get_image):
|
|
"""Image déjà 'done' → reçoit pipeline.done synthétique."""
|
|
client_mock = APIClient(id="client-a", scopes=[])
|
|
client_mock.has_scope = lambda x: False
|
|
mock_auth.return_value = client_mock
|
|
|
|
image_mock = Image()
|
|
image_mock.id = 1
|
|
image_mock.client_id = "client-a"
|
|
image_mock.processing_status = ProcessingStatus.DONE
|
|
mock_get_image.return_value = image_mock
|
|
|
|
with sync_client.websocket_connect(
|
|
"/ws/pipeline/1?token=valid-key"
|
|
) as ws:
|
|
data = ws.receive_json()
|
|
assert data["event"] == "pipeline.done"
|
|
assert data["image_id"] == 1
|
|
assert data["synthetic"] is True
|
|
|
|
def test_error_image_sends_synthetic(self, mock_auth, mock_get_image):
|
|
"""Image déjà 'error' → reçoit pipeline.error synthétique."""
|
|
client_mock = APIClient(id="client-a", scopes=[])
|
|
client_mock.has_scope = lambda x: False
|
|
mock_auth.return_value = client_mock
|
|
|
|
image_mock = Image()
|
|
image_mock.id = 1
|
|
image_mock.client_id = "client-a"
|
|
image_mock.processing_status = ProcessingStatus.ERROR
|
|
image_mock.processing_error = "AI timeout"
|
|
mock_get_image.return_value = image_mock
|
|
|
|
with sync_client.websocket_connect(
|
|
"/ws/pipeline/1?token=valid-key"
|
|
) as ws:
|
|
data = ws.receive_json()
|
|
assert data["event"] == "pipeline.error"
|
|
assert data["image_id"] == 1
|
|
assert "AI timeout" in data["error"]
|
|
assert data["synthetic"] is True
|
|
|
|
|
|
@patch("app.routers.websocket._authenticate_ws")
|
|
class TestWSAdminMonitor:
|
|
"""Tests du monitoring admin."""
|
|
|
|
def test_admin_no_token_rejected(self, mock_auth):
|
|
"""WS admin sans token → refus."""
|
|
mock_auth.return_value = None
|
|
with pytest.raises(Exception):
|
|
with sync_client.websocket_connect("/ws/admin/monitor"):
|
|
pass
|
|
|
|
def test_admin_non_admin_rejected(self, mock_auth):
|
|
"""WS admin avec token non-admin → refus 4003."""
|
|
client_mock = APIClient(id="client-a", scopes=[])
|
|
client_mock.has_scope = lambda x: False
|
|
mock_auth.return_value = client_mock
|
|
|
|
with pytest.raises(Exception):
|
|
with sync_client.websocket_connect(
|
|
"/ws/admin/monitor?token=valid-key"
|
|
):
|
|
pass
|
|
|