Files
office_translator/tests/test_auth_register.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

329 lines
11 KiB
Python

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