test(backend): classe les routes du contrat et dérive les listes d'autorisation

`ROUTES_A_ROLE` était recopiée dans `test_openapi.py`, et deux de ses entrées portaient
`{id}` là où le contrat expose `{user_id}`. Elles ne correspondaient donc à aucune
opération, et `test_every_role_guarded_route_documents_the_role_refusal` passait au vert
sans rien vérifier sur `PATCH /users/{user_id}` ni sur sa réinitialisation de mot de passe :
11 des 13 routes gardées étaient réellement couvertes.

`tests/api/acces.py` porte désormais la classification des 24 routes du contrat en quatre
ensembles, dont la table `ROLE_MINIMUM`, et `test_every_declared_route_is_classified` refuse
aussi bien une route non classée qu'une entrée qui ne correspond plus à rien. C'est ce que
`docs/architecture/20-backend.md` annonçait comme impossible : « ces deux listes sont
maintenues à la main, pas dérivées ».

Au passage, `chemin_concret()` substitue les trois gabarits du contrat et non plus le seul
`{user_id}`, ce qui est sans effet sur le refus anonyme mais nécessaire à un appel qui doit
aboutir.
This commit is contained in:
Johan LEROY
2026-09-18 12:08:59 +02:00
parent 297d85a0ca
commit 7eef960a30
3 changed files with 129 additions and 45 deletions
+6 -15
View File
@@ -8,6 +8,7 @@ from typing import Any
import pytest
from app import cli
from tests.api.acces import ROLE_MINIMUM
METHODES = {"get", "post", "patch", "put", "delete"}
@@ -24,21 +25,11 @@ ORIGINE_VERIFIEE = {
# Toute route derrière `require_role` (LecteurDep, OperateurDep, AdminDep) peut rendre 403 pour
# `password_change_required`, pas seulement les routes `admin`.
ROUTES_A_ROLE = {
("GET", "/api/v1/users"),
("POST", "/api/v1/users"),
("PATCH", "/api/v1/users/{id}"),
("POST", "/api/v1/users/{id}/password-reset"),
("GET", "/api/v1/sites"),
("GET", "/api/v1/sites/{site_id}"),
("GET", "/api/v1/sites/{site_id}/current"),
("GET", "/api/v1/alerts"),
("GET", "/api/v1/recommendations"),
("GET", "/api/v1/recommendations/{recommendation_id}"),
("GET", "/api/v1/stats/summary"),
("GET", "/api/v1/readings"),
("GET", "/api/v1/sensors/status"),
}
# Piège : cette liste était recopiée ici, et deux de ses entrées portaient `{id}` là où le contrat
# expose `{user_id}`. Elles ne correspondaient donc à aucune opération, et le test ci-dessous
# passait au vert sans rien vérifier sur ces deux routes. Elle est maintenant dérivée, et
# `test_every_declared_route_is_classified` interdit l'entrée morte.
ROUTES_A_ROLE = frozenset(ROLE_MINIMUM)
@pytest.fixture(scope="module")