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>
329 lines
11 KiB
Python
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"
|
|
)
|