""" 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