Files
office_translator/tests/test_auth_login.py
Sepehr Ramezani 26bd096a06 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>
2026-04-25 15:01:47 +02:00

332 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 : 1317 min = 7801020s)
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.57.5 jours = 561600648000s)
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