From 50dcb4de32b22a06ab8d0e44bc44e90a2227c756 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 11:45:58 +0200 Subject: [PATCH 1/2] feat(backend): expose GET /api/v1/stats/summary Ajoute le resume instantane de consommation du parc attendu par le frontend (deja developpe contre ce contrat en mode mock). Nouveaux SiteRepository et ReadingRepository (derniere lecture par site via DISTINCT ON), StatsService pour l'agregation et les cas de repli (capacite nulle, absence de lecture, data_quality inconnue), et le endpoint lecteur-seul correspondant. Documentation des routes et du schema des couches mises a jour. --- apps/backend/app/api/deps.py | 10 ++ apps/backend/app/api/v1/endpoints/stats.py | 16 +++ apps/backend/app/api/v1/router.py | 3 +- apps/backend/app/repositories/reading.py | 21 ++++ apps/backend/app/repositories/site.py | 15 +++ apps/backend/app/schemas/stats.py | 26 +++++ apps/backend/app/services/stats.py | 81 +++++++++++++ apps/backend/tests/api/test_stats.py | 73 ++++++++++++ .../tests/repositories/test_reading.py | 72 ++++++++++++ apps/backend/tests/repositories/test_site.py | 31 +++++ apps/backend/tests/services/test_stats.py | 107 ++++++++++++++++++ docs/architecture/20-backend.md | 11 +- 12 files changed, 460 insertions(+), 6 deletions(-) create mode 100644 apps/backend/app/api/v1/endpoints/stats.py create mode 100644 apps/backend/app/repositories/reading.py create mode 100644 apps/backend/app/repositories/site.py create mode 100644 apps/backend/app/schemas/stats.py create mode 100644 apps/backend/app/services/stats.py create mode 100644 apps/backend/tests/api/test_stats.py create mode 100644 apps/backend/tests/repositories/test_reading.py create mode 100644 apps/backend/tests/repositories/test_site.py create mode 100644 apps/backend/tests/services/test_stats.py diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index 4dc32cb..3407de3 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -23,9 +23,12 @@ from app.core.security import decode_access_token as decode_token from app.db.session import get_session from app.repositories.audit_log import AuditLogRepository from app.repositories.login_attempt import LoginAttemptRepository +from app.repositories.reading import ReadingRepository 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.stats import StatsService from app.services.user import UserService SessionDep = Annotated[AsyncSession, Depends(get_session)] @@ -131,6 +134,13 @@ def get_user_service( UserServiceDep = Annotated[UserService, Depends(get_user_service)] +def get_stats_service(session: SessionDep) -> StatsService: + return StatsService(sites=SiteRepository(session), readings=ReadingRepository(session)) + + +StatsServiceDep = Annotated[StatsService, Depends(get_stats_service)] + + async def get_current_principal( credentials: CredentialsDep, session: SessionDep, diff --git a/apps/backend/app/api/v1/endpoints/stats.py b/apps/backend/app/api/v1/endpoints/stats.py new file mode 100644 index 0000000..5a8502e --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/stats.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter + +from app.api.deps import LecteurDep, StatsServiceDep +from app.schemas.stats import StatsSummaryResponse + +router = APIRouter() + + +@router.get( + "/summary", + response_model=StatsSummaryResponse, + summary="Résume la consommation instantanée du parc", +) +async def get_summary(_: LecteurDep, service: StatsServiceDep) -> StatsSummaryResponse: + resume = await service.summary() + return StatsSummaryResponse.model_validate(resume) diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py index 76e6f28..06e8852 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, stats, 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(stats.router, prefix="/stats", tags=["stats"]) diff --git a/apps/backend/app/repositories/reading.py b/apps/backend/app/repositories/reading.py new file mode 100644 index 0000000..5424b46 --- /dev/null +++ b/apps/backend/app/repositories/reading.py @@ -0,0 +1,21 @@ +from collections.abc import Sequence + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Reading + + +class ReadingRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def latest_by_site(self) -> Sequence[Reading]: + # `.distinct(site_id)` compile en `DISTINCT ON (site_id)` sous PostgreSQL : une seule + # ligne par site, la plus récente grâce à l'ordre composite qui suit. + requete = ( + select(Reading) + .distinct(Reading.site_id) + .order_by(Reading.site_id, Reading.timestamp.desc()) + ) + return (await self._session.execute(requete)).scalars().all() diff --git a/apps/backend/app/repositories/site.py b/apps/backend/app/repositories/site.py new file mode 100644 index 0000000..cd36329 --- /dev/null +++ b/apps/backend/app/repositories/site.py @@ -0,0 +1,15 @@ +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.execute(requete)).scalars().all() diff --git a/apps/backend/app/schemas/stats.py b/apps/backend/app/schemas/stats.py new file mode 100644 index 0000000..b119d50 --- /dev/null +++ b/apps/backend/app/schemas/stats.py @@ -0,0 +1,26 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict + + +class SiteSummaryResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + site_id: str + site_name: str + current_consumption_kw: float | None + capacity_kw: float + load_percent: float | None + data_quality: Literal["good", "partial", "degraded", "critical"] + + +class StatsSummaryResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + timestamp: datetime + total_sites: int + total_consumption_kw: float + total_capacity_kw: float + average_load_percent: float + sites: list[SiteSummaryResponse] diff --git a/apps/backend/app/services/stats.py b/apps/backend/app/services/stats.py new file mode 100644 index 0000000..c98eace --- /dev/null +++ b/apps/backend/app/services/stats.py @@ -0,0 +1,81 @@ +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Literal + +from app.models.energy import Reading, Site +from app.repositories.reading import ReadingRepository +from app.repositories.site import SiteRepository + +DataQuality = Literal["good", "partial", "degraded", "critical"] + +QUALITES_CONNUES: frozenset[str] = frozenset({"good", "partial", "degraded", "critical"}) + + +@dataclass(frozen=True, slots=True) +class SiteConsumption: + site_id: str + site_name: str + current_consumption_kw: float | None + capacity_kw: float + load_percent: float | None + data_quality: DataQuality + + +@dataclass(frozen=True, slots=True) +class ConsumptionSummary: + timestamp: datetime + total_sites: int + total_consumption_kw: float + total_capacity_kw: float + average_load_percent: float + sites: list[SiteConsumption] + + +class StatsService: + def __init__(self, sites: SiteRepository, readings: ReadingRepository) -> None: + self._sites = sites + self._readings = readings + + async def summary(self) -> ConsumptionSummary: + sites = await self._sites.list_all() + dernieres = {lecture.site_id: lecture for lecture in await self._readings.latest_by_site()} + + resumes = [self._resume_site(site, dernieres.get(site.site_id)) for site in sites] + consommation_totale = sum(r.current_consumption_kw or 0 for r in resumes) + capacite_totale = sum(r.capacity_kw for r in resumes) + + return ConsumptionSummary( + timestamp=datetime.now(UTC), + total_sites=len(resumes), + total_consumption_kw=consommation_totale, + total_capacity_kw=capacite_totale, + average_load_percent=( + consommation_totale / capacite_totale * 100 if capacite_totale > 0 else 0 + ), + sites=resumes, + ) + + @staticmethod + def _resume_site(site: Site, derniere: Reading | None) -> SiteConsumption: + capacite = site.capacity_kw or 0 + # Piège : `data_quality` est nul dès qu'un site n'a jamais reçu de lecture, ou que le + # producteur n'a pas su la qualifier. Le contrat frontend n'a pas de valeur pour ce cas, + # `critical` est la seule des quatre qui n'induit pas une confiance qu'on n'a pas. + qualite: DataQuality = "critical" + consommation = None + if derniere is not None and derniere.data_quality in QUALITES_CONNUES: + qualite = derniere.data_quality # type: ignore[assignment] + consommation = derniere.consumption_kw + + charge = ( + consommation / capacite * 100 if consommation is not None and capacite > 0 else None + ) + + return SiteConsumption( + site_id=site.site_id, + site_name=site.site_name, + current_consumption_kw=consommation, + capacity_kw=capacite, + load_percent=charge, + data_quality=qualite, + ) diff --git a/apps/backend/tests/api/test_stats.py b/apps/backend/tests/api/test_stats.py new file mode 100644 index 0000000..8e4c439 --- /dev/null +++ b/apps/backend/tests/api/test_stats.py @@ -0,0 +1,73 @@ +from collections.abc import Callable, Iterator +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from httpx import AsyncClient + +from app.api.deps import get_current_principal, get_stats_service +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.services.stats import ConsumptionSummary, SiteConsumption + + +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, + ) + + +class FauxService: + def __init__(self) -> None: + self.resume = ConsumptionSummary( + timestamp=datetime.now(UTC), + total_sites=1, + total_consumption_kw=87.34, + total_capacity_kw=200, + average_load_percent=43.7, + sites=[ + SiteConsumption( + site_id="SITE001", + site_name="Bureau Paris La Défense", + current_consumption_kw=87.34, + capacity_kw=200, + load_percent=43.7, + data_quality="good", + ) + ], + ) + + async def summary(self) -> ConsumptionSummary: + return self.resume + + +@pytest.fixture +def servi(app: FastAPI) -> Iterator[Callable[[], FauxService]]: + def installe() -> FauxService: + service = FauxService() + app.dependency_overrides[get_stats_service] = lambda: service + app.dependency_overrides[get_current_principal] = lambda: principal() + return service + + yield installe + app.dependency_overrides.pop(get_stats_service, None) + app.dependency_overrides.pop(get_current_principal, None) + + +async def test_get_summary_returns_the_service_result( + servi: Callable[[], FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/stats/summary") + + assert response.status_code == 200 + corps = response.json() + assert corps["total_sites"] == 1 + assert corps["sites"][0]["site_id"] == "SITE001" + assert corps["sites"][0]["data_quality"] == "good" diff --git a/apps/backend/tests/repositories/test_reading.py b/apps/backend/tests/repositories/test_reading.py new file mode 100644 index 0000000..650d49a --- /dev/null +++ b/apps/backend/tests/repositories/test_reading.py @@ -0,0 +1,72 @@ +import uuid +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Reading, Site +from app.repositories.reading import ReadingRepository + +pytestmark = pytest.mark.integration + + +def identifiant() -> str: + return f"SITE-{uuid.uuid4().hex[:8]}" + + +def lecture(site_id: str, *, timestamp: datetime, consumption_kw: float) -> Reading: + return Reading( + site_id=site_id, + timestamp=timestamp, + source="api_current", + consumption_kw=consumption_kw, + data_quality="good", + raw_data={}, + ) + + +async def test_latest_by_site_keeps_only_the_most_recent_reading(session: AsyncSession) -> None: + site_id = identifiant() + maintenant = datetime.now(UTC) + session.add(Site(site_id=site_id, site_name="Site", site_type="bureau", capacity_kw=100)) + await session.flush() + session.add_all( + [ + lecture(site_id, timestamp=maintenant - timedelta(hours=1), consumption_kw=10), + lecture(site_id, timestamp=maintenant, consumption_kw=42), + ] + ) + await session.flush() + depot = ReadingRepository(session) + + resultats = await depot.latest_by_site() + consommations = [r.consumption_kw for r in resultats if r.site_id == site_id] + await session.rollback() + + assert consommations == [42] + + +async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) -> None: + premier, second = identifiant(), identifiant() + maintenant = datetime.now(UTC) + session.add_all( + [ + Site(site_id=premier, site_name="A", site_type="bureau", capacity_kw=100), + Site(site_id=second, site_name="B", site_type="bureau", capacity_kw=200), + ] + ) + await session.flush() + session.add_all( + [ + lecture(premier, timestamp=maintenant, consumption_kw=10), + lecture(second, timestamp=maintenant, consumption_kw=20), + ] + ) + await session.flush() + depot = ReadingRepository(session) + + resultats = await depot.latest_by_site() + identifiants = {r.site_id for r in resultats if r.site_id in (premier, second)} + await session.rollback() + + assert identifiants == {premier, second} diff --git a/apps/backend/tests/repositories/test_site.py b/apps/backend/tests/repositories/test_site.py new file mode 100644 index 0000000..499e808 --- /dev/null +++ b/apps/backend/tests/repositories/test_site.py @@ -0,0 +1,31 @@ +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[:8]}" + + +async def test_list_all_returns_every_site_sorted_by_id(session: AsyncSession) -> None: + premier, second = sorted([identifiant(), identifiant()]) + session.add_all( + [ + Site(site_id=second, site_name="B", site_type="bureau", capacity_kw=100), + Site(site_id=premier, site_name="A", site_type="bureau", capacity_kw=50), + ] + ) + await session.flush() + depot = SiteRepository(session) + + sites = await depot.list_all() + identifiants = [site.site_id for site in sites if site.site_id in (premier, second)] + await session.rollback() + + assert identifiants == [premier, second] diff --git a/apps/backend/tests/services/test_stats.py b/apps/backend/tests/services/test_stats.py new file mode 100644 index 0000000..1962678 --- /dev/null +++ b/apps/backend/tests/services/test_stats.py @@ -0,0 +1,107 @@ +from dataclasses import dataclass + +from app.services.stats import StatsService + + +@dataclass +class FauxSite: + site_id: str + site_name: str + capacity_kw: float | None + + +@dataclass +class FauxLecture: + site_id: str + consumption_kw: float | None + data_quality: str | None + + +class FauxDepotSites: + def __init__(self, sites: list[FauxSite]) -> None: + self._sites = sites + + async def list_all(self) -> list[FauxSite]: + return self._sites + + +class FauxDepotLectures: + def __init__(self, lectures: list[FauxLecture]) -> None: + self._lectures = lectures + + async def latest_by_site(self) -> list[FauxLecture]: + return self._lectures + + +async def test_summary_computes_totals_and_the_average_load() -> None: + service = StatsService( + sites=FauxDepotSites([FauxSite("A", "Site A", 200), FauxSite("B", "Site B", 800)]), # type: ignore[arg-type] + readings=FauxDepotLectures( # type: ignore[arg-type] + [ + FauxLecture("A", 100, "good"), + FauxLecture("B", 400, "good"), + ] + ), + ) + + resume = await service.summary() + + assert resume.total_sites == 2 + assert resume.total_consumption_kw == 500 + assert resume.total_capacity_kw == 1000 + assert resume.average_load_percent == 50 + par_site = {site.site_id: site for site in resume.sites} + assert par_site["A"].load_percent == 50 + assert par_site["B"].load_percent == 50 + + +async def test_summary_treats_a_site_without_any_reading_as_critical() -> None: + service = StatsService( + sites=FauxDepotSites([FauxSite("A", "Site A", 200)]), # type: ignore[arg-type] + readings=FauxDepotLectures([]), # type: ignore[arg-type] + ) + + resume = await service.summary() + + site = resume.sites[0] + assert site.data_quality == "critical" + assert site.current_consumption_kw is None + assert site.load_percent is None + + +async def test_summary_treats_a_reading_with_an_unknown_quality_as_critical() -> None: + service = StatsService( + sites=FauxDepotSites([FauxSite("A", "Site A", 200)]), # type: ignore[arg-type] + readings=FauxDepotLectures([FauxLecture("A", 50, None)]), # type: ignore[arg-type] + ) + + resume = await service.summary() + + site = resume.sites[0] + assert site.data_quality == "critical" + assert site.current_consumption_kw is None + + +async def test_summary_exposes_a_missing_capacity_as_zero_without_dividing_by_it() -> None: + service = StatsService( + sites=FauxDepotSites([FauxSite("A", "Site A", None)]), # type: ignore[arg-type] + readings=FauxDepotLectures([FauxLecture("A", 50, "good")]), # type: ignore[arg-type] + ) + + resume = await service.summary() + + site = resume.sites[0] + assert site.capacity_kw == 0 + assert site.current_consumption_kw == 50 + assert site.load_percent is None + + +async def test_summary_returns_zero_average_load_when_no_site_has_a_capacity() -> None: + service = StatsService( + sites=FauxDepotSites([FauxSite("A", "Site A", None)]), # type: ignore[arg-type] + readings=FauxDepotLectures([]), # type: ignore[arg-type] + ) + + resume = await service.summary() + + assert resume.average_load_percent == 0 diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index 8688a6a..d361323 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, stats"] sc["schemas
Pydantic"] - sv["services
AuthService, UserService"] - rp["repositories
user, refresh_token,
login_attempt, audit_log"] - md["models
4 tables"] + sv["services
AuthService, UserService, StatsService"] + rp["repositories
user, refresh_token,
login_attempt, audit_log,
site, reading"] + md["models
6 tables"] db[("PostgreSQL")] ep --> sc @@ -140,6 +140,7 @@ 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/stats/summary` | oui | Résume la consommation instantanée du parc. `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 +149,7 @@ 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 +Le contrat détaillé pour le frontend est dans [31-contrat-authentification.md](31-contrat-authentification.md). ### `/health/ready` From 781644b28ea5916730c76d969249c29635fc84a2 Mon Sep 17 00:00:00 2001 From: Dorian PESCE Date: Wed, 16 Sep 2026 13:30:51 +0200 Subject: [PATCH 2/2] feat(backend): ajoute GET /recommendations et GET /recommendations/{recommendation_id} --- apps/backend/README.md | 2 + apps/backend/app/api/deps.py | 9 + apps/backend/app/api/openapi.py | 7 + .../app/api/v1/endpoints/recommendations.py | 40 ++++ apps/backend/app/api/v1/router.py | 8 +- .../app/repositories/recommendation.py | 22 ++ apps/backend/app/schemas/recommendation.py | 14 ++ apps/backend/app/services/recommendation.py | 26 +++ apps/backend/openapi.json | 190 ++++++++++++++++++ .../backend/tests/api/test_recommendations.py | 144 +++++++++++++ .../tests/repositories/test_recommendation.py | 85 ++++++++ .../tests/services/test_recommendation.py | 55 +++++ docs/architecture/00-vue-ensemble.md | 2 +- docs/architecture/20-backend.md | 7 +- docs/architecture/owasp-traceabilite.md | 6 +- 15 files changed, 611 insertions(+), 6 deletions(-) create mode 100644 apps/backend/app/api/v1/endpoints/recommendations.py create mode 100644 apps/backend/app/repositories/recommendation.py create mode 100644 apps/backend/app/schemas/recommendation.py create mode 100644 apps/backend/app/services/recommendation.py create mode 100644 apps/backend/tests/api/test_recommendations.py create mode 100644 apps/backend/tests/repositories/test_recommendation.py create mode 100644 apps/backend/tests/services/test_recommendation.py diff --git a/apps/backend/README.md b/apps/backend/README.md index 875d8ca..91f9608 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -109,6 +109,8 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie | `/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` | +| `/api/v1/recommendations` | Liste les recommandations | `lecteur` | +| `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation | `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 f16d167..29eb394 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -23,10 +23,12 @@ from app.core.security import decode_access_token as decode_token from app.db.session import get_session from app.repositories.audit_log import AuditLogRepository from app.repositories.login_attempt import LoginAttemptRepository +from app.repositories.recommendation import RecommendationRepository 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.recommendation import RecommendationService from app.services.site import SiteService from app.services.user import UserService @@ -140,6 +142,13 @@ def get_site_service(session: SessionDep) -> SiteService: SiteServiceDep = Annotated[SiteService, Depends(get_site_service)] +def get_recommendation_service(session: SessionDep) -> RecommendationService: + return RecommendationService(recommendations=RecommendationRepository(session)) + + +RecommendationServiceDep = Annotated[RecommendationService, Depends(get_recommendation_service)] + + async def get_current_principal( credentials: CredentialsDep, session: SessionDep, diff --git a/apps/backend/app/api/openapi.py b/apps/backend/app/api/openapi.py index 1eaa3a9..05907a2 100644 --- a/apps/backend/app/api/openapi.py +++ b/apps/backend/app/api/openapi.py @@ -54,6 +54,13 @@ TAGS: Final[list[dict[str, Any]]] = [ "name": "sites", "description": "Consultation du parc de sites. Accessible à partir du rôle `lecteur`.", }, + { + "name": "recommendations", + "description": ( + "Consultation des recommandations issues des alertes. Accessible à partir du rôle " + "`lecteur`." + ), + }, ] cookie_de_rafraichissement = APIKeyCookie( diff --git a/apps/backend/app/api/v1/endpoints/recommendations.py b/apps/backend/app/api/v1/endpoints/recommendations.py new file mode 100644 index 0000000..87e8be1 --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/recommendations.py @@ -0,0 +1,40 @@ +from fastapi import APIRouter, HTTPException, status + +from app.api.deps import LecteurDep, RecommendationServiceDep +from app.api.openapi import REPONSE_VALIDATION, Reponses +from app.schemas.errors import ErrorResponse +from app.schemas.recommendation import RecommendationResponse +from app.services.recommendation import RecommendationNotFoundError + +router = APIRouter() + +REPONSES_INTROUVABLE: Reponses = { + **REPONSE_VALIDATION, + 404: {"model": ErrorResponse, "description": "Aucune recommandation ne porte cet identifiant."}, +} + + +@router.get("", response_model=list[RecommendationResponse], summary="Liste les recommandations") +async def list_recommendations( + _: LecteurDep, service: RecommendationServiceDep +) -> list[RecommendationResponse]: + recommendations = await service.list_all() + return [RecommendationResponse.model_validate(r) for r in recommendations] + + +@router.get( + "/{recommendation_id}", + response_model=RecommendationResponse, + summary="Décrit une recommandation", + responses=REPONSES_INTROUVABLE, +) +async def get_recommendation( + recommendation_id: int, _: LecteurDep, service: RecommendationServiceDep +) -> RecommendationResponse: + try: + recommendation = await service.get_by_id(recommendation_id) + except RecommendationNotFoundError as erreur: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Recommandation introuvable" + ) from erreur + return RecommendationResponse.model_validate(recommendation) diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py index edb035b..05db75f 100644 --- a/apps/backend/app/api/v1/router.py +++ b/apps/backend/app/api/v1/router.py @@ -1,10 +1,16 @@ from fastapi import APIRouter from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR -from app.api.v1.endpoints import auth, health, sites, users +from app.api.v1.endpoints import auth, health, recommendations, sites, users 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"], responses=REPONSES_ADMIN) api_router.include_router(sites.router, prefix="/sites", tags=["sites"], responses=REPONSES_LECTEUR) +api_router.include_router( + recommendations.router, + prefix="/recommendations", + tags=["recommendations"], + responses=REPONSES_LECTEUR, +) diff --git a/apps/backend/app/repositories/recommendation.py b/apps/backend/app/repositories/recommendation.py new file mode 100644 index 0000000..7870131 --- /dev/null +++ b/apps/backend/app/repositories/recommendation.py @@ -0,0 +1,22 @@ +from collections.abc import Sequence + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Recommendation + + +class RecommendationRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def list_all(self) -> Sequence[Recommendation]: + requete = select(Recommendation).order_by(Recommendation.recommendation_id) + return (await self._session.scalars(requete)).all() + + async def get_by_id(self, recommendation_id: int) -> Recommendation | None: + requete = select(Recommendation).where( + Recommendation.recommendation_id == recommendation_id + ) + recommendation: Recommendation | None = await self._session.scalar(requete) + return recommendation diff --git a/apps/backend/app/schemas/recommendation.py b/apps/backend/app/schemas/recommendation.py new file mode 100644 index 0000000..8764615 --- /dev/null +++ b/apps/backend/app/schemas/recommendation.py @@ -0,0 +1,14 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class RecommendationResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + recommendation_id: int + alert_id: int + action: str + explanation: str + rule_reference: str + created_at: datetime diff --git a/apps/backend/app/services/recommendation.py b/apps/backend/app/services/recommendation.py new file mode 100644 index 0000000..31115ae --- /dev/null +++ b/apps/backend/app/services/recommendation.py @@ -0,0 +1,26 @@ +from collections.abc import Sequence + +from app.models.energy import Recommendation +from app.repositories.recommendation import RecommendationRepository + + +class RecommendationError(Exception): + pass + + +class RecommendationNotFoundError(RecommendationError): + pass + + +class RecommendationService: + def __init__(self, *, recommendations: RecommendationRepository) -> None: + self._recommendations = recommendations + + async def list_all(self) -> Sequence[Recommendation]: + return await self._recommendations.list_all() + + async def get_by_id(self, recommendation_id: int) -> Recommendation: + recommendation = await self._recommendations.get_by_id(recommendation_id) + if recommendation is None: + raise RecommendationNotFoundError(recommendation_id) + return recommendation diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index 3e8dc01..507a698 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -920,6 +920,153 @@ } } } + }, + "/api/v1/recommendations": { + "get": { + "tags": [ + "recommendations" + ], + "summary": "Liste les recommandations", + "operationId": "list_recommendations_api_v1_recommendations_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/RecommendationResponse" + }, + "type": "array", + "title": "Response List Recommendations Api V1 Recommendations 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": "Mot de passe provisoire à changer (`detail` vaut `password_change_required`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "Jeton d'accès": [] + } + ] + } + }, + "/api/v1/recommendations/{recommendation_id}": { + "get": { + "tags": [ + "recommendations" + ], + "summary": "Décrit une recommandation", + "operationId": "get_recommendation_api_v1_recommendations__recommendation_id__get", + "security": [ + { + "Jeton d'accès": [] + } + ], + "parameters": [ + { + "name": "recommendation_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Recommendation Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecommendationResponse" + } + } + } + }, + "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": "Mot de passe provisoire à changer (`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": "Aucune recommandation ne porte cet identifiant.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } } }, "components": { @@ -1111,6 +1258,45 @@ ], "title": "ReadinessStatus" }, + "RecommendationResponse": { + "properties": { + "recommendation_id": { + "type": "integer", + "title": "Recommendation Id" + }, + "alert_id": { + "type": "integer", + "title": "Alert Id" + }, + "action": { + "type": "string", + "title": "Action" + }, + "explanation": { + "type": "string", + "title": "Explanation" + }, + "rule_reference": { + "type": "string", + "title": "Rule Reference" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "recommendation_id", + "alert_id", + "action", + "explanation", + "rule_reference", + "created_at" + ], + "title": "RecommendationResponse" + }, "Role": { "type": "string", "enum": [ @@ -1395,6 +1581,10 @@ { "name": "sites", "description": "Consultation du parc de sites. Accessible à partir du rôle `lecteur`." + }, + { + "name": "recommendations", + "description": "Consultation des recommandations issues des alertes. Accessible à partir du rôle `lecteur`." } ] } diff --git a/apps/backend/tests/api/test_recommendations.py b/apps/backend/tests/api/test_recommendations.py new file mode 100644 index 0000000..d01db09 --- /dev/null +++ b/apps/backend/tests/api/test_recommendations.py @@ -0,0 +1,144 @@ +from collections.abc import Callable, Iterator +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from httpx import AsyncClient + +from app.api.deps import get_current_principal, get_recommendation_service +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.models.energy import Recommendation +from app.services.recommendation import RecommendationNotFoundError + +MOMENT = datetime(2024, 1, 1, tzinfo=UTC) + + +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 recommendation(recommendation_id: int = 1) -> Recommendation: + return Recommendation( + recommendation_id=recommendation_id, + alert_id=1, + action="Vérifier la consommation", + explanation="Pic détecté", + rule_reference="spike-v1", + created_at=MOMENT, + ) + + +class FauxService: + def __init__(self, erreur: Exception | None = None) -> None: + self._erreur = erreur + self.recommendation = recommendation() + + async def list_all(self) -> list[Recommendation]: + return [self.recommendation] + + async def get_by_id(self, recommendation_id: int) -> Recommendation: + if self._erreur is not None: + raise self._erreur + return self.recommendation + + +@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_recommendation_service] = lambda: service + return service + + yield installe + app.dependency_overrides.pop(get_recommendation_service, None) + + +async def test_list_recommendations_returns_the_recommendations( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/recommendations") + + assert response.status_code == 200 + corps = response.json() + assert corps == [ + { + "recommendation_id": 1, + "alert_id": 1, + "action": "Vérifier la consommation", + "explanation": "Pic détecté", + "rule_reference": "spike-v1", + "created_at": "2024-01-01T00:00:00Z", + } + ] + + +async def test_get_recommendation_returns_the_matching_recommendation( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/recommendations/1") + + assert response.status_code == 200 + assert response.json()["recommendation_id"] == 1 + + +async def test_get_recommendation_returns_404_for_an_unknown_recommendation( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi(RecommendationNotFoundError(404)) + + response = await client.get("/api/v1/recommendations/404") + + assert response.status_code == 404 + + +async def test_list_recommendations_reaches_the_repository_through_the_session( + lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient +) -> None: + fake_session(result=[recommendation(1), recommendation(2)]) + + response = await client.get("/api/v1/recommendations") + + assert response.status_code == 200 + assert [r["recommendation_id"] for r in response.json()] == [1, 2] + + +async def test_get_recommendation_reaches_the_repository_through_the_session( + lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient +) -> None: + fake_session(result=recommendation(1)) + + response = await client.get("/api/v1/recommendations/1") + + assert response.status_code == 200 + assert response.json()["recommendation_id"] == 1 + + +async def test_get_recommendation_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/recommendations/404") + + assert response.status_code == 404 diff --git a/apps/backend/tests/repositories/test_recommendation.py b/apps/backend/tests/repositories/test_recommendation.py new file mode 100644 index 0000000..075c9eb --- /dev/null +++ b/apps/backend/tests/repositories/test_recommendation.py @@ -0,0 +1,85 @@ +import uuid +from datetime import UTC, datetime + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Alert, Recommendation, Site +from app.repositories.recommendation import RecommendationRepository + +pytestmark = pytest.mark.integration + +MOMENT = datetime(2024, 1, 1, tzinfo=UTC) + + +async def creer_site(session: AsyncSession) -> str: + site_id = f"TEST-{uuid.uuid4()}" + session.add(Site(site_id=site_id, site_name="Site de test", site_type="office")) + await session.flush() + return site_id + + +async def creer_alerte(session: AsyncSession) -> int: + site_id = await creer_site(session) + alerte = Alert( + source_alert_id=str(uuid.uuid4()), + site_id=site_id, + source="api_mock", + timestamp=MOMENT, + type="spike", + severity="high", + message="Test", + raw_data={}, + ) + session.add(alerte) + await session.flush() + return alerte.alert_id + + +async def creer(session: AsyncSession, **overrides: object) -> Recommendation: + recommendation = Recommendation( + alert_id=overrides.get("alert_id") or await creer_alerte(session), + action=overrides.get("action", "Vérifier la consommation"), + explanation=overrides.get("explanation", "Pic détecté"), + rule_reference=overrides.get("rule_reference", f"spike-{uuid.uuid4().hex[:8]}"), + ) + session.add(recommendation) + await session.flush() + return recommendation + + +async def test_get_by_id_returns_the_matching_recommendation(session: AsyncSession) -> None: + depot = RecommendationRepository(session) + cree = await creer(session) + + trouve = await depot.get_by_id(cree.recommendation_id) + action = trouve.action if trouve else None + await session.rollback() + + assert action == "Vérifier la consommation" + + +async def test_get_by_id_returns_nothing_for_an_unknown_identifier( + session: AsyncSession, +) -> None: + trouve = await RecommendationRepository(session).get_by_id(0) + + assert trouve is None + + +async def test_list_all_returns_the_recommendations_sorted_by_identifier( + session: AsyncSession, +) -> None: + depot = RecommendationRepository(session) + premiere = await creer(session) + seconde = await creer(session) + + recommendations = await depot.list_all() + identifiants = [ + r.recommendation_id + for r in recommendations + if r.recommendation_id in (premiere.recommendation_id, seconde.recommendation_id) + ] + await session.rollback() + + assert identifiants == sorted(identifiants) diff --git a/apps/backend/tests/services/test_recommendation.py b/apps/backend/tests/services/test_recommendation.py new file mode 100644 index 0000000..e8ed2b2 --- /dev/null +++ b/apps/backend/tests/services/test_recommendation.py @@ -0,0 +1,55 @@ +from datetime import UTC, datetime + +import pytest + +from app.models.energy import Recommendation +from app.services.recommendation import RecommendationNotFoundError, RecommendationService + + +def recommendation(recommendation_id: int = 1) -> Recommendation: + return Recommendation( + recommendation_id=recommendation_id, + alert_id=1, + action="Vérifier la consommation", + explanation="Pic détecté", + rule_reference="spike-v1", + created_at=datetime(2024, 1, 1, tzinfo=UTC), + ) + + +class FakeRepository: + def __init__(self, recommendations: list[Recommendation]) -> None: + self._recommendations = recommendations + + async def list_all(self) -> list[Recommendation]: + return self._recommendations + + async def get_by_id(self, recommendation_id: int) -> Recommendation | None: + return next( + (r for r in self._recommendations if r.recommendation_id == recommendation_id), None + ) + + +async def test_list_all_returns_the_repository_recommendations() -> None: + service = RecommendationService( + recommendations=FakeRepository([recommendation(1), recommendation(2)]) + ) + + recommendations = await service.list_all() + + assert [r.recommendation_id for r in recommendations] == [1, 2] + + +async def test_get_by_id_returns_the_matching_recommendation() -> None: + service = RecommendationService(recommendations=FakeRepository([recommendation(1)])) + + trouve = await service.get_by_id(1) + + assert trouve.recommendation_id == 1 + + +async def test_get_by_id_raises_when_the_recommendation_is_unknown() -> None: + service = RecommendationService(recommendations=FakeRepository([])) + + with pytest.raises(RecommendationNotFoundError): + await service.get_by_id(404) diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index 96fb992..b49a70f 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -74,7 +74,7 @@ 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`, `GET /sites` et `GET /sites/{site_id}` (première couche métier, endpoints → services → repositories → models) | +| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites` et `recommendations` en lecture (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. 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 | diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index f48178f..9006e3a 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -142,6 +142,8 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke | POST | `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions. `admin` | 401, 403, 404, 422, 500 | | GET | `/api/v1/sites` | Liste les sites. `lecteur` | 401, 403, 500 | | GET | `/api/v1/sites/{site_id}` | Décrit un site. `lecteur` | 401, 403, 404, 422, 500 | +| GET | `/api/v1/recommendations` | Liste les recommandations. `lecteur` | 401, 403, 500 | +| GET | `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation. `lecteur` | 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` | | @@ -160,7 +162,10 @@ déjà créées par la révision Alembic `e6d2026091501`. Elles n'exigent que le 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 +réelle. `GET /recommendations` et `GET /recommendations/{recommendation_id}` reprennent le même +gabarit à la lettre, `recommendation_id` étant un entier plutôt qu'un texte. Une recommandation ne +porte pas `site_id` : elle remonte à un site par sa seule `alert_id`, `alert` n'étant pas encore +exposée. 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 ac4a8af..15c2b51 100644 --- a/docs/architecture/owasp-traceabilite.md +++ b/docs/architecture/owasp-traceabilite.md @@ -9,8 +9,8 @@ Ce qui est défendable, c'est une ligne par contrôle réellement implémenté, et une section qui dit ce qui n'est pas couvert et pourquoi. 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. +`GET /recommendations`, chacune avec sa route de détail, 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 @@ -49,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 : `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. | +| **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}` et `GET /recommendations/{recommendation_id}` répondent à tout compte `lecteur` pour n'importe quel site ou recommandation, 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. |