Trois ADR : le jeton d'accès et le rafraîchissement opaque, le RBAC avec relecture du compte à chaque requête, et le journal d'audit en ajout seul. Chacun porte ses alternatives écartées et son critère de bascule, notamment celui vers OIDC. `31-contrat-authentification.md` est destiné au frontend : endpoints, codes d'erreur à traiter, et les quatre règles qui comptent. La troisième, un seul rafraîchissement en vol, est une exigence et non une optimisation : cinq rotations concurrentes seraient lues comme un rejeu et révoqueraient la session à chaque chargement de page. `owasp-traceabilite.md` remplace la revendication « couverture OWASP Top 10 et API Top 10 » de la NFR4, qui n'a pas de réponse honnête sur vingt items en deux semaines. Un contrôle par ligne, l'item adressé, et une section qui dit ce qui reste ouvert : portée par site, bornage des lectures de séries, transport, et la consommation de l'API Mock. Les vues 00, 20 et 40 suivent, comme l'impose leur propre règle de maintenance. La question ouverte « quel mécanisme d'authentification » est fermée ; trois autres la remplacent, dont la portée par site.
6.5 KiB
Conventions de tests unitaires : Backend
Outil
pytest, avec pytest-asyncio en mode auto : un async def test_* est collecte sans
decorateur. Les appels HTTP passent par httpx sur ASGITransport, qui parle a
l'application en memoire, sans serveur ni port ouvert.
Ou ecrire les tests
tests/ est le miroir de app/ : un test de app/services/consumption.py va dans
tests/services/test_consumption.py. Les paquets core, db, services et
repositories existent deja, vides, pour cette raison.
Nommage
- Fonctions en anglais :
test_<sujet>_<comportement>_when_<condition>. ids=deparametrizeen francais :ids=["erreur_sqlalchemy", "erreur_reseau"].- Pas de docstring : le nom porte l'intention.
Structure attendue (Arrange / Act / Assert)
Une ligne vide separe les trois temps, sans commentaire pour les annoncer.
async def test_readiness_returns_503_when_the_extension_is_missing(
fake_session: Callable[..., None], client: AsyncClient
) -> None:
fake_session(result=None)
response = await client.get("/api/v1/health/ready")
assert response.status_code == 503
assert response.json()["detail"] == "Extension TimescaleDB absente"
Ce qui doit etre teste en priorite
Le sens de dependance du backend est endpoints -> services -> repositories -> models.
| Couche | Ce qu'on teste |
|---|---|
services/ |
La logique metier, cas nominal et cas d'erreur. C'est la priorite. |
repositories/ |
Chaque branche de decision, sous le marqueur integration. |
endpoints/ |
Le code de statut et la forme de la reponse, pas la logique metier. |
schemas/ |
Rien, sauf si le schema porte une validation ecrite a la main. |
Doubles
On remplace une dependance FastAPI par app.dependency_overrides, jamais par
unittest.mock. tests/factories.py fournit le necessaire.
fake_session(result=...): la session repondresult.fake_session(failure=...): la session leve l'exception.make_settings(**overrides): fabrique uneSettings, dont les valeurs priment sur l'environnement et sur.env. C'est le moyen de testercreate_appenprod.
Gabarit : un endpoint
from collections.abc import Callable
from httpx import AsyncClient
async def test_endpoint_returns_the_expected_payload(
fake_session: Callable[..., None], client: AsyncClient
) -> None:
fake_session(result=42)
response = await client.get("/api/v1/...")
assert response.status_code == 200
assert response.json() == {"valeur": 42}
Gabarit : un service avec repository factice
Un service ne connait que son repository : on lui en passe un faux, sans base ni session.
from app.services.consumption import ConsumptionService
class FakeRepository:
async def total_for(self, site_id: int) -> float:
return 12.5
async def test_service_converts_the_total_to_kilowatt_hours() -> None:
service = ConsumptionService(FakeRepository())
total = await service.total_kwh(site_id=1)
assert total == 12.5
Gabarit : un repository sur la vraie base
Un repository parle du SQL : le tester sur un double ne prouve rien. Il porte donc le
marqueur integration, ecarte par defaut.
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.site import Site
from app.repositories.site import SiteRepository
@pytest.mark.integration
async def test_repository_reads_back_what_it_wrote(session: AsyncSession) -> None:
repository = SiteRepository(session)
await repository.add(Site(name="Toulouse"))
assert await repository.by_name("Toulouse") is not None
Marqueurs
integration designe tout test exigeant une base joignable. pytest les ecarte par
defaut, ce qui garde make check jouable sans Docker. Tout autre marqueur doit etre
declare dans pyproject.toml : --strict-markers refuse les marqueurs inconnus.
Couverture
Les branches sont mesurees, pas seulement les lignes. Le seuil de 85 % ne s'applique
qu'aux cibles qui jouent toute la suite, make test et make test-cov : un fichier
joue seul affiche sa couverture sans jamais echouer dessus. Le detail se lit dans
htmlcov/index.html apres make test-cov.
Lancer les tests
make test # suite unitaire, sans base
make test-cov # idem, plus les rapports HTML, XML et JUnit
make db-up && make test-integration # tests exigeant une base, demande Docker
make check # lint + typage + suite unitaire
uv run pytest tests/api/test_health.py # un seul fichier
uv run pytest -k readiness # par motif de nom
Trois fichiers à connaître avant de toucher à l'authentification
tests/api/test_route_protection.py interroge réellement chaque route sans identifiant et
échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Il n'inspecte pas l'arbre de
dépendances : celui-ci 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. Rendre une route publique impose
donc de modifier la liste ROUTES_PUBLIQUES de ce fichier, ce qui apparaît en clair dans la
diff d'une pull request.
tests/services/test_auth.py donne au faux hacheur un compteur d'appels. C'est ce qui rend
possibles les deux assertions qui prouvent la conception, et qu'aucune autre forme de test
n'atteint :
- adresse inconnue → le compteur vaut 1, donc le haché leurre a bien été vérifié et il n'y a pas d'oracle temporel ;
- limite de débit atteinte → le compteur vaut 0, donc la limite est évaluée avant Argon2.
tests/api/test_parcours_authentification.py joue six parcours complets contre la vraie base,
sous le marqueur integration, sans serveur ni port ouvert. C'est là que se démontrent
l'atomicité de la rotation, la mort de la famille au rejeu d'un cookie déjà tourné, et la
révocation immédiate d'un compte désactivé.
Deux pièges d'écriture de test
Lire les attributs avant le rollback. Un session.rollback() périme les attributs chargés,
et les relire déclenche une entrée-sortie hors du contexte greenlet, donc un MissingGreenlet.
On capture la valeur dans une variable locale avant d'annuler.
audit_log ne se nettoie pas. La table est en ajout seul, garanti par déclencheur : un test
ne peut pas effacer ce qu'il y écrit, et les lignes d'une exécution précédente sont encore là.
Chaque test filtre donc sur son propre target_id plutôt que de supposer une table vide.