`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.
106 lines
3.7 KiB
Python
106 lines
3.7 KiB
Python
# Ce test est le garde-fou de l'autorisation : rendre une route publique oblige à modifier
|
|
# `ROUTES_PUBLIQUES` dans `tests/api/acces.py`, ce qui apparaît en clair dans la diff d'une pull
|
|
# request et demande une justification au relecteur.
|
|
# Pourquoi : il interroge réellement chaque route sans jeton au lieu d'inspecter l'arbre de
|
|
# dépendances. L'arbre n'est accessible que par l'API privée de FastAPI, et surtout une route
|
|
# peut porter la bonne dépendance tout en répondant quand même.
|
|
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from httpx import AsyncClient
|
|
|
|
from tests.api.acces import (
|
|
ROLE_MINIMUM,
|
|
ROUTE_COOKIE,
|
|
ROUTES_PUBLIQUES,
|
|
ROUTES_SANS_ROLE,
|
|
Route,
|
|
chemin_concret,
|
|
routes_du_schema,
|
|
)
|
|
|
|
STATUTS_DE_REFUS = {401, 403}
|
|
HORS_SCHEMA = {("GET", "/metrics")}
|
|
|
|
|
|
def routes_declarees(app: FastAPI) -> list[Route]:
|
|
schema: dict[str, Any] = app.openapi()
|
|
return routes_du_schema(schema)
|
|
|
|
|
|
def routes_protegees(app: FastAPI) -> list[Route]:
|
|
return [route for route in routes_declarees(app) if route not in ROUTES_PUBLIQUES]
|
|
|
|
|
|
def test_the_public_allow_list_has_no_stale_entry(app: FastAPI) -> None:
|
|
declarees = set(routes_declarees(app)) | HORS_SCHEMA
|
|
|
|
inconnues = ROUTES_PUBLIQUES - declarees
|
|
|
|
assert inconnues == set()
|
|
|
|
|
|
# Sans lui, une route ajoutée sans être classée n'est vue par aucun test de rôle : elle hérite
|
|
# du seul contrôle anonyme, et une garde posée au mauvais niveau passe inaperçue.
|
|
def test_every_declared_route_is_classified(app: FastAPI) -> None:
|
|
classees = ROUTES_PUBLIQUES | ROUTE_COOKIE | ROUTES_SANS_ROLE | set(ROLE_MINIMUM)
|
|
|
|
non_classees = set(routes_declarees(app)) - classees
|
|
fantomes = classees - set(routes_declarees(app)) - HORS_SCHEMA
|
|
|
|
assert non_classees == set(), "classer la route dans tests/api/acces.py"
|
|
assert fantomes == set(), "entrée morte : la route n'existe plus sous ce chemin"
|
|
|
|
|
|
def test_the_four_classes_of_routes_stay_disjoint() -> None:
|
|
classes = [ROUTES_PUBLIQUES, ROUTE_COOKIE, ROUTES_SANS_ROLE, frozenset(ROLE_MINIMUM)]
|
|
|
|
for rang, classe in enumerate(classes):
|
|
for autre in classes[rang + 1 :]:
|
|
assert classe & autre == frozenset()
|
|
|
|
|
|
async def test_every_route_rejects_an_anonymous_caller_unless_explicitly_public(
|
|
app: FastAPI, client: AsyncClient
|
|
) -> None:
|
|
ouvertes: list[tuple[str, str, int]] = []
|
|
|
|
for methode, chemin in routes_protegees(app):
|
|
response = await client.request(methode, chemin_concret(chemin), json={})
|
|
if response.status_code not in STATUTS_DE_REFUS:
|
|
ouvertes.append((methode, chemin, response.status_code))
|
|
|
|
assert ouvertes == []
|
|
|
|
|
|
async def test_the_declared_routes_are_actually_reachable(app: FastAPI) -> None:
|
|
assert ("POST", "/api/v1/auth/login") in routes_declarees(app)
|
|
assert ("GET", "/api/v1/auth/me") in routes_declarees(app)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"chemin",
|
|
["/api/v1/health/live", "/api/v1/health/ready"],
|
|
ids=["sonde_de_vie", "sonde_de_disponibilite"],
|
|
)
|
|
def test_the_health_probes_stay_public(app: FastAPI, chemin: str) -> None:
|
|
assert ("GET", chemin) in ROUTES_PUBLIQUES
|
|
|
|
|
|
# Piège : ni les routes `include_in_schema=False` (/docs, /redoc) ni un `Mount` Starlette
|
|
# (/static) n'apparaissent dans `app.openapi()["paths"]`. `routes_declarees()` ne les voit
|
|
# donc jamais, et elles échapperaient silencieusement au garde-fou ci-dessus.
|
|
@pytest.mark.parametrize(
|
|
"chemin",
|
|
["/docs", "/redoc", "/static/logo-icon.png"],
|
|
ids=["swagger_ui", "redoc", "logo_statique"],
|
|
)
|
|
async def test_the_documentation_routes_are_public_by_design(
|
|
app: FastAPI, client: AsyncClient, chemin: str
|
|
) -> None:
|
|
response = await client.get(chemin)
|
|
|
|
assert response.status_code == 200
|