From 1325a75e9afec660e168d9828064f9c1498eddaf Mon Sep 17 00:00:00 2001 From: Dorian PESCE Date: Wed, 16 Sep 2026 11:01:13 +0200 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 22ff1d93f4c2391007806189e46ac5b88ca4c5b8 Mon Sep 17 00:00:00 2001 From: Dorian PESCE Date: Wed, 16 Sep 2026 11:29:18 +0200 Subject: [PATCH 5/7] 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 6/7] =?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 7/7] 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]