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>
332 lines
12 KiB
Python
332 lines
12 KiB
Python
"""
|
||
Tests pour POST /api/v1/auth/login
|
||
Couvre les AC 1-5 de la story 1.3 : Login Utilisateur (JWT)
|
||
"""
|
||
|
||
import time
|
||
|
||
import pytest
|
||
import jwt
|
||
from pathlib import Path
|
||
from fastapi.testclient import TestClient
|
||
|
||
|
||
LOGIN_URL = "/api/v1/auth/login"
|
||
REGISTER_URL = "/api/v1/auth/register"
|
||
|
||
VALID_USER = {
|
||
"email": "login@example.com",
|
||
"password": "Password123!",
|
||
"name": "Login User",
|
||
}
|
||
|
||
VALID_CREDENTIALS = {
|
||
"email": "login@example.com",
|
||
"password": "Password123!",
|
||
}
|
||
|
||
|
||
@pytest.fixture()
|
||
def users_file(tmp_path: Path) -> Path:
|
||
"""Fichier de stockage JSON isolé pour les tests."""
|
||
return tmp_path / "users.json"
|
||
|
||
|
||
@pytest.fixture()
|
||
def client(users_file: Path, monkeypatch):
|
||
"""TestClient avec stockage JSON isolé et rate limiting désactivé."""
|
||
import services.auth_service as auth_svc
|
||
|
||
monkeypatch.setattr(auth_svc, "USERS_FILE", users_file)
|
||
monkeypatch.setattr(auth_svc, "USE_DATABASE", False)
|
||
monkeypatch.setattr(auth_svc, "DATABASE_AVAILABLE", False)
|
||
|
||
from middleware.rate_limiting import RateLimitManager
|
||
|
||
async def _check_request_allow(self, request):
|
||
return True, "ok", "test"
|
||
|
||
async def _check_translation_allow(self, request, file_size_mb=0):
|
||
return True, "ok"
|
||
|
||
monkeypatch.setattr(RateLimitManager, "check_request", _check_request_allow)
|
||
monkeypatch.setattr(RateLimitManager, "check_translation", _check_translation_allow)
|
||
|
||
from main import app
|
||
|
||
return TestClient(app, raise_server_exceptions=True)
|
||
|
||
|
||
@pytest.fixture()
|
||
def registered_client(client, users_file: Path):
|
||
"""Client avec un utilisateur déjà enregistré."""
|
||
client.post(REGISTER_URL, json=VALID_USER)
|
||
return client
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# AC1 : Login réussi
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestLoginSuccess:
|
||
"""AC1 : login valide → 200 + access_token (15min) + refresh_token (7j)"""
|
||
|
||
def test_returns_200_on_success(self, registered_client):
|
||
response = registered_client.post(LOGIN_URL, json=VALID_CREDENTIALS)
|
||
assert response.status_code == 200
|
||
|
||
def test_response_contains_data_and_meta(self, registered_client):
|
||
response = registered_client.post(LOGIN_URL, json=VALID_CREDENTIALS)
|
||
body = response.json()
|
||
assert "data" in body
|
||
assert "meta" in body
|
||
|
||
def test_response_data_contains_access_token(self, registered_client):
|
||
response = registered_client.post(LOGIN_URL, json=VALID_CREDENTIALS)
|
||
data = response.json()["data"]
|
||
assert "access_token" in data
|
||
assert data["access_token"]
|
||
|
||
def test_response_data_contains_refresh_token(self, registered_client):
|
||
response = registered_client.post(LOGIN_URL, json=VALID_CREDENTIALS)
|
||
data = response.json()["data"]
|
||
assert "refresh_token" in data
|
||
assert data["refresh_token"]
|
||
|
||
def test_response_data_has_bearer_token_type(self, registered_client):
|
||
response = registered_client.post(LOGIN_URL, json=VALID_CREDENTIALS)
|
||
assert response.json()["data"]["token_type"] == "bearer"
|
||
|
||
def test_access_token_expiry_is_15_minutes(self, registered_client):
|
||
"""AC1 : access_token expire dans ~15 minutes"""
|
||
import services.auth_service as auth_svc
|
||
|
||
if not auth_svc.JWT_AVAILABLE:
|
||
pytest.skip("PyJWT non disponible dans cet environnement")
|
||
|
||
response = registered_client.post(LOGIN_URL, json=VALID_CREDENTIALS)
|
||
token = response.json()["data"]["access_token"]
|
||
payload = jwt.decode(token, options={"verify_signature": False})
|
||
now = time.time()
|
||
exp = payload["exp"]
|
||
# Doit expirer dans ~15 minutes (tolérance : 13–17 min = 780–1020s)
|
||
assert 780 < (exp - now) < 1020, f"Expiry inattendu: {exp - now:.0f}s"
|
||
|
||
def test_refresh_token_expiry_is_7_days(self, registered_client):
|
||
"""AC1 : refresh_token expire dans ~7 jours"""
|
||
import services.auth_service as auth_svc
|
||
|
||
if not auth_svc.JWT_AVAILABLE:
|
||
pytest.skip("PyJWT non disponible dans cet environnement")
|
||
|
||
response = registered_client.post(LOGIN_URL, json=VALID_CREDENTIALS)
|
||
token = response.json()["data"]["refresh_token"]
|
||
payload = jwt.decode(token, options={"verify_signature": False})
|
||
now = time.time()
|
||
exp = payload["exp"]
|
||
# 7 jours = 604800s (tolérance : 6.5–7.5 jours = 561600–648000s)
|
||
assert 561_600 < (exp - now) < 648_000, f"Expiry inattendu: {exp - now:.0f}s"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# AC2 : Signature JWT
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestJWTSigning:
|
||
"""AC2 : tokens signés avec SECRET_KEY depuis la variable d'environnement"""
|
||
|
||
def test_access_token_verifiable_with_secret_key(self, registered_client):
|
||
"""AC2 / 5.7 : access_token signé et vérifiable"""
|
||
import services.auth_service as auth_svc
|
||
|
||
if not auth_svc.JWT_AVAILABLE:
|
||
pytest.skip("PyJWT non disponible dans cet environnement")
|
||
|
||
response = registered_client.post(LOGIN_URL, json=VALID_CREDENTIALS)
|
||
token = response.json()["data"]["access_token"]
|
||
payload = jwt.decode(token, auth_svc.SECRET_KEY, algorithms=["HS256"])
|
||
assert payload is not None
|
||
|
||
def test_refresh_token_verifiable_with_secret_key(self, registered_client):
|
||
"""AC2 / 5.7 : refresh_token signé et vérifiable"""
|
||
import services.auth_service as auth_svc
|
||
|
||
if not auth_svc.JWT_AVAILABLE:
|
||
pytest.skip("PyJWT non disponible dans cet environnement")
|
||
|
||
response = registered_client.post(LOGIN_URL, json=VALID_CREDENTIALS)
|
||
token = response.json()["data"]["refresh_token"]
|
||
payload = jwt.decode(token, auth_svc.SECRET_KEY, algorithms=["HS256"])
|
||
assert payload is not None
|
||
|
||
def test_tokens_contain_correct_user_id_in_sub(
|
||
self, registered_client, users_file: Path
|
||
):
|
||
"""AC2 / 5.6 : tokens contiennent l'id utilisateur dans le claim 'sub'"""
|
||
import services.auth_service as auth_svc
|
||
|
||
if not auth_svc.JWT_AVAILABLE:
|
||
pytest.skip("PyJWT non disponible dans cet environnement")
|
||
|
||
test_email = "sub_test@example.com"
|
||
reg_response = registered_client.post(
|
||
REGISTER_URL,
|
||
json={"email": test_email, "password": "Pass123!", "name": "Sub Test"},
|
||
)
|
||
user_id = reg_response.json()["data"]["id"]
|
||
|
||
login_response = registered_client.post(
|
||
LOGIN_URL,
|
||
json={"email": test_email, "password": "Pass123!"},
|
||
)
|
||
token = login_response.json()["data"]["access_token"]
|
||
payload = jwt.decode(token, auth_svc.SECRET_KEY, algorithms=["HS256"])
|
||
assert payload["sub"] == user_id
|
||
|
||
def test_access_token_contains_tier_claim(self, registered_client):
|
||
"""Task 2.5 : access_token contient le claim 'tier'"""
|
||
import services.auth_service as auth_svc
|
||
|
||
if not auth_svc.JWT_AVAILABLE:
|
||
pytest.skip("PyJWT non disponible dans cet environnement")
|
||
|
||
response = registered_client.post(LOGIN_URL, json=VALID_CREDENTIALS)
|
||
token = response.json()["data"]["access_token"]
|
||
payload = jwt.decode(token, auth_svc.SECRET_KEY, algorithms=["HS256"])
|
||
assert "tier" in payload
|
||
assert payload["tier"] == "free"
|
||
|
||
def test_tokens_use_hs256_algorithm(self, registered_client):
|
||
"""AC2 : tokens signés avec l'algorithme HS256"""
|
||
import services.auth_service as auth_svc
|
||
|
||
if not auth_svc.JWT_AVAILABLE:
|
||
pytest.skip("PyJWT non disponible dans cet environnement")
|
||
|
||
response = registered_client.post(LOGIN_URL, json=VALID_CREDENTIALS)
|
||
access_token = response.json()["data"]["access_token"]
|
||
refresh_token = response.json()["data"]["refresh_token"]
|
||
|
||
access_header = jwt.get_unverified_header(access_token)
|
||
refresh_header = jwt.get_unverified_header(refresh_token)
|
||
assert access_header["alg"] == "HS256"
|
||
assert refresh_header["alg"] == "HS256"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# AC3 : Mot de passe invalide
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestInvalidPassword:
|
||
"""AC3 : mauvais mot de passe → 401 INVALID_CREDENTIALS"""
|
||
|
||
def test_wrong_password_returns_401(self, registered_client):
|
||
response = registered_client.post(
|
||
LOGIN_URL,
|
||
json={"email": VALID_CREDENTIALS["email"], "password": "WrongPassword!"},
|
||
)
|
||
assert response.status_code == 401
|
||
|
||
def test_wrong_password_error_code(self, registered_client):
|
||
response = registered_client.post(
|
||
LOGIN_URL,
|
||
json={"email": VALID_CREDENTIALS["email"], "password": "WrongPassword!"},
|
||
)
|
||
assert response.json()["error"] == "INVALID_CREDENTIALS"
|
||
|
||
def test_wrong_password_has_message(self, registered_client):
|
||
response = registered_client.post(
|
||
LOGIN_URL,
|
||
json={"email": VALID_CREDENTIALS["email"], "password": "WrongPassword!"},
|
||
)
|
||
assert "message" in response.json()
|
||
assert response.json()["message"]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# AC4 : Utilisateur introuvable - maintenant retourne INVALID_CREDENTIALS
|
||
# pour éviter l'énumération d'emails
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestUserNotFound:
|
||
"""AC4 : email inconnu → 401 INVALID_CREDENTIALS (anti-énumération)"""
|
||
|
||
def test_unknown_email_returns_401(self, client):
|
||
response = client.post(
|
||
LOGIN_URL,
|
||
json={"email": "nonexistent@example.com", "password": "Password123!"},
|
||
)
|
||
assert response.status_code == 401
|
||
|
||
def test_unknown_email_returns_invalid_credentials_not_user_not_found(self, client):
|
||
"""Security: unknown email returns same error as wrong password"""
|
||
response = client.post(
|
||
LOGIN_URL,
|
||
json={"email": "nonexistent@example.com", "password": "Password123!"},
|
||
)
|
||
assert response.json()["error"] == "INVALID_CREDENTIALS"
|
||
|
||
def test_unknown_email_has_message(self, client):
|
||
response = client.post(
|
||
LOGIN_URL,
|
||
json={"email": "nonexistent@example.com", "password": "Password123!"},
|
||
)
|
||
assert "message" in response.json()
|
||
assert response.json()["message"]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# AC5 : Versionnage d'API + validation email
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestApiVersioningAndValidation:
|
||
"""AC5 : endpoint à /api/v1/auth/login ; validation email"""
|
||
|
||
def test_endpoint_accessible_at_v1_path(self, registered_client):
|
||
response = registered_client.post(LOGIN_URL, json=VALID_CREDENTIALS)
|
||
assert response.status_code == 200
|
||
|
||
def test_invalid_email_format_returns_400(self, client):
|
||
"""Task 4.3 : email invalide → 400 INVALID_EMAIL"""
|
||
response = client.post(
|
||
LOGIN_URL,
|
||
json={"email": "not-an-email", "password": "Password123!"},
|
||
)
|
||
assert response.status_code == 400
|
||
|
||
def test_invalid_email_error_code(self, client):
|
||
response = client.post(
|
||
LOGIN_URL,
|
||
json={"email": "not-an-email", "password": "Password123!"},
|
||
)
|
||
assert response.json()["error"] == "INVALID_EMAIL"
|
||
|
||
def test_invalid_email_has_message(self, client):
|
||
response = client.post(
|
||
LOGIN_URL,
|
||
json={"email": "not-an-email", "password": "Password123!"},
|
||
)
|
||
assert "message" in response.json()
|
||
assert response.json()["message"]
|
||
|
||
def test_invalid_json_returns_invalid_request(self, client):
|
||
response = client.post(
|
||
LOGIN_URL,
|
||
data="{bad-json",
|
||
headers={"Content-Type": "application/json"},
|
||
)
|
||
assert response.status_code == 400
|
||
assert response.json()["error"] == "INVALID_REQUEST"
|
||
|
||
def test_missing_password_returns_invalid_request(self, client):
|
||
response = client.post(
|
||
LOGIN_URL,
|
||
json={"email": "test@example.com"},
|
||
)
|
||
assert response.status_code == 400
|