Le dépôt vérifiait le refus d'un lecteur sur les cinq routes `admin`, et rien de plus. Les huit routes `lecteur` n'étaient jouées qu'avec un lecteur : une garde posée trop haut, par exemple `AdminDep` sur `/sites`, n'aurait fait échouer aucun test. La matrice couvre les deux sens. Un rôle insuffisant reçoit un 403 `Droits insuffisants`, un rôle suffisant ne le reçoit jamais. L'assertion porte sur le refus de la garde et pas sur un 200, sans quoi elle dépendrait du contenu de la base : un 404 ou un 422 est une réponse acceptable, un 403 non. Sous le marqueur `integration`, la même matrice est rejouée avec de vrais jetons obtenus par `/auth/login`, donc en traversant le décodage du JWT et la relecture du compte en base que `dependency_overrides` court-circuite. Deux invariants y sont figés : `operateur` n'ouvre aujourd'hui aucune route de plus que `lecteur`, faute d'écriture métier dans l'API, et `/auth/logout-all` échappe au refus `password_change_required` parce qu'elle prend un `CurrentPrincipalDep` nu. Le second est signalé, pas corrigé. Closes #61
280 lines
10 KiB
Python
280 lines
10 KiB
Python
# Pourquoi : la matrice rôle x route sur les routes réelles. `test_authorization.py` la joue déjà,
|
|
# mais contre une route jetable montée par une fixture, ce qui ne dit rien du niveau effectivement
|
|
# posé sur `/sites` ou `/users`. `ROLE_MINIMUM` (tests/api/acces.py) est la référence, et ce
|
|
# fichier est ce qui la confronte au comportement observé.
|
|
# Piège : l'assertion porte sur le refus de la garde, pas sur un 200. Un rôle suffisant peut
|
|
# légitimement recevoir 404 ou 422 selon les données ; ce qui compte est qu'il ne reçoive pas le
|
|
# 403 `Droits insuffisants`. Sans cette nuance, le test dépendrait du contenu de la base.
|
|
# Les tests `integration` en fin de fichier rejouent la même matrice avec de vrais jetons, donc en
|
|
# traversant le décodage du JWT et la relecture du compte, ce que l'override court-circuite.
|
|
|
|
import uuid
|
|
from collections.abc import AsyncIterator, Callable, Iterator
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from httpx import AsyncClient, Response
|
|
from sqlalchemy import text
|
|
|
|
from app.api.deps import get_current_principal
|
|
from app.core.hashing import build_hasher
|
|
from app.core.principal import Principal
|
|
from app.core.roles import AccountKind, Role, has_at_least
|
|
from app.db.session import get_session, get_session_factory
|
|
from app.repositories.user import UserRepository
|
|
from tests.api.acces import ROLE_MINIMUM, chemin_concret
|
|
|
|
ROLES = [Role.LECTEUR, Role.OPERATEUR, Role.ADMIN]
|
|
IDS_DE_ROLE = ["lecteur", "operateur", "admin"]
|
|
REFUS_DE_DROITS = "Droits insuffisants"
|
|
REFUS_DE_MOT_DE_PASSE = "password_change_required"
|
|
MOT_DE_PASSE = "un-mot-de-passe-de-recette"
|
|
|
|
|
|
# `FakeSession` de tests/factories.py rend un unique objet pour les trois formes d'appel, ce qui
|
|
# suffit à un test d'endpoint ciblé mais pas à balayer 13 routes qui interrogent chacune la base
|
|
# à sa façon. Ce double rend un résultat vide quelle que soit la forme demandée, pour que la
|
|
# réponse observée vienne de la garde de rôle et jamais d'un double mal ajusté.
|
|
class ResultatVide:
|
|
def scalars(self) -> ResultatVide:
|
|
return self
|
|
|
|
def all(self) -> list[object]:
|
|
return []
|
|
|
|
def first(self) -> None:
|
|
return None
|
|
|
|
def one_or_none(self) -> None:
|
|
return None
|
|
|
|
def scalar_one_or_none(self) -> None:
|
|
return None
|
|
|
|
def mappings(self) -> ResultatVide:
|
|
return self
|
|
|
|
def __iter__(self) -> Iterator[object]:
|
|
return iter(())
|
|
|
|
|
|
class SessionMuette:
|
|
async def scalar(self, *_: object, **__: object) -> None:
|
|
return None
|
|
|
|
async def execute(self, *_: object, **__: object) -> ResultatVide:
|
|
return ResultatVide()
|
|
|
|
async def scalars(self, *_: object, **__: object) -> ResultatVide:
|
|
return ResultatVide()
|
|
|
|
async def get(self, *_: object, **__: object) -> None:
|
|
return None
|
|
|
|
async def flush(self) -> None:
|
|
return None
|
|
|
|
async def commit(self) -> None:
|
|
return None
|
|
|
|
async def rollback(self) -> None:
|
|
return None
|
|
|
|
def add(self, *_: object, **__: object) -> None:
|
|
return None
|
|
|
|
|
|
@pytest.fixture
|
|
def base_muette(app: FastAPI) -> None:
|
|
async def override() -> AsyncIterator[SessionMuette]:
|
|
yield SessionMuette()
|
|
|
|
app.dependency_overrides[get_session] = override
|
|
|
|
|
|
def principal(role: Role, *, must_change_password: bool = False) -> Principal:
|
|
return Principal(
|
|
id=uuid.uuid4(),
|
|
email=f"matrice-{role.value}@enervision.fr",
|
|
role=role,
|
|
kind=AccountKind.HUMAIN,
|
|
must_change_password=must_change_password,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def connecte(app: FastAPI) -> Iterator[Callable[[Principal], None]]:
|
|
def installe(acteur: Principal) -> None:
|
|
app.dependency_overrides[get_current_principal] = lambda: acteur
|
|
|
|
yield installe
|
|
app.dependency_overrides.pop(get_current_principal, None)
|
|
|
|
|
|
async def appelle(client: AsyncClient, methode: str, chemin: str, **kwargs: object) -> Response:
|
|
return await client.request(methode, chemin_concret(chemin), json={}, **kwargs) # type: ignore[arg-type]
|
|
|
|
|
|
def motif_du_refus(response: Response) -> str | None:
|
|
if response.status_code != 403:
|
|
return None
|
|
detail = response.json().get("detail")
|
|
return detail if isinstance(detail, str) else None
|
|
|
|
|
|
@pytest.mark.parametrize("role", ROLES, ids=IDS_DE_ROLE)
|
|
async def test_a_role_below_the_minimum_is_refused_on_every_guarded_route(
|
|
connecte: Callable[[Principal], None],
|
|
client: AsyncClient,
|
|
base_muette: None,
|
|
role: Role,
|
|
) -> None:
|
|
connecte(principal(role))
|
|
laissees_passer: list[tuple[str, str, int]] = []
|
|
|
|
for (methode, chemin), minimum in ROLE_MINIMUM.items():
|
|
if has_at_least(role, minimum):
|
|
continue
|
|
response = await appelle(client, methode, chemin)
|
|
if motif_du_refus(response) != REFUS_DE_DROITS:
|
|
laissees_passer.append((methode, chemin, response.status_code))
|
|
|
|
assert laissees_passer == []
|
|
|
|
|
|
# Le pendant du test précédent : sans lui, une garde posée trop haut, par exemple `AdminDep` sur
|
|
# `/sites`, ne ferait échouer aucun test du dépôt.
|
|
@pytest.mark.parametrize("role", ROLES, ids=IDS_DE_ROLE)
|
|
async def test_a_role_at_or_above_the_minimum_is_never_refused_by_the_guard(
|
|
connecte: Callable[[Principal], None],
|
|
client: AsyncClient,
|
|
base_muette: None,
|
|
role: Role,
|
|
) -> None:
|
|
connecte(principal(role))
|
|
refusees: list[tuple[str, str]] = []
|
|
|
|
for (methode, chemin), minimum in ROLE_MINIMUM.items():
|
|
if not has_at_least(role, minimum):
|
|
continue
|
|
response = await appelle(client, methode, chemin)
|
|
if motif_du_refus(response) == REFUS_DE_DROITS:
|
|
refusees.append((methode, chemin))
|
|
|
|
assert refusees == []
|
|
|
|
|
|
async def test_a_pending_password_change_is_refused_on_every_guarded_route(
|
|
connecte: Callable[[Principal], None],
|
|
client: AsyncClient,
|
|
base_muette: None,
|
|
) -> None:
|
|
connecte(principal(Role.ADMIN, must_change_password=True))
|
|
laissees_passer: list[tuple[str, str, int]] = []
|
|
|
|
for methode, chemin in ROLE_MINIMUM:
|
|
response = await appelle(client, methode, chemin)
|
|
if motif_du_refus(response) != REFUS_DE_MOT_DE_PASSE:
|
|
laissees_passer.append((methode, chemin, response.status_code))
|
|
|
|
assert laissees_passer == []
|
|
|
|
|
|
@pytest.fixture
|
|
async def comptes_par_role() -> AsyncIterator[dict[Role, str]]:
|
|
marque = uuid.uuid4().hex[:12]
|
|
hacheur = build_hasher(time_cost=1, memory_cost_kib=8192, parallelism=1, max_concurrency=2)
|
|
empreinte = await hacheur.hash(MOT_DE_PASSE)
|
|
adresses = {role: f"matrice-{marque}-{role.value}@enervision.fr" for role in ROLES}
|
|
|
|
async with get_session_factory()() as session:
|
|
depot = UserRepository(session)
|
|
for role, email in adresses.items():
|
|
await depot.create(email=email, password_hash=empreinte, role=role)
|
|
await session.commit()
|
|
|
|
yield adresses
|
|
|
|
async with get_session_factory()() as session:
|
|
await session.execute(
|
|
text("delete from app_user where email like :motif"), {"motif": f"matrice-{marque}-%"}
|
|
)
|
|
await session.commit()
|
|
|
|
|
|
async def authentifie(client: AsyncClient, email: str) -> dict[str, str]:
|
|
reponse = await client.post(
|
|
"/api/v1/auth/login", json={"email": email, "password": MOT_DE_PASSE}
|
|
)
|
|
assert reponse.status_code == 200, reponse.text
|
|
return {"Authorization": f"Bearer {reponse.json()['access_token']}"}
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.parametrize("role", ROLES, ids=IDS_DE_ROLE)
|
|
async def test_a_real_token_reaches_exactly_the_routes_of_its_rank(
|
|
comptes_par_role: dict[Role, str], client: AsyncClient, role: Role
|
|
) -> None:
|
|
entetes = await authentifie(client, comptes_par_role[role])
|
|
ecarts: list[tuple[str, str, int, str]] = []
|
|
|
|
for (methode, chemin), minimum in ROLE_MINIMUM.items():
|
|
response = await appelle(client, methode, chemin, headers=entetes)
|
|
refuse = motif_du_refus(response) == REFUS_DE_DROITS
|
|
if refuse is has_at_least(role, minimum):
|
|
ecarts.append((methode, chemin, response.status_code, response.text[:120]))
|
|
|
|
assert ecarts == []
|
|
|
|
|
|
# Contrainte : `operateur` n'ouvre aujourd'hui aucune route de plus que `lecteur`, faute d'écriture
|
|
# métier dans l'API. Figer l'égalité rend la régression visible le jour où une route d'opérateur
|
|
# arrive sans que `ROLE_MINIMUM` soit mis à jour.
|
|
@pytest.mark.integration
|
|
async def test_the_operator_rank_opens_nothing_more_than_the_reader_rank(
|
|
comptes_par_role: dict[Role, str], client: AsyncClient
|
|
) -> None:
|
|
lecteur = await authentifie(client, comptes_par_role[Role.LECTEUR])
|
|
operateur = await authentifie(client, comptes_par_role[Role.OPERATEUR])
|
|
divergences: list[tuple[str, str]] = []
|
|
|
|
for methode, chemin in ROLE_MINIMUM:
|
|
cote_lecteur = await appelle(client, methode, chemin, headers=lecteur)
|
|
cote_operateur = await appelle(client, methode, chemin, headers=operateur)
|
|
if cote_lecteur.status_code != cote_operateur.status_code:
|
|
divergences.append((methode, chemin))
|
|
|
|
assert divergences == []
|
|
|
|
|
|
# Piège : `/auth/logout-all` prend un `CurrentPrincipalDep` nu, donc elle échappe au gate
|
|
# `must_change_password` que seul `require_role` applique. Comportement figé ici, pas corrigé.
|
|
@pytest.mark.integration
|
|
async def test_a_temporary_password_blocks_the_business_routes_but_not_logout_all(
|
|
client: AsyncClient,
|
|
) -> None:
|
|
marque = uuid.uuid4().hex[:12]
|
|
email = f"matrice-{marque}-provisoire@enervision.fr"
|
|
hacheur = build_hasher(time_cost=1, memory_cost_kib=8192, parallelism=1, max_concurrency=2)
|
|
empreinte = await hacheur.hash(MOT_DE_PASSE)
|
|
|
|
async with get_session_factory()() as session:
|
|
await UserRepository(session).create(
|
|
email=email, password_hash=empreinte, role=Role.ADMIN, must_change_password=True
|
|
)
|
|
await session.commit()
|
|
|
|
try:
|
|
entetes = await authentifie(client, email)
|
|
sites = await client.get("/api/v1/sites", headers=entetes)
|
|
identite = await client.get("/api/v1/auth/me", headers=entetes)
|
|
fermeture = await client.post("/api/v1/auth/logout-all", headers=entetes)
|
|
|
|
assert motif_du_refus(sites) == REFUS_DE_MOT_DE_PASSE
|
|
assert identite.status_code == 200
|
|
assert fermeture.status_code == 204
|
|
finally:
|
|
async with get_session_factory()() as session:
|
|
await session.execute(text("delete from app_user where email = :e"), {"e": email})
|
|
await session.commit()
|