From 344f82fcdd38844474aa7d8bb1aa81c66deb3a20 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 10:12:41 +0200 Subject: [PATCH 01/13] feat(backend): documente le contrat d'erreur dans l'OpenAPI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le schéma ne déclarait aucun code d'erreur : ni 401, ni 403, ni 404, ni 409, ni 429. Swagger affirmait que /auth/login ne pouvait répondre que 200 ou 422, alors que 31-contrat-authentification.md décrit ces codes comme le contrat que le frontend doit traiter. Le 422 publié était pire qu'absent : le schéma exposait HTTPValidationError, le modèle par défaut de FastAPI avec sa clé `loc`, quand validation_error_handler renvoie {"detail": [{"champ", "type"}]}. Un client codé sur la documentation lisait une clé qui n'arrive jamais. Les métadonnées arrivent avec : description, résumé et une description par tag. `servers`, `license_info` et `contact` restent absents, ils poseraient des décisions qui ne sont pas prises. Le cookie de rafraîchissement devient visible par un APIKeyCookie en auto_error=False, purement documentaire : lit_le_cookie() reste seul maître du 401 de /auth/refresh. Au passage, health.py posait son tag deux fois, une fois sur son APIRouter et une fois à l'include_router. --- apps/backend/app/api/openapi.py | 114 ++++++++++++++++++++ apps/backend/app/api/v1/endpoints/auth.py | 67 +++++++++++- apps/backend/app/api/v1/endpoints/health.py | 7 +- apps/backend/app/api/v1/endpoints/users.py | 33 +++++- apps/backend/app/api/v1/router.py | 5 +- apps/backend/app/core/config.py | 3 +- apps/backend/app/main.py | 4 + apps/backend/app/schemas/errors.py | 23 ++++ 8 files changed, 245 insertions(+), 11 deletions(-) create mode 100644 apps/backend/app/api/openapi.py create mode 100644 apps/backend/app/schemas/errors.py diff --git a/apps/backend/app/api/openapi.py b/apps/backend/app/api/openapi.py new file mode 100644 index 0000000..fedb3bf --- /dev/null +++ b/apps/backend/app/api/openapi.py @@ -0,0 +1,114 @@ +# Piège : `cookie_de_rafraichissement` est purement documentaire, d'où son `auto_error=False`. +# Avec la valeur par défaut, FastAPI répondrait 403 avant d'atteindre `lit_le_cookie()`, et +# `/auth/refresh` cesserait de rendre le 401 que le frontend attend. + +from typing import Any, Final + +from fastapi.security import APIKeyCookie + +from app.core.config import REFRESH_COOKIE_DEFAUT +from app.schemas.errors import ErrorResponse, InternalErrorResponse, ValidationErrorResponse + +Reponses = dict[int | str, dict[str, Any]] + +SUMMARY: Final = "Collecte, analyse et restitution de séries temporelles énergétiques." + +DESCRIPTION: Final = """ +Toutes les routes sont préfixées par `/api/v1`. + +**Authentification.** Le jeton d'accès se présente dans l'en-tête `Authorization: Bearer ...`. +Le jeton de rafraîchissement est un cookie `HttpOnly` que le code client ne voit jamais : il +suffit d'émettre les requêtes avec les identifiants de session. `POST /auth/refresh` rend un +nouveau jeton d'accès et fait tourner le cookie. + +**Rôles.** `lecteur`, puis `operateur`, puis `admin`. Chaque rôle couvre les droits du +précédent. + +**Erreurs.** Le corps porte toujours une clé `detail`. Un `403` dont le `detail` vaut +`password_change_required` n'est pas un refus de droits : il exige le changement du mot de passe +provisoire avant toute autre action. + +Le parcours de session complet est décrit dans +`docs/architecture/31-contrat-authentification.md`. +""" + +TAGS: Final[list[dict[str, Any]]] = [ + { + "name": "health", + "description": ( + "Sondes d'infrastructure, publiques. `live` prouve que le processus répond, `ready` " + "que la base répond et que l'extension TimescaleDB est chargée." + ), + }, + { + "name": "auth", + "description": ( + "Ouverture, rotation et fermeture de session, et changement de son propre mot de passe." + ), + }, + { + "name": "users", + "description": "Administration des comptes. Réservé au rôle `admin`.", + }, +] + +cookie_de_rafraichissement = APIKeyCookie( + name=REFRESH_COOKIE_DEFAUT, + scheme_name="Cookie de rafraîchissement", + description=( + "Cookie `HttpOnly` posé par `/auth/login` et tourné par `/auth/refresh`. Il prend le " + "préfixe `__Secure-` dès que l'API tourne derrière TLS, et n'est émis que vers " + "`/api/v1/auth`." + ), + auto_error=False, +) + +# Le 422 n'est déclaré que sur les routes qui acceptent un corps ou un paramètre : ailleurs, +# aucune validation ne peut échouer et l'annoncer serait faux. +REPONSE_VALIDATION: Final[Reponses] = { + 422: { + "model": ValidationErrorResponse, + "description": ( + "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la " + "valeur envoyée." + ), + }, +} + +REPONSE_SERVEUR: Final[Reponses] = { + 500: { + "model": InternalErrorResponse, + "description": ( + "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas " + "renvoyée au client." + ), + }, +} + +REPONSE_INDISPONIBLE: Final[Reponses] = { + 503: { + "model": ErrorResponse, + "description": "Base injoignable, ou extension TimescaleDB absente de la base.", + }, +} + +REPONSES_AUTHENTIFIEES: Final[Reponses] = { + 401: { + "model": ErrorResponse, + "description": ( + "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une " + "désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`." + ), + }, +} + +REPONSES_ADMIN: Final[Reponses] = { + **REPONSES_AUTHENTIFIEES, + 403: { + "model": ErrorResponse, + "description": ( + "Droits insuffisants, ou mot de passe provisoire à changer quand `detail` vaut " + "`password_change_required`." + ), + }, +} diff --git a/apps/backend/app/api/v1/endpoints/auth.py b/apps/backend/app/api/v1/endpoints/auth.py index faff2b1..9e79763 100644 --- a/apps/backend/app/api/v1/endpoints/auth.py +++ b/apps/backend/app/api/v1/endpoints/auth.py @@ -11,6 +11,12 @@ from app.api.deps import ( get_client_ip, require_trusted_origin, ) +from app.api.openapi import ( + REPONSE_VALIDATION, + REPONSES_AUTHENTIFIEES, + Reponses, + cookie_de_rafraichissement, +) from app.core.cookies import RefreshCookie, cookie_name from app.core.logging import get_logger from app.schemas.auth import ( @@ -19,6 +25,7 @@ from app.schemas.auth import ( PrincipalResponse, TokenResponse, ) +from app.schemas.errors import ErrorResponse from app.services.auth import ( AuthenticatedSession, InvalidCredentialsError, @@ -32,6 +39,45 @@ logger = get_logger(__name__) DETAIL_IDENTIFIANTS = "Identifiants invalides" DETAIL_SESSION = "Session invalide" +REPONSES_LOGIN: Reponses = { + **REPONSE_VALIDATION, + 401: { + "model": ErrorResponse, + "description": ( + "Identifiants faux, compte inconnu ou compte désactivé. Le message est le même dans " + "les trois cas, et n'apprend donc rien sur l'existence du compte." + ), + }, + 429: { + "model": ErrorResponse, + "description": "Trop de tentatives sur cette fenêtre glissante.", + "headers": { + "Retry-After": { + "description": "Secondes à attendre avant une nouvelle tentative.", + "schema": {"type": "integer"}, + } + }, + }, +} + +REPONSES_REFRESH: Reponses = { + 401: { + "model": ErrorResponse, + "description": ( + "Cookie absent, session expirée, révoquée, ou jeton déjà tourné. Dans ce dernier cas " + "toute la famille de sessions est révoquée et le cookie est effacé avec la réponse." + ), + }, +} + +REPONSES_MOT_DE_PASSE: Reponses = { + **REPONSE_VALIDATION, + 401: { + "model": ErrorResponse, + "description": "Jeton d'accès invalide, ou mot de passe courant faux.", + }, +} + def repond( response: Response, settings: SettingsDep, session: AuthenticatedSession @@ -61,7 +107,12 @@ def lit_le_cookie(request: Request, settings: SettingsDep) -> str: return secret -@router.post("/login", response_model=TokenResponse, summary="Ouvre une session") +@router.post( + "/login", + response_model=TokenResponse, + summary="Ouvre une session", + responses=REPONSES_LOGIN, +) async def login( payload: LoginRequest, request: Request, @@ -98,7 +149,8 @@ async def login( "/refresh", response_model=TokenResponse, summary="Fait tourner la session", - dependencies=[Depends(require_trusted_origin)], + dependencies=[Depends(require_trusted_origin), Depends(cookie_de_rafraichissement)], + responses=REPONSES_REFRESH, ) async def refresh( request: Request, @@ -133,7 +185,7 @@ async def refresh( "/logout", status_code=status.HTTP_204_NO_CONTENT, summary="Ferme la session courante", - dependencies=[Depends(require_trusted_origin)], + dependencies=[Depends(require_trusted_origin), Depends(cookie_de_rafraichissement)], ) async def logout( request: Request, response: Response, settings: SettingsDep, service: AuthServiceDep @@ -150,6 +202,7 @@ async def logout( status_code=status.HTTP_204_NO_CONTENT, summary="Ferme toutes les sessions du compte", dependencies=[Depends(require_trusted_origin)], + responses=REPONSES_AUTHENTIFIEES, ) async def logout_all( principal: CurrentPrincipalDep, @@ -163,7 +216,12 @@ async def logout_all( response.delete_cookie(**RefreshCookie.expired(settings).as_deletion_kwargs()) -@router.get("/me", response_model=PrincipalResponse, summary="Décrit le compte connecté") +@router.get( + "/me", + response_model=PrincipalResponse, + summary="Décrit le compte connecté", + responses=REPONSES_AUTHENTIFIEES, +) async def me(principal: CurrentPrincipalDep) -> PrincipalResponse: return PrincipalResponse.from_principal(principal) @@ -173,6 +231,7 @@ async def me(principal: CurrentPrincipalDep) -> PrincipalResponse: response_model=TokenResponse, summary="Change son propre mot de passe", dependencies=[Depends(require_trusted_origin)], + responses=REPONSES_MOT_DE_PASSE, ) async def change_password( payload: PasswordChangeRequest, diff --git a/apps/backend/app/api/v1/endpoints/health.py b/apps/backend/app/api/v1/endpoints/health.py index bf6b2ee..e6d780a 100644 --- a/apps/backend/app/api/v1/endpoints/health.py +++ b/apps/backend/app/api/v1/endpoints/health.py @@ -3,16 +3,17 @@ from sqlalchemy import text from sqlalchemy.exc import SQLAlchemyError from app.api.deps import SessionDep, SettingsDep +from app.api.openapi import REPONSE_INDISPONIBLE from app.core.logging import get_logger from app.schemas.health import LivenessStatus, ReadinessStatus logger = get_logger(__name__) -router = APIRouter(tags=["health"]) +router = APIRouter() TIMESCALEDB_VERSION = text("SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'") -@router.get("/live", summary="Sonde de vivacite") +@router.get("/live", summary="Sonde de vivacité") async def liveness(settings: SettingsDep) -> LivenessStatus: return LivenessStatus( status="ok", @@ -22,7 +23,7 @@ async def liveness(settings: SettingsDep) -> LivenessStatus: ) -@router.get("/ready", summary="Sonde de disponibilite") +@router.get("/ready", summary="Sonde de disponibilité", responses=REPONSE_INDISPONIBLE) async def readiness(session: SessionDep) -> ReadinessStatus: try: version: str | None = await session.scalar(TIMESCALEDB_VERSION) diff --git a/apps/backend/app/api/v1/endpoints/users.py b/apps/backend/app/api/v1/endpoints/users.py index 825645d..794a10a 100644 --- a/apps/backend/app/api/v1/endpoints/users.py +++ b/apps/backend/app/api/v1/endpoints/users.py @@ -3,7 +3,9 @@ from uuid import UUID from fastapi import APIRouter, HTTPException, Response, status from app.api.deps import AdminDep, UserServiceDep +from app.api.openapi import REPONSE_VALIDATION, Reponses from app.core.logging import get_logger +from app.schemas.errors import ErrorResponse from app.schemas.user import ( TemporaryPasswordResponse, UserCreateRequest, @@ -15,6 +17,28 @@ from app.services.user import EmailAlreadyUsedError, LastAdminError, UserNotFoun router = APIRouter() logger = get_logger(__name__) +REPONSES_CREATION: Reponses = { + **REPONSE_VALIDATION, + 409: {"model": ErrorResponse, "description": "Adresse déjà portée par un autre compte."}, +} + +REPONSES_INTROUVABLE: Reponses = { + **REPONSE_VALIDATION, + 404: {"model": ErrorResponse, "description": "Aucun compte ne porte cet identifiant."}, +} + +REPONSES_MODIFICATION: Reponses = { + **REPONSES_INTROUVABLE, + 400: {"model": ErrorResponse, "description": "Corps vide, aucune modification demandée."}, + 409: { + "model": ErrorResponse, + "description": ( + "L'opération laisserait la plateforme sans administrateur actif, qu'il s'agisse de " + "rétrograder le dernier ou de le désactiver." + ), + }, +} + @router.get("", response_model=list[UserResponse], summary="Liste les comptes") async def list_users(_: AdminDep, service: UserServiceDep) -> list[UserResponse]: @@ -27,6 +51,7 @@ async def list_users(_: AdminDep, service: UserServiceDep) -> list[UserResponse] response_model=TemporaryPasswordResponse, status_code=status.HTTP_201_CREATED, summary="Crée un compte avec un mot de passe provisoire", + responses=REPONSES_CREATION, ) async def create_user( payload: UserCreateRequest, @@ -55,7 +80,12 @@ async def create_user( ) -@router.patch("/{user_id}", response_model=UserResponse, summary="Change le rôle ou l'activation") +@router.patch( + "/{user_id}", + response_model=UserResponse, + summary="Change le rôle ou l'activation", + responses=REPONSES_MODIFICATION, +) async def update_user( user_id: UUID, payload: UserUpdateRequest, @@ -92,6 +122,7 @@ async def update_user( "/{user_id}/password-reset", response_model=TemporaryPasswordResponse, summary="Réinitialise le mot de passe et ferme les sessions", + responses=REPONSES_INTROUVABLE, ) async def reset_password( user_id: UUID, acteur: AdminDep, service: UserServiceDep, response: Response diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py index 76e6f28..4a35810 100644 --- a/apps/backend/app/api/v1/router.py +++ b/apps/backend/app/api/v1/router.py @@ -1,8 +1,9 @@ from fastapi import APIRouter +from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN from app.api.v1.endpoints import auth, health, users -api_router = APIRouter() +api_router = APIRouter(responses=REPONSE_SERVEUR) api_router.include_router(health.router, prefix="/health", tags=["health"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) -api_router.include_router(users.router, prefix="/users", tags=["users"]) +api_router.include_router(users.router, prefix="/users", tags=["users"], responses=REPONSES_ADMIN) diff --git a/apps/backend/app/core/config.py b/apps/backend/app/core/config.py index 4731f81..6733b3a 100644 --- a/apps/backend/app/core/config.py +++ b/apps/backend/app/core/config.py @@ -8,6 +8,7 @@ Environment = Literal["local", "dev", "staging", "prod"] SameSite = Literal["lax", "strict", "none"] SECRET_KEY_MIN_LENGTH = 32 +REFRESH_COOKIE_DEFAUT = "ev_refresh" SENTINELLES_INTERDITES = frozenset( {"change_me", "changeme", "secret", "secret-de-test", "changez-moi", "todo"} ) @@ -38,7 +39,7 @@ class Settings(BaseSettings): access_token_ttl_seconds: int = Field(default=900, ge=60, le=3600) refresh_token_ttl_seconds: int = Field(default=604800, ge=3600, le=2592000) - refresh_cookie_name: str = "ev_refresh" + refresh_cookie_name: str = REFRESH_COOKIE_DEFAUT cookie_path: str = "/api/v1/auth" cookie_samesite: SameSite = "strict" cookie_secure: bool | None = None diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index 6c3c866..de1235e 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -7,6 +7,7 @@ from prometheus_fastapi_instrumentator import Instrumentator from app.api.errors import register_error_handlers from app.api.middleware import SecurityHeadersMiddleware +from app.api.openapi import DESCRIPTION, SUMMARY, TAGS from app.api.security import require_metrics_token from app.api.v1.router import api_router from app.core.config import Settings, get_settings @@ -37,6 +38,9 @@ def create_app(settings: Settings | None = None) -> FastAPI: application = FastAPI( title=resolved.name, version=resolved.version, + summary=SUMMARY, + description=DESCRIPTION, + openapi_tags=TAGS, debug=resolved.debug, lifespan=lifespan, docs_url="/docs" if documentee else None, diff --git a/apps/backend/app/schemas/errors.py b/apps/backend/app/schemas/errors.py new file mode 100644 index 0000000..5ed1d6c --- /dev/null +++ b/apps/backend/app/schemas/errors.py @@ -0,0 +1,23 @@ +# Piège : ces modèles ne décrivent rien, ils publient. Ce sont eux que Swagger montre, donc ils +# doivent suivre `validation_error_handler()` et `unhandled_error_handler()` d'`app/api/errors.py` +# à la lettre. Un champ renommé là-bas sans l'être ici rend la documentation fausse en silence. + +from pydantic import BaseModel + + +class ErrorResponse(BaseModel): + detail: str + + +class FieldError(BaseModel): + champ: str + type: str + + +class ValidationErrorResponse(BaseModel): + detail: list[FieldError] + + +class InternalErrorResponse(BaseModel): + detail: str + correlation: str From da481d7485c20d20c35e50cf013da23e6320ad6b Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 10:12:50 +0200 Subject: [PATCH 02/13] =?UTF-8?q?feat(backend):=20verse=20le=20contrat=20O?= =?UTF-8?q?penAPI=20au=20d=C3=A9p=C3=B4t=20et=20le=20garde=20honn=C3=AAte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `make openapi` écrit apps/backend/openapi.json, et un test compare le fichier versionné au schéma généré. Une route qui change son contrat public le montre donc dans la diff d'une pull request, et une PR qui oublie de régénérer échoue en CI : le fichier vit sous apps/backend, que le filtre de chemins de backend.yml couvre. Le schéma exporté ne lit ni le .env du poste ni les variables APP_ : tout ce qui l'atteint est posé par settings_du_contrat(), sans quoi le fichier changerait de machine en machine. main() réclamait un mot de passe avant de lire la commande. Le branchement passe devant, sinon l'export serait resté bloqué sur getpass. --- Makefile | 5 +- apps/backend/app/cli.py | 46 + apps/backend/openapi.json | 1150 ++++++++++++++++++++++++ apps/backend/tests/api/test_openapi.py | 91 ++ apps/backend/tests/test_cli.py | 52 ++ 5 files changed, 1343 insertions(+), 1 deletion(-) create mode 100644 apps/backend/openapi.json create mode 100644 apps/backend/tests/api/test_openapi.py diff --git a/Makefile b/Makefile index bf45b61..7035680 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ BACKEND := apps/backend .DEFAULT_GOAL := help .PHONY: help install dev lint format typecheck test test-cov test-integration check \ - docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin + openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin help: ## Liste les cibles disponibles @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' @@ -34,6 +34,9 @@ test-integration: ## Exécute les tests exigeant une base joignable check: lint typecheck test ## Chaîne de vérification complète +openapi: ## Régénère apps/backend/openapi.json depuis les routes déclarées + cd $(BACKEND) && uv run python -m app.cli export-openapi + docker-build: ## Construit l'image du backend docker build -t enervision-backend:local $(BACKEND) diff --git a/apps/backend/app/cli.py b/apps/backend/app/cli.py index 74d7a50..37e94fd 100644 --- a/apps/backend/app/cli.py +++ b/apps/backend/app/cli.py @@ -7,18 +7,25 @@ import argparse import asyncio +import json import secrets import sys from getpass import getpass +from pathlib import Path +from typing import Any + +from pydantic import SecretStr from app.core.config import Settings, get_settings from app.core.hashing import build_hasher from app.core.roles import Role from app.db.session import get_session_factory +from app.main import create_app from app.repositories.user import UserRepository LONGUEUR_MOT_DE_PASSE_GENERE = 24 LONGUEUR_MINIMALE = 12 +CHEMIN_CONTRAT = Path(__file__).resolve().parent.parent / "openapi.json" async def create_admin( @@ -55,6 +62,35 @@ async def create_admin( ) +# Piège : le schéma ne doit dépendre ni du `.env` du poste ni des variables `APP_*`, sinon le +# fichier versionné changerait de machine en machine et le test de dérive deviendrait un oracle +# de configuration locale. Tout ce qui atteint le schéma est donc posé ici, `_env_file` compris. +def settings_du_contrat() -> Settings: + return Settings( + _env_file=None, + name="EnerVision API", + version="0.1.0", + env="local", + api_prefix="/api/v1", + secret_key=SecretStr("contrat-openapi-sans-effet-sur-le-schema"), + database_url="postgresql+asyncpg://openapi:contrat@localhost:5432/enervision", + ) + + +def schema_du_contrat() -> dict[str, Any]: + schema: dict[str, Any] = create_app(settings_du_contrat()).openapi() + return schema + + +def rend_le_contrat() -> str: + return json.dumps(schema_du_contrat(), indent=2, ensure_ascii=False) + "\n" + + +def export_openapi(destination: Path) -> str: + destination.write_text(rend_le_contrat(), encoding="utf-8") + return f"Contrat OpenAPI écrit dans {destination}" + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="python -m app.cli", description="Outils EnerVision") sous_commandes = parser.add_subparsers(dest="commande", required=True) @@ -67,6 +103,11 @@ def build_parser() -> argparse.ArgumentParser: admin.add_argument( "--force", action="store_true", help="Crée le compte même si un administrateur existe" ) + + contrat = sous_commandes.add_parser( + "export-openapi", help="Écrit le contrat OpenAPI sur disque" + ) + contrat.add_argument("--output", default=str(CHEMIN_CONTRAT)) return parser @@ -86,6 +127,11 @@ def read_password(*, generate: bool) -> str: def main(argv: list[str] | None = None) -> int: arguments = build_parser().parse_args(argv) + + if arguments.commande == "export-openapi": + print(export_openapi(Path(arguments.output))) + return 0 + mot_de_passe = read_password(generate=arguments.generate) succes, message = asyncio.run( diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json new file mode 100644 index 0000000..8462c4d --- /dev/null +++ b/apps/backend/openapi.json @@ -0,0 +1,1150 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "EnerVision API", + "summary": "Collecte, analyse et restitution de séries temporelles énergétiques.", + "description": "\nToutes les routes sont préfixées par `/api/v1`.\n\n**Authentification.** Le jeton d'accès se présente dans l'en-tête `Authorization: Bearer ...`.\nLe jeton de rafraîchissement est un cookie `HttpOnly` que le code client ne voit jamais : il\nsuffit d'émettre les requêtes avec les identifiants de session. `POST /auth/refresh` rend un\nnouveau jeton d'accès et fait tourner le cookie.\n\n**Rôles.** `lecteur`, puis `operateur`, puis `admin`. Chaque rôle couvre les droits du\nprécédent.\n\n**Erreurs.** Le corps porte toujours une clé `detail`. Un `403` dont le `detail` vaut\n`password_change_required` n'est pas un refus de droits : il exige le changement du mot de passe\nprovisoire avant toute autre action.\n\nLe parcours de session complet est décrit dans\n`docs/architecture/31-contrat-authentification.md`.\n", + "version": "0.1.0" + }, + "paths": { + "/api/v1/health/live": { + "get": { + "tags": [ + "health" + ], + "summary": "Sonde de vivacité", + "operationId": "liveness_api_v1_health_live_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LivenessStatus" + } + } + } + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/health/ready": { + "get": { + "tags": [ + "health" + ], + "summary": "Sonde de disponibilité", + "operationId": "readiness_api_v1_health_ready_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadinessStatus" + } + } + } + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + }, + "503": { + "description": "Base injoignable, ou extension TimescaleDB absente de la base.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/auth/login": { + "post": { + "tags": [ + "auth" + ], + "summary": "Ouvre une session", + "operationId": "login_api_v1_auth_login_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResponse" + } + } + } + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + }, + "422": { + "description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + }, + "401": { + "description": "Identifiants faux, compte inconnu ou compte désactivé. Le message est le même dans les trois cas, et n'apprend donc rien sur l'existence du compte.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Trop de tentatives sur cette fenêtre glissante.", + "headers": { + "Retry-After": { + "description": "Secondes à attendre avant une nouvelle tentative.", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/auth/refresh": { + "post": { + "tags": [ + "auth" + ], + "summary": "Fait tourner la session", + "operationId": "refresh_api_v1_auth_refresh_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResponse" + } + } + } + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + }, + "401": { + "description": "Cookie absent, session expirée, révoquée, ou jeton déjà tourné. Dans ce dernier cas toute la famille de sessions est révoquée et le cookie est effacé avec la réponse.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "Cookie de rafraîchissement": [] + } + ] + } + }, + "/api/v1/auth/logout": { + "post": { + "tags": [ + "auth" + ], + "summary": "Ferme la session courante", + "operationId": "logout_api_v1_auth_logout_post", + "responses": { + "204": { + "description": "Successful Response" + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + } + }, + "security": [ + { + "Cookie de rafraîchissement": [] + } + ] + } + }, + "/api/v1/auth/logout-all": { + "post": { + "tags": [ + "auth" + ], + "summary": "Ferme toutes les sessions du compte", + "operationId": "logout_all_api_v1_auth_logout_all_post", + "responses": { + "204": { + "description": "Successful Response" + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + }, + "401": { + "description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "Jeton d'accès": [] + } + ] + } + }, + "/api/v1/auth/me": { + "get": { + "tags": [ + "auth" + ], + "summary": "Décrit le compte connecté", + "operationId": "me_api_v1_auth_me_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PrincipalResponse" + } + } + } + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + }, + "401": { + "description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "Jeton d'accès": [] + } + ] + } + }, + "/api/v1/auth/password": { + "post": { + "tags": [ + "auth" + ], + "summary": "Change son propre mot de passe", + "operationId": "change_password_api_v1_auth_password_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasswordChangeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResponse" + } + } + } + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + }, + "422": { + "description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + }, + "401": { + "description": "Jeton d'accès invalide, ou mot de passe courant faux.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "Jeton d'accès": [] + } + ] + } + }, + "/api/v1/users": { + "get": { + "tags": [ + "users" + ], + "summary": "Liste les comptes", + "operationId": "list_users_api_v1_users_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/UserResponse" + }, + "type": "array", + "title": "Response List Users Api V1 Users Get" + } + } + } + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + }, + "401": { + "description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Droits insuffisants, ou mot de passe provisoire à changer quand `detail` vaut `password_change_required`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "Jeton d'accès": [] + } + ] + }, + "post": { + "tags": [ + "users" + ], + "summary": "Crée un compte avec un mot de passe provisoire", + "operationId": "create_user_api_v1_users_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemporaryPasswordResponse" + } + } + } + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + }, + "401": { + "description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Droits insuffisants, ou mot de passe provisoire à changer quand `detail` vaut `password_change_required`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + }, + "409": { + "description": "Adresse déjà portée par un autre compte.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "Jeton d'accès": [] + } + ] + } + }, + "/api/v1/users/{user_id}": { + "patch": { + "tags": [ + "users" + ], + "summary": "Change le rôle ou l'activation", + "operationId": "update_user_api_v1_users__user_id__patch", + "security": [ + { + "Jeton d'accès": [] + } + ], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "User Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserUpdateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserResponse" + } + } + } + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + }, + "401": { + "description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Droits insuffisants, ou mot de passe provisoire à changer quand `detail` vaut `password_change_required`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + }, + "404": { + "description": "Aucun compte ne porte cet identifiant.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "400": { + "description": "Corps vide, aucune modification demandée.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "L'opération laisserait la plateforme sans administrateur actif, qu'il s'agisse de rétrograder le dernier ou de le désactiver.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/users/{user_id}/password-reset": { + "post": { + "tags": [ + "users" + ], + "summary": "Réinitialise le mot de passe et ferme les sessions", + "operationId": "reset_password_api_v1_users__user_id__password_reset_post", + "security": [ + { + "Jeton d'accès": [] + } + ], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "User Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemporaryPasswordResponse" + } + } + } + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + }, + "401": { + "description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Droits insuffisants, ou mot de passe provisoire à changer quand `detail` vaut `password_change_required`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + }, + "404": { + "description": "Aucun compte ne porte cet identifiant.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "AccountKind": { + "type": "string", + "enum": [ + "human", + "service" + ], + "title": "AccountKind" + }, + "ErrorResponse": { + "properties": { + "detail": { + "type": "string", + "title": "Detail" + } + }, + "type": "object", + "required": [ + "detail" + ], + "title": "ErrorResponse" + }, + "FieldError": { + "properties": { + "champ": { + "type": "string", + "title": "Champ" + }, + "type": { + "type": "string", + "title": "Type" + } + }, + "type": "object", + "required": [ + "champ", + "type" + ], + "title": "FieldError" + }, + "InternalErrorResponse": { + "properties": { + "detail": { + "type": "string", + "title": "Detail" + }, + "correlation": { + "type": "string", + "title": "Correlation" + } + }, + "type": "object", + "required": [ + "detail", + "correlation" + ], + "title": "InternalErrorResponse" + }, + "LivenessStatus": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status" + }, + "service": { + "type": "string", + "title": "Service" + }, + "version": { + "type": "string", + "title": "Version" + }, + "environment": { + "type": "string", + "title": "Environment" + } + }, + "type": "object", + "required": [ + "status", + "service", + "version", + "environment" + ], + "title": "LivenessStatus" + }, + "LoginRequest": { + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "Email" + }, + "password": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "title": "Password" + } + }, + "type": "object", + "required": [ + "email", + "password" + ], + "title": "LoginRequest" + }, + "PasswordChangeRequest": { + "properties": { + "current_password": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "title": "Current Password" + }, + "new_password": { + "type": "string", + "maxLength": 128, + "minLength": 12, + "title": "New Password" + } + }, + "type": "object", + "required": [ + "current_password", + "new_password" + ], + "title": "PasswordChangeRequest" + }, + "PrincipalResponse": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "email": { + "type": "string", + "title": "Email" + }, + "role": { + "$ref": "#/components/schemas/Role" + }, + "kind": { + "$ref": "#/components/schemas/AccountKind" + }, + "must_change_password": { + "type": "boolean", + "title": "Must Change Password" + } + }, + "type": "object", + "required": [ + "id", + "email", + "role", + "kind", + "must_change_password" + ], + "title": "PrincipalResponse" + }, + "ReadinessStatus": { + "properties": { + "status": { + "type": "string", + "const": "ready", + "title": "Status" + }, + "database": { + "type": "string", + "const": "reachable", + "title": "Database" + }, + "timescaledb": { + "type": "string", + "const": "loaded", + "title": "Timescaledb" + } + }, + "type": "object", + "required": [ + "status", + "database", + "timescaledb" + ], + "title": "ReadinessStatus" + }, + "Role": { + "type": "string", + "enum": [ + "lecteur", + "operateur", + "admin" + ], + "title": "Role" + }, + "TemporaryPasswordResponse": { + "properties": { + "user": { + "$ref": "#/components/schemas/UserResponse" + }, + "temporary_password": { + "type": "string", + "title": "Temporary Password" + } + }, + "type": "object", + "required": [ + "user", + "temporary_password" + ], + "title": "TemporaryPasswordResponse" + }, + "TokenResponse": { + "properties": { + "access_token": { + "type": "string", + "title": "Access Token" + }, + "token_type": { + "type": "string", + "const": "bearer", + "title": "Token Type", + "default": "bearer" + }, + "expires_in": { + "type": "integer", + "title": "Expires In" + }, + "principal": { + "$ref": "#/components/schemas/PrincipalResponse" + } + }, + "type": "object", + "required": [ + "access_token", + "expires_in", + "principal" + ], + "title": "TokenResponse" + }, + "UserCreateRequest": { + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "Email" + }, + "role": { + "$ref": "#/components/schemas/Role" + }, + "full_name": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ], + "title": "Full Name" + } + }, + "type": "object", + "required": [ + "email", + "role" + ], + "title": "UserCreateRequest" + }, + "UserResponse": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "email": { + "type": "string", + "title": "Email" + }, + "role": { + "$ref": "#/components/schemas/Role" + }, + "kind": { + "$ref": "#/components/schemas/AccountKind" + }, + "is_active": { + "type": "boolean", + "title": "Is Active" + }, + "must_change_password": { + "type": "boolean", + "title": "Must Change Password" + }, + "full_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Full Name" + }, + "last_login_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Last Login At" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "email", + "role", + "kind", + "is_active", + "must_change_password", + "full_name", + "last_login_at", + "created_at" + ], + "title": "UserResponse" + }, + "UserUpdateRequest": { + "properties": { + "role": { + "anyOf": [ + { + "$ref": "#/components/schemas/Role" + }, + { + "type": "null" + } + ] + }, + "is_active": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Is Active" + } + }, + "type": "object", + "title": "UserUpdateRequest" + }, + "ValidationErrorResponse": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/FieldError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "required": [ + "detail" + ], + "title": "ValidationErrorResponse" + } + }, + "securitySchemes": { + "Cookie de rafraîchissement": { + "type": "apiKey", + "description": "Cookie `HttpOnly` posé par `/auth/login` et tourné par `/auth/refresh`. Il prend le préfixe `__Secure-` dès que l'API tourne derrière TLS, et n'est émis que vers `/api/v1/auth`.", + "in": "cookie", + "name": "ev_refresh" + }, + "Jeton d'accès": { + "type": "http", + "scheme": "bearer" + } + } + }, + "tags": [ + { + "name": "health", + "description": "Sondes d'infrastructure, publiques. `live` prouve que le processus répond, `ready` que la base répond et que l'extension TimescaleDB est chargée." + }, + { + "name": "auth", + "description": "Ouverture, rotation et fermeture de session, et changement de son propre mot de passe." + }, + { + "name": "users", + "description": "Administration des comptes. Réservé au rôle `admin`." + } + ] +} diff --git a/apps/backend/tests/api/test_openapi.py b/apps/backend/tests/api/test_openapi.py new file mode 100644 index 0000000..ca979d5 --- /dev/null +++ b/apps/backend/tests/api/test_openapi.py @@ -0,0 +1,91 @@ +# Pourquoi : `openapi.json` est versionné, donc une route qui change son contrat public le montre +# dans la diff d'une pull request. `test_the_committed_contract_matches_the_generated_one` est ce +# qui empêche le fichier de dériver du code sans que personne ne le voie. + +import json +from typing import Any + +import pytest + +from app import cli + +METHODES = {"get", "post", "patch", "put", "delete"} + +# `/auth/logout` lit le cookie mais ne le réclame pas : sans session elle répond 204, et un 401 +# documenté y serait faux. +SANS_REFUS = {("POST", "/api/v1/auth/logout")} + + +@pytest.fixture(scope="module") +def schema() -> dict[str, Any]: + return cli.schema_du_contrat() + + +def operations(schema: dict[str, Any]) -> list[tuple[str, str, dict[str, Any]]]: + return [ + (methode.upper(), chemin, operation) + for chemin, operations_du_chemin in schema["paths"].items() + for methode, operation in operations_du_chemin.items() + if methode in METHODES + ] + + +def test_the_committed_contract_matches_the_generated_one(schema: dict[str, Any]) -> None: + publie = json.loads(cli.CHEMIN_CONTRAT.read_text(encoding="utf-8")) + + assert publie == schema, "lancer `make openapi` et versionner le fichier obtenu" + + +def test_every_route_demanding_an_identity_says_how_it_refuses(schema: dict[str, Any]) -> None: + muettes = [ + (methode, chemin) + for methode, chemin, operation in operations(schema) + if operation.get("security") + and (methode, chemin) not in SANS_REFUS + and "401" not in operation["responses"] + ] + + assert muettes == [] + + +def test_every_administration_route_documents_the_role_refusal(schema: dict[str, Any]) -> None: + sans_403 = [ + (methode, chemin) + for methode, chemin, operation in operations(schema) + if "users" in operation.get("tags", []) and "403" not in operation["responses"] + ] + + assert sans_403 == [] + + +def test_the_validation_model_matches_what_the_handler_returns(schema: dict[str, Any]) -> None: + modeles = { + operation["responses"]["422"]["content"]["application/json"]["schema"]["$ref"] + for _, _, operation in operations(schema) + if "422" in operation["responses"] + } + + assert modeles == {"#/components/schemas/ValidationErrorResponse"} + assert "HTTPValidationError" not in schema["components"]["schemas"] + + +def test_the_rate_limit_documents_the_delay_header(schema: dict[str, Any]) -> None: + trop_de_tentatives = schema["paths"]["/api/v1/auth/login"]["post"]["responses"]["429"] + + assert "Retry-After" in trop_de_tentatives["headers"] + + +def test_the_refresh_cookie_appears_in_the_security_schemes(schema: dict[str, Any]) -> None: + schemes = schema["components"]["securitySchemes"] + + assert schemes["Cookie de rafraîchissement"]["in"] == "cookie" + assert schemes["Cookie de rafraîchissement"]["name"] == "ev_refresh" + + +def test_each_tag_used_by_a_route_is_described(schema: dict[str, Any]) -> None: + decrits = {tag["name"] for tag in schema["tags"]} + + for methode, chemin, operation in operations(schema): + poses = operation.get("tags", []) + assert len(poses) == len(set(poses)), f"tag en double sur {methode} {chemin}" + assert set(poses) <= decrits, f"tag non décrit sur {methode} {chemin}" diff --git a/apps/backend/tests/test_cli.py b/apps/backend/tests/test_cli.py index d8465b5..40b8317 100644 --- a/apps/backend/tests/test_cli.py +++ b/apps/backend/tests/test_cli.py @@ -1,3 +1,6 @@ +import json +from pathlib import Path + import pytest from app import cli @@ -55,3 +58,52 @@ def test_read_password_refuses_two_different_entries(monkeypatch: pytest.MonkeyP with pytest.raises(SystemExit): cli.read_password(generate=False) + + +def test_build_parser_reads_the_export_openapi_arguments() -> None: + arguments = cli.build_parser().parse_args( + ["export-openapi", "--output", "ailleurs/contrat.json"] + ) + + assert arguments.commande == "export-openapi" + assert arguments.output == "ailleurs/contrat.json" + + +def test_build_parser_defaults_the_export_to_the_versioned_contract() -> None: + arguments = cli.build_parser().parse_args(["export-openapi"]) + + assert arguments.output == str(cli.CHEMIN_CONTRAT) + + +def test_settings_of_the_contract_ignore_the_local_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("APP_API_PREFIX", "/api/v9") + monkeypatch.setenv("APP_NAME", "API du poste de Johan") + + settings = cli.settings_du_contrat() + + assert settings.api_prefix == "/api/v1" + assert settings.name == "EnerVision API" + + +def test_export_openapi_writes_a_readable_schema_where_asked(tmp_path: Path) -> None: + destination = tmp_path / "contrat.json" + + cli.export_openapi(destination) + + assert json.loads(destination.read_text(encoding="utf-8"))["openapi"].startswith("3.") + + +# Piège : `main()` réclamait un mot de passe avant de lire la commande. Sans le branchement, +# l'export resterait bloqué sur `getpass` et aucune CI ne pourrait le rejouer. +def test_main_exports_the_contract_without_asking_for_a_password( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + destination = tmp_path / "contrat.json" + + code = cli.main(["export-openapi", "--output", str(destination)]) + + assert code == 0 + assert destination.exists() + assert str(destination) in capsys.readouterr().out From 3347fa5bdbcf7f348d469410fc037914fe6c924b Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 10:14:19 +0200 Subject: [PATCH 03/13] docs(architecture): acte le contrat OpenAPI dans la vue backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 20-backend.md gagne une section qui dit où vit le schéma, comment on le régénère, pourquoi il est versionné en plus d'être servi, et pourquoi servers, license_info et contact restent absents. La table des routes gagne la colonne des codes d'erreur déclarés. 31-contrat-authentification.md renvoyait le frontend vers /docs, donc vers une API qui tourne. Il renvoie maintenant vers le fichier, lisible sans rien lancer. --- apps/backend/README.md | 6 +- docs/architecture/20-backend.md | 74 +++++++++++++++---- .../31-contrat-authentification.md | 4 +- docs/architecture/README.md | 2 +- 4 files changed, 68 insertions(+), 18 deletions(-) diff --git a/apps/backend/README.md b/apps/backend/README.md index 12fd9ba..400498c 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -28,7 +28,7 @@ de demarrer sans elles. ## Commandes Depuis la racine du monorepo, via le `Makefile` : `make install`, `make dev`, `make lint`, -`make format`, `make typecheck`, `make test`, `make check`, `make docker-build`. +`make format`, `make typecheck`, `make test`, `make check`, `make openapi`, `make docker-build`. Directement depuis ce dossier : @@ -39,8 +39,12 @@ uv run ruff format . # format uv run mypy app # typage strict uv run pytest # tests + couverture uv run pytest -m integration # tests exigeant une base joignable +uv run python -m app.cli export-openapi # régénère openapi.json ``` +`openapi.json` est versionné : `tests/api/test_openapi.py` échoue si le fichier ne correspond +plus aux routes déclarées. Toute PR qui change une route le régénère dans le même commit. + Les conventions de tests, les gabarits et le detail des marqueurs sont dans [`TESTING.md`](TESTING.md). diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index 8688a6a..1454a39 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -126,22 +126,25 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke ## Routes exposées -| Méthode | Chemin | Dans l'OpenAPI | Rôle | +| Méthode | Chemin | Rôle | Erreurs déclarées | |---|---|---|---| -| GET | `/api/v1/health/live` | oui | Le processus répond. Ne touche pas la base | -| GET | `/api/v1/health/ready` | oui | La base répond **et** l'extension TimescaleDB est chargée | -| POST | `/api/v1/auth/login` | oui | Ouvre une session. Publique | -| POST | `/api/v1/auth/refresh` | oui | Fait tourner la session. Cookie seulement | -| POST | `/api/v1/auth/logout` | oui | Ferme la session courante. Idempotente | -| POST | `/api/v1/auth/logout-all` | oui | Ferme toutes les sessions du compte | -| POST | `/api/v1/auth/password` | oui | Change son propre mot de passe | -| GET | `/api/v1/auth/me` | oui | Décrit le compte connecté | -| GET | `/api/v1/users` | oui | Liste les comptes. `admin` | -| POST | `/api/v1/users` | oui | Crée un compte, rend un mot de passe provisoire. `admin` | -| PATCH | `/api/v1/users/{id}` | oui | Change le rôle ou l'activation. `admin` | -| POST | `/api/v1/users/{id}/password-reset` | oui | Réinitialise et ferme les sessions. `admin` | -| GET | `/metrics` | non | Format Prometheus. Jeton requis si `APP_METRICS_TOKEN` est posé | -| GET | `/docs`, `/redoc`, `/openapi.json` | non | Fermés en `staging` et en `prod` | +| GET | `/api/v1/health/live` | Le processus répond. Ne touche pas la base | 500 | +| GET | `/api/v1/health/ready` | La base répond **et** l'extension TimescaleDB est chargée | 503, 500 | +| POST | `/api/v1/auth/login` | Ouvre une session. Publique | 401, 422, 429, 500 | +| POST | `/api/v1/auth/refresh` | Fait tourner la session. Cookie seulement | 401, 500 | +| POST | `/api/v1/auth/logout` | Ferme la session courante. Idempotente | 500 | +| POST | `/api/v1/auth/logout-all` | Ferme toutes les sessions du compte | 401, 500 | +| POST | `/api/v1/auth/password` | Change son propre mot de passe | 401, 422, 500 | +| GET | `/api/v1/auth/me` | Décrit le compte connecté | 401, 500 | +| GET | `/api/v1/users` | Liste les comptes. `admin` | 401, 403, 500 | +| POST | `/api/v1/users` | Crée un compte, rend un mot de passe provisoire. `admin` | 401, 403, 409, 422, 500 | +| PATCH | `/api/v1/users/{id}` | Change le rôle ou l'activation. `admin` | 400, 401, 403, 404, 409, 422, 500 | +| POST | `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions. `admin` | 401, 403, 404, 422, 500 | +| GET | `/metrics` | Format Prometheus, hors du schéma. Jeton requis si `APP_METRICS_TOKEN` est posé | | +| GET | `/docs`, `/redoc`, `/openapi.json` | Hors du schéma. Fermés en `staging` et en `prod` | | + +Les codes de la dernière colonne sont ceux que le schéma **déclare**, et le fichier +`openapi.json` versionné interdit qu'ils divergent de ce que les routes rendent. **Quatre routes seulement sont publiques** : les deux sondes, `/auth/login` et `/auth/logout`. `tests/api/test_route_protection.py` interroge réellement chaque autre route sans identifiant et @@ -182,6 +185,47 @@ sequenceDiagram end ``` +## Contrat OpenAPI + +Statut : `Fait`. + +Le schéma est servi sur `/openapi.json`, `/docs` et `/redoc`, fermés en `staging` et en `prod`. +Il est aussi **versionné** dans [`apps/backend/openapi.json`](../../apps/backend/openapi.json) : + +```bash +make openapi +``` + +Pourquoi un fichier en plus de la route. Une route qui change son contrat public le montre alors +dans la diff de la pull request, et le frontend dispose d'une référence lisible sans lancer l'API. +`tests/api/test_openapi.py` compare le fichier au schéma généré et échoue si l'un bouge sans +l'autre ; le fichier vivant sous `apps/backend/`, le filtre de chemins de `backend.yml` le couvre. + +**Le schéma exporté ne dépend pas du poste.** `settings_du_contrat()` pose le nom, la version et +le préfixe, et coupe la lecture du `.env`. Sans cela, un `APP_API_PREFIX` local suffirait à faire +diverger le fichier d'une machine à l'autre, et le test deviendrait un oracle de configuration +plutôt qu'un garde-fou de contrat. + +Trois champs sont volontairement absents d'`info`, parce qu'ils poseraient une décision qui n'est +pas prise : + +| Champ | Pourquoi | +|---|---| +| `servers` | L'URL publique dépend de l'ingress, question ouverte dans [10-infra.md](10-infra.md) | +| `license_info` | Aucune licence n'est choisie | +| `contact` | Aucun canal de support n'existe | + +Deux schémas de sécurité sont déclarés : `Jeton d'accès` pour le porteur JWT, et +`Cookie de rafraîchissement` pour `/auth/refresh` et `/auth/logout`. **Le second est purement +documentaire** : son `auto_error=False` garantit qu'il ne décide d'aucun refus. Le passer à vrai +ferait répondre 403 avant d'atteindre `lit_le_cookie()`, et `/auth/refresh` cesserait de rendre le +401 sur lequel le frontend déclenche sa déconnexion. + +Les modèles de `app/schemas/errors.py` décrivent ce que les gestionnaires renvoient réellement. +`ValidationErrorResponse` remplace le `HTTPValidationError` par défaut de FastAPI, dont la clé +`loc` n'apparaît dans aucune réponse de cette API : `validation_error_handler()` rend `champ` et +`type`. Renommer un champ là-bas sans le faire ici rend la documentation fausse en silence. + ## Sécurité Voir la vue consolidée dans [00-vue-ensemble.md](00-vue-ensemble.md) et les décisions dans les diff --git a/docs/architecture/31-contrat-authentification.md b/docs/architecture/31-contrat-authentification.md index f02fd1b..ec852c1 100644 --- a/docs/architecture/31-contrat-authentification.md +++ b/docs/architecture/31-contrat-authentification.md @@ -26,7 +26,9 @@ gérer : il suffit d'envoyer les requêtes avec `withCredentials`. | PATCH | `/api/v1/users/{id}` | jeton d'accès, `admin` | `200` `UserResponse` | | POST | `/api/v1/users/{id}/password-reset` | jeton d'accès, `admin` | `200` `TemporaryPasswordResponse` | -Le schéma exact est dans `/docs` (Swagger), servi en local et en développement. +Le schéma exact est dans [`apps/backend/openapi.json`](../../apps/backend/openapi.json), +lisible sans lancer l'API, et servi par `/docs` en local et en développement. La table des +codes d'erreur ci-dessous reste la référence de comportement, le schéma celle de forme. ## Charges utiles diff --git a/docs/architecture/README.md b/docs/architecture/README.md index c6b91f0..1c8c9a9 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -10,7 +10,7 @@ contredisent, c'est l'ADR qui fait foi et la vue qui est en retard. |---|---| | [00-vue-ensemble.md](00-vue-ensemble.md) | Jalons du projet, contexte, conteneurs, sécurité, flux bout en bout | | [10-infra.md](10-infra.md) | Poste de développement, cible k3s, décisions figées, ports et noms | -| [20-backend.md](20-backend.md) | Couches FastAPI, séquence de démarrage, routes, configuration | +| [20-backend.md](20-backend.md) | Couches FastAPI, séquence de démarrage, routes, configuration, contrat OpenAPI | | [30-frontend.md](30-frontend.md) | Angular, arborescence cible, flux HTTP | | [31-contrat-authentification.md](31-contrat-authentification.md) | Ce que le frontend doit savoir pour coder la connexion | | [40-data.md](40-data.md) | Frontières `db/` et `alembic/`, cycle de vie d'une mesure, modèle | From 5e7cb005acb8a4c14ecbb0c44cd9ce52655ff4a8 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 10:25:22 +0200 Subject: [PATCH 04/13] feat(build): branche le frontend sur make dev Ajoute install-frontend/dev-frontend au Makefile, dev/install deviennent composites (backend + frontend lances ensemble), et met a jour README et docs/architecture en consequence. Closes #75 --- Makefile | 22 +++++++++++++++++++--- README.md | 15 +++++++++------ docs/architecture/10-infra.md | 7 ++++--- docs/architecture/30-frontend.md | 5 +++-- 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/Makefile b/Makefile index bf45b61..1576eae 100644 --- a/Makefile +++ b/Makefile @@ -1,18 +1,34 @@ BACKEND := apps/backend +FRONTEND := apps/frontend .DEFAULT_GOAL := help -.PHONY: help install dev lint format typecheck test test-cov test-integration check \ +.PHONY: help install install-backend install-frontend dev dev-backend dev-frontend \ + lint format typecheck test test-cov test-integration check \ docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin help: ## Liste les cibles disponibles @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' -install: ## Installe les dépendances du backend +install: install-backend install-frontend ## Installe les dépendances backend et frontend + +install-backend: ## Installe les dépendances du backend cd $(BACKEND) && uv sync --all-groups -dev: ## Lance l'API en rechargement à chaud +install-frontend: ## Installe les dépendances du frontend + cd $(FRONTEND) && npm ci + +dev: ## Lance toute la stack (backend + frontend) en rechargement à chaud + @trap 'kill 0' EXIT INT TERM; \ + $(MAKE) dev-backend & \ + $(MAKE) dev-frontend & \ + wait + +dev-backend: ## Lance l'API seule en rechargement à chaud cd $(BACKEND) && uv run uvicorn app.main:create_app --factory --reload --host 0.0.0.0 --port 8000 +dev-frontend: ## Lance le frontend seul en rechargement à chaud + cd $(FRONTEND) && npm start + lint: ## Analyse statique du backend cd $(BACKEND) && uv run ruff check . diff --git a/README.md b/README.md index 75a1ab6..27affce 100644 --- a/README.md +++ b/README.md @@ -63,16 +63,17 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans ## Demarrage -Prerequis : uv, Docker. Le poste doit disposer de Python 3.14, que `uv` installe seul. +Prerequis : uv, Docker, Node 24 LTS (npm fourni). Le poste doit disposer de Python 3.14, que +`uv` installe seul. ```bash cp .env.example .env # variables de docker-compose cp apps/backend/.env.example apps/backend/.env # variables du backend hors conteneur make db-up # PostgreSQL + TimescaleDB, publie sur le port 5433 -make install # dependances du backend +make install # dependances du backend et du frontend make migrate # applique les migrations Alembic -make dev # API sur http://localhost:8000, docs sur /docs +make dev # backend sur http://localhost:8000 (docs sur /docs), frontend sur http://localhost:4200 make check # lint + typage + tests ``` @@ -83,9 +84,11 @@ Deux fichiers d'environnement, deux usages : `.env` a la racine alimente `docker 5432, souvent deja pris par une autre base. La boucle de developpement est `make db-up` puis `make dev` : seule la base tourne en -conteneur. Le service `backend` du `docker-compose.yml` sert la stack complete et la recette, -et n'embarque pas le source, donc toute modification y demande un -`docker compose up -d --build backend`. +conteneur, le backend et le frontend tournent tous les deux sur le poste, lances ensemble par +`make dev` (logs entrelaces dans le meme terminal, Ctrl+C arrete les deux). `make dev-backend` +et `make dev-frontend` restent disponibles pour lancer un seul des deux. Le service `backend` +du `docker-compose.yml` sert la stack complete et la recette, et n'embarque pas le source, donc +toute modification y demande un `docker compose up -d --build backend`. Verifier que la base repond et que l'extension est chargee : diff --git a/docs/architecture/10-infra.md b/docs/architecture/10-infra.md index 4e82445..745c6f5 100644 --- a/docs/architecture/10-infra.md +++ b/docs/architecture/10-infra.md @@ -35,9 +35,10 @@ flowchart TB | `backend` | Construite depuis `apps/backend` | `depends_on: db, condition: service_healthy`. **N'embarque pas le source** : toute modification impose `docker compose up -d --build backend` | **La boucle de développement n'utilise pas le service `backend`.** `make db-up` puis `make dev` : -seule la base tourne en conteneur, l'API tourne sur le poste avec le rechargement à chaud. Le -service `backend` sert la stack complète et la recette. Les deux occupent le port 8000, ils ne se -lancent donc pas ensemble. +seule la base tourne en conteneur, l'API et `ng serve` tournent sur le poste avec le rechargement +à chaud, lancés ensemble par `make dev` (`make dev-backend`/`make dev-frontend` pour lancer l'un +des deux seul). Le service `backend` sert la stack complète et la recette. Les deux occupent le +port 8000, ils ne se lancent donc pas ensemble. Deux pièges sont documentés en tête du `docker-compose.yml`, ils ne se devinent pas : diff --git a/docs/architecture/30-frontend.md b/docs/architecture/30-frontend.md index baea451..3c361e5 100644 --- a/docs/architecture/30-frontend.md +++ b/docs/architecture/30-frontend.md @@ -108,8 +108,9 @@ déploiement, en même temps que sera tranchée la question de l'ingress dans le message d'erreur arrive avant toute compilation. Un poste en 22.21 ou en 24.12 ne peut donc ni tester ni construire le frontend. -Le frontend **n'a pas de cible dans le `Makefile` racine** et **aucun service dans -`docker-compose.yml`** : il se pilote uniquement par `npm`, depuis `apps/frontend`. Le port 4200 +Le frontend a ses cibles dans le `Makefile` racine (`install-frontend`, `dev-frontend`, +englobées par `install` et `dev`), mais **aucun service dans `docker-compose.yml`** : en +développement il tourne toujours directement via `npm`, depuis `apps/frontend`. Le port 4200 n'apparaît dans le compose que comme valeur par défaut d'`APP_CORS_ORIGINS`, côté backend. Un `Dockerfile` frontend existe sur la branche `feat/pipeline-cd`, mais il est mono-étage et sans From 16a0cc4d3b4dfb35e7ec9425b0bc333afcde7e86 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 10:36:31 +0200 Subject: [PATCH 05/13] fix(build): stabilise make dev pour le frontend Desactive le prompt d'analytics Angular CLI (bloquait ng serve en sous-processus non interactif) et affiche les URLs backend/frontend au demarrage de make dev. --- Makefile | 6 ++++-- apps/frontend/angular.json | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 1576eae..05a8f7d 100644 --- a/Makefile +++ b/Makefile @@ -19,14 +19,16 @@ install-frontend: ## Installe les dépendances du frontend dev: ## Lance toute la stack (backend + frontend) en rechargement à chaud @trap 'kill 0' EXIT INT TERM; \ - $(MAKE) dev-backend & \ - $(MAKE) dev-frontend & \ + $(MAKE) --no-print-directory dev-backend & \ + $(MAKE) --no-print-directory dev-frontend & \ wait dev-backend: ## Lance l'API seule en rechargement à chaud + @echo "backend -> http://localhost:8000 (docs sur /docs)" cd $(BACKEND) && uv run uvicorn app.main:create_app --factory --reload --host 0.0.0.0 --port 8000 dev-frontend: ## Lance le frontend seul en rechargement à chaud + @echo "frontend -> http://localhost:4200" cd $(FRONTEND) && npm start lint: ## Analyse statique du backend diff --git a/apps/frontend/angular.json b/apps/frontend/angular.json index ddf87a3..814e4f8 100644 --- a/apps/frontend/angular.json +++ b/apps/frontend/angular.json @@ -2,7 +2,8 @@ "$schema": "./node_modules/@angular/cli/lib/config/schema.json", "version": 1, "cli": { - "packageManager": "npm" + "packageManager": "npm", + "analytics": false }, "newProjectRoot": "projects", "projects": { From 1325a75e9afec660e168d9828064f9c1498eddaf Mon Sep 17 00:00:00 2001 From: Dorian PESCE Date: Wed, 16 Sep 2026 11:01:13 +0200 Subject: [PATCH 06/13] feat(backend): ajoute les endpoints GET /sites et GET /sites/{site_id} --- apps/backend/app/api/v1/endpoints/health.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/app/api/v1/endpoints/health.py b/apps/backend/app/api/v1/endpoints/health.py index bf6b2ee..095eafa 100644 --- a/apps/backend/app/api/v1/endpoints/health.py +++ b/apps/backend/app/api/v1/endpoints/health.py @@ -26,7 +26,7 @@ async def liveness(settings: SettingsDep) -> LivenessStatus: async def readiness(session: SessionDep) -> ReadinessStatus: try: version: str | None = await session.scalar(TIMESCALEDB_VERSION) - except SQLAlchemyError, OSError: + except (SQLAlchemyError, OSError): logger.exception("Base de données injoignable") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, From fc6600aeafbaddd8795bc9a3d55f7663c572e1ca Mon Sep 17 00:00:00 2001 From: Dorian PESCE Date: Wed, 16 Sep 2026 11:02:00 +0200 Subject: [PATCH 07/13] Revert "feat(backend): ajoute les endpoints GET /sites et GET /sites/{site_id}" This reverts commit 1325a75e9afec660e168d9828064f9c1498eddaf. --- apps/backend/app/api/v1/endpoints/health.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/app/api/v1/endpoints/health.py b/apps/backend/app/api/v1/endpoints/health.py index 095eafa..bf6b2ee 100644 --- a/apps/backend/app/api/v1/endpoints/health.py +++ b/apps/backend/app/api/v1/endpoints/health.py @@ -26,7 +26,7 @@ async def liveness(settings: SettingsDep) -> LivenessStatus: async def readiness(session: SessionDep) -> ReadinessStatus: try: version: str | None = await session.scalar(TIMESCALEDB_VERSION) - except (SQLAlchemyError, OSError): + except SQLAlchemyError, OSError: logger.exception("Base de données injoignable") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, From 50dddf952b5b236fde081473ecbc7d854989bfc7 Mon Sep 17 00:00:00 2001 From: Dorian PESCE Date: Wed, 16 Sep 2026 11:03:06 +0200 Subject: [PATCH 08/13] feat(backend): ajoute les endpoints GET /sites et GET /sites/{site_id} --- apps/backend/README.md | 2 + apps/backend/app/api/deps.py | 9 ++ apps/backend/app/api/v1/endpoints/sites.py | 24 ++++ apps/backend/app/api/v1/router.py | 3 +- apps/backend/app/repositories/site.py | 19 +++ apps/backend/app/schemas/site.py | 12 ++ apps/backend/app/services/site.py | 26 ++++ apps/backend/tests/api/test_sites.py | 141 +++++++++++++++++++ apps/backend/tests/factories.py | 14 ++ apps/backend/tests/repositories/test_site.py | 58 ++++++++ apps/backend/tests/services/test_site.py | 49 +++++++ docs/architecture/00-vue-ensemble.md | 4 +- docs/architecture/20-backend.md | 19 ++- docs/architecture/owasp-traceabilite.md | 7 +- 14 files changed, 376 insertions(+), 11 deletions(-) create mode 100644 apps/backend/app/api/v1/endpoints/sites.py create mode 100644 apps/backend/app/repositories/site.py create mode 100644 apps/backend/app/schemas/site.py create mode 100644 apps/backend/app/services/site.py create mode 100644 apps/backend/tests/api/test_sites.py create mode 100644 apps/backend/tests/repositories/test_site.py create mode 100644 apps/backend/tests/services/test_site.py diff --git a/apps/backend/README.md b/apps/backend/README.md index 12fd9ba..7be51b8 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -103,6 +103,8 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie | `/api/v1/users` | Liste et crée des comptes | `admin` | | `/api/v1/users/{id}` | Change le rôle ou l'activation | `admin` | | `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions | `admin` | +| `/api/v1/sites` | Liste les sites | `lecteur` | +| `/api/v1/sites/{site_id}` | Décrit un site | `lecteur` | | `/metrics` | Métriques au format Prometheus | jeton si `APP_METRICS_TOKEN` | | `/docs`, `/openapi.json` | Documentation, fermée en `staging` et `prod` | public sinon | diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index 4dc32cb..f16d167 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -24,8 +24,10 @@ from app.db.session import get_session from app.repositories.audit_log import AuditLogRepository from app.repositories.login_attempt import LoginAttemptRepository from app.repositories.refresh_token import RefreshTokenRepository +from app.repositories.site import SiteRepository from app.repositories.user import UserRepository from app.services.auth import AuthService, LoginPolicy +from app.services.site import SiteService from app.services.user import UserService SessionDep = Annotated[AsyncSession, Depends(get_session)] @@ -131,6 +133,13 @@ def get_user_service( UserServiceDep = Annotated[UserService, Depends(get_user_service)] +def get_site_service(session: SessionDep) -> SiteService: + return SiteService(sites=SiteRepository(session)) + + +SiteServiceDep = Annotated[SiteService, Depends(get_site_service)] + + async def get_current_principal( credentials: CredentialsDep, session: SessionDep, diff --git a/apps/backend/app/api/v1/endpoints/sites.py b/apps/backend/app/api/v1/endpoints/sites.py new file mode 100644 index 0000000..c71ec7f --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/sites.py @@ -0,0 +1,24 @@ +from fastapi import APIRouter, HTTPException, status + +from app.api.deps import LecteurDep, SiteServiceDep +from app.schemas.site import SiteResponse +from app.services.site import SiteNotFoundError + +router = APIRouter() + + +@router.get("", response_model=list[SiteResponse], summary="Liste les sites") +async def list_sites(_: LecteurDep, service: SiteServiceDep) -> list[SiteResponse]: + sites = await service.list_all() + return [SiteResponse.model_validate(site) for site in sites] + + +@router.get("/{site_id}", response_model=SiteResponse, summary="Décrit un site") +async def get_site(site_id: str, _: LecteurDep, service: SiteServiceDep) -> SiteResponse: + try: + site = await service.get_by_id(site_id) + except SiteNotFoundError as erreur: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable" + ) from erreur + return SiteResponse.model_validate(site) diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py index 76e6f28..4d151be 100644 --- a/apps/backend/app/api/v1/router.py +++ b/apps/backend/app/api/v1/router.py @@ -1,8 +1,9 @@ from fastapi import APIRouter -from app.api.v1.endpoints import auth, health, users +from app.api.v1.endpoints import auth, health, sites, users api_router = APIRouter() api_router.include_router(health.router, prefix="/health", tags=["health"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(users.router, prefix="/users", tags=["users"]) +api_router.include_router(sites.router, prefix="/sites", tags=["sites"]) diff --git a/apps/backend/app/repositories/site.py b/apps/backend/app/repositories/site.py new file mode 100644 index 0000000..383a566 --- /dev/null +++ b/apps/backend/app/repositories/site.py @@ -0,0 +1,19 @@ +from collections.abc import Sequence + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Site + + +class SiteRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def list_all(self) -> Sequence[Site]: + requete = select(Site).order_by(Site.site_id) + return (await self._session.scalars(requete)).all() + + async def get_by_id(self, site_id: str) -> Site | None: + requete = select(Site).where(Site.site_id == site_id) + return await self._session.scalar(requete) diff --git a/apps/backend/app/schemas/site.py b/apps/backend/app/schemas/site.py new file mode 100644 index 0000000..82035f5 --- /dev/null +++ b/apps/backend/app/schemas/site.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel, ConfigDict + + +class SiteResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + site_id: str + site_name: str + site_type: str + location: str | None + capacity_kw: float | None + status: str | None diff --git a/apps/backend/app/services/site.py b/apps/backend/app/services/site.py new file mode 100644 index 0000000..515497a --- /dev/null +++ b/apps/backend/app/services/site.py @@ -0,0 +1,26 @@ +from collections.abc import Sequence + +from app.models.energy import Site +from app.repositories.site import SiteRepository + + +class SiteError(Exception): + pass + + +class SiteNotFoundError(SiteError): + pass + + +class SiteService: + def __init__(self, *, sites: SiteRepository) -> None: + self._sites = sites + + async def list_all(self) -> Sequence[Site]: + return await self._sites.list_all() + + async def get_by_id(self, site_id: str) -> Site: + site = await self._sites.get_by_id(site_id) + if site is None: + raise SiteNotFoundError(site_id) + return site diff --git a/apps/backend/tests/api/test_sites.py b/apps/backend/tests/api/test_sites.py new file mode 100644 index 0000000..3692565 --- /dev/null +++ b/apps/backend/tests/api/test_sites.py @@ -0,0 +1,141 @@ +from collections.abc import Callable, Iterator +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from httpx import AsyncClient + +from app.api.deps import get_current_principal, get_site_service +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.models.energy import Site +from app.services.site import SiteNotFoundError + + +def principal(role: Role = Role.LECTEUR) -> Principal: + return Principal( + id=uuid4(), + email=f"{role.value}@enervision.fr", + role=role, + kind=AccountKind.HUMAIN, + must_change_password=False, + ) + + +def site(site_id: str = "site-1") -> Site: + return Site( + site_id=site_id, + site_name="Site de test", + site_type="industriel", + location="Toulouse", + capacity_kw=42.0, + status="actif", + ) + + +class FauxService: + def __init__(self, erreur: Exception | None = None) -> None: + self._erreur = erreur + self.site = site() + + async def list_all(self) -> list[Site]: + return [self.site] + + async def get_by_id(self, site_id: str) -> Site: + if self._erreur is not None: + raise self._erreur + return self.site + + +@pytest.fixture +def lecteur_connecte(app: FastAPI) -> Iterator[None]: + app.dependency_overrides[get_current_principal] = lambda: principal() + yield + app.dependency_overrides.pop(get_current_principal, None) + + +@pytest.fixture +def servi( + app: FastAPI, lecteur_connecte: None +) -> Iterator[Callable[[Exception | None], FauxService]]: + def installe(erreur: Exception | None = None) -> FauxService: + service = FauxService(erreur) + app.dependency_overrides[get_site_service] = lambda: service + return service + + yield installe + app.dependency_overrides.pop(get_site_service, None) + + +async def test_list_sites_returns_the_sites( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/sites") + + assert response.status_code == 200 + corps = response.json() + assert corps == [ + { + "site_id": "site-1", + "site_name": "Site de test", + "site_type": "industriel", + "location": "Toulouse", + "capacity_kw": 42.0, + "status": "actif", + } + ] + + +async def test_get_site_returns_the_matching_site( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/sites/site-1") + + assert response.status_code == 200 + assert response.json()["site_id"] == "site-1" + + +async def test_get_site_returns_404_for_an_unknown_site( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi(SiteNotFoundError("site-inconnu")) + + response = await client.get("/api/v1/sites/site-inconnu") + + assert response.status_code == 404 + + +async def test_list_sites_reaches_the_repository_through_the_session( + lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient +) -> None: + fake_session(result=[site("a"), site("b")]) + + response = await client.get("/api/v1/sites") + + assert response.status_code == 200 + assert [s["site_id"] for s in response.json()] == ["a", "b"] + + +async def test_get_site_reaches_the_repository_through_the_session( + lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient +) -> None: + fake_session(result=site("a")) + + response = await client.get("/api/v1/sites/a") + + assert response.status_code == 200 + assert response.json()["site_id"] == "a" + + +async def test_get_site_returns_404_when_the_session_finds_nothing( + lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient +) -> None: + fake_session(result=None) + + response = await client.get("/api/v1/sites/inconnu") + + assert response.status_code == 404 diff --git a/apps/backend/tests/factories.py b/apps/backend/tests/factories.py index 17433c5..c606ee0 100644 --- a/apps/backend/tests/factories.py +++ b/apps/backend/tests/factories.py @@ -1,3 +1,4 @@ +from collections.abc import Sequence from typing import Any from app.core.config import Settings @@ -12,6 +13,16 @@ SETTINGS_DE_TEST: dict[str, Any] = { } +class FakeScalars: + """Resultat factice pour `.scalars()` : `.all()` renvoie les lignes fournies.""" + + def __init__(self, rows: Sequence[object]) -> None: + self._rows = rows + + def all(self) -> Sequence[object]: + return self._rows + + class FakeSession: """Session factice : renvoie `result`, ou leve `failure` si elle est fournie.""" @@ -25,6 +36,9 @@ class FakeSession: async def execute(self, *_: object, **__: object) -> object: return self._repondre() + async def scalars(self, *_: object, **__: object) -> FakeScalars: + return FakeScalars(self._repondre() or []) + def _repondre(self) -> object: if self._failure is not None: raise self._failure diff --git a/apps/backend/tests/repositories/test_site.py b/apps/backend/tests/repositories/test_site.py new file mode 100644 index 0000000..222d398 --- /dev/null +++ b/apps/backend/tests/repositories/test_site.py @@ -0,0 +1,58 @@ +import uuid + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Site +from app.repositories.site import SiteRepository + +pytestmark = pytest.mark.integration + + +def identifiant() -> str: + return f"site-{uuid.uuid4().hex[:12]}" + + +async def creer(session: AsyncSession, **overrides: object) -> Site: + site = Site( + site_id=overrides.get("site_id", identifiant()), + site_name=overrides.get("site_name", "Site de test"), + site_type=overrides.get("site_type", "industriel"), + location=overrides.get("location", "Toulouse"), + capacity_kw=overrides.get("capacity_kw", 42.0), + status=overrides.get("status", "actif"), + ) + session.add(site) + await session.flush() + return site + + +async def test_get_by_id_returns_the_matching_site(session: AsyncSession) -> None: + depot = SiteRepository(session) + cree = await creer(session) + + trouve = await depot.get_by_id(cree.site_id) + nom = trouve.site_name if trouve else None + await session.rollback() + + assert nom == "Site de test" + + +async def test_get_by_id_returns_nothing_for_an_unknown_identifier( + session: AsyncSession, +) -> None: + trouve = await SiteRepository(session).get_by_id(identifiant()) + + assert trouve is None + + +async def test_list_all_returns_the_sites_sorted_by_identifier(session: AsyncSession) -> None: + depot = SiteRepository(session) + await creer(session, site_id=f"zz-{identifiant()}") + await creer(session, site_id=f"aa-{identifiant()}") + + sites = await depot.list_all() + identifiants = [site.site_id for site in sites] + await session.rollback() + + assert identifiants == sorted(identifiants) diff --git a/apps/backend/tests/services/test_site.py b/apps/backend/tests/services/test_site.py new file mode 100644 index 0000000..73ef21f --- /dev/null +++ b/apps/backend/tests/services/test_site.py @@ -0,0 +1,49 @@ +import pytest + +from app.models.energy import Site +from app.services.site import SiteNotFoundError, SiteService + + +def site(site_id: str = "site-1") -> Site: + return Site( + site_id=site_id, + site_name="Site de test", + site_type="industriel", + location="Toulouse", + capacity_kw=42.0, + status="actif", + ) + + +class FakeRepository: + def __init__(self, sites: list[Site]) -> None: + self._sites = sites + + async def list_all(self) -> list[Site]: + return self._sites + + async def get_by_id(self, site_id: str) -> Site | None: + return next((s for s in self._sites if s.site_id == site_id), None) + + +async def test_list_all_returns_the_repository_sites() -> None: + service = SiteService(sites=FakeRepository([site("a"), site("b")])) + + sites = await service.list_all() + + assert [s.site_id for s in sites] == ["a", "b"] + + +async def test_get_by_id_returns_the_matching_site() -> None: + service = SiteService(sites=FakeRepository([site("a")])) + + trouve = await service.get_by_id("a") + + assert trouve.site_id == "a" + + +async def test_get_by_id_raises_when_the_site_is_unknown() -> None: + service = SiteService(sites=FakeRepository([])) + + with pytest.raises(SiteNotFoundError): + await service.get_by_id("inconnu") diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index d083985..96fb992 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -74,9 +74,9 @@ collecteur ne vient le lire. | Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement | |---|---|---|---|---| -| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`. Aucune couche métier | +| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, `GET /sites` et `GET /sites/{site_id}` (première couche métier, endpoints → services → repositories → models) | | Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, deux services HTTP, graphiques Chart.js, données servies par des fixtures | -| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Aucune table applicative | +| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) | | Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API | | ETL | Apache Airflow | `etl/airflow` | `Cible` | Rien | diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index 8688a6a..2972a24 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -12,11 +12,11 @@ Les quatre couches existent désormais, portées par l'authentification. ```mermaid flowchart TB - ep["endpoints
health, auth, users"] + ep["endpoints
health, auth, users, sites"] sc["schemas
Pydantic"] - sv["services
AuthService, UserService"] - rp["repositories
user, refresh_token,
login_attempt, audit_log"] - md["models
4 tables"] + sv["services
AuthService, UserService,
SiteService"] + rp["repositories
user, refresh_token,
login_attempt, audit_log,
site"] + md["models
10 tables"] db[("PostgreSQL")] ep --> sc @@ -140,6 +140,8 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke | POST | `/api/v1/users` | oui | Crée un compte, rend un mot de passe provisoire. `admin` | | PATCH | `/api/v1/users/{id}` | oui | Change le rôle ou l'activation. `admin` | | POST | `/api/v1/users/{id}/password-reset` | oui | Réinitialise et ferme les sessions. `admin` | +| GET | `/api/v1/sites` | oui | Liste les sites. `lecteur` | +| GET | `/api/v1/sites/{site_id}` | oui | Décrit un site. `lecteur` | | GET | `/metrics` | non | Format Prometheus. Jeton requis si `APP_METRICS_TOKEN` est posé | | GET | `/docs`, `/redoc`, `/openapi.json` | non | Fermés en `staging` et en `prod` | @@ -148,7 +150,14 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Rendre une route publique impose donc de modifier la liste dans ce fichier de test. -Aucune route métier n'existe à ce jour. Le contrat détaillé pour le frontend est dans +`GET /sites` et `GET /sites/{site_id}` sont la première route métier, et le gabarit à réutiliser +pour les suivantes (`reading`, `dataset`, `prediction`, `alert`, `recommendation`) : les quatre +couches `endpoints → services → repositories → models` y sont toutes présentes, sur des tables +déjà créées par la révision Alembic `e6d2026091501`. Elles n'exigent que le rôle `lecteur`, +contrairement aux routes d'administration qui exigent `admin`. `SiteRepository` lit par +`AsyncSession.scalar()` (une ligne) et `AsyncSession.scalars()` (plusieurs lignes) plutôt que par +`execute()`, ce qui la rend testable par la fixture `fake_session` au niveau endpoint sans base +réelle. Le contrat détaillé pour le frontend est dans [31-contrat-authentification.md](31-contrat-authentification.md). ### `/health/ready` diff --git a/docs/architecture/owasp-traceabilite.md b/docs/architecture/owasp-traceabilite.md index ada1a45..ac4a8af 100644 --- a/docs/architecture/owasp-traceabilite.md +++ b/docs/architecture/owasp-traceabilite.md @@ -8,8 +8,9 @@ de réponse honnête. Ce qui est défendable, c'est une ligne par contrôle réellement implémenté, l'item qu'il adresse, et une section qui dit ce qui n'est pas couvert et pourquoi. -Statut : `Fait` pour le périmètre authentification et autorisation. Les endpoints métier -n'existent pas encore, donc plusieurs lignes resteront à compléter. +Statut : `Fait` pour le périmètre authentification et autorisation. `GET /sites` et +`GET /sites/{site_id}` sont les premiers endpoints métier, en lecture seule ; plusieurs lignes +resteront à compléter une fois les endpoints d'écriture posés. ## Contrôles en place @@ -48,7 +49,7 @@ règles Bandit. Ajouter Bandit à la CI serait redondant, contrairement à ce qu | Item | État | Raison | |---|---|---| -| **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site. Un opérateur du site A pourra agir sur le site B dès que les endpoints métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. | +| **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site : `GET /sites/{site_id}` répond à tout compte `lecteur` pour n'importe quel site, sans vérifier une affectation compte-site qui n'existe pas encore. Un opérateur du site A pourra agir sur le site B dès que les endpoints d'écriture métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. | | **API4, lectures de séries temporelles** | **ouvert** | Pas encore d'endpoint métier, donc ni pagination plafonnée, ni fenêtre temporelle maximale, ni `statement_timeout`. C'est la façon la plus probable dont la démonstration tombera : une requête sur dix ans d'historique suffit. | | **API8 Security Misconfiguration, transport** | **ouvert** | Pas de TLS, donc ni HSTS, ni cookie `Secure` réellement posé en production. Ils appartiennent au terminateur TLS, qui n'existe pas. | | **API10 Unsafe Consumption of APIs** | **ouvert, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse doit être traitée comme une entrée hostile : bornes physiques, taille de tableau plafonnée, timeout, et frontière d'anti-corruption. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. | From 31a9cb109f2de407d8b49f1f8edc45b9d4d3b688 Mon Sep 17 00:00:00 2001 From: Dorian PESCE Date: Wed, 16 Sep 2026 11:04:27 +0200 Subject: [PATCH 09/13] fix(backend): corrige la syntaxe except invalide de la sonde /health/ready --- apps/backend/app/api/v1/endpoints/health.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/app/api/v1/endpoints/health.py b/apps/backend/app/api/v1/endpoints/health.py index bf6b2ee..095eafa 100644 --- a/apps/backend/app/api/v1/endpoints/health.py +++ b/apps/backend/app/api/v1/endpoints/health.py @@ -26,7 +26,7 @@ async def liveness(settings: SettingsDep) -> LivenessStatus: async def readiness(session: SessionDep) -> ReadinessStatus: try: version: str | None = await session.scalar(TIMESCALEDB_VERSION) - except SQLAlchemyError, OSError: + except (SQLAlchemyError, OSError): logger.exception("Base de données injoignable") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, From fabd073aaffe39cad03fcab99db7f2ae8c7fc5f4 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 11:16:38 +0200 Subject: [PATCH 10/13] fix(backend): documente le 403 CSRF de require_trusted_origin Le contrat OpenAPI et 31-contrat-authentification.md passaient sous silence le 403 leve par require_trusted_origin sur refresh, logout, logout-all et password. Ajoute REPONSE_ORIGINE_REFUSEE, regenere openapi.json et etend test_openapi.py pour verifier que ces quatre routes le declarent. --- apps/backend/app/api/openapi.py | 7 ++++ apps/backend/app/api/v1/endpoints/auth.py | 10 ++++- apps/backend/openapi.json | 40 +++++++++++++++++++ apps/backend/tests/api/test_openapi.py | 17 ++++++++ docs/architecture/20-backend.md | 8 ++-- .../31-contrat-authentification.md | 1 + 6 files changed, 78 insertions(+), 5 deletions(-) diff --git a/apps/backend/app/api/openapi.py b/apps/backend/app/api/openapi.py index fedb3bf..c96e351 100644 --- a/apps/backend/app/api/openapi.py +++ b/apps/backend/app/api/openapi.py @@ -112,3 +112,10 @@ REPONSES_ADMIN: Final[Reponses] = { ), }, } + +REPONSE_ORIGINE_REFUSEE: Final[Reponses] = { + 403: { + "model": ErrorResponse, + "description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).", + }, +} diff --git a/apps/backend/app/api/v1/endpoints/auth.py b/apps/backend/app/api/v1/endpoints/auth.py index 9e79763..32bf8b2 100644 --- a/apps/backend/app/api/v1/endpoints/auth.py +++ b/apps/backend/app/api/v1/endpoints/auth.py @@ -12,6 +12,7 @@ from app.api.deps import ( require_trusted_origin, ) from app.api.openapi import ( + REPONSE_ORIGINE_REFUSEE, REPONSE_VALIDATION, REPONSES_AUTHENTIFIEES, Reponses, @@ -61,6 +62,7 @@ REPONSES_LOGIN: Reponses = { } REPONSES_REFRESH: Reponses = { + **REPONSE_ORIGINE_REFUSEE, 401: { "model": ErrorResponse, "description": ( @@ -70,8 +72,13 @@ REPONSES_REFRESH: Reponses = { }, } +REPONSES_LOGOUT: Reponses = {**REPONSE_ORIGINE_REFUSEE} + +REPONSES_LOGOUT_ALL: Reponses = {**REPONSES_AUTHENTIFIEES, **REPONSE_ORIGINE_REFUSEE} + REPONSES_MOT_DE_PASSE: Reponses = { **REPONSE_VALIDATION, + **REPONSE_ORIGINE_REFUSEE, 401: { "model": ErrorResponse, "description": "Jeton d'accès invalide, ou mot de passe courant faux.", @@ -186,6 +193,7 @@ async def refresh( status_code=status.HTTP_204_NO_CONTENT, summary="Ferme la session courante", dependencies=[Depends(require_trusted_origin), Depends(cookie_de_rafraichissement)], + responses=REPONSES_LOGOUT, ) async def logout( request: Request, response: Response, settings: SettingsDep, service: AuthServiceDep @@ -202,7 +210,7 @@ async def logout( status_code=status.HTTP_204_NO_CONTENT, summary="Ferme toutes les sessions du compte", dependencies=[Depends(require_trusted_origin)], - responses=REPONSES_AUTHENTIFIEES, + responses=REPONSES_LOGOUT_ALL, ) async def logout_all( principal: CurrentPrincipalDep, diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index 8462c4d..cca65d0 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -186,6 +186,16 @@ } } }, + "403": { + "description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "401": { "description": "Cookie absent, session expirée, révoquée, ou jeton déjà tourné. Dans ce dernier cas toute la famille de sessions est révoquée et le cookie est effacé avec la réponse.", "content": { @@ -224,6 +234,16 @@ } } } + }, + "403": { + "description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } }, "security": [ @@ -263,6 +283,16 @@ } } } + }, + "403": { + "description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } }, "security": [ @@ -366,6 +396,16 @@ } } }, + "403": { + "description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "401": { "description": "Jeton d'accès invalide, ou mot de passe courant faux.", "content": { diff --git a/apps/backend/tests/api/test_openapi.py b/apps/backend/tests/api/test_openapi.py index ca979d5..96297c0 100644 --- a/apps/backend/tests/api/test_openapi.py +++ b/apps/backend/tests/api/test_openapi.py @@ -15,6 +15,13 @@ METHODES = {"get", "post", "patch", "put", "delete"} # documenté y serait faux. SANS_REFUS = {("POST", "/api/v1/auth/logout")} +ORIGINE_VERIFIEE = { + ("POST", "/api/v1/auth/refresh"), + ("POST", "/api/v1/auth/logout"), + ("POST", "/api/v1/auth/logout-all"), + ("POST", "/api/v1/auth/password"), +} + @pytest.fixture(scope="module") def schema() -> dict[str, Any]: @@ -58,6 +65,16 @@ def test_every_administration_route_documents_the_role_refusal(schema: dict[str, assert sans_403 == [] +def test_every_origin_checked_route_documents_the_csrf_refusal(schema: dict[str, Any]) -> None: + sans_403 = [ + (methode, chemin) + for methode, chemin, operation in operations(schema) + if (methode, chemin) in ORIGINE_VERIFIEE and "403" not in operation["responses"] + ] + + assert sans_403 == [] + + def test_the_validation_model_matches_what_the_handler_returns(schema: dict[str, Any]) -> None: modeles = { operation["responses"]["422"]["content"]["application/json"]["schema"]["$ref"] diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index 1454a39..7158c9a 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -131,10 +131,10 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke | GET | `/api/v1/health/live` | Le processus répond. Ne touche pas la base | 500 | | GET | `/api/v1/health/ready` | La base répond **et** l'extension TimescaleDB est chargée | 503, 500 | | POST | `/api/v1/auth/login` | Ouvre une session. Publique | 401, 422, 429, 500 | -| POST | `/api/v1/auth/refresh` | Fait tourner la session. Cookie seulement | 401, 500 | -| POST | `/api/v1/auth/logout` | Ferme la session courante. Idempotente | 500 | -| POST | `/api/v1/auth/logout-all` | Ferme toutes les sessions du compte | 401, 500 | -| POST | `/api/v1/auth/password` | Change son propre mot de passe | 401, 422, 500 | +| POST | `/api/v1/auth/refresh` | Fait tourner la session. Cookie seulement | 401, 403, 500 | +| POST | `/api/v1/auth/logout` | Ferme la session courante. Idempotente | 403, 500 | +| POST | `/api/v1/auth/logout-all` | Ferme toutes les sessions du compte | 401, 403, 500 | +| POST | `/api/v1/auth/password` | Change son propre mot de passe | 401, 403, 422, 500 | | GET | `/api/v1/auth/me` | Décrit le compte connecté | 401, 500 | | GET | `/api/v1/users` | Liste les comptes. `admin` | 401, 403, 500 | | POST | `/api/v1/users` | Crée un compte, rend un mot de passe provisoire. `admin` | 401, 403, 409, 422, 500 | diff --git a/docs/architecture/31-contrat-authentification.md b/docs/architecture/31-contrat-authentification.md index ec852c1..9c9fe66 100644 --- a/docs/architecture/31-contrat-authentification.md +++ b/docs/architecture/31-contrat-authentification.md @@ -68,6 +68,7 @@ Le secret de rafraîchissement **n'apparaît jamais** dans le corps de la répon | `401` sur `/auth/refresh` | session révoquée, expirée ou rejouée | **déconnecter** et renvoyer vers la page de connexion | | `403` avec `detail: "password_change_required"` | mot de passe provisoire | rediriger vers l'écran de changement de mot de passe | | `403` avec `detail: "Droits insuffisants"` | rôle trop bas | masquer ou griser l'action, ne pas déconnecter | +| `403` sur `/auth/refresh`, `/logout`, `/logout-all`, `/password` | origine hors liste autorisée (voir « Origines autorisées ») | erreur de configuration réseau, pas un cas à gérer par l'utilisateur | | `422` | corps invalide | le détail donne `champ` et `type`, jamais la valeur envoyée | ## Les quatre règles qui comptent From 22ff1d93f4c2391007806189e46ac5b88ca4c5b8 Mon Sep 17 00:00:00 2001 From: Dorian PESCE Date: Wed, 16 Sep 2026 11:29:18 +0200 Subject: [PATCH 11/13] fix(backend): type le retour de SiteRepository.get_by_id pour mypy strict --- apps/backend/app/repositories/site.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/backend/app/repositories/site.py b/apps/backend/app/repositories/site.py index 383a566..c7abbe8 100644 --- a/apps/backend/app/repositories/site.py +++ b/apps/backend/app/repositories/site.py @@ -16,4 +16,5 @@ class SiteRepository: async def get_by_id(self, site_id: str) -> Site | None: requete = select(Site).where(Site.site_id == site_id) - return await self._session.scalar(requete) + site: Site | None = await self._session.scalar(requete) + return site From d25e544db62713320cd5d4d5ff603dea0e8da387 Mon Sep 17 00:00:00 2001 From: Dorian PESCE Date: Wed, 16 Sep 2026 11:41:53 +0200 Subject: [PATCH 12/13] =?UTF-8?q?fix(backend):=20contourne=20un=20bug=20de?= =?UTF-8?q?=20ruff=20format=20sur=20le=20except=20=C3=A0=20deux=20types=20?= =?UTF-8?q?de=20health.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/app/api/v1/endpoints/health.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/backend/app/api/v1/endpoints/health.py b/apps/backend/app/api/v1/endpoints/health.py index 095eafa..fab0a1e 100644 --- a/apps/backend/app/api/v1/endpoints/health.py +++ b/apps/backend/app/api/v1/endpoints/health.py @@ -26,7 +26,9 @@ async def liveness(settings: SettingsDep) -> LivenessStatus: async def readiness(session: SessionDep) -> ReadinessStatus: try: version: str | None = await session.scalar(TIMESCALEDB_VERSION) - except (SQLAlchemyError, OSError): + # `# fmt: skip` contourne un bug de ruff format 0.16.7 : il retire les parenthèses de ce + # `except` à deux types, ce qui produit une syntaxe invalide (`except A, B:`). + except (SQLAlchemyError, OSError): # fmt: skip logger.exception("Base de données injoignable") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, From ad149db0cb37397fcbc16e501cd9dc93e765d268 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 11:55:03 +0200 Subject: [PATCH 13/13] fix(backend): corrige une assertion tautologique dans test_list_all_returns_the_sites_sorted_by_identifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'assertion comparait le résultat à lui-même trié, donc vraie quel que soit l'ordre réellement renvoyé par SiteRepository.list_all(). Compare désormais à des identifiants connus à l'avance. --- apps/backend/tests/repositories/test_site.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/backend/tests/repositories/test_site.py b/apps/backend/tests/repositories/test_site.py index 222d398..a9864a6 100644 --- a/apps/backend/tests/repositories/test_site.py +++ b/apps/backend/tests/repositories/test_site.py @@ -48,11 +48,12 @@ async def test_get_by_id_returns_nothing_for_an_unknown_identifier( async def test_list_all_returns_the_sites_sorted_by_identifier(session: AsyncSession) -> None: depot = SiteRepository(session) - await creer(session, site_id=f"zz-{identifiant()}") - await creer(session, site_id=f"aa-{identifiant()}") + premier, second = sorted([f"zz-{identifiant()}", f"aa-{identifiant()}"]) + await creer(session, site_id=second) + await creer(session, site_id=premier) sites = await depot.list_all() - identifiants = [site.site_id for site in sites] + identifiants = [site.site_id for site in sites if site.site_id in (premier, second)] await session.rollback() - assert identifiants == sorted(identifiants) + assert identifiants == [premier, second]