Files
Forge-Engine/tests/test_auth.py
T
william 50835a18e2
Build and deploy / test-python (push) Successful in 9m41s
Build and deploy / test-js (push) Successful in 1m0s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Supprime le type d'écran "document" et recentre le produit sur le jeu 2D
Le document de cadrage produit cible des formateurs non techniques créant
des serious games/quiz gamifiés — l'éditeur générique "document" (blocs de
logique en nœuds, timeline d'animation, définitions d'objets/relations,
templates réutilisables) est une complexité hors cible que l'effort
d'ingénierie récent avait déjà abandonnée au profit du jeu_2d.

- Onboarding : ne garde que le parcours "RPG" (jeu_2d), retire
  Quiz/Embranchement/Créer mon jeu de A à Z (tous document-only)
- Suppression en bloc des modules exclusifs au document : routes/elements,
  routes/element_types, routes/objects, routes/legacy_actions,
  screens/elements, screens/element_types, screens/widgets,
  screens/legacy_actions, le rendu render_element_html.py et son cluster,
  templates/screen_edit.html, templates/game_dashboard.html,
  flow-editor.js/tabs-and-blocks.js/animation-timeline.js
- Dashboard toujours simplifié (un seul mode possible désormais)
- Tests document-only supprimés, tests de logique partagée (flow,
  événements personnalisés, animations) retargetés sur des écrans jeu_2d
- Aucune régression jeu_2d : 299 tests passent

Carte d'onboarding retravaillée : argumentaire RH non technique (liste à
coche, badge "Compatible LMS"), taille et interaction de retournement
ajustées.
2026-09-04 20:56:32 +02:00

359 lines
16 KiB
Python

"""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. Un compte "user" n'a plus de projet
créé d'office ici (voir routes/onboarding/onboarding_new.py) — la
redirection renvoie désormais vers /onboarding, pas /game/<slug> ;
voir _complete_onboarding ci-dessous pour les tests qui ont besoin
d'un vrai projet ensuite."""
html = client.get("/register/2fa").get_data(as_text=True)
secret = re.search(r"<code>([A-Z0-9]+)</code>", html).group(1)
code = pyotp.TOTP(secret).now()
return client.post("/register/2fa", data={"code": code}, follow_redirects=False), secret
def _complete_onboarding(client, name="Mon jeu", onboarding_type="rpg"):
"""Termine l'onboarding guidé (voir routes/onboarding/onboarding_new.py)
— la plupart des tests de ce fichier n'ont pas besoin de tester
l'onboarding lui-même (voir test_onboarding.py), juste d'obtenir un
project_slug réel pour la suite ; "rpg" est le seul type restant."""
return client.post(
"/onboarding", data={"onboarding_type": onboarding_type, "name": name}, follow_redirects=False
)
def _cleanup_project(email):
import os
user = auth.get_user_by_email(email)
if user and user.get("project_slug"):
if os.path.isdir(db.game_dir(user["project_slug"])):
# db.delete_game (voir db/games/project_slug.py — structure de
# dossiers par utilisateur) nettoie aussi le dossier
# propriétaire s'il devient vide, contrairement à un simple
# shutil.rmtree(game_dir) — sans ça, des dossiers propriétaires
# vides s'accumulent sous projects/ d'un run de tests à l'autre
# (déjà observé : ça a fini par provoquer une collision de nom
# dans db.move_game, un dossier vide comptant comme "occupé").
db.delete_game(user["project_slug"])
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
# Onboarding guidé (voir routes/onboarding/onboarding_new.py) : plus
# de projet créé d'office ici — connecté mais sans project_slug tant
# qu'il n'a pas choisi un type de jeu.
assert user["project_slug"] is None
try:
assert resp.headers["Location"] == "/onboarding"
# Connecté pour de vrai maintenant, mais renvoyé vers l'onboarding
# tant qu'aucun projet n'existe (voir core/auth_guard.py).
resp2 = anon_client.get("/", follow_redirects=False)
assert resp2.status_code == 302
assert resp2.headers["Location"] == "/onboarding"
onboard_resp = _complete_onboarding(anon_client)
assert onboard_resp.status_code == 302
user = auth.get_user_by_email("flow@example.com")
assert user["project_slug"] is not None
assert onboard_resp.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)
_complete_onboarding(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)
_complete_onboarding(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)
_complete_onboarding(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_lockout_escalation_is_5_10_20_40_capped_at_60():
assert [auth.lockout_minutes_for(n) for n in range(9)] == [0, 0, 0, 0, 5, 10, 20, 40, 60]
def test_login_locks_out_after_3_free_attempts_then_escalates(anon_client):
"""3 tentatives libres, puis un verrouillage qui double à chaque
nouvel échec (5, 10, 20 min...) — voir auth/rate_limit.py."""
_register(anon_client, "bruteforce@example.com")
_confirm_2fa(anon_client)
user = auth.get_user_by_email("bruteforce@example.com")
try:
anon_client.post("/logout")
for _ in range(3):
resp = anon_client.post("/login", data={"email": "bruteforce@example.com", "password": "wrong"})
assert "incorrect" in resp.get_data(as_text=True)
assert auth.get_user_by_email("bruteforce@example.com")["failed_attempts"] == 3
assert auth.seconds_locked_remaining(auth.get_user_by_email("bruteforce@example.com")) == 0
# 4e échec -> premier verrouillage (5 minutes).
resp = anon_client.post("/login", data={"email": "bruteforce@example.com", "password": "wrong"})
body = resp.get_data(as_text=True)
assert "Trop de tentatives" in body
assert "5 minute" in body
locked_user = auth.get_user_by_email("bruteforce@example.com")
assert auth.seconds_locked_remaining(locked_user) > 0
# Même avec le BON mot de passe, verrouillé = verrouillé.
resp = anon_client.post("/login", data={"email": "bruteforce@example.com", "password": "Sup3r$ecret!"})
assert "Trop de tentatives" in resp.get_data(as_text=True)
# Un essai bloqué par le verrouillage n'incrémente pas davantage
# le compteur (pas d'escalade supplémentaire tant qu'on est
# encore dans la fenêtre déjà posée).
assert auth.get_user_by_email("bruteforce@example.com")["failed_attempts"] == 4
finally:
_cleanup_project("bruteforce@example.com")
def test_login_2fa_wrong_code_also_counts_towards_the_same_lockout(anon_client):
_register(anon_client, "bruteforce2fa@example.com")
resp, secret = _confirm_2fa(anon_client)
user = auth.get_user_by_email("bruteforce2fa@example.com")
try:
anon_client.post("/logout")
anon_client.post("/login", data={"email": "bruteforce2fa@example.com", "password": "Sup3r$ecret!"})
for _ in range(3):
resp = anon_client.post("/login/2fa", data={"code": "000000"})
assert "Code invalide" in resp.get_data(as_text=True)
resp = anon_client.post("/login/2fa", data={"code": "000000"})
assert "Trop de tentatives" in resp.get_data(as_text=True)
# Le bon code ne débloque rien tant que le verrouillage est actif.
code = pyotp.TOTP(secret).now()
resp = anon_client.post("/login/2fa", data={"code": code})
assert "Trop de tentatives" in resp.get_data(as_text=True)
finally:
_cleanup_project("bruteforce2fa@example.com")
def test_successful_login_resets_the_failed_attempts_counter(anon_client):
_register(anon_client, "resetcounter@example.com")
resp, secret = _confirm_2fa(anon_client)
try:
anon_client.post("/logout")
anon_client.post("/login", data={"email": "resetcounter@example.com", "password": "wrong"})
anon_client.post("/login", data={"email": "resetcounter@example.com", "password": "wrong"})
assert auth.get_user_by_email("resetcounter@example.com")["failed_attempts"] == 2
anon_client.post("/login", data={"email": "resetcounter@example.com", "password": "Sup3r$ecret!"})
code = pyotp.TOTP(secret).now()
resp = anon_client.post("/login/2fa", data={"code": code}, follow_redirects=False)
assert resp.status_code == 302
user = auth.get_user_by_email("resetcounter@example.com")
assert user["failed_attempts"] == 0
assert user["locked_until"] is None
finally:
_cleanup_project("resetcounter@example.com")
def test_recovery_codes_are_shown_once_after_confirming_2fa(anon_client):
_register(anon_client, "recoveryshown@example.com")
resp, secret = _confirm_2fa(anon_client)
try:
html = anon_client.get(resp.headers["Location"]).get_data(as_text=True)
assert "recoveryCodesModal" in html
# Jamais réaffichés sur un chargement suivant (jeton flash à usage
# unique, voir core/recovery_codes_flash.py).
html_again = anon_client.get(resp.headers["Location"]).get_data(as_text=True)
assert "recoveryCodesModal" not in html_again
finally:
_cleanup_project("recoveryshown@example.com")
def test_login_with_a_recovery_code_instead_of_totp(anon_client):
_register(anon_client, "recoverylogin@example.com")
_confirm_2fa(anon_client)
_complete_onboarding(anon_client)
user = auth.get_user_by_email("recoverylogin@example.com")
slug = user["project_slug"]
codes = auth.generate_recovery_codes(user["id"])
try:
anon_client.post("/logout")
anon_client.post("/login", data={"email": "recoverylogin@example.com", "password": "Sup3r$ecret!"})
resp = anon_client.post("/login/2fa", data={"code": codes[0]}, follow_redirects=False)
assert resp.status_code == 302
assert resp.headers["Location"] == "/game/" + slug
# Un code déjà utilisé ne fonctionne plus une seconde fois.
anon_client.post("/logout")
anon_client.post("/login", data={"email": "recoverylogin@example.com", "password": "Sup3r$ecret!"})
resp = anon_client.post("/login/2fa", data={"code": codes[0]})
assert "Code invalide" in resp.get_data(as_text=True)
# Un autre code du même lot, lui, fonctionne toujours.
anon_client.post("/logout")
anon_client.post("/login", data={"email": "recoverylogin@example.com", "password": "Sup3r$ecret!"})
resp = anon_client.post("/login/2fa", data={"code": codes[1]}, follow_redirects=False)
assert resp.status_code == 302
finally:
_cleanup_project("recoverylogin@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)