feat: production deployment - full update with providers, admin, glossaries, pricing, tests
Major changes across backend, frontend, infrastructure: - Provider system with model selection (Google, DeepL, OpenAI, Ollama, Google Cloud) - Admin panel: user management, pricing, settings - Glossary system with CSV import/export - Subscription and tier quota management - Security hardening (rate limiting, API key auth, path traversal fixes) - Docker compose for dev, prod, and IONOS deployment - Alembic migrations for new tables - Frontend: dashboard, pricing page, landing page, i18n (en/fr) - Test suite and verification scripts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
251
tests/test_auth_logout.py
Normal file
251
tests/test_auth_logout.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
Tests pour POST /api/v1/auth/logout
|
||||
Couvre les AC 1-6 de la story 1.4 : Logout Utilisateur
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
LOGOUT_URL = "/api/v1/auth/logout"
|
||||
LOGIN_URL = "/api/v1/auth/login"
|
||||
REGISTER_URL = "/api/v1/auth/register"
|
||||
|
||||
VALID_USER = {
|
||||
"email": "logout@example.com",
|
||||
"password": "Password123!",
|
||||
"name": "Logout User",
|
||||
}
|
||||
|
||||
VALID_CREDENTIALS = {
|
||||
"email": "logout@example.com",
|
||||
"password": "Password123!",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path: Path, monkeypatch):
|
||||
"""TestClient avec stockage JSON isolé, blocklist réinitialisée et rate limiting désactivé."""
|
||||
import services.auth_service as auth_svc
|
||||
|
||||
monkeypatch.setattr(auth_svc, "USERS_FILE", tmp_path / "users.json")
|
||||
monkeypatch.setattr(auth_svc, "USE_DATABASE", False)
|
||||
monkeypatch.setattr(auth_svc, "DATABASE_AVAILABLE", False)
|
||||
monkeypatch.setattr(auth_svc, "_revoked_jtis", {})
|
||||
|
||||
from middleware.rate_limiting import RateLimitManager
|
||||
|
||||
async def _allow(self, request):
|
||||
return True, "ok", "test"
|
||||
|
||||
async def _allow_translation(self, request, file_size_mb=0):
|
||||
return True, "ok"
|
||||
|
||||
monkeypatch.setattr(RateLimitManager, "check_request", _allow)
|
||||
monkeypatch.setattr(RateLimitManager, "check_translation", _allow_translation)
|
||||
|
||||
from main import app
|
||||
|
||||
return TestClient(app, raise_server_exceptions=True)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def tokens(client):
|
||||
"""Enregistre un utilisateur et retourne ses tokens access + refresh."""
|
||||
client.post(REGISTER_URL, json=VALID_USER)
|
||||
resp = client.post(LOGIN_URL, json=VALID_CREDENTIALS)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()["data"]
|
||||
return data["access_token"], data["refresh_token"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC1 & AC4 : Endpoint existe et retourne 200 avec message succès
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLogoutSuccess:
|
||||
"""AC1 & AC4 : POST /logout valide → 200 + message Déconnexion réussie"""
|
||||
|
||||
def test_returns_200_on_success(self, client, tokens):
|
||||
access_token, _ = tokens
|
||||
response = client.post(
|
||||
LOGOUT_URL,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_response_structure(self, client, tokens):
|
||||
access_token, _ = tokens
|
||||
response = client.post(
|
||||
LOGOUT_URL,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
body = response.json()
|
||||
assert "data" in body
|
||||
assert body["data"]["message"] == "Déconnexion réussie"
|
||||
assert "meta" in body
|
||||
assert body["meta"] == {}
|
||||
|
||||
def test_logout_with_refresh_token_in_body(self, client, tokens):
|
||||
access_token, refresh_token = tokens
|
||||
response = client.post(
|
||||
LOGOUT_URL,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
json={"refresh_token": refresh_token},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC3 : Après logout, l'access token est révoqué
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAccessTokenRevoked:
|
||||
"""AC3 : access token révoqué après logout → 401 à la réutilisation"""
|
||||
|
||||
def test_reusing_access_token_after_logout_returns_401(self, client, tokens):
|
||||
access_token, _ = tokens
|
||||
logout_resp = client.post(
|
||||
LOGOUT_URL,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
assert logout_resp.status_code == 200
|
||||
|
||||
reuse_resp = client.post(
|
||||
LOGOUT_URL,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
assert reuse_resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC2 : Après logout, le refresh token est révoqué
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRefreshTokenRevoked:
|
||||
"""AC2 : refresh token révoqué après logout → ne peut plus obtenir de nouvel access token"""
|
||||
|
||||
def test_refresh_token_revoked_after_logout(self, client, tokens):
|
||||
access_token, refresh_token = tokens
|
||||
logout_resp = client.post(
|
||||
LOGOUT_URL,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
json={"refresh_token": refresh_token},
|
||||
)
|
||||
assert logout_resp.status_code == 200
|
||||
|
||||
# AC2: utiliser le refresh token révoqué doit retourner 401
|
||||
refresh_resp = client.post(
|
||||
"/api/v1/auth/refresh",
|
||||
json={"refresh_token": refresh_token},
|
||||
)
|
||||
assert refresh_resp.status_code == 401, (
|
||||
"Le refresh token révoqué ne doit plus permettre d'obtenir un access token"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC5 : Token manquant → 401 TOKEN_MISSING
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTokenMissing:
|
||||
"""AC5 : Appel sans Authorization header → 401 TOKEN_MISSING"""
|
||||
|
||||
def test_no_auth_header_returns_401(self, client):
|
||||
response = client.post(LOGOUT_URL)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_no_auth_header_error_code(self, client):
|
||||
response = client.post(LOGOUT_URL)
|
||||
body = response.json()
|
||||
assert body["error"] == "TOKEN_MISSING"
|
||||
assert "Token d'authentification requis" in body["message"]
|
||||
|
||||
def test_non_bearer_auth_returns_401(self, client):
|
||||
response = client.post(
|
||||
LOGOUT_URL,
|
||||
headers={"Authorization": "Basic dXNlcjpwYXNz"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["error"] == "TOKEN_MISSING"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC6 : Token invalide/expiré → 401 TOKEN_INVALID
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTokenInvalid:
|
||||
"""AC6 : Token invalide ou malformé → 401 TOKEN_INVALID"""
|
||||
|
||||
def test_malformed_token_returns_401(self, client):
|
||||
response = client.post(
|
||||
LOGOUT_URL,
|
||||
headers={"Authorization": "Bearer this.is.not.a.valid.jwt"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_malformed_token_error_code(self, client):
|
||||
response = client.post(
|
||||
LOGOUT_URL,
|
||||
headers={"Authorization": "Bearer invalid"},
|
||||
)
|
||||
body = response.json()
|
||||
assert body["error"] == "TOKEN_INVALID"
|
||||
assert "Token invalide ou expiré" in body["message"]
|
||||
|
||||
def test_expired_token_returns_401(self, client, monkeypatch):
|
||||
"""Un token créé avec une expiry passée doit retourner 401."""
|
||||
import jwt as pyjwt
|
||||
import services.auth_service as auth_svc
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
expired_payload = {
|
||||
"sub": "some-user-id",
|
||||
"type": "access",
|
||||
"exp": datetime.now(timezone.utc) - timedelta(minutes=5),
|
||||
"jti": "expired-jti-123",
|
||||
}
|
||||
expired_token = pyjwt.encode(
|
||||
expired_payload, auth_svc.SECRET_KEY, algorithm=auth_svc.ALGORITHM
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
LOGOUT_URL,
|
||||
headers={"Authorization": f"Bearer {expired_token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["error"] == "TOKEN_INVALID"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backward Compatibility : tokens sans JTI restent valides
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBackwardCompatibility:
|
||||
"""Tokens créés sans JTI (avant cette story) ne sont pas révocables mais fonctionnent."""
|
||||
|
||||
def test_token_without_jti_is_valid(self, client):
|
||||
"""Un token sans JTI doit être accepté (verify_token retourne le payload)."""
|
||||
import jwt as pyjwt
|
||||
import services.auth_service as auth_svc
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
payload_no_jti = {
|
||||
"sub": "some-user-id",
|
||||
"type": "access",
|
||||
"exp": datetime.now(timezone.utc) + timedelta(minutes=15),
|
||||
}
|
||||
token_no_jti = pyjwt.encode(
|
||||
payload_no_jti, auth_svc.SECRET_KEY, algorithm=auth_svc.ALGORITHM
|
||||
)
|
||||
|
||||
result = auth_svc.verify_token(token_no_jti)
|
||||
assert result is not None, "Token sans JTI doit rester valide"
|
||||
assert result.get("jti") is None
|
||||
Reference in New Issue
Block a user