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>
This commit is contained in:
328
tests/test_auth_register.py
Normal file
328
tests/test_auth_register.py
Normal file
@@ -0,0 +1,328 @@
|
||||
"""
|
||||
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"
|
||||
)
|
||||
Reference in New Issue
Block a user