Build and deploy / test-python (push) Successful in 11m5s
Build and deploy / test-js (push) Successful in 55s
Build and deploy / lint-python (push) Successful in 6m29s
Build and deploy / lint-js (push) Failing after 1m39s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 5m18s
Bug reel constate le 21/09/2026 : un element Scenario cree avant la refonte en arbre de decision (ancien schema plat "situation"/"choices"/ "correct_index") faisait planter silencieusement le panneau Proprietes cote client des la selection - scenario.nodes etait inexistant sur l'ancienne forme, aucune erreur visible, juste "il ne se passe rien". Cause : document_edit.py et document_render.py renvoyaient les attributs BRUTS de la base au client, jamais revalides - contrairement a la route d'ecriture qui, elle, sanitize deja avant de persister. Ajoute document_engine.sanitize_element_attributes(kind, attributes), point d'entree unique de dispatch kind -> sanitize_X_config, utilise desormais a la fois en ecriture (document_element_update.py, qui reutilise ce nouveau dispatch au lieu de son if/elif duplique) ET en lecture (document_edit.py/document_render.py). Elimine toute la classe de bug "schema devenu obsolete apres une evolution du modele de donnees d'un mini-jeu, donnee jamais retouchee depuis" - present et futur, pas seulement pour Scenario. Migre les donnees reelles deja affectees (support de test, element 46) vers le nouveau schema en arbre, en preservant integralement le contenu deja redige par l'utilisateur (situation + 3 choix/consequences du scenario "chat sur la route"). Ajoute un test de non-regression qui ecrit delibirement l'ancien schema en base (en contournant le sanitize de la route d'ecriture, pour simuler une donnee reellement ancienne jamais nettoyee) puis verifie que /edit et /render renvoient une structure saine au client. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
228 lines
9.8 KiB
Python
228 lines
9.8 KiB
Python
"""Routes HTTP du support de formation (routes/document/) — création,
|
|
édition directe (sans page intermédiaire), CRUD des éléments en AJAX,
|
|
publication, et isolation par propriétaire (core/auth_guard.py)."""
|
|
|
|
from typing import Any
|
|
|
|
from flask.testing import FlaskClient
|
|
|
|
import auth
|
|
import db
|
|
import document_engine
|
|
|
|
|
|
def test_documents_new_creates_a_support_not_a_game(client: FlaskClient) -> None:
|
|
resp = client.post("/documents/new", data={"name": "Sécurité incendie"}, follow_redirects=False)
|
|
assert resp.status_code == 302
|
|
assert "/document/" in resp.headers["Location"]
|
|
assert "/edit" in resp.headers["Location"]
|
|
slug = resp.headers["Location"].rstrip("/").split("/")[-2]
|
|
try:
|
|
assert db.support_meta(slug)["name"] == "Sécurité incendie"
|
|
finally:
|
|
db.delete_support(slug)
|
|
|
|
|
|
def test_document_edit_renders_directly_no_intermediate_page(client: FlaskClient, support: str) -> None:
|
|
resp = client.get(f"/document/{support}/edit")
|
|
assert resp.status_code == 200
|
|
|
|
|
|
def test_document_element_add_returns_rendered_html(client: FlaskClient, support: str) -> None:
|
|
resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre"})
|
|
assert resp.status_code == 200
|
|
payload = resp.get_json()
|
|
assert payload["kind"] == "titre"
|
|
assert "docText" in payload["rendered_html"]
|
|
|
|
|
|
def test_document_element_add_rejects_unknown_kind(client: FlaskClient, support: str) -> None:
|
|
assert client.post(f"/document/{support}/elements/add", data={"kind": "n-importe-quoi"}).status_code == 400
|
|
|
|
|
|
def test_document_element_add_accepts_row_for_the_layout_engine(client: FlaskClient, support: str) -> None:
|
|
""" "row" n'apparaît jamais dans la bibliothèque (voir
|
|
document_engine.ELEMENT_LIBRARY) mais reste un kind valide pour cette
|
|
route — c'est le moteur de layout côté client qui en crée une à la
|
|
volée au moment d'un dépôt latéral (voir
|
|
static/document/js/document-editor.js)."""
|
|
resp = client.post(f"/document/{support}/elements/add", data={"kind": "row"})
|
|
assert resp.status_code == 200
|
|
assert resp.get_json()["kind"] == "row"
|
|
|
|
|
|
def test_document_element_update_persists_attributes(client: FlaskClient, support: str) -> None:
|
|
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "paragraphe"})
|
|
element_id = add_resp.get_json()["id"]
|
|
|
|
resp = client.post(
|
|
f"/document/{support}/elements/{element_id}/update",
|
|
json={"content": "Texte mis à jour", "style": "legende"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.get_json()["ok"] is True
|
|
|
|
reread = client.post(f"/document/{support}/elements/add", data={"kind": "titre"})
|
|
assert reread.status_code == 200
|
|
|
|
|
|
def test_document_element_update_sanitizes_quiz_config_on_write(client: FlaskClient, support: str) -> None:
|
|
"""Quiz et Association sont revalidés côté serveur (voir
|
|
routes/document/document_element_update.py) : un choix en trop
|
|
(5e) doit être tronqué, et la réponse renvoie les attributs
|
|
RÉELLEMENT persistés (pas ceux envoyés tels quels) pour que le
|
|
client ne dérive jamais de la vérité serveur."""
|
|
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "quiz"})
|
|
element_id = add_resp.get_json()["id"]
|
|
|
|
resp = client.post(
|
|
f"/document/{support}/elements/{element_id}/update",
|
|
json={
|
|
"theme_color": "#ff5f2e",
|
|
"timer_enabled": False,
|
|
"timer_seconds": 30,
|
|
"questions": [
|
|
{"text": "Une question", "choices": ["A", "B", "C", "D", "E"], "correct_index": 0, "points": 10},
|
|
],
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
payload = resp.get_json()
|
|
assert payload["ok"] is True
|
|
assert payload["attributes"]["questions"][0]["choices"] == ["A", "B", "C", "D"]
|
|
assert "1 question" in payload["rendered_html"]
|
|
|
|
|
|
def test_document_element_update_sanitizes_association_config_on_write(client: FlaskClient, support: str) -> None:
|
|
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "association"})
|
|
element_id = add_resp.get_json()["id"]
|
|
|
|
resp = client.post(
|
|
f"/document/{support}/elements/{element_id}/update",
|
|
json={
|
|
"theme_color": "#ffb020",
|
|
"pairs": [
|
|
{"left": "Chat", "right": "Un félin"},
|
|
{"left": "Sans correspondance", "right": ""},
|
|
],
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
payload = resp.get_json()
|
|
assert payload["ok"] is True
|
|
assert payload["attributes"]["pairs"] == [{"left": "Chat", "right": "Un félin"}]
|
|
assert "1 paire" in payload["rendered_html"]
|
|
|
|
|
|
def test_document_element_update_sanitizes_memory_config_on_write(client: FlaskClient, support: str) -> None:
|
|
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "memory"})
|
|
element_id = add_resp.get_json()["id"]
|
|
|
|
resp = client.post(
|
|
f"/document/{support}/elements/{element_id}/update",
|
|
json={
|
|
"theme_color": "#ffb020",
|
|
"mode": "single",
|
|
"cards": [
|
|
{"recto": {"text": "?"}, "verso": {"text": "Chat"}},
|
|
{"recto": {"text": "?"}, "verso": {"image": "", "text": ""}},
|
|
],
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
payload = resp.get_json()
|
|
assert payload["ok"] is True
|
|
assert payload["attributes"]["mode"] == "single"
|
|
assert len(payload["attributes"]["cards"]) == 1
|
|
assert "1 carte" in payload["rendered_html"]
|
|
assert "mode simple" in payload["rendered_html"]
|
|
|
|
|
|
def test_document_edit_sanitizes_stale_scenario_attributes_from_an_old_schema(
|
|
client: FlaskClient, support: str
|
|
) -> None:
|
|
"""Bug réel constaté le 21/09/2026 : un élément Scénario créé AVANT la
|
|
refonte en arbre de décision (ancien schéma plat "situation"/"choices"/
|
|
"correct_index", sans "title"/"nodes") faisait planter silencieusement
|
|
le panneau Propriétés côté client (`scenario.nodes` inexistant), sans
|
|
qu'aucune erreur ne remonte — voir document_engine/labels/
|
|
sanitize_element_attributes.py. `update_document_element_attributes`
|
|
(bas niveau, jamais utilisée directement par une route) sert ici à
|
|
écrire ce schéma obsolète tel quel, en contournant volontairement le
|
|
sanitize de la route d'update — pour simuler une ligne réellement
|
|
ancienne en base, jamais nettoyée depuis."""
|
|
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "scenario"})
|
|
element_id = add_resp.get_json()["id"]
|
|
stale_attributes = {
|
|
"theme_color": "#ff5f2e",
|
|
"scenarios": [
|
|
{
|
|
"situation": "Un chat va se faire renverser sous vos yeux, que faites-vous ?",
|
|
"choices": [{"text": "Je le sauve", "consequence": "Le chat est sauvé."}],
|
|
"correct_index": 0,
|
|
}
|
|
],
|
|
}
|
|
document_engine.update_document_element_attributes(support, element_id, stale_attributes)
|
|
|
|
edit_resp = client.get(f"/document/{support}/edit")
|
|
assert edit_resp.status_code == 200
|
|
render_resp = client.get(f"/document/{support}/render")
|
|
assert render_resp.status_code == 200
|
|
rendered_element = next(el for el in render_resp.get_json()["elements"] if el["id"] == element_id)
|
|
# Le vieux schéma n'a ni "title" ni "nodes" valides : sanitize_scenario_config
|
|
# le rejette entièrement plutôt que de renvoyer une structure à moitié
|
|
# ancienne/à moitié nouvelle — c'est la structure ACTUELLE garantie qui
|
|
# compte ici, jamais un plantage silencieux côté client.
|
|
assert rendered_element["attributes"] == document_engine.DEFAULT_SCENARIO_CONFIG
|
|
|
|
|
|
def test_document_element_move_and_delete(client: FlaskClient, support: str) -> None:
|
|
first_id = client.post(f"/document/{support}/elements/add", data={"kind": "titre"}).get_json()["id"]
|
|
second_id = client.post(f"/document/{support}/elements/add", data={"kind": "paragraphe"}).get_json()["id"]
|
|
|
|
move_resp = client.post(
|
|
f"/document/{support}/elements/{second_id}/move",
|
|
json={"new_parent_id": None, "new_index": 0},
|
|
)
|
|
assert move_resp.status_code == 200
|
|
assert move_resp.get_json()["ok"] is True
|
|
|
|
delete_resp = client.post(f"/document/{support}/elements/{first_id}/delete")
|
|
assert delete_resp.status_code == 200
|
|
assert delete_resp.get_json()["ok"] is True
|
|
|
|
|
|
def test_restricted_user_can_have_one_game_and_one_support_at_once(
|
|
user_client: FlaskClient, tmp_game_slug_cleanup: Any, tmp_support_slug_cleanup: Any
|
|
) -> None:
|
|
with user_client.session_transaction() as sess:
|
|
user_id = sess["user_id"]
|
|
game_slug = db.create_game("Un environnement 2D", owner_folder=str(user_id))
|
|
tmp_game_slug_cleanup(game_slug)
|
|
auth.set_project_slug(user_id, game_slug)
|
|
|
|
resp = user_client.post("/documents/new", data={"name": "Un support"}, follow_redirects=False)
|
|
assert resp.status_code == 302
|
|
support_slug = resp.headers["Location"].rstrip("/").split("/")[-2]
|
|
tmp_support_slug_cleanup(support_slug)
|
|
|
|
user = auth.get_user_by_id(user_id)
|
|
assert user is not None
|
|
assert user["project_slug"] == game_slug
|
|
assert user["support_slug"] == support_slug
|
|
|
|
|
|
def test_cannot_open_another_owners_support(client: FlaskClient, user_client: FlaskClient) -> None:
|
|
"""Isolation par propriétaire (core/auth_guard.py, split_slug déjà
|
|
générique quel que soit le type de projet) — le compte admin
|
|
(`client`) ne doit pas pouvoir ouvrir le support d'un autre compte."""
|
|
with user_client.session_transaction() as sess:
|
|
victim_user_id = sess["user_id"]
|
|
victim_slug = db.create_support("Support de la victime", owner_folder=str(victim_user_id))
|
|
try:
|
|
assert client.get(f"/document/{victim_slug}/edit").status_code == 403
|
|
assert client.post(f"/document/{victim_slug}/delete").status_code == 403
|
|
finally:
|
|
db.delete_support(victim_slug)
|