""" Tests pour POST /api/v1/auth/register Couvre les AC 1-5 de la story 1.2 : Inscription Utilisateur """ import json import pytest from pathlib import Path from fastapi.testclient import TestClient REGISTER_URL = "/api/v1/auth/register" VALID_PAYLOAD = { "email": "test@example.com", "password": "Password123!", "name": "Test User", } @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) # Désactiver le rate limiting pour les tests import main as main_module 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) # --------------------------------------------------------------------------- # AC1 : Inscription réussie # --------------------------------------------------------------------------- class TestRegistrationSuccess: """AC1 : inscription valide → 201 + données utilisateur""" def test_returns_201_on_success(self, client): response = client.post(REGISTER_URL, json=VALID_PAYLOAD) assert response.status_code == 201 def test_response_contains_data_and_meta(self, client): response = client.post(REGISTER_URL, json=VALID_PAYLOAD) body = response.json() assert "data" in body assert "meta" in body def test_response_data_contains_id(self, client): response = client.post(REGISTER_URL, json=VALID_PAYLOAD) assert "id" in response.json()["data"] assert response.json()["data"]["id"] # non vide def test_response_data_contains_correct_email(self, client): response = client.post(REGISTER_URL, json=VALID_PAYLOAD) assert response.json()["data"]["email"] == VALID_PAYLOAD["email"] def test_new_user_has_free_tier(self, client): """AC1 : nouvel utilisateur créé avec tier='free'""" response = client.post(REGISTER_URL, json=VALID_PAYLOAD) assert response.json()["data"]["tier"] == "free" # --------------------------------------------------------------------------- # AC2 : Hachage du mot de passe # --------------------------------------------------------------------------- class TestPasswordHashing: """AC2 : le mot de passe est haché avec passlib[bcrypt]""" def test_password_not_stored_as_plaintext(self, client, users_file: Path): password = "MySecret123!" client.post( REGISTER_URL, json={ "email": "hash@example.com", "password": password, "name": "Hash User", }, ) assert users_file.exists(), ( "Le fichier utilisateurs doit exister après inscription" ) users_data = json.loads(users_file.read_text()) for user in users_data.values(): stored = user.get("password_hash", "") assert stored != password, ( "Le mot de passe ne doit pas être stocké en clair" ) assert len(stored) > 0, "Un hash doit être présent" def test_password_hash_uses_bcrypt(self, client, users_file: Path): """Le hash doit commencer par '$2b$' (bcrypt) lorsque passlib est disponible.""" import services.auth_service as auth_svc if not auth_svc.PASSLIB_AVAILABLE: pytest.skip("passlib non disponible dans cet environnement") client.post( REGISTER_URL, json={ "email": "bcrypt@example.com", "password": "BCrypt123!", "name": "BCrypt User", }, ) users_data = json.loads(users_file.read_text()) for user in users_data.values(): if user.get("email") == "bcrypt@example.com": assert user["password_hash"].startswith("$2b$"), ( "Le hash doit être au format bcrypt ($2b$)" ) # --------------------------------------------------------------------------- # AC3 : Email dupliqué # --------------------------------------------------------------------------- class TestDuplicateEmail: """AC3 : email déjà utilisé → 400 EMAIL_EXISTS""" def test_duplicate_email_returns_400(self, client): client.post(REGISTER_URL, json=VALID_PAYLOAD) response = client.post(REGISTER_URL, json=VALID_PAYLOAD) assert response.status_code == 400 def test_duplicate_email_error_code(self, client): client.post(REGISTER_URL, json=VALID_PAYLOAD) response = client.post(REGISTER_URL, json=VALID_PAYLOAD) assert response.json()["error"] == "EMAIL_EXISTS" def test_duplicate_email_has_message(self, client): client.post(REGISTER_URL, json=VALID_PAYLOAD) response = client.post(REGISTER_URL, json=VALID_PAYLOAD) assert "message" in response.json() assert response.json()["message"] # --------------------------------------------------------------------------- # AC4 : Email invalide # --------------------------------------------------------------------------- class TestInvalidEmail: """AC4 : format d'email invalide → 400 INVALID_EMAIL""" @pytest.mark.parametrize( "bad_email", [ "not-an-email", "missing@domain", "@nodomain.com", "spaces in@email.com", "", ], ) def test_invalid_email_returns_400(self, client, bad_email): response = client.post( REGISTER_URL, json={"email": bad_email, "password": "Password123!", "name": "Bad Email"}, ) assert response.status_code == 400 def test_invalid_email_error_code(self, client): response = client.post( REGISTER_URL, json={ "email": "not-an-email", "password": "Password123!", "name": "Bad Email", }, ) assert response.json()["error"] == "INVALID_EMAIL" def test_invalid_email_has_message(self, client): response = client.post( REGISTER_URL, json={ "email": "not-an-email", "password": "Password123!", "name": "Bad Email", }, ) assert "message" in response.json() assert response.json()["message"] def test_missing_password_returns_invalid_request(self, client): response = client.post( REGISTER_URL, json={"email": "missing-password@example.com", "name": "Bad Payload"}, ) assert response.status_code == 400 assert response.json()["error"] == "INVALID_REQUEST" def test_invalid_json_body_returns_invalid_request(self, client): response = client.post( REGISTER_URL, data="{bad-json", headers={"Content-Type": "application/json"}, ) assert response.status_code == 400 assert response.json()["error"] == "INVALID_REQUEST" class TestPasswordStrengthValidation: """Tests pour la validation de force du mot de passe""" def test_password_too_short_returns_weak_password(self, client): """Mot de passe < 8 caractères rejeté""" response = client.post( REGISTER_URL, json={ "email": "weak@example.com", "password": "1234567", "name": "Test User", }, ) assert response.status_code == 400 assert response.json()["error"] == "WEAK_PASSWORD" # Check for either the French message or English fallback msg = response.json()["message"] assert "8" in msg and ("caractères" in msg or "characters" in msg.lower()) def test_password_missing_uppercase_returns_weak_password(self, client): """Mot de passe sans majuscule rejeté""" response = client.post( REGISTER_URL, json={ "email": "weak@example.com", "password": "password123!", "name": "Test User", }, ) assert response.status_code == 400 assert response.json()["error"] == "WEAK_PASSWORD" assert "majuscule" in response.json()["message"] def test_password_missing_lowercase_returns_weak_password(self, client): """Mot de passe sans minuscule rejeté""" response = client.post( REGISTER_URL, json={ "email": "weak@example.com", "password": "PASSWORD123!", "name": "Test User", }, ) assert response.status_code == 400 assert response.json()["error"] == "WEAK_PASSWORD" assert "minuscule" in response.json()["message"] def test_password_missing_digit_returns_weak_password(self, client): """Mot de passe sans chiffre rejeté""" response = client.post( REGISTER_URL, json={ "email": "weak@example.com", "password": "PasswordOnly!", "name": "Test User", }, ) assert response.status_code == 400 assert response.json()["error"] == "WEAK_PASSWORD" assert "chiffre" in response.json()["message"] def test_strong_password_accepted(self, client): """Mot de passe fort accepté""" response = client.post( REGISTER_URL, json={ "email": "strong@example.com", "password": "StrongPass123!", "name": "Test User", }, ) assert response.status_code == 201 assert "id" in response.json()["data"] # --------------------------------------------------------------------------- # AC5 : Versionnage d'API # --------------------------------------------------------------------------- class TestApiVersioning: """AC5 : endpoint accessible à /api/v1/auth/register""" def test_endpoint_accessible_at_v1_path(self, client): response = client.post("/api/v1/auth/register", json=VALID_PAYLOAD) assert response.status_code == 201 @pytest.mark.skip( reason="Story 3.5: Legacy /api/auth paths are no longer supported. All endpoints must use /api/v1 prefix." ) def test_legacy_path_still_works(self, client): """Le chemin herite /api/auth/register doit desormais retourner 404.""" response = client.post("/api/auth/register", json=VALID_PAYLOAD) assert response.status_code in (200, 201), ( "L'endpoint herite doit rester operationnel" )