"""Comptes utilisateurs (auth/) : inscription (nom/prénom/email unique, mot de passe fort, 2FA TOTP obligatoire), connexion, et isolation par utilisateur d'un SEUL projet (sauf le rôle "admin", illimité — voir core/auth_guard.py). Le tout premier compte jamais créé devient automatiquement admin (auth/create_user.py) : la fixture `client` de conftest.py en a déjà créé un pour authentifier tous les AUTRES tests du moteur — ces tests-ci utilisent volontairement un client Flask NU (pas la fixture `client`, déjà connectée) pour exercer le parcours anonyme (inscription/connexion) de bout en bout.""" import re import pyotp import pytest import auth import db from core.flask_app import app as flask_app @pytest.fixture def anon_client(): flask_app.config["TESTING"] = True with flask_app.test_client() as c: yield c def _register(client, email, prenom="Alice", nom="Test", password="Sup3r$ecret!"): return client.post("/register", data={ "prenom": prenom, "nom": nom, "email": email, "password": password, "password_confirm": password, }, follow_redirects=False) def _confirm_2fa(client): """Récupère la clé TOTP affichée sur la page de confirmation et soumet le code du moment — comme un humain qui vient de scanner le QR code avec son application.""" html = client.get("/register/2fa").get_data(as_text=True) secret = re.search(r"([A-Z0-9]+)", html).group(1) code = pyotp.TOTP(secret).now() return client.post("/register/2fa", data={"code": code}, follow_redirects=False), secret def _cleanup_project(email): game_dir = db.game_dir(db.slugify(email)) import os import shutil if os.path.isdir(game_dir): shutil.rmtree(game_dir) def test_anonymous_request_redirects_to_login(anon_client): resp = anon_client.get("/", follow_redirects=False) assert resp.status_code == 302 assert resp.headers["Location"].startswith("/login") def test_registration_requires_matching_password_confirmation(anon_client): resp = anon_client.post("/register", data={ "prenom": "A", "nom": "B", "email": "mismatch@example.com", "password": "Sup3r$ecret!", "password_confirm": "Autre$ecret1", }) assert resp.status_code == 200 assert "ne correspondent pas" in resp.get_data(as_text=True) assert auth.get_user_by_email("mismatch@example.com") is None def test_registration_rejects_a_weak_password(anon_client): resp = anon_client.post("/register", data={ "prenom": "A", "nom": "B", "email": "weak@example.com", "password": "abcdefgh", "password_confirm": "abcdefgh", }) assert resp.status_code == 200 assert "faible" in resp.get_data(as_text=True) assert auth.get_user_by_email("weak@example.com") is None def test_registration_rejects_a_duplicate_email(anon_client): _register(anon_client, "dup@example.com") resp = _register(anon_client, "dup@example.com") assert resp.status_code == 200 assert "existe déjà" in resp.get_data(as_text=True) def test_full_registration_flow_requires_totp_before_account_is_usable(anon_client): resp = _register(anon_client, "flow@example.com") assert resp.status_code == 302 assert resp.headers["Location"] == "/register/2fa" user = auth.get_user_by_email("flow@example.com") assert user["totp_confirmed"] == 0 # Compte pas encore confirmé -> pas encore une vraie session connectée, # même après l'étape de mot de passe (voir register.py : la connexion # ("user_id") n'est posée qu'après confirm_totp(), jamais avant). resp = anon_client.get("/", follow_redirects=False) assert resp.status_code == 302 # toujours redirigé vers /login resp, secret = _confirm_2fa(anon_client) assert resp.status_code == 302 user = auth.get_user_by_email("flow@example.com") assert user["totp_confirmed"] == 1 assert user["role"] == "user" # jamais le premier compte de la session de tests assert user["project_slug"] == db.slugify("flow@example.com") try: assert resp.headers["Location"] == "/game/" + user["project_slug"] # Connecté pour de vrai maintenant. resp2 = anon_client.get("/", follow_redirects=False) assert resp2.status_code == 302 assert resp2.headers["Location"] == "/game/" + user["project_slug"] finally: _cleanup_project("flow@example.com") def test_wrong_totp_code_is_rejected(anon_client): _register(anon_client, "badcode@example.com") resp = anon_client.post("/register/2fa", data={"code": "000000"}) assert resp.status_code == 200 assert "invalide" in resp.get_data(as_text=True) user = auth.get_user_by_email("badcode@example.com") assert user["totp_confirmed"] == 0 def test_login_requires_correct_password_then_totp(anon_client): _register(anon_client, "login@example.com") resp, secret = _confirm_2fa(anon_client) slug = auth.get_user_by_email("login@example.com")["project_slug"] try: anon_client.post("/logout") resp = anon_client.post("/login", data={"email": "login@example.com", "password": "wrong"}) assert resp.status_code == 200 assert "incorrect" in resp.get_data(as_text=True) resp = anon_client.post("/login", data={"email": "login@example.com", "password": "Sup3r$ecret!"}, follow_redirects=False) assert resp.status_code == 302 assert resp.headers["Location"] == "/login/2fa" code = pyotp.TOTP(secret).now() resp = anon_client.post("/login/2fa", data={"code": code}, follow_redirects=False) assert resp.status_code == 302 assert resp.headers["Location"] == "/game/" + slug finally: _cleanup_project("login@example.com") def test_non_admin_user_is_isolated_to_their_own_project(anon_client): _register(anon_client, "isolated@example.com") _confirm_2fa(anon_client) user = auth.get_user_by_email("isolated@example.com") try: # Un autre projet (celui de l'admin partagé par conftest.py, ou # n'importe quel autre slug) doit rester inaccessible. resp = anon_client.get("/game/un-projet-qui-nest-pas-le-sien") assert resp.status_code == 403 resp = anon_client.get(f"/game/{user['project_slug']}") assert resp.status_code == 200 # Impossible de créer un second projet : redirigé vers le sien. resp = anon_client.post("/games/new", data={"name": "second"}, follow_redirects=False) assert resp.status_code == 302 assert resp.headers["Location"] == "/game/" + user["project_slug"] finally: _cleanup_project("isolated@example.com") # Filet de sécurité si "second" avait quand même été créé (ne # devrait jamais arriver, voir l'assertion ci-dessus). _cleanup_project("second") def test_non_admin_user_cannot_delete_their_only_project(anon_client): """Sans issue de secours (games_new renvoie toujours vers son project_slug, existant ou non), le supprimer serait un piège sans retour — bloqué plutôt que risqué.""" _register(anon_client, "nodelete@example.com") _confirm_2fa(anon_client) user = auth.get_user_by_email("nodelete@example.com") try: resp = anon_client.post(f"/game/{user['project_slug']}/delete") assert resp.status_code == 403 assert db.game_meta(user["project_slug"]) is not None finally: _cleanup_project("nodelete@example.com") def test_admin_role_is_not_restricted_to_a_single_project(client, game): """`client`/`game` (conftest.py) sont déjà connectés en tant qu'admin de la session de tests — la création d'un second jeu, déjà exercée par des dizaines d'autres tests via la fixture `game`, doit continuer de fonctionner sans redirection forcée.""" resp = client.post("/games/new", data={"name": "pytest_second_admin_game"}, follow_redirects=False) assert resp.status_code == 302 slug = resp.headers["Location"].rstrip("/").split("/")[-1] assert slug != game import shutil import os game_dir = db.game_dir(slug) if os.path.isdir(game_dir): shutil.rmtree(game_dir)