From e4d1b43a44d88ba5bed1fb9e930e4ca5854aad7e Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:21:46 +0200 Subject: [PATCH 001/103] =?UTF-8?q?style(backend):=20r=C3=A9tablit=20les?= =?UTF-8?q?=20accents=20dans=20les=20messages=20et=20commentaires?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le français du dépôt s'écrit accentué. Harmonise les commentaires d'en-tête, les docstrings, le message de démarrage et les deux détails d'erreur de la sonde de disponibilité, avec leurs assertions. --- apps/backend/app/api/v1/endpoints/health.py | 4 ++-- apps/backend/app/db/base.py | 2 +- apps/backend/app/main.py | 2 +- apps/backend/app/models/__init__.py | 4 ++-- apps/backend/tests/api/test_health.py | 2 +- apps/backend/tests/conftest.py | 10 +++++----- apps/backend/tests/factories.py | 4 ++-- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/backend/app/api/v1/endpoints/health.py b/apps/backend/app/api/v1/endpoints/health.py index be3abf8..f97caaf 100644 --- a/apps/backend/app/api/v1/endpoints/health.py +++ b/apps/backend/app/api/v1/endpoints/health.py @@ -27,10 +27,10 @@ async def readiness(session: SessionDep) -> ReadinessStatus: try: version: str | None = await session.scalar(TIMESCALEDB_VERSION) except SQLAlchemyError, OSError: - logger.exception("Base de donnees injoignable") + logger.exception("Base de données injoignable") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Base de donnees injoignable", + detail="Base de données injoignable", ) from None if version is None: diff --git a/apps/backend/app/db/base.py b/apps/backend/app/db/base.py index a1a552c..1830f3e 100644 --- a/apps/backend/app/db/base.py +++ b/apps/backend/app/db/base.py @@ -2,4 +2,4 @@ from sqlalchemy.orm import DeclarativeBase class Base(DeclarativeBase): - """Base declarative commune a tous les modeles.""" + """Base déclarative commune à tous les modèles.""" diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index fa717f5..1008100 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -17,7 +17,7 @@ logger = get_logger(__name__) async def lifespan(_: FastAPI) -> AsyncIterator[None]: settings = get_settings() logger.info( - "Demarrage de %s %s en environnement %s", settings.name, settings.version, settings.env + "Démarrage de %s %s en environnement %s", settings.name, settings.version, settings.env ) yield await get_engine().dispose() diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py index 6d71227..2ac405d 100644 --- a/apps/backend/app/models/__init__.py +++ b/apps/backend/app/models/__init__.py @@ -1,2 +1,2 @@ -# Piege : tout modele absent de ce module reste invisible de `alembic revision -# --autogenerate`, qui genererait alors un drop de sa table. +# Piège : tout modèle absent de ce module reste invisible de `alembic revision +# --autogenerate`, qui générerait alors un drop de sa table. diff --git a/apps/backend/tests/api/test_health.py b/apps/backend/tests/api/test_health.py index a7a61b6..d5ba9bc 100644 --- a/apps/backend/tests/api/test_health.py +++ b/apps/backend/tests/api/test_health.py @@ -59,7 +59,7 @@ async def test_readiness_returns_503_when_database_is_unreachable( response = await client.get("/api/v1/health/ready") assert response.status_code == 503 - assert response.json()["detail"] == "Base de donnees injoignable" + assert response.json()["detail"] == "Base de données injoignable" @pytest.mark.parametrize("path", ["/openapi.json", "/metrics"]) diff --git a/apps/backend/tests/conftest.py b/apps/backend/tests/conftest.py index 4560b75..70ba87a 100644 --- a/apps/backend/tests/conftest.py +++ b/apps/backend/tests/conftest.py @@ -12,8 +12,8 @@ from app.main import create_app from tests.factories import FakeSession -# Piege : les variables d'environnement priment sur apps/backend/.env. Celles qu'on ne -# pose pas ici, c'est le .env du poste qui les decide, et les assertions avec. +# Piège : les variables d'environnement priment sur apps/backend/.env. Celles qu'on ne +# pose pas ici, c'est le .env du poste qui les décide, et les assertions avec. @pytest.fixture(autouse=True, scope="session") def environment() -> Iterator[None]: os.environ.update( @@ -33,8 +33,8 @@ def environment() -> Iterator[None]: get_settings.cache_clear() -# Piege : get_engine est lru_cache et pytest-asyncio ouvre une boucle par test. Sans ce -# recyclage, le 2e test touchant vraiment la base heriterait d une boucle morte. +# Piège : get_engine est lru_cache et pytest-asyncio ouvre une boucle par test. Sans ce +# recyclage, le 2e test touchant vraiment la base hériterait d'une boucle morte. @pytest.fixture(autouse=True) async def engine_per_test() -> AsyncIterator[None]: yield @@ -67,7 +67,7 @@ def fake_session(app: FastAPI) -> Callable[..., None]: return install -# Contrainte : ouvre une vraie connexion, donc reservee aux tests `integration`. +# Contrainte : ouvre une vraie connexion, donc réservée aux tests `integration`. @pytest.fixture async def session() -> AsyncIterator[AsyncSession]: async with get_session_factory()() as async_session: diff --git a/apps/backend/tests/factories.py b/apps/backend/tests/factories.py index 05ba3fc..3098863 100644 --- a/apps/backend/tests/factories.py +++ b/apps/backend/tests/factories.py @@ -31,7 +31,7 @@ class FakeSession: return self._result -# Piege : les arguments nommes priment sur l'environnement et sur .env, contrairement -# aux variables posees par la fixture `environment`, qui restent surchargeables. +# Piège : les arguments nommés priment sur l'environnement et sur .env, contrairement +# aux variables posées par la fixture `environment`, qui restent surchargeables. def make_settings(**overrides: Any) -> Settings: return Settings(**{**SETTINGS_DE_TEST, **overrides}) From 008cf581a7c0c131c1ef10fffc343e03326cfe27 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:22:09 +0200 Subject: [PATCH 002/103] =?UTF-8?q?ci(backend):=20v=C3=A9rifie=20format,?= =?UTF-8?q?=20lint,=20typage=20et=20tests=20=C3=A0=20chaque=20pouss=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.github/workflows/` ne contenait qu'un `.gitkeep` alors que l'EC03 évalue la CI en continu. Périmètre volontairement minimal, aligné sur `make check` : le scan de sécurité et la construction d'image relèvent du chantier CI/CD et viendront l'étendre. --- .github/workflows/backend.yml | 58 +++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/backend.yml diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml new file mode 100644 index 0000000..b146eb5 --- /dev/null +++ b/.github/workflows/backend.yml @@ -0,0 +1,58 @@ +name: Backend + +# Piège : la version de Python vient de apps/backend/.python-version, et elle doit rester +# en 3.14. Le code utilise le PEP 758, qu'un interpréteur 3.13 refuse de compiler. + +on: + push: + paths: + - "apps/backend/**" + - ".github/workflows/backend.yml" + pull_request: + paths: + - "apps/backend/**" + - ".github/workflows/backend.yml" + +permissions: + contents: read + +concurrency: + group: backend-${{ github.ref }} + cancel-in-progress: true + +jobs: + verification: + name: Lint, typage et tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/backend + + steps: + - name: Récupère le dépôt + uses: actions/checkout@v4 + + - name: Installe uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: apps/backend/uv.lock + + - name: Installe l'interpréteur déclaré par .python-version + run: uv python install + + - name: Synchronise les dépendances sans dévier du verrou + run: uv sync --all-groups --frozen + + - name: Vérifie le formatage + run: uv run ruff format --check . + + - name: Analyse statique + run: uv run ruff check --output-format=github . + + - name: Typage + run: uv run mypy app + + # Le marqueur `integration` est exclu par défaut, donc aucune base n'est nécessaire ici. + - name: Tests et couverture + run: uv run pytest --cov-fail-under=85 From 53af7a76d8ac16d21ed2ef97554fc5fbc649ff65 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:25:41 +0200 Subject: [PATCH 003/103] =?UTF-8?q?feat(backend):=20pose=20les=20primitive?= =?UTF-8?q?s=20de=20s=C3=A9curit=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Couche pure, sans FastAPI ni session : rôles ordonnés, `Principal`, encodage et décodage des jetons d'accès, empreinte des jetons de rafraîchissement, et hachage Argon2id poussé dans un fil borné. Aucun de ces modules ne lit `get_settings()`, mis en cache par `lru_cache` et donc contaminé entre tests : les paramètres arrivent par `TokenPolicy` et par `build_hasher()`. Argon2id est calibré à m=19456 KiB, t=2, p=1, soit 17 ms mesurés sur un poste de développement. --- apps/backend/app/core/hashing.py | 63 ++++++++ apps/backend/app/core/principal.py | 18 +++ apps/backend/app/core/roles.py | 26 +++ apps/backend/app/core/security.py | 117 ++++++++++++++ apps/backend/pyproject.toml | 6 +- apps/backend/tests/core/test_hashing.py | 61 +++++++ apps/backend/tests/core/test_roles.py | 40 +++++ apps/backend/tests/core/test_security.py | 194 +++++++++++++++++++++++ apps/backend/uv.lock | 103 ++++++++++++ 9 files changed, 627 insertions(+), 1 deletion(-) create mode 100644 apps/backend/app/core/hashing.py create mode 100644 apps/backend/app/core/principal.py create mode 100644 apps/backend/app/core/roles.py create mode 100644 apps/backend/app/core/security.py create mode 100644 apps/backend/tests/core/test_hashing.py create mode 100644 apps/backend/tests/core/test_roles.py create mode 100644 apps/backend/tests/core/test_security.py diff --git a/apps/backend/app/core/hashing.py b/apps/backend/app/core/hashing.py new file mode 100644 index 0000000..0cbc975 --- /dev/null +++ b/apps/backend/app/core/hashing.py @@ -0,0 +1,63 @@ +# Piège : `PasswordHasher.verify()` bloque 17 ms. Appelé tel quel dans un `async def`, il fige +# la boucle d'événements et gèle toutes les requêtes en cours, pas seulement la connexion. +# `Argon2Hasher` le pousse donc dans un fil, sous un `CapacityLimiter` : le pool par défaut +# d'anyio accepte 40 fils, soit 40 x 19 Mio dans le pire cas sur une machine qui héberge aussi +# PostgreSQL, Prometheus et Grafana. +# Piège : `verify_dummy()` doit être appelé quand l'utilisateur est introuvable. Sans lui, +# l'écart entre 2 ms et 17 ms est un oracle d'existence de compte, mesurable à distance. + +import secrets + +import anyio +import anyio.to_thread +from argon2 import PasswordHasher +from argon2.exceptions import Argon2Error, InvalidHashError, VerificationError + +_ERREURS_DE_VERIFICATION = (VerificationError, InvalidHashError, Argon2Error) + + +class Argon2Hasher: + def __init__(self, hasher: PasswordHasher, *, max_concurrency: int) -> None: + self._hasher = hasher + self._limiter = anyio.CapacityLimiter(max_concurrency) + self._leurre = hasher.hash(secrets.token_urlsafe(32)) + + async def hash(self, password: str) -> str: + return await anyio.to_thread.run_sync(self._hasher.hash, password, limiter=self._limiter) + + async def verify(self, stored: str, password: str) -> bool: + return await anyio.to_thread.run_sync(self._verify, stored, password, limiter=self._limiter) + + async def verify_dummy(self) -> None: + await self.verify(self._leurre, "") + + def needs_rehash(self, stored: str) -> bool: + try: + return self._hasher.check_needs_rehash(stored) + except _ERREURS_DE_VERIFICATION: + return True + + def _verify(self, stored: str, password: str) -> bool: + try: + return self._hasher.verify(stored, password) + except _ERREURS_DE_VERIFICATION: + return False + + +def build_hasher( + *, + time_cost: int, + memory_cost_kib: int, + parallelism: int, + max_concurrency: int, +) -> Argon2Hasher: + return Argon2Hasher( + PasswordHasher( + time_cost=time_cost, + memory_cost=memory_cost_kib, + parallelism=parallelism, + hash_len=32, + salt_len=16, + ), + max_concurrency=max_concurrency, + ) diff --git a/apps/backend/app/core/principal.py b/apps/backend/app/core/principal.py new file mode 100644 index 0000000..af69bdc --- /dev/null +++ b/apps/backend/app/core/principal.py @@ -0,0 +1,18 @@ +# Pourquoi : tout le code métier dépend de `Principal` et jamais du modèle ORM ni des claims +# du jeton. C'est ce qui garde la bascule vers un fournisseur OIDC locale à +# `get_current_principal()` et à `AuthService.authenticate()`, au lieu de la répandre dans +# chaque endpoint. + +from dataclasses import dataclass +from uuid import UUID + +from app.core.roles import AccountKind, Role + + +@dataclass(frozen=True, slots=True) +class Principal: + id: UUID + email: str + role: Role + kind: AccountKind + must_change_password: bool diff --git a/apps/backend/app/core/roles.py b/apps/backend/app/core/roles.py new file mode 100644 index 0000000..211b187 --- /dev/null +++ b/apps/backend/app/core/roles.py @@ -0,0 +1,26 @@ +from enum import StrEnum +from typing import Final + + +class Role(StrEnum): + # Contrainte : ces valeurs voyagent en base, en JSON et dans les jetons. Elles restent + # en ASCII, contrairement au libellé « opérateur » affiché à l'utilisateur. + LECTEUR = "lecteur" + OPERATEUR = "operateur" + ADMIN = "admin" + + +class AccountKind(StrEnum): + HUMAIN = "human" + SERVICE = "service" + + +ROLE_RANK: Final[dict[Role, int]] = { + Role.LECTEUR: 0, + Role.OPERATEUR: 1, + Role.ADMIN: 2, +} + + +def has_at_least(actual: Role, required: Role) -> bool: + return ROLE_RANK[actual] >= ROLE_RANK[required] diff --git a/apps/backend/app/core/security.py b/apps/backend/app/core/security.py new file mode 100644 index 0000000..a9b71e5 --- /dev/null +++ b/apps/backend/app/core/security.py @@ -0,0 +1,117 @@ +# Piège : `decode_access_token()` porte trois barrières indépendantes, et retirer l'une +# d'elles ne casse aucun test évident. L'algorithme est épinglé, sinon un jeton forgé en +# `alg: none` passerait. L'audience et l'émetteur sont vérifiés, sinon un jeton émis pour +# un autre service serait accepté. Le claim `typ` est comparé, sinon un jeton de +# rafraîchissement servirait de jeton d'accès, ce qui transformerait une fenêtre de +# 15 minutes en fenêtre de 7 jours. +# Contrainte : ce module ne lit jamais `get_settings()`, qui est mis en cache par +# `lru_cache` et se contaminerait entre tests. Tout paramètre arrive par `TokenPolicy`. + +import hashlib +import secrets +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import Final +from uuid import UUID, uuid4 + +import jwt + +ACCESS_TOKEN_TYPE: Final = "access" # noqa: S105 +REFRESH_SECRET_BYTES: Final = 32 + +_ALGORITHME: Final = "HS256" +_CLAIMS_REQUIS: Final = ["iss", "aud", "sub", "iat", "exp", "jti", "typ", "role", "kind"] + + +class TokenInvalidError(Exception): + pass + + +class TokenExpiredError(TokenInvalidError): + pass + + +@dataclass(frozen=True, slots=True) +class TokenPolicy: + secret: str + issuer: str + audience: str + access_ttl: timedelta + + +@dataclass(frozen=True, slots=True) +class AccessClaims: + subject: UUID + role: str + kind: str + token_id: UUID + issued_at: datetime + + +def encode_access_token( + policy: TokenPolicy, + *, + subject: UUID, + role: str, + kind: str, + now: datetime | None = None, +) -> str: + emis_a = now or datetime.now(UTC) + return jwt.encode( + { + "iss": policy.issuer, + "aud": policy.audience, + "sub": str(subject), + "iat": emis_a, + "exp": emis_a + policy.access_ttl, + "jti": str(uuid4()), + "typ": ACCESS_TOKEN_TYPE, + "role": role, + "kind": kind, + }, + policy.secret, + algorithm=_ALGORITHME, + ) + + +def decode_access_token(policy: TokenPolicy, token: str) -> AccessClaims: + try: + charge = jwt.decode( + token, + policy.secret, + algorithms=[_ALGORITHME], + audience=policy.audience, + issuer=policy.issuer, + options={"require": _CLAIMS_REQUIS}, + ) + except jwt.ExpiredSignatureError as erreur: + raise TokenExpiredError("Jeton expiré") from erreur + except jwt.InvalidTokenError as erreur: + raise TokenInvalidError("Jeton invalide") from erreur + + if charge["typ"] != ACCESS_TOKEN_TYPE: + raise TokenInvalidError("Type de jeton inattendu") + + try: + sujet = UUID(charge["sub"]) + identifiant = UUID(charge["jti"]) + except (AttributeError, TypeError, ValueError) as erreur: + raise TokenInvalidError("Identifiants du jeton illisibles") from erreur + + return AccessClaims( + subject=sujet, + role=str(charge["role"]), + kind=str(charge["kind"]), + token_id=identifiant, + issued_at=datetime.fromtimestamp(charge["iat"], tz=UTC), + ) + + +def generate_refresh_secret() -> str: + return secrets.token_urlsafe(REFRESH_SECRET_BYTES) + + +# SHA-256 nu, pas Argon2id : 256 bits de CSPRNG n'ont ni dictionnaire ni préimage atteignable, +# et une KDF lente coûterait 17 ms à chaque rafraîchissement pour aucun gain. +def fingerprint_refresh(secret: str) -> bytes: + return hashlib.sha256(secret.encode("utf-8")).digest() diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index 1c27c08..684524d 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -13,6 +13,9 @@ dependencies = [ "alembic>=1.20.0", "prometheus-fastapi-instrumentator>=8.1.0", "python-json-logger>=4.2.0", + "pyjwt>=2.10", + "argon2-cffi>=23.1", + "anyio>=4.0", ] [dependency-groups] @@ -57,7 +60,8 @@ select = [ ignore = ["B008"] [tool.ruff.lint.per-file-ignores] -"tests/**/*.py" = ["S101"] +# S105 et S106 signalent les secrets en dur, ce qui est justement la matière des tests d'auth. +"tests/**/*.py" = ["S101", "S105", "S106"] [tool.ruff.lint.isort] known-first-party = ["app"] diff --git a/apps/backend/tests/core/test_hashing.py b/apps/backend/tests/core/test_hashing.py new file mode 100644 index 0000000..c56c713 --- /dev/null +++ b/apps/backend/tests/core/test_hashing.py @@ -0,0 +1,61 @@ +from app.core.hashing import Argon2Hasher, build_hasher + +MOT_DE_PASSE = "un-mot-de-passe-de-test-assez-long" + + +def fabrique(time_cost: int = 1, max_concurrency: int = 2) -> Argon2Hasher: + return build_hasher( + time_cost=time_cost, + memory_cost_kib=8192, + parallelism=1, + max_concurrency=max_concurrency, + ) + + +async def test_hash_produces_a_distinct_digest_for_the_same_password() -> None: + hacheur = fabrique() + + premier = await hacheur.hash(MOT_DE_PASSE) + second = await hacheur.hash(MOT_DE_PASSE) + + assert premier != second + assert premier.startswith("$argon2id$") + + +async def test_verify_accepts_the_right_password_and_rejects_the_others() -> None: + hacheur = fabrique() + + empreinte = await hacheur.hash(MOT_DE_PASSE) + + assert await hacheur.verify(empreinte, MOT_DE_PASSE) is True + assert await hacheur.verify(empreinte, "un-autre-mot-de-passe") is False + + +async def test_verify_returns_false_when_the_stored_digest_is_malformed() -> None: + hacheur = fabrique() + + accorde = await hacheur.verify("pas-une-empreinte-argon2", MOT_DE_PASSE) + + assert accorde is False + + +async def test_needs_rehash_is_true_when_the_parameters_changed() -> None: + ancien = fabrique(time_cost=1) + recent = fabrique(time_cost=3) + + empreinte = await ancien.hash(MOT_DE_PASSE) + + assert ancien.needs_rehash(empreinte) is False + assert recent.needs_rehash(empreinte) is True + + +def test_needs_rehash_is_true_when_the_stored_digest_is_malformed() -> None: + hacheur = fabrique() + + assert hacheur.needs_rehash("pas-une-empreinte-argon2") is True + + +async def test_verify_dummy_completes_without_revealing_anything() -> None: + hacheur = fabrique() + + await hacheur.verify_dummy() diff --git a/apps/backend/tests/core/test_roles.py b/apps/backend/tests/core/test_roles.py new file mode 100644 index 0000000..fdcafe7 --- /dev/null +++ b/apps/backend/tests/core/test_roles.py @@ -0,0 +1,40 @@ +import pytest + +from app.core.roles import Role, has_at_least + + +@pytest.mark.parametrize( + ("actual", "required", "expected"), + [ + (Role.LECTEUR, Role.LECTEUR, True), + (Role.LECTEUR, Role.OPERATEUR, False), + (Role.LECTEUR, Role.ADMIN, False), + (Role.OPERATEUR, Role.LECTEUR, True), + (Role.OPERATEUR, Role.OPERATEUR, True), + (Role.OPERATEUR, Role.ADMIN, False), + (Role.ADMIN, Role.LECTEUR, True), + (Role.ADMIN, Role.OPERATEUR, True), + (Role.ADMIN, Role.ADMIN, True), + ], + ids=[ + "lecteur_sur_lecteur", + "lecteur_sur_operateur", + "lecteur_sur_admin", + "operateur_sur_lecteur", + "operateur_sur_operateur", + "operateur_sur_admin", + "admin_sur_lecteur", + "admin_sur_operateur", + "admin_sur_admin", + ], +) +def test_has_at_least_orders_the_three_roles(actual: Role, required: Role, expected: bool) -> None: + accorde = has_at_least(actual, required) + + assert accorde is expected + + +def test_role_values_stay_ascii_for_the_wire_format() -> None: + valeurs = [role.value for role in Role] + + assert all(valeur.isascii() for valeur in valeurs) diff --git a/apps/backend/tests/core/test_security.py b/apps/backend/tests/core/test_security.py new file mode 100644 index 0000000..88a8611 --- /dev/null +++ b/apps/backend/tests/core/test_security.py @@ -0,0 +1,194 @@ +import base64 +import json +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import jwt +import pytest + +from app.core.security import ( + AccessClaims, + TokenExpiredError, + TokenInvalidError, + TokenPolicy, + decode_access_token, + encode_access_token, + fingerprint_refresh, + generate_refresh_secret, +) + +POLITIQUE = TokenPolicy( + secret="un-secret-de-test-de-plus-de-trente-deux-caracteres", + issuer="enervision-api", + audience="enervision-web", + access_ttl=timedelta(minutes=15), +) + + +def emets(**surcharges: object) -> str: + charge = { + "iss": POLITIQUE.issuer, + "aud": POLITIQUE.audience, + "sub": str(uuid4()), + "iat": datetime.now(UTC), + "exp": datetime.now(UTC) + timedelta(minutes=15), + "jti": str(uuid4()), + "typ": "access", + "role": "lecteur", + "kind": "human", + } + charge.update(surcharges) + return jwt.encode(charge, POLITIQUE.secret, algorithm="HS256") + + +def test_decode_access_token_returns_the_claims_when_the_token_is_valid() -> None: + sujet = uuid4() + + jeton = encode_access_token(POLITIQUE, subject=sujet, role="operateur", kind="human") + claims = decode_access_token(POLITIQUE, jeton) + + assert isinstance(claims, AccessClaims) + assert claims.subject == sujet + assert claims.role == "operateur" + assert claims.kind == "human" + + +def test_decode_access_token_raises_expired_when_the_lifetime_has_passed() -> None: + passe = datetime.now(UTC) - timedelta(hours=2) + + jeton = encode_access_token(POLITIQUE, subject=uuid4(), role="lecteur", kind="human", now=passe) + + with pytest.raises(TokenExpiredError): + decode_access_token(POLITIQUE, jeton) + + +def test_decode_access_token_raises_invalid_when_the_signature_was_forged() -> None: + autre = TokenPolicy( + secret="un-autre-secret-tout-aussi-long-que-le-premier", + issuer=POLITIQUE.issuer, + audience=POLITIQUE.audience, + access_ttl=POLITIQUE.access_ttl, + ) + + jeton = encode_access_token(autre, subject=uuid4(), role="lecteur", kind="human") + + with pytest.raises(TokenInvalidError): + decode_access_token(POLITIQUE, jeton) + + +@pytest.mark.parametrize( + "surcharges", + [ + {"aud": "un-autre-public"}, + {"iss": "un-autre-emetteur"}, + {"typ": "refresh"}, + ], + ids=["audience_invalide", "emetteur_invalide", "jeton_de_rafraichissement"], +) +def test_decode_access_token_raises_invalid_when_a_claim_is_wrong( + surcharges: dict[str, object], +) -> None: + jeton = emets(**surcharges) + + with pytest.raises(TokenInvalidError): + decode_access_token(POLITIQUE, jeton) + + +@pytest.mark.parametrize( + "claim", + ["jti", "typ", "role", "kind"], + ids=["identifiant", "type", "role", "nature_du_compte"], +) +def test_decode_access_token_raises_invalid_when_a_required_claim_is_missing(claim: str) -> None: + charge = { + "iss": POLITIQUE.issuer, + "aud": POLITIQUE.audience, + "sub": str(uuid4()), + "iat": datetime.now(UTC), + "exp": datetime.now(UTC) + timedelta(minutes=15), + "jti": str(uuid4()), + "typ": "access", + "role": "lecteur", + "kind": "human", + } + del charge[claim] + + jeton = jwt.encode(charge, POLITIQUE.secret, algorithm="HS256") + + with pytest.raises(TokenInvalidError): + decode_access_token(POLITIQUE, jeton) + + +def test_decode_access_token_rejects_a_token_forged_with_the_none_algorithm() -> None: + def encode(donnees: dict[str, object]) -> str: + brut = json.dumps(donnees, separators=(",", ":")).encode() + return base64.urlsafe_b64encode(brut).rstrip(b"=").decode() + + entete = encode({"alg": "none", "typ": "JWT"}) + charge = encode( + { + "iss": POLITIQUE.issuer, + "aud": POLITIQUE.audience, + "sub": str(uuid4()), + "iat": int(datetime.now(UTC).timestamp()), + "exp": int((datetime.now(UTC) + timedelta(minutes=15)).timestamp()), + "jti": str(uuid4()), + "typ": "access", + "role": "admin", + "kind": "human", + } + ) + + with pytest.raises(TokenInvalidError): + decode_access_token(POLITIQUE, f"{entete}.{charge}.") + + +def test_decode_access_token_rejects_a_token_signed_with_another_algorithm() -> None: + charge = { + "iss": POLITIQUE.issuer, + "aud": POLITIQUE.audience, + "sub": str(uuid4()), + "iat": datetime.now(UTC), + "exp": datetime.now(UTC) + timedelta(minutes=15), + "jti": str(uuid4()), + "typ": "access", + "role": "admin", + "kind": "human", + } + + jeton = jwt.encode(charge, POLITIQUE.secret * 2, algorithm="HS512") + + with pytest.raises(TokenInvalidError): + decode_access_token(POLITIQUE, jeton) + + +@pytest.mark.parametrize( + "surcharges", + [{"sub": "pas-un-uuid"}, {"jti": "pas-un-uuid"}], + ids=["sujet_illisible", "identifiant_illisible"], +) +def test_decode_access_token_raises_invalid_when_an_identifier_is_not_a_uuid( + surcharges: dict[str, object], +) -> None: + jeton = emets(**surcharges) + + with pytest.raises(TokenInvalidError): + decode_access_token(POLITIQUE, jeton) + + +def test_generate_refresh_secret_returns_distinct_url_safe_values() -> None: + secrets_generes = {generate_refresh_secret() for _ in range(100)} + + assert len(secrets_generes) == 100 + assert all(len(valeur) >= 43 for valeur in secrets_generes) + + +def test_fingerprint_refresh_is_stable_and_distinguishes_two_secrets() -> None: + premier = generate_refresh_secret() + second = generate_refresh_secret() + + empreinte = fingerprint_refresh(premier) + + assert len(empreinte) == 32 + assert empreinte == fingerprint_refresh(premier) + assert empreinte != fingerprint_refresh(second) diff --git a/apps/backend/uv.lock b/apps/backend/uv.lock index f799110..edc09c2 100644 --- a/apps/backend/uv.lock +++ b/apps/backend/uv.lock @@ -47,6 +47,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, ] +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/43/bb8b6e8708d49a5ab36781333af092d9f483b198a2710d01281204640055/argon2_cffi_bindings-26.1.0.tar.gz", hash = "sha256:63505c71542a44b68b1e38060450fb006404170da375feb31af153e7f9c6205d", size = 1790807, upload-time = "2026-08-20T07:44:22.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/d2/0ae991f1b2181e5be49007c574710a800ad36c2978683addb3e67c474e55/argon2_cffi_bindings-26.1.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:21ca0396fe5ec995dd54431c32698189666f9224810acfa752e50d2bd94d9df2", size = 25521, upload-time = "2026-08-20T07:32:43.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e4/ad91d8297638aa2258aad4501c306aca99480dfe76ccd638173fa3702db9/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78de2d65e0b9ea7ce9d1b1c3e87297b2d7305a02c266ee2a2d6910daddd7ee69", size = 27177, upload-time = "2026-08-20T07:32:44.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/86/5363df11b86d02cf3662208e7406496327649cc90eb365bf6f4e8a54a41f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27f1821903e2ceadcb88ec2b45ef190897b7682449c772f4d9b53e42c520cf29", size = 26597, upload-time = "2026-08-20T07:32:45.172Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b5/a14dcc592652347dad23ee93b278a4da5d2a25c9ed3ebd10d68eea823a4f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d88e5f7e60f28ae0b0cc6b2f16c43e87cd642a196a86f85e0d8bb6fe016fc16d", size = 27403, upload-time = "2026-08-20T07:32:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/b4a20d4902af7f796390bf9245ff83c5217dfa7367efa1d14986956c482b/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:34b7d9c24a4165a2c61cc8ae11d44d48c9ce2830fb536cb7914e11fdd9962728", size = 27132, upload-time = "2026-08-20T07:32:47.13Z" }, + { url = "https://files.pythonhosted.org/packages/7e/1b/c8de358af07b1c490e0fcb863ef98e46ddb486e45567aca5a60bd68d9daa/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:224865cbbcb7a2bd1356741dff12b0134df726b6d44bb7b500df8e303cbd9e81", size = 27588, upload-time = "2026-08-20T07:32:48.087Z" }, + { url = "https://files.pythonhosted.org/packages/48/2f/7ee62a6e79f9309f9d9982d301b22a00010adb580c05c8109b94d7b33de0/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ffff613aaa9ce6236766e2fc6dc560bb5abde7a2e2416e3db1f9ae395a2b4dd4", size = 26785, upload-time = "2026-08-20T07:32:48.977Z" }, + { url = "https://files.pythonhosted.org/packages/e9/10/960d0ee93d4897741bcaf4799c697dae2d81499f66fd1ed042a7dd54c1f4/argon2_cffi_bindings-26.1.0-cp310-abi3-win32.whl", hash = "sha256:a86c069c91a747a2c4e5c51473590aeb48172fff9b2130d23729a42d98665ecb", size = 23898, upload-time = "2026-08-20T07:32:50.114Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3a/0cc14a05810e6add9bce5e87693334baa2222de5f647fa31781885b6573f/argon2_cffi_bindings-26.1.0-cp310-abi3-win_amd64.whl", hash = "sha256:2c36ff87b5dfaa477d0bd51e9d7f6abdae7c8955d2983c97419085d842154b3e", size = 25730, upload-time = "2026-08-20T07:32:51.091Z" }, + { url = "https://files.pythonhosted.org/packages/4e/db/d83cf2af140547f0b9cdaece05b2dc2dcbf991be4667331d073eff771435/argon2_cffi_bindings-26.1.0-cp310-abi3-win_arm64.whl", hash = "sha256:f9c4420a7a864fe1b86ce35befc95b8e39fb852493b81cf798671ddc265de638", size = 24478, upload-time = "2026-08-20T07:32:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/76/38/de696045960f5b846d428c0fb6c130ed3da87aac2af209b05c193815404c/argon2_cffi_bindings-26.1.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:db0fcd827ca61622a01b220aadfbece01939acf53888f2cb98cd93e9b1e2c97e", size = 15449, upload-time = "2026-08-20T07:32:54.075Z" }, + { url = "https://files.pythonhosted.org/packages/91/0a/c25af768f6b75a5a71e31207f87c540656b2808c015260444a22763221ad/argon2_cffi_bindings-26.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:28524438cd3e723f25412f63d4fd516ff5bae9ae5aa56acbe2a1404398a0cf31", size = 25683, upload-time = "2026-08-20T07:32:55.05Z" }, + { url = "https://files.pythonhosted.org/packages/a8/7e/be212c751ab0bcea7f646615f933bf262e8e50b3f7bef32f861d0a2d066b/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac82fc756a446b6ccd7139ce70efa9d8bbe541e7ad579a12dcb52764b7175c5f", size = 27311, upload-time = "2026-08-20T07:32:56.166Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ee/f84b28e4afd13d3cac36c1d8fa8c239d2dc2c51cd978d02ee5d5ad98d9bb/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6a4e68eed961a8de6928d1c17ff3dc2a547e0e923c17f8f1cd79fb7bc9502f98", size = 26771, upload-time = "2026-08-20T07:32:57.206Z" }, + { url = "https://files.pythonhosted.org/packages/21/c3/95c07a023691ecd529da9cb6a8f0779e13ebc1bdfaa86d145fdc1c6e7e79/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:151dfaad9de753f4af2a7854e707e4784f2acc434340ade64239c5b104b2d605", size = 27568, upload-time = "2026-08-20T07:32:58.361Z" }, + { url = "https://files.pythonhosted.org/packages/e6/31/3a18e31406d8694b4d6a31573c3e572fff6bed318bb744453eb653766d22/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:061a6919145bbf282ebf1f9c59d3135d4833c25313c8595c0d68cf7712ddfce2", size = 27280, upload-time = "2026-08-20T07:32:59.343Z" }, + { url = "https://files.pythonhosted.org/packages/0b/39/d4be4577e178b2397aa5b5575c8a309bf0da2afe05fe0c72c8f398662d63/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:62ff20cd130c956c7c9144d5fe35228f98b51c579b2439e988b27ef93e16c02a", size = 27776, upload-time = "2026-08-20T07:33:00.325Z" }, + { url = "https://files.pythonhosted.org/packages/71/47/78f4dd96f7411339f723b96fe24039c1bd5835102b8a5ba71ac4ec712ac7/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:19423e5d7ac1cc354baab59eaabf18db2ec04ef6593b5abe5a34f323c4a8f87a", size = 26932, upload-time = "2026-08-20T07:33:01.272Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/96bfd37434cc0a848a9066c291d84b28846c4c9ea289ed9866b1164d622b/argon2_cffi_bindings-26.1.0-cp314-cp314t-win32.whl", hash = "sha256:4f84cdd868978d7b7350a566c254042d44216d9e37f241f3a6d3b1dfebeede35", size = 24878, upload-time = "2026-08-20T07:33:02.189Z" }, + { url = "https://files.pythonhosted.org/packages/f1/42/d8b6810abd9b1bd2f47ebbccf460da59c9f32e94888bea4f7b137d998797/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2b741888c93147444fdfc851abd81cc207f37f7f7da42062a00deb3888e57da8", size = 26656, upload-time = "2026-08-20T07:33:03.222Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d1/095d95eaf2ed1d9f77268cf3291bde148c6cd56121f8db2c74c1ba618a0e/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6ab674f668d5962a3a4136ae0812519b0f1586874263723a32181d60d64137e1", size = 25378, upload-time = "2026-08-20T07:33:04.332Z" }, +] + [[package]] name = "ast-serialize" version = "0.11.2" @@ -143,6 +187,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, +] + [[package]] name = "click" version = "8.5.0" @@ -206,11 +285,14 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "alembic" }, + { name = "anyio" }, + { name = "argon2-cffi" }, { name = "asyncpg" }, { name = "fastapi" }, { name = "prometheus-fastapi-instrumentator" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "pyjwt" }, { name = "python-json-logger" }, { name = "sqlalchemy", extra = ["asyncio"] }, { name = "uvicorn", extra = ["standard"] }, @@ -229,11 +311,14 @@ dev = [ [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.20.0" }, + { name = "anyio", specifier = ">=4.0" }, + { name = "argon2-cffi", specifier = ">=23.1" }, { name = "asyncpg", specifier = ">=0.31.0" }, { name = "fastapi", specifier = ">=0.141.1" }, { name = "prometheus-fastapi-instrumentator", specifier = ">=8.1.0" }, { name = "pydantic", specifier = ">=2.13.5" }, { name = "pydantic-settings", specifier = ">=2.15.0" }, + { name = "pyjwt", specifier = ">=2.10" }, { name = "python-json-logger", specifier = ">=4.2.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.52" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.53.0" }, @@ -537,6 +622,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/b9/91a2246e6cf01b7ccb14479803c8a50f9c258ae5c6a0f16ba3294820632b/prometheus_fastapi_instrumentator-8.1.0-py3-none-any.whl", hash = "sha256:b9f40b2cff3f7891ca0610b3ae4fc6ec723fd326b04bb659819aaeb821a0fc7d", size = 19649, upload-time = "2026-07-26T11:12:45.168Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.5" @@ -616,6 +710,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] +[[package]] +name = "pyjwt" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" }, +] + [[package]] name = "pytest" version = "9.1.1" From a8f59e6e76fca98de7ab3946c130cdc6591f4ade Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:30:05 +0200 Subject: [PATCH 004/103] =?UTF-8?q?feat(backend):=20ajoute=20les=20comptes?= =?UTF-8?q?=20applicatifs=20et=20l'amor=C3=A7age=20du=20premier=20admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Table `app_user`, son dépôt, et la commande `create-admin`. Le nom évite `user`, mot réservé de PostgreSQL, et rappelle qu'il s'agit d'un compte applicatif, par opposition au rôle PostgreSQL qui portera le cantonnement des accès ETL et ML. `credentials_changed_at` couvre à elle seule le changement de mot de passe, le changement de rôle et la désactivation : tout jeton émis avant cet instant sera refusé, sans attendre son expiration. La configuration refuse désormais de démarrer sur cinq erreurs silencieuses : secret trop court ou laissé à sa valeur d'exemple, `debug` en production, joker CORS, origines vides hors local, et cookie `SameSite=None` sans `Secure`. Les fixtures de test et les deux `.env.example` suivent, sans quoi rien ne démarrerait. Le mot de passe de l'admin ne transite jamais par `argv`, visible de tout `ps` : il est saisi par `getpass` ou tiré au sort. Une révision Alembic qui insérerait ce compte graverait son empreinte dans Git pour toujours. --- .env.example | 12 +- Makefile | 25 +++-- apps/backend/.env.example | 6 +- .../b1a7c3d9e240_comptes_applicatifs.py | 72 ++++++++++++ apps/backend/app/cli.py | 104 ++++++++++++++++++ apps/backend/app/core/config.py | 72 +++++++++++- apps/backend/app/core/cookies.py | 54 +++++++++ apps/backend/app/models/__init__.py | 4 + apps/backend/app/models/user.py | 50 +++++++++ apps/backend/app/repositories/user.py | 97 ++++++++++++++++ apps/backend/tests/conftest.py | 2 +- apps/backend/tests/core/test_config.py | 78 +++++++++++++ apps/backend/tests/core/test_cookies.py | 58 ++++++++++ apps/backend/tests/factories.py | 2 +- 14 files changed, 615 insertions(+), 21 deletions(-) create mode 100644 apps/backend/alembic/versions/b1a7c3d9e240_comptes_applicatifs.py create mode 100644 apps/backend/app/cli.py create mode 100644 apps/backend/app/core/cookies.py create mode 100644 apps/backend/app/models/user.py create mode 100644 apps/backend/app/repositories/user.py create mode 100644 apps/backend/tests/core/test_config.py create mode 100644 apps/backend/tests/core/test_cookies.py diff --git a/.env.example b/.env.example index a5fba5a..54dc3d8 100644 --- a/.env.example +++ b/.env.example @@ -1,17 +1,19 @@ -# Variables lues par docker-compose.yml a la racine. -# Le backend lance hors conteneur (`make dev`) lit apps/backend/.env, pas ce fichier. +# Variables lues par docker-compose.yml à la racine. +# Le backend lancé hors conteneur (`make dev`) lit apps/backend/.env, pas ce fichier. POSTGRES_USER=enervision POSTGRES_PASSWORD=change_me POSTGRES_DB=enervision -# 5432 est souvent deja pris par une autre base du poste. +# 5432 est souvent déjà pris par une autre base du poste. POSTGRES_PORT=5433 -# `basic` renvoie des statistiques d'usage a Timescale. +# `basic` renvoie des statistiques d'usage à Timescale. TIMESCALEDB_TELEMETRY=off APP_ENV=local -APP_DEBUG=true +APP_DEBUG=false APP_LOG_LEVEL=INFO +# L'API refuse de démarrer tant que cette valeur reste un exemple ou fait moins de +# 32 caractères. Générer la vôtre : python -c "import secrets; print(secrets.token_urlsafe(48))" APP_SECRET_KEY=change_me APP_CORS_ORIGINS=http://localhost:4200 BACKEND_PORT=8000 diff --git a/Makefile b/Makefile index 81c3e6d..bf45b61 100644 --- a/Makefile +++ b/Makefile @@ -2,15 +2,15 @@ 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 + 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 dependances du backend +install: ## Installe les dépendances du backend cd $(BACKEND) && uv sync --all-groups -dev: ## Lance l'API en rechargement a chaud +dev: ## Lance l'API en rechargement à chaud cd $(BACKEND) && uv run uvicorn app.main:create_app --factory --reload --host 0.0.0.0 --port 8000 lint: ## Analyse statique du backend @@ -19,31 +19,31 @@ lint: ## Analyse statique du backend format: ## Formate et corrige le backend cd $(BACKEND) && uv run ruff format . && uv run ruff check --fix . -typecheck: ## Verifie le typage du backend +typecheck: ## Vérifie le typage du backend cd $(BACKEND) && uv run mypy app -test: ## Execute les tests backend ne demandant pas de base +test: ## Exécute les tests backend ne demandant pas de base cd $(BACKEND) && uv run pytest --cov-fail-under=85 -test-cov: ## Rapports de couverture HTML et XML, plus les resultats au format JUnit +test-cov: ## Rapports de couverture HTML et XML, plus les résultats au format JUnit cd $(BACKEND) && uv run pytest --cov-fail-under=85 --cov-report=html \ --cov-report=xml --junitxml=test-results/junit.xml -test-integration: ## Execute les tests exigeant une base joignable +test-integration: ## Exécute les tests exigeant une base joignable cd $(BACKEND) && uv run pytest -m integration -check: lint typecheck test ## Chaine de verification complete +check: lint typecheck test ## Chaîne de vérification complète docker-build: ## Construit l'image du backend docker build -t enervision-backend:local $(BACKEND) -db-up: ## Demarre la base PostgreSQL TimescaleDB +db-up: ## Démarre la base PostgreSQL TimescaleDB docker compose up -d db -db-down: ## Arrete la base en conservant ses donnees +db-down: ## Arrête la base en conservant ses données docker compose stop db -db-reset: ## Detruit la base et rejoue db/init +db-reset: ## Détruit la base et rejoue db/init docker compose down -v && docker compose up -d db db-logs: ## Suit les journaux de la base @@ -54,3 +54,6 @@ db-psql: ## Ouvre une session psql sur la base applicative migrate: ## Applique les migrations Alembic cd $(BACKEND) && uv run alembic upgrade head + +bootstrap-admin: ## Crée le premier administrateur, mot de passe saisi au clavier + cd $(BACKEND) && uv run python -m app.cli create-admin --email $${EMAIL:?EMAIL=... requis} diff --git a/apps/backend/.env.example b/apps/backend/.env.example index cd96463..f36551e 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -1,6 +1,10 @@ APP_ENV=local -APP_DEBUG=true +APP_DEBUG=false APP_LOG_LEVEL=INFO + +# L'API refuse de démarrer tant que cette valeur reste un exemple ou fait moins de +# 32 caractères. Générer la vôtre : python -c "import secrets; print(secrets.token_urlsafe(48))" APP_SECRET_KEY=change_me + APP_CORS_ORIGINS=http://localhost:4200 DATABASE_URL=postgresql+asyncpg://enervision:change_me@localhost:5433/enervision diff --git a/apps/backend/alembic/versions/b1a7c3d9e240_comptes_applicatifs.py b/apps/backend/alembic/versions/b1a7c3d9e240_comptes_applicatifs.py new file mode 100644 index 0000000..db50a12 --- /dev/null +++ b/apps/backend/alembic/versions/b1a7c3d9e240_comptes_applicatifs.py @@ -0,0 +1,72 @@ +"""comptes applicatifs + +Revision ID: b1a7c3d9e240 +Revises: 5353c0e4f094 +Create Date: 2026-09-15 14:40:00.000000 + +Cree `app_user`, la table des comptes humains et de service. Le nom evite `user`, +mot reserve de PostgreSQL. `gen_random_uuid()` est au coeur de PG17, aucune +extension n'est necessaire. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "b1a7c3d9e240" +down_revision: str | Sequence[str] | None = "5353c0e4f094" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "app_user", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + nullable=False, + ), + sa.Column("email", sa.String(length=320), nullable=False), + sa.Column("password_hash", sa.Text(), nullable=False), + sa.Column("role", sa.Text(), nullable=False), + sa.Column("kind", sa.Text(), server_default=sa.text("'human'"), nullable=False), + sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False), + sa.Column( + "must_change_password", sa.Boolean(), server_default=sa.text("false"), nullable=False + ), + sa.Column( + "credentials_changed_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("full_name", sa.Text(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.CheckConstraint("email = lower(email)", name="ck_app_user_email_minuscule"), + sa.CheckConstraint( + "role in ('lecteur', 'operateur', 'admin')", name="ck_app_user_role" + ), + sa.CheckConstraint("kind in ('human', 'service')", name="ck_app_user_kind"), + sa.PrimaryKeyConstraint("id", name="pk_app_user"), + sa.UniqueConstraint("email", name="uq_app_user_email"), + ) + + +def downgrade() -> None: + op.drop_table("app_user") diff --git a/apps/backend/app/cli.py b/apps/backend/app/cli.py new file mode 100644 index 0000000..74d7a50 --- /dev/null +++ b/apps/backend/app/cli.py @@ -0,0 +1,104 @@ +# Pourquoi : `create_admin()` est une commande et non une révision Alembic. Une révision qui +# insérerait un compte graverait son empreinte dans Git pour toujours, et son mot de passe +# serait connu de quiconque lit le dépôt. L'ADR 0001 pose par ailleurs qu'Alembic porte le +# schéma, pas les données. +# Piège : le mot de passe ne transite jamais par `argv`, visible de tout `ps`, ni par +# l'historique du shell. Il est saisi par `getpass` ou tiré au sort par la commande. + +import argparse +import asyncio +import secrets +import sys +from getpass import getpass + +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.repositories.user import UserRepository + +LONGUEUR_MOT_DE_PASSE_GENERE = 24 +LONGUEUR_MINIMALE = 12 + + +async def create_admin( + settings: Settings, *, email: str, password: str, force: bool +) -> tuple[bool, str]: + hacheur = build_hasher( + time_cost=settings.argon2_time_cost, + memory_cost_kib=settings.argon2_memory_cost_kib, + parallelism=settings.argon2_parallelism, + max_concurrency=settings.argon2_max_concurrency, + ) + empreinte = await hacheur.hash(password) + + async with get_session_factory()() as session: + depot = UserRepository(session) + + if not force and await depot.count_active_admins() > 0: + return False, "Un administrateur actif existe déjà, relancer avec --force pour forcer" + + if await depot.get_by_email(email) is not None: + return False, f"Le compte {email} existe déjà" + + await depot.create( + email=email, + password_hash=empreinte, + role=Role.ADMIN, + must_change_password=True, + ) + await session.commit() + + return ( + True, + f"Administrateur {email.strip().lower()} créé, mot de passe à changer à la connexion", + ) + + +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) + + admin = sous_commandes.add_parser("create-admin", help="Crée le premier administrateur") + admin.add_argument("--email", required=True) + admin.add_argument( + "--generate", action="store_true", help="Tire un mot de passe au sort et l'affiche une fois" + ) + admin.add_argument( + "--force", action="store_true", help="Crée le compte même si un administrateur existe" + ) + return parser + + +def read_password(*, generate: bool) -> str: + if generate: + mot_de_passe = secrets.token_urlsafe(LONGUEUR_MOT_DE_PASSE_GENERE) + print(f"Mot de passe généré, il ne sera plus affiché : {mot_de_passe}") + return mot_de_passe + + mot_de_passe = getpass("Mot de passe : ") + if len(mot_de_passe) < LONGUEUR_MINIMALE: + raise SystemExit(f"Le mot de passe doit faire au moins {LONGUEUR_MINIMALE} caractères") + if mot_de_passe != getpass("Confirmation : "): + raise SystemExit("Les deux saisies diffèrent") + return mot_de_passe + + +def main(argv: list[str] | None = None) -> int: + arguments = build_parser().parse_args(argv) + mot_de_passe = read_password(generate=arguments.generate) + + succes, message = asyncio.run( + create_admin( + get_settings(), + email=arguments.email, + password=mot_de_passe, + force=arguments.force, + ) + ) + print(message) + return 0 if succes else 1 + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/apps/backend/app/core/config.py b/apps/backend/app/core/config.py index c3dbbe2..4731f81 100644 --- a/apps/backend/app/core/config.py +++ b/apps/backend/app/core/config.py @@ -1,10 +1,16 @@ from functools import lru_cache -from typing import Literal +from typing import Literal, Self -from pydantic import Field, SecretStr +from pydantic import Field, SecretStr, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict Environment = Literal["local", "dev", "staging", "prod"] +SameSite = Literal["lax", "strict", "none"] + +SECRET_KEY_MIN_LENGTH = 32 +SENTINELLES_INTERDITES = frozenset( + {"change_me", "changeme", "secret", "secret-de-test", "changez-moi", "todo"} +) class Settings(BaseSettings): @@ -27,6 +33,30 @@ class Settings(BaseSettings): database_pool_size: int = 5 database_max_overflow: int = 10 + jwt_issuer: str = "enervision-api" + jwt_audience: str = "enervision-web" + 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" + cookie_path: str = "/api/v1/auth" + cookie_samesite: SameSite = "strict" + cookie_secure: bool | None = None + + argon2_time_cost: int = Field(default=2, ge=1, le=10) + argon2_memory_cost_kib: int = Field(default=19456, ge=8192) + argon2_parallelism: int = Field(default=1, ge=1, le=4) + argon2_max_concurrency: int = Field(default=4, ge=1, le=32) + + login_window_seconds: int = Field(default=900, ge=60) + login_max_failures_per_identifier_and_ip: int = Field(default=5, ge=1) + login_max_failures_per_ip: int = Field(default=20, ge=1) + login_max_failures_per_identifier: int = Field(default=50, ge=1) + + trust_proxy_headers: bool = False + expose_api_docs: bool | None = None + metrics_token: SecretStr | None = None + @property def allowed_origins(self) -> list[str]: return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()] @@ -35,6 +65,44 @@ class Settings(BaseSettings): def is_production(self) -> bool: return self.env == "prod" + @property + def cookies_are_secure(self) -> bool: + return self.env != "local" if self.cookie_secure is None else self.cookie_secure + + @property + def api_docs_are_exposed(self) -> bool: + if self.expose_api_docs is not None: + return self.expose_api_docs + return self.env not in ("staging", "prod") + + @model_validator(mode="after") + def _refuse_les_configurations_dangereuses(self) -> Self: + secret = self.secret_key.get_secret_value() + if len(secret) < SECRET_KEY_MIN_LENGTH: + raise ValueError( + f"APP_SECRET_KEY doit faire au moins {SECRET_KEY_MIN_LENGTH} caractères" + ) + if secret.strip().lower() in SENTINELLES_INTERDITES: + raise ValueError("APP_SECRET_KEY est une valeur d'exemple, il faut en générer une") + + # Piège : `create_app()` passe `debug` à FastAPI, qui renvoie alors la trace complète + # au client, et à l'engine, qui journalise le SQL et ses paramètres. + if self.debug and self.env in ("staging", "prod"): + raise ValueError("APP_DEBUG doit rester faux hors des environnements locaux") + + if "*" in self.cors_origins: + raise ValueError("APP_CORS_ORIGINS n'accepte pas de joker, les origines sont listées") + + # Sans origines, aucun middleware CORS n'est monté et la vérification d'`Origin` des + # routes d'authentification n'a plus de référentiel auquel comparer. + if self.env != "local" and not self.allowed_origins: + raise ValueError("APP_CORS_ORIGINS doit lister au moins une origine hors local") + + if self.cookie_samesite == "none" and not self.cookies_are_secure: + raise ValueError("Un cookie SameSite=None est rejeté par les navigateurs sans Secure") + + return self + @lru_cache def get_settings() -> Settings: diff --git a/apps/backend/app/core/cookies.py b/apps/backend/app/core/cookies.py new file mode 100644 index 0000000..f54dbff --- /dev/null +++ b/apps/backend/app/core/cookies.py @@ -0,0 +1,54 @@ +# Piège : le cookie de suppression doit reprendre exactement le nom et le `Path` du cookie +# posé, sinon le navigateur en garde une copie et la déconnexion n'est que cosmétique. +# `RefreshCookie.expired()` existe pour que les deux ne puissent pas diverger. + +from dataclasses import asdict, dataclass +from typing import Any, Self + +from app.core.config import SameSite, Settings + +SECURE_PREFIX = "__Secure-" + + +@dataclass(frozen=True, slots=True) +class RefreshCookie: + key: str + value: str + max_age: int + path: str + secure: bool + httponly: bool + samesite: SameSite + + @classmethod + def build(cls, settings: Settings, value: str) -> Self: + return cls( + key=cookie_name(settings), + value=value, + max_age=settings.refresh_token_ttl_seconds, + path=settings.cookie_path, + secure=settings.cookies_are_secure, + httponly=True, + samesite=settings.cookie_samesite, + ) + + @classmethod + def expired(cls, settings: Settings) -> Self: + return cls( + key=cookie_name(settings), + value="", + max_age=0, + path=settings.cookie_path, + secure=settings.cookies_are_secure, + httponly=True, + samesite=settings.cookie_samesite, + ) + + def as_kwargs(self) -> dict[str, Any]: + return asdict(self) + + +def cookie_name(settings: Settings) -> str: + if settings.cookies_are_secure: + return f"{SECURE_PREFIX}{settings.refresh_cookie_name}" + return settings.refresh_cookie_name diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py index 2ac405d..dd6cc73 100644 --- a/apps/backend/app/models/__init__.py +++ b/apps/backend/app/models/__init__.py @@ -1,2 +1,6 @@ # Piège : tout modèle absent de ce module reste invisible de `alembic revision # --autogenerate`, qui générerait alors un drop de sa table. + +from app.models.user import AppUser + +__all__ = ["AppUser"] diff --git a/apps/backend/app/models/user.py b/apps/backend/app/models/user.py new file mode 100644 index 0000000..b2dcf4b --- /dev/null +++ b/apps/backend/app/models/user.py @@ -0,0 +1,50 @@ +# Contrainte : la table s'appelle `app_user` et non `user`, qui est un mot réservé PostgreSQL, +# raccourci de `CURRENT_USER`. Le nom rappelle aussi qu'il s'agit d'un compte applicatif, par +# opposition au rôle PostgreSQL qui porte, lui, le cantonnement des accès. + +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, CheckConstraint, DateTime, String, Text, func, text +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.roles import AccountKind, Role +from app.db.base import Base + +ROLES_AUTORISES = ", ".join(f"'{role.value}'" for role in Role) +NATURES_AUTORISEES = ", ".join(f"'{nature.value}'" for nature in AccountKind) + + +class AppUser(Base): + __tablename__ = "app_user" + __table_args__ = ( + CheckConstraint("email = lower(email)", name="ck_app_user_email_minuscule"), + CheckConstraint(f"role in ({ROLES_AUTORISES})", name="ck_app_user_role"), + CheckConstraint(f"kind in ({NATURES_AUTORISEES})", name="ck_app_user_kind"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid() + ) + email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False) + password_hash: Mapped[str] = mapped_column(Text, nullable=False) + role: Mapped[str] = mapped_column(Text, nullable=False) + kind: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'human'")) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true")) + must_change_password: Mapped[bool] = mapped_column( + Boolean, nullable=False, server_default=text("false") + ) + # Une seule colonne couvre le changement de mot de passe, le changement de rôle et la + # désactivation : tout jeton émis avant cet instant est périmé. + credentials_changed_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + full_name: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() + ) diff --git a/apps/backend/app/repositories/user.py b/apps/backend/app/repositories/user.py new file mode 100644 index 0000000..9155db1 --- /dev/null +++ b/apps/backend/app/repositories/user.py @@ -0,0 +1,97 @@ +# Piège : `set_role()` et `set_active()` avancent `credentials_changed_at`. C'est ce qui rend +# un changement de rôle ou une désactivation effectifs à la requête suivante au lieu d'attendre +# l'expiration du jeton d'accès. Une mise à jour qui l'oublierait laisserait 15 minutes de +# privilèges périmés. + +from collections.abc import Sequence +from uuid import UUID + +from sqlalchemy import func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.roles import AccountKind, Role +from app.models.user import AppUser + + +class UserRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_email(self, email: str) -> AppUser | None: + requete = select(AppUser).where(AppUser.email == email.strip().lower()) + return (await self._session.execute(requete)).scalar_one_or_none() + + async def get_by_id(self, user_id: UUID) -> AppUser | None: + return await self._session.get(AppUser, user_id) + + async def list_all(self) -> Sequence[AppUser]: + requete = select(AppUser).order_by(AppUser.email) + return (await self._session.execute(requete)).scalars().all() + + async def count_active_admins(self) -> int: + requete = ( + select(func.count()) + .select_from(AppUser) + .where(AppUser.role == Role.ADMIN.value, AppUser.is_active.is_(True)) + ) + return (await self._session.execute(requete)).scalar_one() + + async def create( + self, + *, + email: str, + password_hash: str, + role: Role, + kind: AccountKind = AccountKind.HUMAIN, + full_name: str | None = None, + must_change_password: bool = False, + ) -> AppUser: + compte = AppUser( + email=email.strip().lower(), + password_hash=password_hash, + role=role.value, + kind=kind.value, + full_name=full_name, + must_change_password=must_change_password, + ) + self._session.add(compte) + await self._session.flush() + return compte + + async def update_password( + self, user_id: UUID, password_hash: str, *, must_change_password: bool + ) -> None: + await self._session.execute( + update(AppUser) + .where(AppUser.id == user_id) + .values( + password_hash=password_hash, + must_change_password=must_change_password, + credentials_changed_at=func.now(), + ) + ) + + async def rehash_password(self, user_id: UUID, password_hash: str) -> None: + # Un simple recalcul avec des paramètres Argon2 plus récents ne périme aucun jeton. + await self._session.execute( + update(AppUser).where(AppUser.id == user_id).values(password_hash=password_hash) + ) + + async def touch_last_login(self, user_id: UUID) -> None: + await self._session.execute( + update(AppUser).where(AppUser.id == user_id).values(last_login_at=func.now()) + ) + + async def set_role(self, user_id: UUID, role: Role) -> None: + await self._session.execute( + update(AppUser) + .where(AppUser.id == user_id) + .values(role=role.value, credentials_changed_at=func.now()) + ) + + async def set_active(self, user_id: UUID, *, is_active: bool) -> None: + await self._session.execute( + update(AppUser) + .where(AppUser.id == user_id) + .values(is_active=is_active, credentials_changed_at=func.now()) + ) diff --git a/apps/backend/tests/conftest.py b/apps/backend/tests/conftest.py index 70ba87a..bc8ccfb 100644 --- a/apps/backend/tests/conftest.py +++ b/apps/backend/tests/conftest.py @@ -22,7 +22,7 @@ def environment() -> Iterator[None]: "APP_DEBUG": "false", "APP_LOG_LEVEL": "WARNING", "APP_CORS_ORIGINS": "", - "APP_SECRET_KEY": "secret-de-test", + "APP_SECRET_KEY": "secret-de-test-assez-long-pour-le-validateur", } ) os.environ.setdefault( diff --git a/apps/backend/tests/core/test_config.py b/apps/backend/tests/core/test_config.py new file mode 100644 index 0000000..6c67120 --- /dev/null +++ b/apps/backend/tests/core/test_config.py @@ -0,0 +1,78 @@ +import pytest +from pydantic import ValidationError + +from tests.factories import make_settings + +SECRET_VALIDE = "un-secret-de-test-de-plus-de-trente-deux-caracteres" + + +@pytest.mark.parametrize( + "surcharges", + [ + {"secret_key": "trop-court"}, + {"secret_key": "change_me"}, + {"env": "prod", "debug": True, "cors_origins": "https://enervision.fr"}, + {"cors_origins": "*"}, + {"env": "prod", "cors_origins": ""}, + {"cookie_samesite": "none", "cookie_secure": False}, + ], + ids=[ + "secret_trop_court", + "secret_sentinelle", + "debug_en_production", + "joker_dans_les_origines", + "origines_vides_hors_local", + "samesite_none_sans_secure", + ], +) +def test_settings_refuses_to_build_when_the_configuration_is_unsafe( + surcharges: dict[str, object], +) -> None: + with pytest.raises(ValidationError): + make_settings(**surcharges) + + +def test_settings_accepts_debug_in_local_environment() -> None: + settings = make_settings(env="local", debug=True) + + assert settings.debug is True + + +@pytest.mark.parametrize( + ("env", "attendu"), + [("local", False), ("dev", True), ("staging", True), ("prod", True)], + ids=["local", "dev", "staging", "production"], +) +def test_cookies_are_secure_follows_the_environment(env: str, attendu: bool) -> None: + settings = make_settings(env=env, cors_origins="https://enervision.fr") + + assert settings.cookies_are_secure is attendu + + +def test_cookies_are_secure_honours_an_explicit_override() -> None: + settings = make_settings(env="prod", cors_origins="https://enervision.fr", cookie_secure=False) + + assert settings.cookies_are_secure is False + + +@pytest.mark.parametrize( + ("env", "attendu"), + [("local", True), ("dev", True), ("staging", False), ("prod", False)], + ids=["local", "dev", "staging", "production"], +) +def test_api_docs_are_exposed_closes_staging_and_production(env: str, attendu: bool) -> None: + settings = make_settings(env=env, cors_origins="https://enervision.fr") + + assert settings.api_docs_are_exposed is attendu + + +def test_api_docs_are_exposed_honours_an_explicit_override() -> None: + settings = make_settings(env="prod", cors_origins="https://enervision.fr", expose_api_docs=True) + + assert settings.api_docs_are_exposed is True + + +def test_allowed_origins_splits_and_trims_the_list() -> None: + settings = make_settings(cors_origins=" http://localhost:4200 , https://enervision.fr ") + + assert settings.allowed_origins == ["http://localhost:4200", "https://enervision.fr"] diff --git a/apps/backend/tests/core/test_cookies.py b/apps/backend/tests/core/test_cookies.py new file mode 100644 index 0000000..7454e47 --- /dev/null +++ b/apps/backend/tests/core/test_cookies.py @@ -0,0 +1,58 @@ +from app.core.cookies import RefreshCookie, cookie_name +from tests.factories import make_settings + + +def test_build_marks_the_cookie_http_only_and_scopes_it_to_the_auth_routes() -> None: + settings = make_settings(env="local") + + cookie = RefreshCookie.build(settings, "un-secret-opaque") + + assert cookie.httponly is True + assert cookie.samesite == "strict" + assert cookie.path == "/api/v1/auth" + assert cookie.max_age == settings.refresh_token_ttl_seconds + + +def test_build_prefixes_and_secures_the_cookie_outside_local() -> None: + settings = make_settings(env="prod", cors_origins="https://enervision.fr") + + cookie = RefreshCookie.build(settings, "un-secret-opaque") + + assert cookie.secure is True + assert cookie.key.startswith("__Secure-") + + +def test_build_leaves_the_cookie_unprefixed_in_local() -> None: + settings = make_settings(env="local") + + cookie = RefreshCookie.build(settings, "un-secret-opaque") + + assert cookie.key == "ev_refresh" + + +def test_expired_reuses_the_exact_name_and_path_of_the_posted_cookie() -> None: + settings = make_settings(env="prod", cors_origins="https://enervision.fr") + + pose = RefreshCookie.build(settings, "un-secret-opaque") + suppression = RefreshCookie.expired(settings) + + assert suppression.key == pose.key + assert suppression.path == pose.path + assert suppression.secure == pose.secure + assert suppression.samesite == pose.samesite + assert suppression.max_age == 0 + assert suppression.value == "" + + +def test_as_kwargs_matches_the_starlette_set_cookie_signature() -> None: + settings = make_settings(env="local") + + arguments = RefreshCookie.build(settings, "un-secret-opaque").as_kwargs() + + assert set(arguments) == {"key", "value", "max_age", "path", "secure", "httponly", "samesite"} + + +def test_cookie_name_follows_the_configured_name() -> None: + settings = make_settings(env="local", refresh_cookie_name="autre_nom") + + assert cookie_name(settings) == "autre_nom" diff --git a/apps/backend/tests/factories.py b/apps/backend/tests/factories.py index 3098863..17433c5 100644 --- a/apps/backend/tests/factories.py +++ b/apps/backend/tests/factories.py @@ -7,7 +7,7 @@ SETTINGS_DE_TEST: dict[str, Any] = { "debug": False, "log_level": "WARNING", "cors_origins": "", - "secret_key": "secret-de-test", + "secret_key": "secret-de-test-assez-long-pour-le-validateur", "database_url": "postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_test", } From ef933bea1ab59623ad63e5b0cfe27926619c3883 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:41:25 +0200 Subject: [PATCH 005/103] =?UTF-8?q?feat(backend):=20authentifie=20par=20mo?= =?UTF-8?q?t=20de=20passe=20et=20refuse=20les=20routes=20par=20d=C3=A9faut?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connexion, lecture du compte connecté, RBAC à trois rôles ordonnés et limitation de débit à fenêtre glissante. Ajoute `login_attempt`, le compteur de la limitation, et `audit_log`, en ajout seul. Trois ordres d'exécution portent la sécurité de ce commit, et chacun a son test : - les compteurs sont lus AVANT le hachage Argon2, sinon chaque requête rejetée coûterait quand même 17 ms et 19 Mio, et la protection serait l'amplificateur de déni de service qu'elle doit empêcher ; - un haché leurre est vérifié quand l'adresse est inconnue, sinon l'écart entre 2 ms et 17 ms est un oracle d'existence de compte ; - la tentative échouée est validée en base avant que l'erreur ne soit levée, `get_session()` ne validant pas de lui-même. Pas de verrouillage de compte : il suffirait de cinq requêtes pour mettre un administrateur dehors, et il ne fait rien contre le bourrage d'identifiants horizontal. Trois seuils le remplacent, dont un par couple (identifiant, IP) qui garantit qu'un attaquant ne peut pas empêcher la victime de se connecter depuis sa propre adresse. `audit_log` est en ajout seul au niveau de PostgreSQL, par deux déclencheurs. Le second n'est pas redondant : TRUNCATE ne passe pas par les déclencheurs de ligne. `test_route_protection.py` interroge réellement chaque route sans jeton. Rendre une route publique impose donc de modifier une liste dans un fichier de test, ce qui se voit en revue. Le gestionnaire de 422 arrive ici et non plus tard : la réponse par défaut de FastAPI contient la valeur rejetée, donc le mot de passe. Le test qui le prouve serait rouge sans lui. --- ...tatives_de_connexion_et_journal_d_audit.py | 119 +++++++++ apps/backend/app/api/deps.py | 166 ++++++++++++- apps/backend/app/api/errors.py | 47 ++++ apps/backend/app/api/v1/endpoints/auth.py | 56 +++++ apps/backend/app/api/v1/router.py | 5 +- apps/backend/app/main.py | 3 + apps/backend/app/models/__init__.py | 4 +- apps/backend/app/models/audit_log.py | 64 +++++ apps/backend/app/models/login_attempt.py | 44 ++++ apps/backend/app/repositories/audit_log.py | 62 +++++ .../backend/app/repositories/login_attempt.py | 67 +++++ apps/backend/app/repositories/user.py | 6 +- apps/backend/app/schemas/auth.py | 44 ++++ apps/backend/app/services/auth.py | 171 +++++++++++++ apps/backend/pyproject.toml | 2 +- apps/backend/tests/api/test_auth.py | 106 ++++++++ apps/backend/tests/api/test_authorization.py | 113 +++++++++ .../tests/api/test_route_protection.py | 74 ++++++ .../tests/repositories/test_audit_log.py | 125 ++++++++++ .../tests/repositories/test_login_attempt.py | 118 +++++++++ apps/backend/tests/repositories/test_user.py | 196 +++++++++++++++ apps/backend/tests/services/test_auth.py | 234 ++++++++++++++++++ apps/backend/tests/test_cli.py | 57 +++++ apps/backend/uv.lock | 31 ++- 24 files changed, 1904 insertions(+), 10 deletions(-) create mode 100644 apps/backend/alembic/versions/517053a3c044_tentatives_de_connexion_et_journal_d_audit.py create mode 100644 apps/backend/app/api/errors.py create mode 100644 apps/backend/app/api/v1/endpoints/auth.py create mode 100644 apps/backend/app/models/audit_log.py create mode 100644 apps/backend/app/models/login_attempt.py create mode 100644 apps/backend/app/repositories/audit_log.py create mode 100644 apps/backend/app/repositories/login_attempt.py create mode 100644 apps/backend/app/schemas/auth.py create mode 100644 apps/backend/app/services/auth.py create mode 100644 apps/backend/tests/api/test_auth.py create mode 100644 apps/backend/tests/api/test_authorization.py create mode 100644 apps/backend/tests/api/test_route_protection.py create mode 100644 apps/backend/tests/repositories/test_audit_log.py create mode 100644 apps/backend/tests/repositories/test_login_attempt.py create mode 100644 apps/backend/tests/repositories/test_user.py create mode 100644 apps/backend/tests/services/test_auth.py create mode 100644 apps/backend/tests/test_cli.py diff --git a/apps/backend/alembic/versions/517053a3c044_tentatives_de_connexion_et_journal_d_audit.py b/apps/backend/alembic/versions/517053a3c044_tentatives_de_connexion_et_journal_d_audit.py new file mode 100644 index 0000000..59ffb85 --- /dev/null +++ b/apps/backend/alembic/versions/517053a3c044_tentatives_de_connexion_et_journal_d_audit.py @@ -0,0 +1,119 @@ +"""tentatives de connexion et journal d audit + +Revision ID: 517053a3c044 +Revises: b1a7c3d9e240 +Create Date: 2026-09-15 14:31:07.966180 + +Deux tables aux vocations opposees. `login_attempt` est le compteur de la limitation +de debit : son volume est pilote par l'attaquant, donc elle se purge. `audit_log` est +en ajout seul, garanti par deux declencheurs. + +Le declencheur TRUNCATE n'est pas redondant : TRUNCATE ne passe pas par les +declencheurs de ligne. Et RAISE EXCEPTION plutot qu'un RETURN NULL, qui annulerait +l'operation silencieusement. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "517053a3c044" +down_revision: str | Sequence[str] | None = "b1a7c3d9e240" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +FONCTION_AJOUT_SEUL = """ +CREATE FUNCTION audit_log_append_only() RETURNS trigger AS $$ +BEGIN + RAISE EXCEPTION 'audit_log est en ajout seul : % interdit', TG_OP; +END +$$ LANGUAGE plpgsql; +""" + +DECLENCHEUR_LIGNE = """ +CREATE TRIGGER audit_log_no_update_delete + BEFORE UPDATE OR DELETE ON audit_log + FOR EACH ROW EXECUTE FUNCTION audit_log_append_only(); +""" + +DECLENCHEUR_TRUNCATE = """ +CREATE TRIGGER audit_log_no_truncate + BEFORE TRUNCATE ON audit_log + FOR EACH STATEMENT EXECUTE FUNCTION audit_log_append_only(); +""" + + +def upgrade() -> None: + op.create_table( + "login_attempt", + sa.Column("id", sa.BigInteger(), sa.Identity(always=True), nullable=False), + sa.Column( + "occurred_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("email_tried", sa.String(length=320), nullable=False), + sa.Column("client_ip", postgresql.INET(), nullable=True), + sa.Column("outcome", sa.Text(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=True), + sa.CheckConstraint( + "outcome in ('success', 'bad_credentials', 'throttled', 'inactive')", + name="ck_login_attempt_outcome", + ), + sa.PrimaryKeyConstraint("id", name="pk_login_attempt"), + ) + op.create_index( + "ix_login_attempt_email_date", "login_attempt", ["email_tried", "occurred_at"] + ) + op.create_index("ix_login_attempt_ip_date", "login_attempt", ["client_ip", "occurred_at"]) + + op.create_table( + "audit_log", + sa.Column("id", sa.BigInteger(), sa.Identity(always=True), nullable=False), + sa.Column( + "occurred_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("actor_id", sa.UUID(), nullable=True), + sa.Column("actor_email", sa.Text(), nullable=True), + sa.Column("actor_role", sa.Text(), nullable=True), + sa.Column("action", sa.Text(), nullable=False), + sa.Column("target_type", sa.Text(), nullable=True), + sa.Column("target_id", sa.Text(), nullable=True), + sa.Column("outcome", sa.Text(), nullable=False), + sa.Column("client_ip", postgresql.INET(), nullable=True), + sa.Column("user_agent", sa.Text(), nullable=True), + sa.Column( + "detail", + postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("jsonb_build_object()"), + nullable=False, + ), + sa.CheckConstraint("outcome in ('success', 'failure')", name="ck_audit_log_outcome"), + sa.PrimaryKeyConstraint("id", name="pk_audit_log"), + ) + op.create_index("ix_audit_log_date", "audit_log", ["occurred_at"]) + op.create_index("ix_audit_log_action_date", "audit_log", ["action", "occurred_at"]) + + op.execute(FONCTION_AJOUT_SEUL) + op.execute(DECLENCHEUR_LIGNE) + op.execute(DECLENCHEUR_TRUNCATE) + + +def downgrade() -> None: + op.execute("DROP TRIGGER IF EXISTS audit_log_no_truncate ON audit_log;") + op.execute("DROP TRIGGER IF EXISTS audit_log_no_update_delete ON audit_log;") + op.execute("DROP FUNCTION IF EXISTS audit_log_append_only();") + + op.drop_index("ix_audit_log_action_date", table_name="audit_log") + op.drop_index("ix_audit_log_date", table_name="audit_log") + op.drop_table("audit_log") + + op.drop_index("ix_login_attempt_ip_date", table_name="login_attempt") + op.drop_index("ix_login_attempt_email_date", table_name="login_attempt") + op.drop_table("login_attempt") diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index a25b1e1..a35d628 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -1,10 +1,174 @@ +# Piège : `get_current_principal()` relit le compte en base à chaque requête au lieu de faire +# confiance aux claims. C'est le renoncement assumé à la propriété « sans état » : sur un seul +# service et une seule base, elle n'achetait rien, et la lecture par clé primaire coûte moins +# d'un pour cent du budget d'une requête. Ce qu'elle achète, c'est la révocation immédiate. +# Piège : le `Principal` est construit depuis la ligne, jamais depuis le claim `role`. Un claim +# périmé ne peut donc pas provoquer d'élévation de privilège. + +from collections.abc import Callable +from datetime import timedelta +from functools import lru_cache from typing import Annotated -from fastapi import Depends +from fastapi import Depends, HTTPException, Request, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import Settings, get_settings +from app.core.hashing import Argon2Hasher, build_hasher +from app.core.principal import Principal +from app.core.roles import AccountKind, Role, has_at_least +from app.core.security import TokenExpiredError, TokenInvalidError, TokenPolicy +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.user import UserRepository +from app.services.auth import AuthService, LoginPolicy SessionDep = Annotated[AsyncSession, Depends(get_session)] SettingsDep = Annotated[Settings, Depends(get_settings)] + +CODE_CHANGEMENT_REQUIS = "password_change_required" + +_porteur = HTTPBearer(auto_error=False, scheme_name="Jeton d'accès") +CredentialsDep = Annotated[HTTPAuthorizationCredentials | None, Depends(_porteur)] + + +def _non_authentifie(description: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentification requise", + headers={"WWW-Authenticate": f'Bearer error="{description}"'}, + ) + + +def get_token_policy(settings: SettingsDep) -> TokenPolicy: + return TokenPolicy( + secret=settings.secret_key.get_secret_value(), + issuer=settings.jwt_issuer, + audience=settings.jwt_audience, + access_ttl=timedelta(seconds=settings.access_token_ttl_seconds), + ) + + +# Construire un `Argon2Hasher` calcule un haché leurre, donc 17 ms : il est mis en cache sur +# les paramètres plutôt que reconstruit à chaque requête. +@lru_cache +def _hasher_cache( + time_cost: int, memory_cost_kib: int, parallelism: int, max_concurrency: int +) -> Argon2Hasher: + return build_hasher( + time_cost=time_cost, + memory_cost_kib=memory_cost_kib, + parallelism=parallelism, + max_concurrency=max_concurrency, + ) + + +def get_hasher(settings: SettingsDep) -> Argon2Hasher: + return _hasher_cache( + settings.argon2_time_cost, + settings.argon2_memory_cost_kib, + settings.argon2_parallelism, + settings.argon2_max_concurrency, + ) + + +def get_client_ip(request: Request, settings: SettingsDep) -> str | None: + # Derrière un proxy, `request.client.host` vaut l'IP du proxy : le compteur par IP + # deviendrait global, donc un déni de service auto-infligé. Le dernier élément est le seul + # qu'un proxy de confiance ait écrit, les précédents sont fournis par le client. + if settings.trust_proxy_headers: + transmis = request.headers.get("x-forwarded-for") + if transmis: + return transmis.split(",")[-1].strip() + return request.client.host if request.client else None + + +def get_auth_service( + session: SessionDep, + settings: SettingsDep, + hasher: Annotated[Argon2Hasher, Depends(get_hasher)], + token_policy: Annotated[TokenPolicy, Depends(get_token_policy)], +) -> AuthService: + return AuthService( + users=UserRepository(session), + attempts=LoginAttemptRepository(session), + audit=AuditLogRepository(session), + hasher=hasher, + transaction=session, + token_policy=token_policy, + login_policy=LoginPolicy( + window_seconds=settings.login_window_seconds, + max_failures_per_identifier_and_ip=(settings.login_max_failures_per_identifier_and_ip), + max_failures_per_ip=settings.login_max_failures_per_ip, + max_failures_per_identifier=settings.login_max_failures_per_identifier, + ), + ) + + +AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)] + + +async def get_current_principal( + credentials: CredentialsDep, + session: SessionDep, + token_policy: Annotated[TokenPolicy, Depends(get_token_policy)], +) -> Principal: + if credentials is None: + raise _non_authentifie("invalid_request") + + try: + claims = decode_token(token_policy, credentials.credentials) + except TokenExpiredError as erreur: + raise _non_authentifie("expired") from erreur + except TokenInvalidError as erreur: + raise _non_authentifie("invalid_token") from erreur + + compte = await UserRepository(session).get_by_id(claims.subject) + if compte is None or not compte.is_active: + raise _non_authentifie("invalid_token") + if claims.issued_at < compte.credentials_changed_at: + raise _non_authentifie("token_stale") + if claims.role != compte.role: + raise _non_authentifie("token_stale") + + return Principal( + id=compte.id, + email=compte.email, + role=Role(compte.role), + kind=AccountKind(compte.kind), + must_change_password=compte.must_change_password, + ) + + +CurrentPrincipalDep = Annotated[Principal, Depends(get_current_principal)] + + +def require_role(minimum: Role) -> Callable[[Principal], Principal]: + def garde(principal: CurrentPrincipalDep) -> Principal: + if principal.must_change_password: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=CODE_CHANGEMENT_REQUIS + ) + if not has_at_least(principal.role, minimum): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Droits insuffisants") + return principal + + return garde + + +LecteurDep = Annotated[Principal, Depends(require_role(Role.LECTEUR))] +OperateurDep = Annotated[Principal, Depends(require_role(Role.OPERATEUR))] +AdminDep = Annotated[Principal, Depends(require_role(Role.ADMIN))] + + +def require_trusted_origin(request: Request, settings: SettingsDep) -> None: + # Un navigateur envoie toujours `Origin` sur une requête non sûre. Son absence signale un + # client hors navigateur, qui ne détient aucun cookie de victime : rien à protéger. + origine = request.headers.get("origin") + if origine is None: + return + if origine not in settings.allowed_origins: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Origine refusée") diff --git a/apps/backend/app/api/errors.py b/apps/backend/app/api/errors.py new file mode 100644 index 0000000..7485b55 --- /dev/null +++ b/apps/backend/app/api/errors.py @@ -0,0 +1,47 @@ +# Piège : la réponse 422 par défaut de FastAPI contient la clé `input`, c'est-à-dire la valeur +# rejetée. Sur `/auth/login`, un corps malformé renverrait donc le mot de passe au client et le +# déposerait dans les journaux d'erreur. `validation_error_handler()` ne laisse passer que le +# champ fautif et le type d'erreur. + +import uuid +from typing import Any + +from fastapi import FastAPI, Request, status +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse + +from app.core.logging import get_logger + +logger = get_logger(__name__) + + +async def validation_error_handler(_: Request, exception: RequestValidationError) -> JSONResponse: + champs: list[dict[str, Any]] = [ + { + "champ": ".".join(str(element) for element in erreur["loc"]), + "type": erreur["type"], + } + for erreur in exception.errors() + ] + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content={"detail": champs} + ) + + +async def unhandled_error_handler(request: Request, exception: Exception) -> JSONResponse: + correlation = uuid.uuid4().hex + logger.exception( + "erreur non gérée correlation=%s methode=%s chemin=%s", + correlation, + request.method, + request.url.path, + ) + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"detail": "Erreur interne", "correlation": correlation}, + ) + + +def register_error_handlers(application: FastAPI) -> None: + application.add_exception_handler(RequestValidationError, validation_error_handler) # type: ignore[arg-type] + application.add_exception_handler(Exception, unhandled_error_handler) diff --git a/apps/backend/app/api/v1/endpoints/auth.py b/apps/backend/app/api/v1/endpoints/auth.py new file mode 100644 index 0000000..0def0d4 --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/auth.py @@ -0,0 +1,56 @@ +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status + +from app.api.deps import AuthServiceDep, CurrentPrincipalDep, get_client_ip +from app.core.logging import get_logger +from app.schemas.auth import LoginRequest, PrincipalResponse, TokenResponse +from app.services.auth import InvalidCredentialsError, RateLimitedError + +router = APIRouter() +logger = get_logger(__name__) + +DETAIL_IDENTIFIANTS = "Identifiants invalides" + + +@router.post("/login", response_model=TokenResponse, summary="Ouvre une session") +async def login( + payload: LoginRequest, + request: Request, + response: Response, + service: AuthServiceDep, + client_ip: str | None = Depends(get_client_ip), +) -> TokenResponse: + # Une réponse d'authentification ne doit jamais être conservée par un intermédiaire. + response.headers["Cache-Control"] = "no-store" + agent = request.headers.get("user-agent") + + try: + session = await service.authenticate( + email=payload.email, + password=payload.password, + client_ip=client_ip, + user_agent=agent, + ) + except RateLimitedError as erreur: + logger.warning("auth.rate_limited email=%s ip=%s", payload.email, client_ip) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Trop de tentatives, réessayez plus tard", + headers={"Retry-After": str(erreur.retry_after)}, + ) from erreur + except InvalidCredentialsError as erreur: + logger.warning("auth.login.failure email=%s ip=%s", payload.email, client_ip) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail=DETAIL_IDENTIFIANTS + ) from erreur + + logger.info("auth.login.success user_id=%s ip=%s", session.principal.id, client_ip) + return TokenResponse( + access_token=session.access_token, + expires_in=session.expires_in, + principal=PrincipalResponse.from_principal(session.principal), + ) + + +@router.get("/me", response_model=PrincipalResponse, summary="Décrit le compte connecté") +async def me(principal: CurrentPrincipalDep) -> PrincipalResponse: + return PrincipalResponse.from_principal(principal) diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py index 8571d8f..473a024 100644 --- a/apps/backend/app/api/v1/router.py +++ b/apps/backend/app/api/v1/router.py @@ -1,6 +1,7 @@ from fastapi import APIRouter -from app.api.v1.endpoints import health +from app.api.v1.endpoints import auth, health api_router = APIRouter() -api_router.include_router(health.router, prefix="/health") +api_router.include_router(health.router, prefix="/health", tags=["health"]) +api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index 1008100..2ddf1dd 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -5,6 +5,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from prometheus_fastapi_instrumentator import Instrumentator +from app.api.errors import register_error_handlers from app.api.v1.router import api_router from app.core.config import Settings, get_settings from app.core.logging import configure_logging, get_logger @@ -46,6 +47,8 @@ def create_app(settings: Settings | None = None) -> FastAPI: allow_headers=["*"], ) + register_error_handlers(application) + Instrumentator().instrument(application).expose( application, endpoint="/metrics", include_in_schema=False ) diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py index dd6cc73..222295d 100644 --- a/apps/backend/app/models/__init__.py +++ b/apps/backend/app/models/__init__.py @@ -1,6 +1,8 @@ # Piège : tout modèle absent de ce module reste invisible de `alembic revision # --autogenerate`, qui générerait alors un drop de sa table. +from app.models.audit_log import AuditLog +from app.models.login_attempt import LoginAttempt from app.models.user import AppUser -__all__ = ["AppUser"] +__all__ = ["AppUser", "AuditLog", "LoginAttempt"] diff --git a/apps/backend/app/models/audit_log.py b/apps/backend/app/models/audit_log.py new file mode 100644 index 0000000..5775f5e --- /dev/null +++ b/apps/backend/app/models/audit_log.py @@ -0,0 +1,64 @@ +# Pourquoi : `actor_id` ne porte volontairement aucune clé étrangère. Une contrainte +# `ON DELETE SET NULL` déclencherait un UPDATE que le déclencheur d'ajout seul refuserait, donc +# la suppression d'un compte échouerait ; une contrainte `NO ACTION` interdirait toute +# suppression. `actor_email` et `actor_role` sont dénormalisés pour la même raison : le journal +# dit ce qui était vrai au moment de l'acte, pas ce qui est vrai aujourd'hui. + +import uuid +from datetime import datetime +from enum import StrEnum +from typing import Any + +from sqlalchemy import BigInteger, CheckConstraint, DateTime, Identity, Index, Text, func +from sqlalchemy.dialects.postgresql import INET, JSONB +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class AuditOutcome(StrEnum): + SUCCES = "success" + ECHEC = "failure" + + +class AuditAction(StrEnum): + COMPTE_CREE = "user.created" + COMPTE_ROLE_CHANGE = "user.role_changed" + COMPTE_DESACTIVE = "user.disabled" + COMPTE_ACTIVE = "user.enabled" + COMPTE_MOT_DE_PASSE_REINITIALISE = "user.password_reset_by_admin" + COMPTE_MOT_DE_PASSE_CHANGE = "user.password_changed" + REFRESH_REUTILISE = "auth.refresh_reuse_detected" + SESSIONS_REVOQUEES = "auth.all_sessions_revoked" + LIMITE_PAR_IDENTIFIANT = "auth.identifier_throttled" + ADMIN_AMORCE = "bootstrap.admin_created" + + +ISSUES_AUTORISEES = ", ".join(f"'{issue.value}'" for issue in AuditOutcome) + + +class AuditLog(Base): + __tablename__ = "audit_log" + __table_args__ = ( + CheckConstraint(f"outcome in ({ISSUES_AUTORISEES})", name="ck_audit_log_outcome"), + Index("ix_audit_log_date", "occurred_at"), + Index("ix_audit_log_action_date", "action", "occurred_at"), + ) + + id: Mapped[int] = mapped_column(BigInteger, Identity(always=True), primary_key=True) + occurred_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + actor_id: Mapped[uuid.UUID | None] = mapped_column(PG_UUID(as_uuid=True), nullable=True) + actor_email: Mapped[str | None] = mapped_column(Text, nullable=True) + actor_role: Mapped[str | None] = mapped_column(Text, nullable=True) + action: Mapped[str] = mapped_column(Text, nullable=False) + target_type: Mapped[str | None] = mapped_column(Text, nullable=True) + target_id: Mapped[str | None] = mapped_column(Text, nullable=True) + outcome: Mapped[str] = mapped_column(Text, nullable=False) + client_ip: Mapped[str | None] = mapped_column(INET, nullable=True) + user_agent: Mapped[str | None] = mapped_column(Text, nullable=True) + detail: Mapped[dict[str, Any]] = mapped_column( + JSONB, nullable=False, server_default=func.jsonb_build_object() + ) diff --git a/apps/backend/app/models/login_attempt.py b/apps/backend/app/models/login_attempt.py new file mode 100644 index 0000000..f4b7701 --- /dev/null +++ b/apps/backend/app/models/login_attempt.py @@ -0,0 +1,44 @@ +# Pourquoi : les tentatives vivent ici et non dans `audit_log`, qui est en ajout seul. Leur +# volume est piloté par l'attaquant : une force brute y écrirait des millions de lignes +# indestructibles. Cette table-ci se purge, et c'est aussi le compteur de la limitation. +# Piège : la tentative est enregistrée même quand l'email est inconnu, sinon le 429 dirait +# qu'un compte existe. + +import uuid +from datetime import datetime +from enum import StrEnum + +from sqlalchemy import BigInteger, CheckConstraint, DateTime, Identity, Index, String, Text, func +from sqlalchemy.dialects.postgresql import INET +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class LoginOutcome(StrEnum): + SUCCES = "success" + IDENTIFIANTS_INVALIDES = "bad_credentials" + LIMITE = "throttled" + COMPTE_INDISPONIBLE = "inactive" + + +ISSUES_AUTORISEES = ", ".join(f"'{issue.value}'" for issue in LoginOutcome) + + +class LoginAttempt(Base): + __tablename__ = "login_attempt" + __table_args__ = ( + CheckConstraint(f"outcome in ({ISSUES_AUTORISEES})", name="ck_login_attempt_outcome"), + Index("ix_login_attempt_email_date", "email_tried", "occurred_at"), + Index("ix_login_attempt_ip_date", "client_ip", "occurred_at"), + ) + + id: Mapped[int] = mapped_column(BigInteger, Identity(always=True), primary_key=True) + occurred_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + email_tried: Mapped[str] = mapped_column(String(320), nullable=False) + client_ip: Mapped[str | None] = mapped_column(INET, nullable=True) + outcome: Mapped[str] = mapped_column(Text, nullable=False) + user_id: Mapped[uuid.UUID | None] = mapped_column(PG_UUID(as_uuid=True), nullable=True) diff --git a/apps/backend/app/repositories/audit_log.py b/apps/backend/app/repositories/audit_log.py new file mode 100644 index 0000000..aa00f72 --- /dev/null +++ b/apps/backend/app/repositories/audit_log.py @@ -0,0 +1,62 @@ +# Piège : `detail` passe par une liste blanche de clés et jamais par un `dict(**kwargs)`. La +# table est en ajout seul : une clé inattendue qui porterait un secret ou une donnée +# personnelle ne pourrait plus en être retirée. + +from collections.abc import Mapping +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.principal import Principal +from app.models.audit_log import AuditAction, AuditLog, AuditOutcome + +CLES_DE_DETAIL_AUTORISEES = frozenset( + { + "email", + "role_avant", + "role_apres", + "famille", + "motif", + "source", + "sessions_revoquees", + } +) + + +def assemble_detail(brut: Mapping[str, Any] | None) -> dict[str, Any]: + if not brut: + return {} + return {cle: valeur for cle, valeur in brut.items() if cle in CLES_DE_DETAIL_AUTORISEES} + + +class AuditLogRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def record( + self, + *, + action: AuditAction, + outcome: AuditOutcome = AuditOutcome.SUCCES, + actor: Principal | None = None, + actor_label: str | None = None, + target_type: str | None = None, + target_id: str | None = None, + client_ip: str | None = None, + user_agent: str | None = None, + detail: Mapping[str, Any] | None = None, + ) -> None: + self._session.add( + AuditLog( + actor_id=actor.id if actor else None, + actor_email=actor.email if actor else actor_label, + actor_role=actor.role.value if actor else None, + action=action.value, + target_type=target_type, + target_id=target_id, + outcome=outcome.value, + client_ip=client_ip, + user_agent=user_agent, + detail=assemble_detail(detail), + ) + ) diff --git a/apps/backend/app/repositories/login_attempt.py b/apps/backend/app/repositories/login_attempt.py new file mode 100644 index 0000000..8f8df09 --- /dev/null +++ b/apps/backend/app/repositories/login_attempt.py @@ -0,0 +1,67 @@ +# Pourquoi : les trois compteurs tiennent en une seule requête, grâce aux clauses FILTER de +# PostgreSQL. Trois `count(*)` séparés feraient trois allers-retours sur le chemin critique de +# la connexion, qui est justement celui qu'un attaquant martèle. + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from uuid import UUID + +from sqlalchemy import and_, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.login_attempt import LoginAttempt, LoginOutcome + + +@dataclass(frozen=True, slots=True) +class FailureCounts: + per_identifier_and_ip: int + per_ip: int + per_identifier: int + + +class LoginAttemptRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def record( + self, + *, + email: str, + client_ip: str | None, + outcome: LoginOutcome, + user_id: UUID | None = None, + ) -> None: + self._session.add( + LoginAttempt( + email_tried=email.strip().lower(), + client_ip=client_ip, + outcome=outcome.value, + user_id=user_id, + ) + ) + + async def count_recent_failures( + self, *, email: str, client_ip: str | None, window_seconds: int + ) -> FailureCounts: + identifiant = email.strip().lower() + meme_email = LoginAttempt.email_tried == identifiant + meme_ip = LoginAttempt.client_ip == client_ip + + requete = select( + func.count().filter(and_(meme_email, meme_ip)), + func.count().filter(meme_ip), + func.count().filter(meme_email), + ).where( + LoginAttempt.outcome != LoginOutcome.SUCCES.value, + LoginAttempt.occurred_at > datetime.now(UTC) - timedelta(seconds=window_seconds), + meme_email | meme_ip, + ) + + par_identifiant_et_ip, par_ip, par_identifiant = ( + await self._session.execute(requete) + ).one() + return FailureCounts( + per_identifier_and_ip=par_identifiant_et_ip, + per_ip=par_ip, + per_identifier=par_identifiant, + ) diff --git a/apps/backend/app/repositories/user.py b/apps/backend/app/repositories/user.py index 9155db1..eaac079 100644 --- a/apps/backend/app/repositories/user.py +++ b/apps/backend/app/repositories/user.py @@ -67,7 +67,7 @@ class UserRepository: .values( password_hash=password_hash, must_change_password=must_change_password, - credentials_changed_at=func.now(), + credentials_changed_at=func.clock_timestamp(), ) ) @@ -86,12 +86,12 @@ class UserRepository: await self._session.execute( update(AppUser) .where(AppUser.id == user_id) - .values(role=role.value, credentials_changed_at=func.now()) + .values(role=role.value, credentials_changed_at=func.clock_timestamp()) ) async def set_active(self, user_id: UUID, *, is_active: bool) -> None: await self._session.execute( update(AppUser) .where(AppUser.id == user_id) - .values(is_active=is_active, credentials_changed_at=func.now()) + .values(is_active=is_active, credentials_changed_at=func.clock_timestamp()) ) diff --git a/apps/backend/app/schemas/auth.py b/apps/backend/app/schemas/auth.py new file mode 100644 index 0000000..522b4c5 --- /dev/null +++ b/apps/backend/app/schemas/auth.py @@ -0,0 +1,44 @@ +# Contrainte : le mot de passe est borné à 128 caractères. Sans plafond, une chaîne de dix +# mégaoctets ferait travailler Argon2 gratuitement, à la charge du serveur. + +from typing import Literal, Self +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, EmailStr, Field + +from app.core.principal import Principal +from app.core.roles import AccountKind, Role + +PASSWORD_MIN_LENGTH = 12 +PASSWORD_MAX_LENGTH = 128 + + +class LoginRequest(BaseModel): + email: EmailStr + password: str = Field(min_length=1, max_length=PASSWORD_MAX_LENGTH) + + +class PasswordChangeRequest(BaseModel): + current_password: str = Field(min_length=1, max_length=PASSWORD_MAX_LENGTH) + new_password: str = Field(min_length=PASSWORD_MIN_LENGTH, max_length=PASSWORD_MAX_LENGTH) + + +class PrincipalResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + email: str + role: Role + kind: AccountKind + must_change_password: bool + + @classmethod + def from_principal(cls, principal: Principal) -> Self: + return cls.model_validate(principal) + + +class TokenResponse(BaseModel): + access_token: str + token_type: Literal["bearer"] = "bearer" # noqa: S105 + expires_in: int + principal: PrincipalResponse diff --git a/apps/backend/app/services/auth.py b/apps/backend/app/services/auth.py new file mode 100644 index 0000000..19a4e26 --- /dev/null +++ b/apps/backend/app/services/auth.py @@ -0,0 +1,171 @@ +# Piège : les compteurs de limitation sont lus AVANT le hachage Argon2. Dans l'autre ordre, +# chaque requête rejetée coûterait quand même 17 ms de processeur et 19 Mio de mémoire, et la +# protection deviendrait l'amplificateur de déni de service qu'elle est censée empêcher. +# Piège : quand l'email est inconnu, `verify_dummy()` consomme le même temps qu'une +# vérification réelle. Sans lui, l'écart de temps de réponse est un oracle d'existence. +# Piège : la tentative échouée est validée en base AVANT que l'erreur ne soit levée. +# `get_session()` ne valide pas de lui-même, donc la preuve disparaîtrait avec la transaction. + +from dataclasses import dataclass +from typing import NoReturn, Protocol +from uuid import UUID + +from app.core.hashing import Argon2Hasher +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.core.security import TokenPolicy, encode_access_token +from app.models.audit_log import AuditAction +from app.models.login_attempt import LoginOutcome +from app.repositories.audit_log import AuditLogRepository +from app.repositories.login_attempt import LoginAttemptRepository +from app.repositories.user import UserRepository + + +class Transaction(Protocol): + async def commit(self) -> None: ... + + +class AuthError(Exception): + pass + + +class InvalidCredentialsError(AuthError): + pass + + +class RateLimitedError(AuthError): + def __init__(self, retry_after: int) -> None: + super().__init__("Trop de tentatives") + self.retry_after = retry_after + + +@dataclass(frozen=True, slots=True) +class LoginPolicy: + window_seconds: int + max_failures_per_identifier_and_ip: int + max_failures_per_ip: int + max_failures_per_identifier: int + + +@dataclass(frozen=True, slots=True) +class AuthenticatedSession: + principal: Principal + access_token: str + expires_in: int + + +class AuthService: + def __init__( + self, + *, + users: UserRepository, + attempts: LoginAttemptRepository, + audit: AuditLogRepository, + hasher: Argon2Hasher, + transaction: Transaction, + token_policy: TokenPolicy, + login_policy: LoginPolicy, + ) -> None: + self._users = users + self._attempts = attempts + self._audit = audit + self._hasher = hasher + self._transaction = transaction + self._token_policy = token_policy + self._login_policy = login_policy + + async def authenticate( + self, *, email: str, password: str, client_ip: str | None, user_agent: str | None + ) -> AuthenticatedSession: + await self._refuse_si_limite(email=email, client_ip=client_ip, user_agent=user_agent) + + compte = await self._users.get_by_email(email) + if compte is None: + await self._hasher.verify_dummy() + await self._echoue(email, client_ip, LoginOutcome.IDENTIFIANTS_INVALIDES) + + if not await self._hasher.verify(compte.password_hash, password): + await self._echoue( + email, client_ip, LoginOutcome.IDENTIFIANTS_INVALIDES, user_id=compte.id + ) + + if not compte.is_active or compte.kind != AccountKind.HUMAIN.value: + await self._echoue( + email, client_ip, LoginOutcome.COMPTE_INDISPONIBLE, user_id=compte.id + ) + + if self._hasher.needs_rehash(compte.password_hash): + await self._users.rehash_password(compte.id, await self._hasher.hash(password)) + + await self._users.touch_last_login(compte.id) + await self._attempts.record( + email=email, client_ip=client_ip, outcome=LoginOutcome.SUCCES, user_id=compte.id + ) + await self._transaction.commit() + + return self.issue_access_token( + Principal( + id=compte.id, + email=compte.email, + role=Role(compte.role), + kind=AccountKind(compte.kind), + must_change_password=compte.must_change_password, + ) + ) + + def issue_access_token(self, principal: Principal) -> AuthenticatedSession: + jeton = encode_access_token( + self._token_policy, + subject=principal.id, + role=principal.role.value, + kind=principal.kind.value, + ) + return AuthenticatedSession( + principal=principal, + access_token=jeton, + expires_in=int(self._token_policy.access_ttl.total_seconds()), + ) + + async def _refuse_si_limite( + self, *, email: str, client_ip: str | None, user_agent: str | None + ) -> None: + politique = self._login_policy + compteurs = await self._attempts.count_recent_failures( + email=email, client_ip=client_ip, window_seconds=politique.window_seconds + ) + + depasse = ( + compteurs.per_identifier_and_ip >= politique.max_failures_per_identifier_and_ip + or compteurs.per_ip >= politique.max_failures_per_ip + or compteurs.per_identifier >= politique.max_failures_per_identifier + ) + if not depasse: + return + + await self._attempts.record(email=email, client_ip=client_ip, outcome=LoginOutcome.LIMITE) + # Un blocage déclenché par l'identifiant seul signe une attaque distribuée : lui seul + # mérite une trace durable, les échecs ordinaires restent dans `login_attempt`. + if compteurs.per_identifier >= politique.max_failures_per_identifier: + await self._audit.record( + action=AuditAction.LIMITE_PAR_IDENTIFIANT, + actor_label=email.strip().lower(), + client_ip=client_ip, + user_agent=user_agent, + detail={"motif": "seuil par identifiant depasse"}, + ) + await self._transaction.commit() + raise RateLimitedError(politique.window_seconds) + + async def _echoue( + self, + email: str, + client_ip: str | None, + outcome: LoginOutcome, + *, + user_id: UUID | None = None, + ) -> NoReturn: + await self._attempts.record( + email=email, client_ip=client_ip, outcome=outcome, user_id=user_id + ) + await self._transaction.commit() + raise InvalidCredentialsError("Identifiants invalides") diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index 684524d..f0e4f22 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -6,7 +6,7 @@ requires-python = ">=3.14,<3.15" dependencies = [ "fastapi>=0.141.1", "uvicorn[standard]>=0.53.0", - "pydantic>=2.13.5", + "pydantic[email]>=2.13.5", "pydantic-settings>=2.15.0", "sqlalchemy[asyncio]>=2.0.52", "asyncpg>=0.31.0", diff --git a/apps/backend/tests/api/test_auth.py b/apps/backend/tests/api/test_auth.py new file mode 100644 index 0000000..08ceddd --- /dev/null +++ b/apps/backend/tests/api/test_auth.py @@ -0,0 +1,106 @@ +from collections.abc import Iterator +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from httpx import AsyncClient + +from app.api.deps import get_auth_service +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.services.auth import ( + AuthenticatedSession, + InvalidCredentialsError, + RateLimitedError, +) + +IDENTIFIANTS = {"email": "operateur@enervision.fr", "password": "un-mot-de-passe-valide"} + +PRINCIPAL = Principal( + id=uuid4(), + email="operateur@enervision.fr", + role=Role.OPERATEUR, + kind=AccountKind.HUMAIN, + must_change_password=False, +) + + +class FauxService: + def __init__(self, erreur: Exception | None = None) -> None: + self._erreur = erreur + + async def authenticate(self, **_: object) -> AuthenticatedSession: + if self._erreur is not None: + raise self._erreur + return AuthenticatedSession( + principal=PRINCIPAL, access_token="un.jeton.factice", expires_in=900 + ) + + +@pytest.fixture +def fake_auth_service(app: FastAPI) -> Iterator[list[Exception | None]]: + programme: list[Exception | None] = [None] + app.dependency_overrides[get_auth_service] = lambda: FauxService(programme[0]) + yield programme + app.dependency_overrides.pop(get_auth_service, None) + + +async def test_login_returns_the_token_and_the_principal_when_credentials_match( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post("/api/v1/auth/login", json=IDENTIFIANTS) + + assert response.status_code == 200 + corps = response.json() + assert corps["access_token"] == "un.jeton.factice" + assert corps["token_type"] == "bearer" + assert corps["principal"]["role"] == "operateur" + + +async def test_login_forbids_intermediaries_from_caching_the_response( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post("/api/v1/auth/login", json=IDENTIFIANTS) + + assert response.headers["cache-control"] == "no-store" + + +async def test_login_never_reveals_which_half_of_the_credentials_was_wrong( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + fake_auth_service[0] = InvalidCredentialsError("Identifiants invalides") + + response = await client.post("/api/v1/auth/login", json=IDENTIFIANTS) + + assert response.status_code == 401 + assert response.json() == {"detail": "Identifiants invalides"} + + +async def test_login_returns_429_with_a_retry_after_when_the_rate_limit_is_reached( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + fake_auth_service[0] = RateLimitedError(900) + + response = await client.post("/api/v1/auth/login", json=IDENTIFIANTS) + + assert response.status_code == 429 + assert response.headers["retry-after"] == "900" + + +@pytest.mark.parametrize( + "corps", + [ + {"email": "pas-une-adresse", "password": "un-mot-de-passe-valide"}, + {"email": "operateur@enervision.fr"}, + {"email": "operateur@enervision.fr", "password": "x" * 129}, + ], + ids=["adresse_invalide", "mot_de_passe_absent", "mot_de_passe_trop_long"], +) +async def test_login_rejects_a_malformed_body_without_echoing_the_password( + fake_auth_service: list[Exception | None], client: AsyncClient, corps: dict[str, str] +) -> None: + response = await client.post("/api/v1/auth/login", json=corps) + + assert response.status_code == 422 + assert "un-mot-de-passe-valide" not in response.text + assert "x" * 129 not in response.text diff --git a/apps/backend/tests/api/test_authorization.py b/apps/backend/tests/api/test_authorization.py new file mode 100644 index 0000000..05c9e28 --- /dev/null +++ b/apps/backend/tests/api/test_authorization.py @@ -0,0 +1,113 @@ +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 AdminDep, get_current_principal, require_role +from app.core.principal import Principal +from app.core.roles import AccountKind, Role + +CHEMIN_ADMIN = "/api/v1/essai-admin" + + +def principal(role: Role = Role.LECTEUR, *, must_change_password: bool = False) -> Principal: + return Principal( + id=uuid4(), + email=f"{role.value}@enervision.fr", + role=role, + kind=AccountKind.HUMAIN, + must_change_password=must_change_password, + ) + + +@pytest.fixture +def route_admin(app: FastAPI) -> None: + @app.get(CHEMIN_ADMIN) + async def _reserve_aux_admins(acteur: AdminDep) -> dict[str, str]: + return {"email": acteur.email} + + +@pytest.fixture +def connecte(app: FastAPI) -> Iterator[Callable[[Principal], None]]: + def installe(acteur: Principal) -> None: + app.dependency_overrides[get_current_principal] = lambda: acteur + + yield installe + app.dependency_overrides.pop(get_current_principal, None) + + +async def test_me_returns_401_when_no_credentials_are_sent(client: AsyncClient) -> None: + response = await client.get("/api/v1/auth/me") + + assert response.status_code == 401 + assert "Bearer" in response.headers["www-authenticate"] + + +async def test_me_returns_401_when_the_token_is_not_readable(client: AsyncClient) -> None: + response = await client.get( + "/api/v1/auth/me", headers={"Authorization": "Bearer nimporte.quoi.ici"} + ) + + assert response.status_code == 401 + assert 'error="invalid_token"' in response.headers["www-authenticate"] + + +async def test_me_describes_the_connected_account( + connecte: Callable[[Principal], None], client: AsyncClient +) -> None: + acteur = principal(Role.OPERATEUR) + connecte(acteur) + + response = await client.get("/api/v1/auth/me") + + assert response.status_code == 200 + assert response.json()["email"] == acteur.email + + +@pytest.mark.parametrize( + ("role", "attendu"), + [(Role.LECTEUR, 403), (Role.OPERATEUR, 403), (Role.ADMIN, 200)], + ids=["lecteur_refuse", "operateur_refuse", "admin_accepte"], +) +async def test_an_admin_route_only_answers_to_an_admin( + route_admin: None, + connecte: Callable[[Principal], None], + client: AsyncClient, + role: Role, + attendu: int, +) -> None: + connecte(principal(role)) + + response = await client.get(CHEMIN_ADMIN) + + assert response.status_code == attendu + + +async def test_a_pending_password_change_blocks_every_business_route( + route_admin: None, connecte: Callable[[Principal], None], client: AsyncClient +) -> None: + connecte(principal(Role.ADMIN, must_change_password=True)) + + response = await client.get(CHEMIN_ADMIN) + + assert response.status_code == 403 + assert response.json()["detail"] == "password_change_required" + + +async def test_a_pending_password_change_still_allows_reading_ones_own_account( + connecte: Callable[[Principal], None], client: AsyncClient +) -> None: + connecte(principal(Role.LECTEUR, must_change_password=True)) + + response = await client.get("/api/v1/auth/me") + + assert response.status_code == 200 + assert response.json()["must_change_password"] is True + + +def test_require_role_builds_one_guard_per_minimum_level() -> None: + garde = require_role(Role.OPERATEUR) + + assert callable(garde) diff --git a/apps/backend/tests/api/test_route_protection.py b/apps/backend/tests/api/test_route_protection.py new file mode 100644 index 0000000..bfdd2fe --- /dev/null +++ b/apps/backend/tests/api/test_route_protection.py @@ -0,0 +1,74 @@ +# Ce test est le garde-fou de l'autorisation : rendre une route publique oblige à modifier +# `ROUTES_PUBLIQUES` ci-dessous, ce qui apparaît en clair dans la diff d'une pull request et +# demande une justification au relecteur. +# Pourquoi : il interroge réellement chaque route sans jeton au lieu d'inspecter l'arbre de +# dépendances. L'arbre n'est accessible que par l'API privée de FastAPI, et surtout une route +# peut porter la bonne dépendance tout en répondant quand même. + +from typing import Any + +import pytest +from fastapi import FastAPI +from httpx import AsyncClient + +ROUTES_PUBLIQUES = frozenset( + { + ("GET", "/api/v1/health/live"), + ("GET", "/api/v1/health/ready"), + ("POST", "/api/v1/auth/login"), + ("GET", "/metrics"), + } +) + +VALEURS_DE_SUBSTITUTION = "00000000-0000-0000-0000-000000000000" +STATUTS_DE_REFUS = {401, 403} + + +def routes_declarees(app: FastAPI) -> list[tuple[str, str]]: + schema: dict[str, Any] = app.openapi() + return [ + (methode.upper(), chemin) + for chemin, operations in schema["paths"].items() + for methode in operations + if methode.upper() in {"GET", "POST", "PATCH", "PUT", "DELETE"} + ] + + +def routes_protegees(app: FastAPI) -> list[tuple[str, str]]: + return [route for route in routes_declarees(app) if route not in ROUTES_PUBLIQUES] + + +def test_the_public_allow_list_has_no_stale_entry(app: FastAPI) -> None: + declarees = set(routes_declarees(app)) | {("GET", "/metrics")} + + inconnues = ROUTES_PUBLIQUES - declarees + + assert inconnues == set() + + +async def test_every_route_rejects_an_anonymous_caller_unless_explicitly_public( + app: FastAPI, client: AsyncClient +) -> None: + ouvertes: list[tuple[str, str, int]] = [] + + for methode, chemin in routes_protegees(app): + concret = chemin.replace("{user_id}", VALEURS_DE_SUBSTITUTION) + response = await client.request(methode, concret, json={}) + if response.status_code not in STATUTS_DE_REFUS: + ouvertes.append((methode, chemin, response.status_code)) + + assert ouvertes == [] + + +async def test_the_declared_routes_are_actually_reachable(app: FastAPI) -> None: + assert ("POST", "/api/v1/auth/login") in routes_declarees(app) + assert ("GET", "/api/v1/auth/me") in routes_declarees(app) + + +@pytest.mark.parametrize( + "chemin", + ["/api/v1/health/live", "/api/v1/health/ready"], + ids=["sonde_de_vie", "sonde_de_disponibilite"], +) +def test_the_health_probes_stay_public(app: FastAPI, chemin: str) -> None: + assert ("GET", chemin) in ROUTES_PUBLIQUES diff --git a/apps/backend/tests/repositories/test_audit_log.py b/apps/backend/tests/repositories/test_audit_log.py new file mode 100644 index 0000000..1c6fc64 --- /dev/null +++ b/apps/backend/tests/repositories/test_audit_log.py @@ -0,0 +1,125 @@ +# Les trois refus ci-dessous sont la preuve que l'ajout seul est une propriété de la base et +# non une convention de code Python. Ce sont eux qu'il faut montrer, pas la classe du dépôt. + +import uuid + +import pytest +from sqlalchemy import text +from sqlalchemy.exc import DBAPIError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.models.audit_log import AuditAction, AuditOutcome +from app.repositories.audit_log import ( + CLES_DE_DETAIL_AUTORISEES, + AuditLogRepository, + assemble_detail, +) + +pytestmark = pytest.mark.integration + +ACTEUR = Principal( + id=uuid.uuid4(), + email="admin@enervision.fr", + role=Role.ADMIN, + kind=AccountKind.HUMAIN, + must_change_password=False, +) + + +async def une_ligne(session: AsyncSession) -> None: + await AuditLogRepository(session).record( + action=AuditAction.COMPTE_CREE, actor=ACTEUR, target_type="app_user", target_id="x" + ) + await session.flush() + + +@pytest.mark.parametrize( + "instruction", + [ + "update audit_log set action = 'falsifie'", + "delete from audit_log", + "truncate audit_log", + ], + ids=["modification", "suppression", "vidage"], +) +async def test_the_database_refuses_to_mutate_the_audit_log( + session: AsyncSession, instruction: str +) -> None: + await une_ligne(session) + + with pytest.raises(DBAPIError, match="ajout seul"): + await session.execute(text(instruction)) + await session.rollback() + + +async def test_record_keeps_a_snapshot_of_the_actor(session: AsyncSession) -> None: + depot = AuditLogRepository(session) + + await depot.record(action=AuditAction.COMPTE_DESACTIVE, actor=ACTEUR) + await session.flush() + ligne = ( + await session.execute( + text("select actor_id, actor_email, actor_role, outcome from audit_log") + ) + ).one() + await session.rollback() + + assert ligne.actor_id == ACTEUR.id + assert ligne.actor_email == ACTEUR.email + assert ligne.actor_role == Role.ADMIN.value + assert ligne.outcome == AuditOutcome.SUCCES.value + + +async def test_record_accepts_a_label_when_there_is_no_authenticated_actor( + session: AsyncSession, +) -> None: + depot = AuditLogRepository(session) + + await depot.record(action=AuditAction.ADMIN_AMORCE, actor_label="cli") + await session.flush() + ligne = (await session.execute(text("select actor_id, actor_email from audit_log"))).one() + await session.rollback() + + assert ligne.actor_id is None + assert ligne.actor_email == "cli" + + +async def test_record_drops_the_detail_keys_outside_the_allow_list( + session: AsyncSession, +) -> None: + depot = AuditLogRepository(session) + + await depot.record( + action=AuditAction.COMPTE_ROLE_CHANGE, + actor=ACTEUR, + detail={"role_avant": "lecteur", "mot_de_passe": "ne-doit-pas-passer"}, + ) + await session.flush() + detail = (await session.execute(text("select detail from audit_log"))).scalar_one() + await session.rollback() + + assert detail == {"role_avant": "lecteur"} + + +@pytest.mark.parametrize( + ("brut", "attendu"), + [ + (None, {}), + ({}, {}), + ({"motif": "reutilisation"}, {"motif": "reutilisation"}), + ({"password": "x"}, {}), + ], + ids=["absent", "vide", "cle_autorisee", "cle_refusee"], +) +def test_assemble_detail_only_keeps_the_allowed_keys( + brut: dict[str, str] | None, attendu: dict[str, str] +) -> None: + assert assemble_detail(brut) == attendu + + +def test_the_allow_list_never_mentions_a_secret() -> None: + suspects = {"password", "mot_de_passe", "token", "jeton", "secret", "hash"} + + assert CLES_DE_DETAIL_AUTORISEES & suspects == set() diff --git a/apps/backend/tests/repositories/test_login_attempt.py b/apps/backend/tests/repositories/test_login_attempt.py new file mode 100644 index 0000000..7ac620a --- /dev/null +++ b/apps/backend/tests/repositories/test_login_attempt.py @@ -0,0 +1,118 @@ +import uuid + +import pytest +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.login_attempt import LoginOutcome +from app.repositories.login_attempt import LoginAttemptRepository + +pytestmark = pytest.mark.integration + +IP = "203.0.113.10" +AUTRE_IP = "198.51.100.7" + + +def adresse() -> str: + return f"tentative-{uuid.uuid4().hex[:12]}@enervision.fr" + + +async def echoue( + depot: LoginAttemptRepository, email: str, ip: str | None, combien: int = 1 +) -> None: + for _ in range(combien): + await depot.record(email=email, client_ip=ip, outcome=LoginOutcome.IDENTIFIANTS_INVALIDES) + + +async def test_count_recent_failures_separates_the_three_counters( + session: AsyncSession, +) -> None: + depot = LoginAttemptRepository(session) + cible, voisin = adresse(), adresse() + await echoue(depot, cible, IP, combien=3) + await echoue(depot, cible, AUTRE_IP, combien=2) + await echoue(depot, voisin, IP, combien=4) + await session.flush() + + compteurs = await depot.count_recent_failures(email=cible, client_ip=IP, window_seconds=900) + await session.rollback() + + assert compteurs.per_identifier_and_ip == 3 + assert compteurs.per_identifier == 5 + assert compteurs.per_ip == 7 + + +async def test_count_recent_failures_ignores_successful_attempts( + session: AsyncSession, +) -> None: + depot = LoginAttemptRepository(session) + cible = adresse() + await echoue(depot, cible, IP, combien=2) + await depot.record(email=cible, client_ip=IP, outcome=LoginOutcome.SUCCES) + await session.flush() + + compteurs = await depot.count_recent_failures(email=cible, client_ip=IP, window_seconds=900) + await session.rollback() + + assert compteurs.per_identifier_and_ip == 2 + + +async def test_count_recent_failures_forgets_what_falls_outside_the_window( + session: AsyncSession, +) -> None: + depot = LoginAttemptRepository(session) + cible = adresse() + await echoue(depot, cible, IP, combien=2) + await session.flush() + await session.execute( + text( + "update login_attempt set occurred_at = now() - interval '2 hours' " + "where email_tried = :e" + ), + {"e": cible}, + ) + + compteurs = await depot.count_recent_failures(email=cible, client_ip=IP, window_seconds=900) + await session.rollback() + + assert compteurs.per_identifier_and_ip == 0 + + +async def test_count_recent_failures_still_counts_when_the_address_is_unknown( + session: AsyncSession, +) -> None: + depot = LoginAttemptRepository(session) + inconnu = adresse() + await echoue(depot, inconnu, IP, combien=5) + await session.flush() + + compteurs = await depot.count_recent_failures(email=inconnu, client_ip=IP, window_seconds=900) + await session.rollback() + + assert compteurs.per_identifier_and_ip == 5 + + +async def test_record_normalises_the_address_before_counting(session: AsyncSession) -> None: + depot = LoginAttemptRepository(session) + cible = adresse() + await echoue(depot, cible.upper(), IP, combien=2) + await session.flush() + + compteurs = await depot.count_recent_failures(email=cible, client_ip=IP, window_seconds=900) + await session.rollback() + + assert compteurs.per_identifier_and_ip == 2 + + +async def test_count_recent_failures_tolerates_a_missing_client_address( + session: AsyncSession, +) -> None: + depot = LoginAttemptRepository(session) + cible = adresse() + await echoue(depot, cible, None, combien=2) + await session.flush() + + compteurs = await depot.count_recent_failures(email=cible, client_ip=None, window_seconds=900) + await session.rollback() + + assert compteurs.per_identifier == 2 diff --git a/apps/backend/tests/repositories/test_user.py b/apps/backend/tests/repositories/test_user.py new file mode 100644 index 0000000..0701a2d --- /dev/null +++ b/apps/backend/tests/repositories/test_user.py @@ -0,0 +1,196 @@ +import uuid + +import pytest +from sqlalchemy import text +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.roles import AccountKind, Role +from app.repositories.user import UserRepository + +pytestmark = pytest.mark.integration + + +def adresse() -> str: + return f"compte-{uuid.uuid4().hex[:12]}@enervision.fr" + + +async def test_create_normalises_the_email_to_lower_case(session: AsyncSession) -> None: + depot = UserRepository(session) + saisie = adresse().upper() + + compte = await depot.create(email=saisie, password_hash="$argon2id$x", role=Role.LECTEUR) + enregistre = compte.email + await session.rollback() + + assert enregistre == saisie.lower() + + +async def test_the_database_refuses_an_email_written_in_upper_case( + session: AsyncSession, +) -> None: + saisie = adresse().upper() + + with pytest.raises(IntegrityError): + await session.execute( + text( + "insert into app_user (email, password_hash, role) " + "values (:e, '$argon2id$x', 'lecteur')" + ), + {"e": saisie}, + ) + await session.rollback() + + +async def test_the_database_refuses_two_accounts_sharing_an_email( + session: AsyncSession, +) -> None: + depot = UserRepository(session) + saisie = adresse() + + await depot.create(email=saisie, password_hash="$argon2id$x", role=Role.LECTEUR) + + with pytest.raises(IntegrityError): + await depot.create(email=saisie, password_hash="$argon2id$y", role=Role.ADMIN) + await session.rollback() + + +async def test_get_by_email_is_case_insensitive(session: AsyncSession) -> None: + depot = UserRepository(session) + saisie = adresse() + await depot.create(email=saisie, password_hash="$argon2id$x", role=Role.OPERATEUR) + + trouve = await depot.get_by_email(saisie.upper()) + role = trouve.role if trouve else None + await session.rollback() + + assert role == Role.OPERATEUR.value + + +async def test_get_by_email_returns_nothing_for_an_unknown_address( + session: AsyncSession, +) -> None: + trouve = await UserRepository(session).get_by_email(adresse()) + + assert trouve is None + + +async def test_set_role_moves_the_credentials_marker_forward(session: AsyncSession) -> None: + depot = UserRepository(session) + compte = await depot.create(email=adresse(), password_hash="$argon2id$x", role=Role.LECTEUR) + avant = compte.credentials_changed_at + + await depot.set_role(compte.id, Role.ADMIN) + await session.refresh(compte) + apres, role = compte.credentials_changed_at, compte.role + await session.rollback() + + assert role == Role.ADMIN.value + assert apres > avant + + +async def test_set_active_moves_the_credentials_marker_forward(session: AsyncSession) -> None: + depot = UserRepository(session) + compte = await depot.create(email=adresse(), password_hash="$argon2id$x", role=Role.LECTEUR) + avant = compte.credentials_changed_at + + await depot.set_active(compte.id, is_active=False) + await session.refresh(compte) + apres, actif = compte.credentials_changed_at, compte.is_active + await session.rollback() + + assert actif is False + assert apres > avant + + +async def test_rehash_password_leaves_the_credentials_marker_untouched( + session: AsyncSession, +) -> None: + depot = UserRepository(session) + compte = await depot.create(email=adresse(), password_hash="$argon2id$x", role=Role.LECTEUR) + avant = compte.credentials_changed_at + + await depot.rehash_password(compte.id, "$argon2id$plus-recent") + await session.refresh(compte) + apres, empreinte = compte.credentials_changed_at, compte.password_hash + await session.rollback() + + assert empreinte == "$argon2id$plus-recent" + assert apres == avant + + +async def test_update_password_moves_the_credentials_marker_forward( + session: AsyncSession, +) -> None: + depot = UserRepository(session) + compte = await depot.create(email=adresse(), password_hash="$argon2id$x", role=Role.LECTEUR) + avant = compte.credentials_changed_at + + await depot.update_password(compte.id, "$argon2id$neuf", must_change_password=False) + await session.refresh(compte) + apres = compte.credentials_changed_at + await session.rollback() + + assert apres > avant + + +async def test_touch_last_login_records_the_connection_date(session: AsyncSession) -> None: + depot = UserRepository(session) + compte = await depot.create(email=adresse(), password_hash="$argon2id$x", role=Role.LECTEUR) + + await depot.touch_last_login(compte.id) + await session.refresh(compte) + date = compte.last_login_at + await session.rollback() + + assert date is not None + + +async def test_count_active_admins_only_counts_enabled_administrators( + session: AsyncSession, +) -> None: + depot = UserRepository(session) + depart = await depot.count_active_admins() + + await depot.create(email=adresse(), password_hash="$argon2id$x", role=Role.ADMIN) + desactive = await depot.create(email=adresse(), password_hash="$argon2id$x", role=Role.ADMIN) + await depot.set_active(desactive.id, is_active=False) + total = await depot.count_active_admins() + await session.rollback() + + assert total == depart + 1 + + +async def test_create_accepts_a_service_account(session: AsyncSession) -> None: + depot = UserRepository(session) + + compte = await depot.create( + email=adresse(), + password_hash="$argon2id$x", + role=Role.OPERATEUR, + kind=AccountKind.SERVICE, + ) + nature = compte.kind + await session.rollback() + + assert nature == AccountKind.SERVICE.value + + +async def test_list_all_returns_the_accounts_sorted_by_email(session: AsyncSession) -> None: + depot = UserRepository(session) + await depot.create(email=f"zz-{adresse()}", password_hash="$argon2id$x", role=Role.LECTEUR) + await depot.create(email=f"aa-{adresse()}", password_hash="$argon2id$x", role=Role.LECTEUR) + + comptes = await depot.list_all() + emails = [compte.email for compte in comptes] + await session.rollback() + + assert emails == sorted(emails) + + +async def test_get_by_id_returns_nothing_for_an_unknown_identifier( + session: AsyncSession, +) -> None: + trouve = await UserRepository(session).get_by_id(uuid.uuid4()) + + assert trouve is None diff --git a/apps/backend/tests/services/test_auth.py b/apps/backend/tests/services/test_auth.py new file mode 100644 index 0000000..3b8902f --- /dev/null +++ b/apps/backend/tests/services/test_auth.py @@ -0,0 +1,234 @@ +from collections.abc import Mapping +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from typing import Any +from uuid import UUID, uuid4 + +import pytest + +from app.core.security import TokenPolicy, decode_access_token +from app.models.login_attempt import LoginOutcome +from app.repositories.login_attempt import FailureCounts +from app.services.auth import ( + AuthService, + InvalidCredentialsError, + LoginPolicy, + RateLimitedError, +) + +POLITIQUE_JETON = TokenPolicy( + secret="un-secret-de-test-de-plus-de-trente-deux-caracteres", + issuer="enervision-api", + audience="enervision-web", + access_ttl=timedelta(minutes=15), +) +POLITIQUE_CONNEXION = LoginPolicy( + window_seconds=900, + max_failures_per_identifier_and_ip=5, + max_failures_per_ip=20, + max_failures_per_identifier=50, +) + + +@dataclass +class FauxCompte: + id: UUID = field(default_factory=uuid4) + email: str = "operateur@enervision.fr" + password_hash: str = "$argon2id$factice" + role: str = "operateur" + kind: str = "human" + is_active: bool = True + must_change_password: bool = False + credentials_changed_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + + +class FauxDepotComptes: + def __init__(self, compte: FauxCompte | None) -> None: + self.compte = compte + self.rehachages = 0 + self.connexions_datees = 0 + + async def get_by_email(self, email: str) -> FauxCompte | None: + return self.compte + + async def rehash_password(self, user_id: UUID, password_hash: str) -> None: + self.rehachages += 1 + + async def touch_last_login(self, user_id: UUID) -> None: + self.connexions_datees += 1 + + +class FauxDepotTentatives: + def __init__(self, compteurs: FailureCounts | None = None) -> None: + self.compteurs = compteurs or FailureCounts(0, 0, 0) + self.enregistrees: list[str] = [] + + async def count_recent_failures(self, **_: object) -> FailureCounts: + return self.compteurs + + async def record(self, *, outcome: object, **_: object) -> None: + self.enregistrees.append(str(outcome)) + + +class FauxDepotAudit: + def __init__(self) -> None: + self.lignes: list[tuple[str, Mapping[str, Any] | None]] = [] + + async def record(self, *, action: object, detail: Any = None, **_: object) -> None: + self.lignes.append((str(action), detail)) + + +class FauxHacheur: + def __init__(self, *, accepte: bool = True, rehachage_requis: bool = False) -> None: + self.verifications = 0 + self.hachages = 0 + self._accepte = accepte + self._rehachage_requis = rehachage_requis + + async def hash(self, password: str) -> str: + self.hachages += 1 + return "$argon2id$nouvelle" + + async def verify(self, stored: str, password: str) -> bool: + self.verifications += 1 + return self._accepte + + async def verify_dummy(self) -> None: + self.verifications += 1 + + def needs_rehash(self, stored: str) -> bool: + return self._rehachage_requis + + +class FausseTransaction: + def __init__(self) -> None: + self.validations = 0 + + async def commit(self) -> None: + self.validations += 1 + + +def fabrique_service( + *, + compte: FauxCompte | None = None, + compteurs: FailureCounts | None = None, + hacheur: FauxHacheur | None = None, +) -> tuple[AuthService, FauxDepotComptes, FauxDepotTentatives, FauxDepotAudit, FauxHacheur]: + comptes = FauxDepotComptes(compte) + tentatives = FauxDepotTentatives(compteurs) + audit = FauxDepotAudit() + hacheur = hacheur or FauxHacheur() + service = AuthService( + users=comptes, # type: ignore[arg-type] + attempts=tentatives, # type: ignore[arg-type] + audit=audit, # type: ignore[arg-type] + hasher=hacheur, # type: ignore[arg-type] + transaction=FausseTransaction(), + token_policy=POLITIQUE_JETON, + login_policy=POLITIQUE_CONNEXION, + ) + return service, comptes, tentatives, audit, hacheur + + +async def connecte(service: AuthService, mot_de_passe: str = "un-mot-de-passe-valide") -> object: + return await service.authenticate( + email="operateur@enervision.fr", + password=mot_de_passe, + client_ip="203.0.113.10", + user_agent="pytest", + ) + + +async def test_authenticate_returns_a_readable_access_token_when_credentials_match() -> None: + compte = FauxCompte() + service, comptes, tentatives, _, _ = fabrique_service(compte=compte) + + session = await connecte(service) + + claims = decode_access_token(POLITIQUE_JETON, session.access_token) # type: ignore[attr-defined] + assert claims.subject == compte.id + assert claims.role == "operateur" + assert tentatives.enregistrees == [LoginOutcome.SUCCES.value] + assert comptes.connexions_datees == 1 + + +async def test_authenticate_verifies_a_decoy_digest_when_the_email_is_unknown() -> None: + service, _, tentatives, _, hacheur = fabrique_service(compte=None) + + with pytest.raises(InvalidCredentialsError): + await connecte(service) + + assert hacheur.verifications == 1 + assert tentatives.enregistrees == [LoginOutcome.IDENTIFIANTS_INVALIDES.value] + + +async def test_authenticate_skips_hashing_entirely_when_the_rate_limit_is_reached() -> None: + compteurs = FailureCounts(per_identifier_and_ip=5, per_ip=5, per_identifier=5) + service, _, tentatives, audit, hacheur = fabrique_service( + compte=FauxCompte(), compteurs=compteurs + ) + + with pytest.raises(RateLimitedError): + await connecte(service) + + assert hacheur.verifications == 0 + assert hacheur.hachages == 0 + assert tentatives.enregistrees == [LoginOutcome.LIMITE.value] + assert audit.lignes == [] + + +async def test_authenticate_audits_when_the_identifier_threshold_alone_is_reached() -> None: + compteurs = FailureCounts(per_identifier_and_ip=0, per_ip=0, per_identifier=50) + service, _, _, audit, _ = fabrique_service(compte=FauxCompte(), compteurs=compteurs) + + with pytest.raises(RateLimitedError): + await connecte(service) + + assert len(audit.lignes) == 1 + assert "identifier_throttled" in audit.lignes[0][0] + + +async def test_authenticate_rejects_a_wrong_password_with_the_generic_error() -> None: + service, _, tentatives, _, _ = fabrique_service( + compte=FauxCompte(), hacheur=FauxHacheur(accepte=False) + ) + + with pytest.raises(InvalidCredentialsError): + await connecte(service) + + assert tentatives.enregistrees == [LoginOutcome.IDENTIFIANTS_INVALIDES.value] + + +@pytest.mark.parametrize( + "compte", + [FauxCompte(is_active=False), FauxCompte(kind="service")], + ids=["compte_desactive", "compte_de_service"], +) +async def test_authenticate_rejects_unavailable_accounts_after_checking_the_password( + compte: FauxCompte, +) -> None: + service, _, tentatives, _, hacheur = fabrique_service(compte=compte) + + with pytest.raises(InvalidCredentialsError): + await connecte(service) + + assert hacheur.verifications == 1 + assert tentatives.enregistrees == [LoginOutcome.COMPTE_INDISPONIBLE.value] + + +async def test_authenticate_rehashes_the_password_when_the_parameters_changed() -> None: + service, comptes, _, _, _ = fabrique_service( + compte=FauxCompte(), hacheur=FauxHacheur(rehachage_requis=True) + ) + + await connecte(service) + + assert comptes.rehachages == 1 + + +async def test_authenticate_leaves_the_digest_alone_when_the_parameters_match() -> None: + service, comptes, _, _, _ = fabrique_service(compte=FauxCompte()) + + await connecte(service) + + assert comptes.rehachages == 0 diff --git a/apps/backend/tests/test_cli.py b/apps/backend/tests/test_cli.py new file mode 100644 index 0000000..d8465b5 --- /dev/null +++ b/apps/backend/tests/test_cli.py @@ -0,0 +1,57 @@ +import pytest + +from app import cli + + +def test_build_parser_reads_the_create_admin_arguments() -> None: + arguments = cli.build_parser().parse_args( + ["create-admin", "--email", "admin@enervision.fr", "--generate", "--force"] + ) + + assert arguments.commande == "create-admin" + assert arguments.email == "admin@enervision.fr" + assert arguments.generate is True + assert arguments.force is True + + +def test_build_parser_requires_a_subcommand() -> None: + with pytest.raises(SystemExit): + cli.build_parser().parse_args([]) + + +def test_build_parser_requires_an_email() -> None: + with pytest.raises(SystemExit): + cli.build_parser().parse_args(["create-admin"]) + + +def test_read_password_generates_a_long_secret_when_asked( + capsys: pytest.CaptureFixture[str], +) -> None: + mot_de_passe = cli.read_password(generate=True) + + assert len(mot_de_passe) >= cli.LONGUEUR_MOT_DE_PASSE_GENERE + assert mot_de_passe in capsys.readouterr().out + + +def test_read_password_accepts_two_matching_entries(monkeypatch: pytest.MonkeyPatch) -> None: + saisies = iter(["un-mot-de-passe-valide", "un-mot-de-passe-valide"]) + monkeypatch.setattr(cli, "getpass", lambda _: next(saisies)) + + assert cli.read_password(generate=False) == "un-mot-de-passe-valide" + + +def test_read_password_refuses_a_password_below_the_minimum_length( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(cli, "getpass", lambda _: "court") + + with pytest.raises(SystemExit): + cli.read_password(generate=False) + + +def test_read_password_refuses_two_different_entries(monkeypatch: pytest.MonkeyPatch) -> None: + saisies = iter(["un-mot-de-passe-valide", "un-autre-mot-de-passe"]) + monkeypatch.setattr(cli, "getpass", lambda _: next(saisies)) + + with pytest.raises(SystemExit): + cli.read_password(generate=False) diff --git a/apps/backend/uv.lock b/apps/backend/uv.lock index edc09c2..7c2b8f4 100644 --- a/apps/backend/uv.lock +++ b/apps/backend/uv.lock @@ -279,6 +279,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/1a/d6d16babd0a5fe4c3fae40702158c570351694e74516d8d81b86c5637448/coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4", size = 215264, upload-time = "2026-09-13T19:12:18.895Z" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + [[package]] name = "enervision-backend" version = "0.1.0" @@ -290,7 +312,7 @@ dependencies = [ { name = "asyncpg" }, { name = "fastapi" }, { name = "prometheus-fastapi-instrumentator" }, - { name = "pydantic" }, + { name = "pydantic", extra = ["email"] }, { name = "pydantic-settings" }, { name = "pyjwt" }, { name = "python-json-logger" }, @@ -316,7 +338,7 @@ requires-dist = [ { name = "asyncpg", specifier = ">=0.31.0" }, { name = "fastapi", specifier = ">=0.141.1" }, { name = "prometheus-fastapi-instrumentator", specifier = ">=8.1.0" }, - { name = "pydantic", specifier = ">=2.13.5" }, + { name = "pydantic", extras = ["email"], specifier = ">=2.13.5" }, { name = "pydantic-settings", specifier = ">=2.15.0" }, { name = "pyjwt", specifier = ">=2.10" }, { name = "python-json-logger", specifier = ">=4.2.0" }, @@ -646,6 +668,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, ] +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + [[package]] name = "pydantic-core" version = "2.46.5" From 1f6210698df331fc9b3237eb56add56d16501989 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:49:30 +0200 Subject: [PATCH 006/103] =?UTF-8?q?feat(backend):=20fait=20tourner=20les?= =?UTF-8?q?=20jetons=20de=20rafra=C3=AEchissement=20et=20d=C3=A9tecte=20le?= =?UTF-8?q?ur=20r=C3=A9utilisation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le jeton de rafraîchissement est une chaîne opaque de 256 bits, jamais un JWT. Il doit être révocable, donc sa ligne en base existe de toute façon, et le JWT n'ajouterait qu'un second chemin de signature. Surtout, la séparation d'avec le jeton d'accès devient structurelle : un JWT ne figure dans aucune ligne, une chaîne opaque échoue au décodage. La confusion refresh-vers-accès, qui transforme une fenêtre de 15 minutes en fenêtre de 7 jours, est impossible même si quelqu'un oublie le test. Seule l'empreinte SHA-256 est stockée. Pas d'Argon2 : l'entrée fait 256 bits de CSPRNG, aucun dictionnaire ne l'atteint, et une KDF coûterait 17 ms à chaque rafraîchissement. La rotation ne protège de rien par elle-même : elle rend la réutilisation détectable, et c'est la détection qui termine le vol. Un jeton déjà tourné révoque donc toute sa famille et laisse une trace dans `audit_log` ; un jeton expiré, lui, ne révoque rien, ce n'est pas une preuve de compromission. Les deux cas ont leur test. La revendication est une seule instruction SQL avec RETURNING. Un SELECT puis un UPDATE laisseraient une fenêtre où deux onglets réussissent la même rotation ; le test d'intégration le prouve, ce qui est indémontrable sur un double. `expires_at` est absolu et hérité du prédécesseur : s'il glissait, la promesse de sept jours serait fictive. Corrige au passage un défaut trouvé par un test : une `HTTPException` construit sa propre réponse, donc l'effacement du cookie posé sur la `Response` injectée était perdu. Un navigateur gardait un cookie mort après une détection de réutilisation. --- ...821f71be74c0_jetons_de_rafraichissement.py | 77 +++++ apps/backend/app/api/deps.py | 3 + apps/backend/app/api/v1/endpoints/auth.py | 131 +++++++- apps/backend/app/core/cookies.py | 7 + apps/backend/app/models/__init__.py | 3 +- apps/backend/app/models/refresh_token.py | 64 ++++ .../backend/app/repositories/refresh_token.py | 106 +++++++ apps/backend/app/services/auth.py | 139 ++++++++- apps/backend/pyproject.toml | 4 +- apps/backend/tests/api/test_auth.py | 104 ++++++- .../tests/api/test_route_protection.py | 2 + .../tests/repositories/test_refresh_token.py | 190 ++++++++++++ apps/backend/tests/services/test_auth.py | 286 +++++++++++++++--- 13 files changed, 1047 insertions(+), 69 deletions(-) create mode 100644 apps/backend/alembic/versions/821f71be74c0_jetons_de_rafraichissement.py create mode 100644 apps/backend/app/models/refresh_token.py create mode 100644 apps/backend/app/repositories/refresh_token.py create mode 100644 apps/backend/tests/repositories/test_refresh_token.py diff --git a/apps/backend/alembic/versions/821f71be74c0_jetons_de_rafraichissement.py b/apps/backend/alembic/versions/821f71be74c0_jetons_de_rafraichissement.py new file mode 100644 index 0000000..15fb453 --- /dev/null +++ b/apps/backend/alembic/versions/821f71be74c0_jetons_de_rafraichissement.py @@ -0,0 +1,77 @@ +"""jetons de rafraichissement + +Revision ID: 821f71be74c0 +Revises: 517053a3c044 +Create Date: 2026-09-15 14:42:09.757949 + +Le jeton lui-meme n'est jamais stocke : seule son empreinte SHA-256 l'est, dans +`token_hash`. Un pg_dump qui fuiterait ne livrerait donc aucune session utilisable. + +L'index partiel `ix_refresh_token_vivants` sert la revocation en cascade et la +recherche des sessions actives, qui ne regardent jamais les lignes deja tournees. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "821f71be74c0" +down_revision: str | Sequence[str] | None = "517053a3c044" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +MOTIFS = "'logout', 'rotation', 'reuse_detected', 'password_change', 'admin'" +JETONS_VIVANTS = "revoked_at is null and rotated_at is null" + + +def upgrade() -> None: + op.create_table( + "refresh_token", + sa.Column( + "id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False + ), + sa.Column("family_id", sa.UUID(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("token_hash", sa.LargeBinary(), nullable=False), + sa.Column( + "issued_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("rotated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_reason", sa.Text(), nullable=True), + sa.Column("replaced_by", sa.UUID(), nullable=True), + sa.Column("client_ip", postgresql.INET(), nullable=True), + sa.Column("user_agent", sa.Text(), nullable=True), + sa.CheckConstraint( + f"revoked_reason is null or revoked_reason in ({MOTIFS})", + name="ck_refresh_token_revoked_reason", + ), + sa.ForeignKeyConstraint( + ["user_id"], ["app_user.id"], name="fk_refresh_token_user", ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id", name="pk_refresh_token"), + sa.UniqueConstraint("token_hash", name="uq_refresh_token_hash"), + ) + op.create_index("ix_refresh_token_family", "refresh_token", ["family_id"]) + op.create_index("ix_refresh_token_user", "refresh_token", ["user_id"]) + op.create_index( + "ix_refresh_token_vivants", + "refresh_token", + ["user_id"], + postgresql_where=JETONS_VIVANTS, + ) + + +def downgrade() -> None: + op.drop_index( + "ix_refresh_token_vivants", table_name="refresh_token", postgresql_where=JETONS_VIVANTS + ) + op.drop_index("ix_refresh_token_user", table_name="refresh_token") + op.drop_index("ix_refresh_token_family", table_name="refresh_token") + op.drop_table("refresh_token") diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index a35d628..82dc357 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -23,6 +23,7 @@ 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.refresh_token import RefreshTokenRepository from app.repositories.user import UserRepository from app.services.auth import AuthService, LoginPolicy @@ -95,6 +96,7 @@ def get_auth_service( return AuthService( users=UserRepository(session), attempts=LoginAttemptRepository(session), + refresh_tokens=RefreshTokenRepository(session), audit=AuditLogRepository(session), hasher=hasher, transaction=session, @@ -105,6 +107,7 @@ def get_auth_service( max_failures_per_ip=settings.login_max_failures_per_ip, max_failures_per_identifier=settings.login_max_failures_per_identifier, ), + refresh_ttl=timedelta(seconds=settings.refresh_token_ttl_seconds), ) diff --git a/apps/backend/app/api/v1/endpoints/auth.py b/apps/backend/app/api/v1/endpoints/auth.py index 0def0d4..4d57559 100644 --- a/apps/backend/app/api/v1/endpoints/auth.py +++ b/apps/backend/app/api/v1/endpoints/auth.py @@ -1,14 +1,59 @@ +# Piège : le jeton de rafraîchissement ne quitte jamais le cookie httpOnly, et le jeton +# d'accès ne va jamais dans un cookie. C'est ce qui réduit la surface CSRF aux trois routes de +# ce module : partout ailleurs, le navigateur n'attache rien de lui-même. + from fastapi import APIRouter, Depends, HTTPException, Request, Response, status -from app.api.deps import AuthServiceDep, CurrentPrincipalDep, get_client_ip +from app.api.deps import ( + AuthServiceDep, + CurrentPrincipalDep, + SettingsDep, + get_client_ip, + require_trusted_origin, +) +from app.core.cookies import RefreshCookie, cookie_name from app.core.logging import get_logger from app.schemas.auth import LoginRequest, PrincipalResponse, TokenResponse -from app.services.auth import InvalidCredentialsError, RateLimitedError +from app.services.auth import ( + AuthenticatedSession, + InvalidCredentialsError, + RateLimitedError, + SessionRejectedError, +) router = APIRouter() logger = get_logger(__name__) DETAIL_IDENTIFIANTS = "Identifiants invalides" +DETAIL_SESSION = "Session invalide" + + +def repond( + response: Response, settings: SettingsDep, session: AuthenticatedSession +) -> TokenResponse: + response.headers["Cache-Control"] = "no-store" + response.set_cookie(**RefreshCookie.build(settings, session.refresh_secret).as_kwargs()) + return TokenResponse( + access_token=session.access_token, + expires_in=session.expires_in, + principal=PrincipalResponse.from_principal(session.principal), + ) + + +# Piège : une `HTTPException` construit sa propre réponse, donc tout en-tête posé sur la +# `Response` injectée est perdu. L'effacement du cookie doit voyager avec l'exception, +# sans quoi un navigateur garderait un cookie mort après une détection de réutilisation. +def entete_de_suppression(settings: SettingsDep) -> str: + temoin = Response() + temoin.delete_cookie(**RefreshCookie.expired(settings).as_deletion_kwargs()) + return temoin.headers["set-cookie"] + + +def lit_le_cookie(request: Request, settings: SettingsDep) -> str: + secret = request.cookies.get(cookie_name(settings)) + if not secret: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=DETAIL_SESSION) + return secret @router.post("/login", response_model=TokenResponse, summary="Ouvre une session") @@ -16,19 +61,16 @@ async def login( payload: LoginRequest, request: Request, response: Response, + settings: SettingsDep, service: AuthServiceDep, client_ip: str | None = Depends(get_client_ip), ) -> TokenResponse: - # Une réponse d'authentification ne doit jamais être conservée par un intermédiaire. response.headers["Cache-Control"] = "no-store" agent = request.headers.get("user-agent") try: session = await service.authenticate( - email=payload.email, - password=payload.password, - client_ip=client_ip, - user_agent=agent, + email=payload.email, password=payload.password, client_ip=client_ip, user_agent=agent ) except RateLimitedError as erreur: logger.warning("auth.rate_limited email=%s ip=%s", payload.email, client_ip) @@ -44,11 +86,76 @@ async def login( ) from erreur logger.info("auth.login.success user_id=%s ip=%s", session.principal.id, client_ip) - return TokenResponse( - access_token=session.access_token, - expires_in=session.expires_in, - principal=PrincipalResponse.from_principal(session.principal), - ) + return repond(response, settings, session) + + +@router.post( + "/refresh", + response_model=TokenResponse, + summary="Fait tourner la session", + dependencies=[Depends(require_trusted_origin)], +) +async def refresh( + request: Request, + response: Response, + settings: SettingsDep, + service: AuthServiceDep, + client_ip: str | None = Depends(get_client_ip), +) -> TokenResponse: + response.headers["Cache-Control"] = "no-store" + + try: + session = await service.refresh( + secret=lit_le_cookie(request, settings), + client_ip=client_ip, + user_agent=request.headers.get("user-agent"), + ) + except SessionRejectedError as erreur: + logger.warning("auth.refresh.rejected ip=%s", client_ip) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=DETAIL_SESSION, + headers={ + "Set-Cookie": entete_de_suppression(settings), + "Cache-Control": "no-store", + }, + ) from erreur + + return repond(response, settings, session) + + +@router.post( + "/logout", + status_code=status.HTTP_204_NO_CONTENT, + summary="Ferme la session courante", + dependencies=[Depends(require_trusted_origin)], +) +async def logout( + request: Request, response: Response, settings: SettingsDep, service: AuthServiceDep +) -> None: + response.headers["Cache-Control"] = "no-store" + secret = request.cookies.get(cookie_name(settings)) + if secret: + await service.logout(secret=secret) + response.delete_cookie(**RefreshCookie.expired(settings).as_deletion_kwargs()) + + +@router.post( + "/logout-all", + status_code=status.HTTP_204_NO_CONTENT, + summary="Ferme toutes les sessions du compte", + dependencies=[Depends(require_trusted_origin)], +) +async def logout_all( + principal: CurrentPrincipalDep, + response: Response, + settings: SettingsDep, + service: AuthServiceDep, +) -> None: + response.headers["Cache-Control"] = "no-store" + revoquees = await service.logout_all(principal) + logger.info("auth.logout_all user_id=%s sessions=%s", principal.id, revoquees) + response.delete_cookie(**RefreshCookie.expired(settings).as_deletion_kwargs()) @router.get("/me", response_model=PrincipalResponse, summary="Décrit le compte connecté") diff --git a/apps/backend/app/core/cookies.py b/apps/backend/app/core/cookies.py index f54dbff..1221085 100644 --- a/apps/backend/app/core/cookies.py +++ b/apps/backend/app/core/cookies.py @@ -47,6 +47,13 @@ class RefreshCookie: def as_kwargs(self) -> dict[str, Any]: return asdict(self) + def as_deletion_kwargs(self) -> dict[str, Any]: + # `Response.delete_cookie()` n'accepte ni `value` ni `max_age`, mais il exige le même + # nom, le même chemin et les mêmes attributs, sinon le navigateur garde le cookie. + arguments = asdict(self) + del arguments["value"], arguments["max_age"] + return arguments + def cookie_name(settings: Settings) -> str: if settings.cookies_are_secure: diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py index 222295d..9e65265 100644 --- a/apps/backend/app/models/__init__.py +++ b/apps/backend/app/models/__init__.py @@ -3,6 +3,7 @@ from app.models.audit_log import AuditLog from app.models.login_attempt import LoginAttempt +from app.models.refresh_token import RefreshToken from app.models.user import AppUser -__all__ = ["AppUser", "AuditLog", "LoginAttempt"] +__all__ = ["AppUser", "AuditLog", "LoginAttempt", "RefreshToken"] diff --git a/apps/backend/app/models/refresh_token.py b/apps/backend/app/models/refresh_token.py new file mode 100644 index 0000000..8153776 --- /dev/null +++ b/apps/backend/app/models/refresh_token.py @@ -0,0 +1,64 @@ +# Pourquoi : un jeton de rafraîchissement est une chaîne opaque, jamais un JWT. Il doit être +# révocable, donc cette ligne existe de toute façon ; le JWT n'ajouterait qu'un second chemin de +# signature. Surtout, la séparation devient structurelle : un JWT ne figure dans aucune ligne, +# une chaîne opaque échoue au décodage. Aucune confusion de type n'est possible. +# Piège : `expires_at` est absolu et hérité du prédécesseur à chaque rotation. S'il glissait, +# la promesse de sept jours serait fictive et une session active ne finirait jamais. + +import uuid +from datetime import datetime +from enum import StrEnum + +from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, LargeBinary, Text, func +from sqlalchemy.dialects.postgresql import INET +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class RevocationReason(StrEnum): + DECONNEXION = "logout" + ROTATION = "rotation" + REUTILISATION = "reuse_detected" + CHANGEMENT_MOT_DE_PASSE = "password_change" + ADMINISTRATION = "admin" + + +MOTIFS_AUTORISES = ", ".join(f"'{motif.value}'" for motif in RevocationReason) + + +class RefreshToken(Base): + __tablename__ = "refresh_token" + __table_args__ = ( + CheckConstraint( + f"revoked_reason is null or revoked_reason in ({MOTIFS_AUTORISES})", + name="ck_refresh_token_revoked_reason", + ), + Index("ix_refresh_token_family", "family_id"), + Index("ix_refresh_token_user", "user_id"), + Index( + "ix_refresh_token_vivants", + "user_id", + postgresql_where="revoked_at is null and rotated_at is null", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid() + ) + family_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), nullable=False) + user_id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False + ) + token_hash: Mapped[bytes] = mapped_column(LargeBinary, nullable=False, unique=True) + issued_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + rotated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + revoked_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + replaced_by: Mapped[uuid.UUID | None] = mapped_column(PG_UUID(as_uuid=True), nullable=True) + client_ip: Mapped[str | None] = mapped_column(INET, nullable=True) + user_agent: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/apps/backend/app/repositories/refresh_token.py b/apps/backend/app/repositories/refresh_token.py new file mode 100644 index 0000000..08d7980 --- /dev/null +++ b/apps/backend/app/repositories/refresh_token.py @@ -0,0 +1,106 @@ +# Piège : `claim_for_rotation()` est une seule instruction. Un SELECT puis un UPDATE +# laisseraient une fenêtre où deux onglets réussissent la même rotation. Zéro ligne retournée +# signifie donc, sans ambiguïté, que le jeton était déjà tourné, révoqué, expiré ou inconnu, et +# c'est `inspect()` qui départage ensuite ces cas. + +from dataclasses import dataclass +from datetime import datetime +from uuid import UUID + +from sqlalchemy import func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.refresh_token import RefreshToken, RevocationReason + + +@dataclass(frozen=True, slots=True) +class ClaimedToken: + id: UUID + family_id: UUID + user_id: UUID + expires_at: datetime + + +class RefreshTokenRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def create( + self, + *, + user_id: UUID, + family_id: UUID, + token_hash: bytes, + expires_at: datetime, + client_ip: str | None, + user_agent: str | None, + ) -> RefreshToken: + jeton = RefreshToken( + user_id=user_id, + family_id=family_id, + token_hash=token_hash, + expires_at=expires_at, + client_ip=client_ip, + user_agent=user_agent, + ) + self._session.add(jeton) + await self._session.flush() + return jeton + + async def claim_for_rotation(self, token_hash: bytes) -> ClaimedToken | None: + requete = ( + update(RefreshToken) + .where( + RefreshToken.token_hash == token_hash, + RefreshToken.rotated_at.is_(None), + RefreshToken.revoked_at.is_(None), + RefreshToken.expires_at > func.clock_timestamp(), + ) + .values( + rotated_at=func.clock_timestamp(), + revoked_at=func.clock_timestamp(), + revoked_reason=RevocationReason.ROTATION.value, + ) + .returning( + RefreshToken.id, + RefreshToken.family_id, + RefreshToken.user_id, + RefreshToken.expires_at, + ) + ) + ligne = (await self._session.execute(requete)).one_or_none() + if ligne is None: + return None + return ClaimedToken( + id=ligne.id, + family_id=ligne.family_id, + user_id=ligne.user_id, + expires_at=ligne.expires_at, + ) + + async def inspect(self, token_hash: bytes) -> RefreshToken | None: + requete = select(RefreshToken).where(RefreshToken.token_hash == token_hash) + return (await self._session.execute(requete)).scalar_one_or_none() + + async def link_replacement(self, ancien_id: UUID, nouveau_id: UUID) -> None: + await self._session.execute( + update(RefreshToken).where(RefreshToken.id == ancien_id).values(replaced_by=nouveau_id) + ) + + async def revoke_family(self, family_id: UUID, reason: RevocationReason) -> int: + resultat = await self._session.execute( + update(RefreshToken) + .where(RefreshToken.family_id == family_id, RefreshToken.revoked_at.is_(None)) + .values(revoked_at=func.clock_timestamp(), revoked_reason=reason.value) + .returning(RefreshToken.id) + ) + return len(resultat.all()) + + async def revoke_all_for_user(self, user_id: UUID, reason: RevocationReason) -> int: + resultat = await self._session.execute( + update(RefreshToken) + .where(RefreshToken.user_id == user_id, RefreshToken.revoked_at.is_(None)) + .values(revoked_at=func.clock_timestamp(), revoked_reason=reason.value) + .returning(RefreshToken.id) + ) + return len(resultat.all()) diff --git a/apps/backend/app/services/auth.py b/apps/backend/app/services/auth.py index 19a4e26..4e15dbd 100644 --- a/apps/backend/app/services/auth.py +++ b/apps/backend/app/services/auth.py @@ -5,19 +5,30 @@ # vérification réelle. Sans lui, l'écart de temps de réponse est un oracle d'existence. # Piège : la tentative échouée est validée en base AVANT que l'erreur ne soit levée. # `get_session()` ne valide pas de lui-même, donc la preuve disparaîtrait avec la transaction. +# Piège : dans `refresh()`, un jeton expiré ne révoque PAS la famille, un jeton déjà tourné si. +# La rotation ne protège de rien par elle-même : elle rend la réutilisation détectable, et +# c'est la détection qui termine le vol. from dataclasses import dataclass +from datetime import UTC, datetime, timedelta from typing import NoReturn, Protocol -from uuid import UUID +from uuid import UUID, uuid4 from app.core.hashing import Argon2Hasher from app.core.principal import Principal from app.core.roles import AccountKind, Role -from app.core.security import TokenPolicy, encode_access_token -from app.models.audit_log import AuditAction +from app.core.security import ( + TokenPolicy, + encode_access_token, + fingerprint_refresh, + generate_refresh_secret, +) +from app.models.audit_log import AuditAction, AuditOutcome from app.models.login_attempt import LoginOutcome +from app.models.refresh_token import RevocationReason from app.repositories.audit_log import AuditLogRepository from app.repositories.login_attempt import LoginAttemptRepository +from app.repositories.refresh_token import RefreshTokenRepository from app.repositories.user import UserRepository @@ -33,6 +44,10 @@ class InvalidCredentialsError(AuthError): pass +class SessionRejectedError(AuthError): + pass + + class RateLimitedError(AuthError): def __init__(self, retry_after: int) -> None: super().__init__("Trop de tentatives") @@ -52,6 +67,7 @@ class AuthenticatedSession: principal: Principal access_token: str expires_in: int + refresh_secret: str class AuthService: @@ -60,19 +76,23 @@ class AuthService: *, users: UserRepository, attempts: LoginAttemptRepository, + refresh_tokens: RefreshTokenRepository, audit: AuditLogRepository, hasher: Argon2Hasher, transaction: Transaction, token_policy: TokenPolicy, login_policy: LoginPolicy, + refresh_ttl: timedelta, ) -> None: self._users = users self._attempts = attempts + self._refresh = refresh_tokens self._audit = audit self._hasher = hasher self._transaction = transaction self._token_policy = token_policy self._login_policy = login_policy + self._refresh_ttl = refresh_ttl async def authenticate( self, *, email: str, password: str, client_ip: str | None, user_agent: str | None @@ -101,19 +121,60 @@ class AuthService: await self._attempts.record( email=email, client_ip=client_ip, outcome=LoginOutcome.SUCCES, user_id=compte.id ) + secret = await self._ouvre_une_famille( + user_id=compte.id, client_ip=client_ip, user_agent=user_agent + ) await self._transaction.commit() - return self.issue_access_token( - Principal( - id=compte.id, - email=compte.email, - role=Role(compte.role), - kind=AccountKind(compte.kind), - must_change_password=compte.must_change_password, - ) - ) + return self._session(self._en_principal(compte), secret) - def issue_access_token(self, principal: Principal) -> AuthenticatedSession: + async def refresh( + self, *, secret: str, client_ip: str | None, user_agent: str | None + ) -> AuthenticatedSession: + empreinte = fingerprint_refresh(secret) + revendique = await self._refresh.claim_for_rotation(empreinte) + if revendique is None: + await self._traite_rotation_refusee(empreinte, client_ip, user_agent) + + compte = await self._users.get_by_id(revendique.user_id) + if compte is None or not compte.is_active: + await self._refresh.revoke_family(revendique.family_id, RevocationReason.ADMINISTRATION) + await self._transaction.commit() + raise SessionRejectedError("Session révoquée") + + nouveau_secret = generate_refresh_secret() + nouveau = await self._refresh.create( + user_id=revendique.user_id, + family_id=revendique.family_id, + token_hash=fingerprint_refresh(nouveau_secret), + expires_at=revendique.expires_at, + client_ip=client_ip, + user_agent=user_agent, + ) + await self._refresh.link_replacement(revendique.id, nouveau.id) + await self._transaction.commit() + + return self._session(self._en_principal(compte), nouveau_secret) + + async def logout(self, *, secret: str) -> None: + ligne = await self._refresh.inspect(fingerprint_refresh(secret)) + if ligne is not None: + await self._refresh.revoke_family(ligne.family_id, RevocationReason.DECONNEXION) + await self._transaction.commit() + + async def logout_all(self, principal: Principal) -> int: + revoquees = await self._refresh.revoke_all_for_user( + principal.id, RevocationReason.DECONNEXION + ) + await self._audit.record( + action=AuditAction.SESSIONS_REVOQUEES, + actor=principal, + detail={"sessions_revoquees": revoquees}, + ) + await self._transaction.commit() + return revoquees + + def _session(self, principal: Principal, refresh_secret: str) -> AuthenticatedSession: jeton = encode_access_token( self._token_policy, subject=principal.id, @@ -124,8 +185,59 @@ class AuthService: principal=principal, access_token=jeton, expires_in=int(self._token_policy.access_ttl.total_seconds()), + refresh_secret=refresh_secret, ) + def _en_principal(self, compte: object) -> Principal: + return Principal( + id=compte.id, # type: ignore[attr-defined] + email=compte.email, # type: ignore[attr-defined] + role=Role(compte.role), # type: ignore[attr-defined] + kind=AccountKind(compte.kind), # type: ignore[attr-defined] + must_change_password=compte.must_change_password, # type: ignore[attr-defined] + ) + + async def _ouvre_une_famille( + self, *, user_id: UUID, client_ip: str | None, user_agent: str | None + ) -> str: + secret = generate_refresh_secret() + await self._refresh.create( + user_id=user_id, + family_id=uuid4(), + token_hash=fingerprint_refresh(secret), + expires_at=datetime.now(UTC) + self._refresh_ttl, + client_ip=client_ip, + user_agent=user_agent, + ) + return secret + + async def _traite_rotation_refusee( + self, empreinte: bytes, client_ip: str | None, user_agent: str | None + ) -> NoReturn: + ligne = await self._refresh.inspect(empreinte) + if ligne is None: + raise SessionRejectedError("Session inconnue") + + if ligne.expires_at <= datetime.now(UTC): + raise SessionRejectedError("Session expirée") + + # Présenter un jeton déjà tourné est une preuve de compromission, pas un accident : toute + # la famille tombe, y compris la session encore vivante du voleur ou de la victime. + revoquees = await self._refresh.revoke_family( + ligne.family_id, RevocationReason.REUTILISATION + ) + await self._audit.record( + action=AuditAction.REFRESH_REUTILISE, + outcome=AuditOutcome.ECHEC, + target_type="refresh_token", + target_id=str(ligne.family_id), + client_ip=client_ip, + user_agent=user_agent, + detail={"famille": str(ligne.family_id), "sessions_revoquees": revoquees}, + ) + await self._transaction.commit() + raise SessionRejectedError("Session révoquée") + async def _refuse_si_limite( self, *, email: str, client_ip: str | None, user_agent: str | None ) -> None: @@ -148,6 +260,7 @@ class AuthService: if compteurs.per_identifier >= politique.max_failures_per_identifier: await self._audit.record( action=AuditAction.LIMITE_PAR_IDENTIFIANT, + outcome=AuditOutcome.ECHEC, actor_label=email.strip().lower(), client_ip=client_ip, user_agent=user_agent, diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index f0e4f22..18bf979 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -60,8 +60,8 @@ select = [ ignore = ["B008"] [tool.ruff.lint.per-file-ignores] -# S105 et S106 signalent les secrets en dur, ce qui est justement la matière des tests d'auth. -"tests/**/*.py" = ["S101", "S105", "S106"] +# S105 à S107 signalent les secrets en dur, qui sont justement la matière des tests d'auth. +"tests/**/*.py" = ["S101", "S105", "S106", "S107"] [tool.ruff.lint.isort] known-first-party = ["app"] diff --git a/apps/backend/tests/api/test_auth.py b/apps/backend/tests/api/test_auth.py index 08ceddd..1d734da 100644 --- a/apps/backend/tests/api/test_auth.py +++ b/apps/backend/tests/api/test_auth.py @@ -12,6 +12,7 @@ from app.services.auth import ( AuthenticatedSession, InvalidCredentialsError, RateLimitedError, + SessionRejectedError, ) IDENTIFIANTS = {"email": "operateur@enervision.fr", "password": "un-mot-de-passe-valide"} @@ -29,11 +30,20 @@ class FauxService: def __init__(self, erreur: Exception | None = None) -> None: self._erreur = erreur + async def refresh(self, **_: object) -> AuthenticatedSession: + return await self.authenticate() + + async def logout(self, **_: object) -> None: + return None + async def authenticate(self, **_: object) -> AuthenticatedSession: if self._erreur is not None: raise self._erreur return AuthenticatedSession( - principal=PRINCIPAL, access_token="un.jeton.factice", expires_in=900 + principal=PRINCIPAL, + access_token="un.jeton.factice", + expires_in=900, + refresh_secret="un-secret-opaque", ) @@ -104,3 +114,95 @@ async def test_login_rejects_a_malformed_body_without_echoing_the_password( assert response.status_code == 422 assert "un-mot-de-passe-valide" not in response.text assert "x" * 129 not in response.text + + +async def test_login_posts_an_http_only_refresh_cookie_scoped_to_the_auth_routes( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post("/api/v1/auth/login", json=IDENTIFIANTS) + + depose = response.headers["set-cookie"] + assert depose.startswith("ev_refresh=un-secret-opaque") + assert "HttpOnly" in depose + assert "SameSite=strict" in depose + assert "Path=/api/v1/auth" in depose + + +async def test_login_keeps_the_refresh_secret_out_of_the_response_body( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post("/api/v1/auth/login", json=IDENTIFIANTS) + + assert "un-secret-opaque" not in response.text + + +async def test_refresh_returns_401_when_no_cookie_is_presented( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post("/api/v1/auth/refresh") + + assert response.status_code == 401 + + +async def test_refresh_rotates_the_cookie_when_the_session_is_still_valid( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + client.cookies.set("ev_refresh", "un-secret-opaque") + + response = await client.post("/api/v1/auth/refresh") + + assert response.status_code == 200 + assert "ev_refresh=" in response.headers["set-cookie"] + + +async def test_refresh_clears_the_cookie_when_the_session_is_rejected( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + fake_auth_service[0] = SessionRejectedError("Session révoquée") + client.cookies.set("ev_refresh", "un-secret-rejoue") + + response = await client.post("/api/v1/auth/refresh") + + assert response.status_code == 401 + assert 'ev_refresh=""' in response.headers["set-cookie"] + assert "Path=/api/v1/auth" in response.headers["set-cookie"] + + +async def test_logout_answers_204_and_clears_the_cookie( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + client.cookies.set("ev_refresh", "un-secret-opaque") + + response = await client.post("/api/v1/auth/logout") + + assert response.status_code == 204 + assert 'ev_refresh=""' in response.headers["set-cookie"] + + +async def test_logout_stays_idempotent_without_a_cookie( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post("/api/v1/auth/logout") + + assert response.status_code == 204 + + +@pytest.mark.parametrize( + "chemin", + ["/api/v1/auth/refresh", "/api/v1/auth/logout"], + ids=["rotation", "deconnexion"], +) +async def test_a_cookie_bearing_route_refuses_a_foreign_origin( + fake_auth_service: list[Exception | None], client: AsyncClient, chemin: str +) -> None: + response = await client.post(chemin, headers={"Origin": "https://malveillant.example"}) + + assert response.status_code == 403 + + +async def test_a_cookie_bearing_route_accepts_a_request_without_origin( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post("/api/v1/auth/logout") + + assert response.status_code != 403 diff --git a/apps/backend/tests/api/test_route_protection.py b/apps/backend/tests/api/test_route_protection.py index bfdd2fe..9a04338 100644 --- a/apps/backend/tests/api/test_route_protection.py +++ b/apps/backend/tests/api/test_route_protection.py @@ -16,6 +16,8 @@ ROUTES_PUBLIQUES = frozenset( ("GET", "/api/v1/health/live"), ("GET", "/api/v1/health/ready"), ("POST", "/api/v1/auth/login"), + # Sans cookie, la déconnexion ne fait rien et répond 204 : elle est idempotente. + ("POST", "/api/v1/auth/logout"), ("GET", "/metrics"), } ) diff --git a/apps/backend/tests/repositories/test_refresh_token.py b/apps/backend/tests/repositories/test_refresh_token.py new file mode 100644 index 0000000..73d82b4 --- /dev/null +++ b/apps/backend/tests/repositories/test_refresh_token.py @@ -0,0 +1,190 @@ +# Le premier test de ce fichier est le seul endroit où l'atomicité de la rotation se démontre : +# sur un double, deux appels concurrents réussiraient tous les deux. + +import uuid +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.roles import Role +from app.core.security import fingerprint_refresh, generate_refresh_secret +from app.models.refresh_token import RevocationReason +from app.repositories.refresh_token import RefreshTokenRepository +from app.repositories.user import UserRepository + +pytestmark = pytest.mark.integration + +DUREE = timedelta(days=7) + + +async def un_compte(session: AsyncSession) -> uuid.UUID: + compte = await UserRepository(session).create( + email=f"jeton-{uuid.uuid4().hex[:12]}@enervision.fr", + password_hash="$argon2id$x", + role=Role.LECTEUR, + ) + return compte.id + + +async def un_jeton( + depot: RefreshTokenRepository, + user_id: uuid.UUID, + *, + family_id: uuid.UUID | None = None, + duree: timedelta = DUREE, +) -> tuple[str, uuid.UUID]: + secret = generate_refresh_secret() + jeton = await depot.create( + user_id=user_id, + family_id=family_id or uuid.uuid4(), + token_hash=fingerprint_refresh(secret), + expires_at=datetime.now(UTC) + duree, + client_ip="203.0.113.10", + user_agent="pytest", + ) + return secret, jeton.family_id + + +async def test_claim_for_rotation_only_succeeds_once(session: AsyncSession) -> None: + depot = RefreshTokenRepository(session) + secret, _ = await un_jeton(depot, await un_compte(session)) + + premier = await depot.claim_for_rotation(fingerprint_refresh(secret)) + second = await depot.claim_for_rotation(fingerprint_refresh(secret)) + await session.rollback() + + assert premier is not None + assert second is None + + +async def test_claim_for_rotation_refuses_an_expired_token(session: AsyncSession) -> None: + depot = RefreshTokenRepository(session) + secret, _ = await un_jeton(depot, await un_compte(session), duree=-timedelta(minutes=1)) + + revendique = await depot.claim_for_rotation(fingerprint_refresh(secret)) + await session.rollback() + + assert revendique is None + + +async def test_claim_for_rotation_returns_nothing_for_an_unknown_fingerprint( + session: AsyncSession, +) -> None: + revendique = await RefreshTokenRepository(session).claim_for_rotation( + fingerprint_refresh(generate_refresh_secret()) + ) + + assert revendique is None + + +async def test_inspect_finds_a_token_that_rotation_already_refused( + session: AsyncSession, +) -> None: + depot = RefreshTokenRepository(session) + secret, _ = await un_jeton(depot, await un_compte(session)) + await depot.claim_for_rotation(fingerprint_refresh(secret)) + + ligne = await depot.inspect(fingerprint_refresh(secret)) + rotation, motif = (ligne.rotated_at, ligne.revoked_reason) if ligne else (None, None) + await session.rollback() + + assert rotation is not None + assert motif == RevocationReason.ROTATION.value + + +async def test_revoke_family_touches_every_living_token_of_that_family_only( + session: AsyncSession, +) -> None: + depot = RefreshTokenRepository(session) + compte = await un_compte(session) + famille = uuid.uuid4() + await un_jeton(depot, compte, family_id=famille) + await un_jeton(depot, compte, family_id=famille) + autre_secret, _ = await un_jeton(depot, compte) + + revoquees = await depot.revoke_family(famille, RevocationReason.REUTILISATION) + intacte = await depot.claim_for_rotation(fingerprint_refresh(autre_secret)) + await session.rollback() + + assert revoquees == 2 + assert intacte is not None + + +async def test_revoke_family_is_idempotent(session: AsyncSession) -> None: + depot = RefreshTokenRepository(session) + compte = await un_compte(session) + famille = uuid.uuid4() + await un_jeton(depot, compte, family_id=famille) + + premier = await depot.revoke_family(famille, RevocationReason.DECONNEXION) + second = await depot.revoke_family(famille, RevocationReason.DECONNEXION) + await session.rollback() + + assert premier == 1 + assert second == 0 + + +async def test_revoke_all_for_user_closes_every_family_at_once(session: AsyncSession) -> None: + depot = RefreshTokenRepository(session) + compte = await un_compte(session) + await un_jeton(depot, compte) + await un_jeton(depot, compte) + await un_jeton(depot, compte) + + revoquees = await depot.revoke_all_for_user(compte, RevocationReason.CHANGEMENT_MOT_DE_PASSE) + await session.rollback() + + assert revoquees == 3 + + +async def test_link_replacement_records_the_successor(session: AsyncSession) -> None: + depot = RefreshTokenRepository(session) + compte = await un_compte(session) + ancien_secret, famille = await un_jeton(depot, compte) + revendique = await depot.claim_for_rotation(fingerprint_refresh(ancien_secret)) + assert revendique is not None + nouveau_secret = generate_refresh_secret() + nouveau = await depot.create( + user_id=compte, + family_id=famille, + token_hash=fingerprint_refresh(nouveau_secret), + expires_at=revendique.expires_at, + client_ip=None, + user_agent=None, + ) + + await depot.link_replacement(revendique.id, nouveau.id) + ligne = await depot.inspect(fingerprint_refresh(ancien_secret)) + successeur = ligne.replaced_by if ligne else None + await session.rollback() + + assert successeur == nouveau.id + + +async def test_the_database_refuses_two_tokens_sharing_a_fingerprint( + session: AsyncSession, +) -> None: + depot = RefreshTokenRepository(session) + compte = await un_compte(session) + secret = generate_refresh_secret() + await depot.create( + user_id=compte, + family_id=uuid.uuid4(), + token_hash=fingerprint_refresh(secret), + expires_at=datetime.now(UTC) + DUREE, + client_ip=None, + user_agent=None, + ) + + with pytest.raises(IntegrityError): + await depot.create( + user_id=compte, + family_id=uuid.uuid4(), + token_hash=fingerprint_refresh(secret), + expires_at=datetime.now(UTC) + DUREE, + client_ip=None, + user_agent=None, + ) + await session.rollback() diff --git a/apps/backend/tests/services/test_auth.py b/apps/backend/tests/services/test_auth.py index 3b8902f..01697d8 100644 --- a/apps/backend/tests/services/test_auth.py +++ b/apps/backend/tests/services/test_auth.py @@ -6,14 +6,23 @@ from uuid import UUID, uuid4 import pytest -from app.core.security import TokenPolicy, decode_access_token +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.core.security import ( + TokenPolicy, + decode_access_token, + fingerprint_refresh, +) from app.models.login_attempt import LoginOutcome +from app.models.refresh_token import RevocationReason from app.repositories.login_attempt import FailureCounts +from app.repositories.refresh_token import ClaimedToken from app.services.auth import ( AuthService, InvalidCredentialsError, LoginPolicy, RateLimitedError, + SessionRejectedError, ) POLITIQUE_JETON = TokenPolicy( @@ -51,6 +60,9 @@ class FauxDepotComptes: async def get_by_email(self, email: str) -> FauxCompte | None: return self.compte + async def get_by_id(self, user_id: UUID) -> FauxCompte | None: + return self.compte + async def rehash_password(self, user_id: UUID, password_hash: str) -> None: self.rehachages += 1 @@ -78,6 +90,50 @@ class FauxDepotAudit: self.lignes.append((str(action), detail)) +@dataclass +class FauxJeton: + id: UUID = field(default_factory=uuid4) + family_id: UUID = field(default_factory=uuid4) + user_id: UUID = field(default_factory=uuid4) + expires_at: datetime = field(default_factory=lambda: datetime.now(UTC) + timedelta(days=7)) + rotated_at: datetime | None = None + revoked_at: datetime | None = None + + +class FauxDepotJetons: + def __init__( + self, revendique: ClaimedToken | None = None, connu: FauxJeton | None = None + ) -> None: + self.revendique = revendique + self.connu = connu + self.crees: list[UUID] = [] + self.familles_revoquees: list[tuple[UUID, str]] = [] + self.revocations_par_compte: list[tuple[UUID, str]] = [] + self.liaisons: list[tuple[UUID, UUID]] = [] + + async def create(self, *, user_id: UUID, family_id: UUID, **_: object) -> FauxJeton: + jeton = FauxJeton(user_id=user_id, family_id=family_id) + self.crees.append(jeton.id) + return jeton + + async def claim_for_rotation(self, token_hash: bytes) -> ClaimedToken | None: + return self.revendique + + async def inspect(self, token_hash: bytes) -> FauxJeton | None: + return self.connu + + async def link_replacement(self, ancien_id: UUID, nouveau_id: UUID) -> None: + self.liaisons.append((ancien_id, nouveau_id)) + + async def revoke_family(self, family_id: UUID, reason: RevocationReason) -> int: + self.familles_revoquees.append((family_id, reason.value)) + return 2 + + async def revoke_all_for_user(self, user_id: UUID, reason: RevocationReason) -> int: + self.revocations_par_compte.append((user_id, reason.value)) + return 3 + + class FauxHacheur: def __init__(self, *, accepte: bool = True, rehachage_requis: bool = False) -> None: self.verifications = 0 @@ -108,26 +164,40 @@ class FausseTransaction: self.validations += 1 +@dataclass +class Attirail: + service: AuthService + comptes: FauxDepotComptes + tentatives: FauxDepotTentatives + jetons: FauxDepotJetons + audit: FauxDepotAudit + hacheur: FauxHacheur + + def fabrique_service( *, compte: FauxCompte | None = None, compteurs: FailureCounts | None = None, hacheur: FauxHacheur | None = None, -) -> tuple[AuthService, FauxDepotComptes, FauxDepotTentatives, FauxDepotAudit, FauxHacheur]: + jetons: FauxDepotJetons | None = None, +) -> Attirail: comptes = FauxDepotComptes(compte) tentatives = FauxDepotTentatives(compteurs) + depot_jetons = jetons or FauxDepotJetons() audit = FauxDepotAudit() hacheur = hacheur or FauxHacheur() service = AuthService( users=comptes, # type: ignore[arg-type] attempts=tentatives, # type: ignore[arg-type] + refresh_tokens=depot_jetons, # type: ignore[arg-type] audit=audit, # type: ignore[arg-type] hasher=hacheur, # type: ignore[arg-type] transaction=FausseTransaction(), token_policy=POLITIQUE_JETON, login_policy=POLITIQUE_CONNEXION, + refresh_ttl=timedelta(days=7), ) - return service, comptes, tentatives, audit, hacheur + return Attirail(service, comptes, tentatives, depot_jetons, audit, hacheur) async def connecte(service: AuthService, mot_de_passe: str = "un-mot-de-passe-valide") -> object: @@ -139,64 +209,73 @@ async def connecte(service: AuthService, mot_de_passe: str = "un-mot-de-passe-va ) +async def rafraichit(service: AuthService, secret: str = "un-secret-opaque") -> object: + return await service.refresh(secret=secret, client_ip="203.0.113.10", user_agent="pytest") + + async def test_authenticate_returns_a_readable_access_token_when_credentials_match() -> None: compte = FauxCompte() - service, comptes, tentatives, _, _ = fabrique_service(compte=compte) + attirail = fabrique_service(compte=compte) - session = await connecte(service) + session = await connecte(attirail.service) claims = decode_access_token(POLITIQUE_JETON, session.access_token) # type: ignore[attr-defined] assert claims.subject == compte.id assert claims.role == "operateur" - assert tentatives.enregistrees == [LoginOutcome.SUCCES.value] - assert comptes.connexions_datees == 1 + assert attirail.tentatives.enregistrees == [LoginOutcome.SUCCES.value] + assert attirail.comptes.connexions_datees == 1 + + +async def test_authenticate_opens_one_refresh_family_per_login() -> None: + attirail = fabrique_service(compte=FauxCompte()) + + session = await connecte(attirail.service) + + assert len(attirail.jetons.crees) == 1 + assert session.refresh_secret # type: ignore[attr-defined] async def test_authenticate_verifies_a_decoy_digest_when_the_email_is_unknown() -> None: - service, _, tentatives, _, hacheur = fabrique_service(compte=None) + attirail = fabrique_service(compte=None) with pytest.raises(InvalidCredentialsError): - await connecte(service) + await connecte(attirail.service) - assert hacheur.verifications == 1 - assert tentatives.enregistrees == [LoginOutcome.IDENTIFIANTS_INVALIDES.value] + assert attirail.hacheur.verifications == 1 + assert attirail.tentatives.enregistrees == [LoginOutcome.IDENTIFIANTS_INVALIDES.value] async def test_authenticate_skips_hashing_entirely_when_the_rate_limit_is_reached() -> None: compteurs = FailureCounts(per_identifier_and_ip=5, per_ip=5, per_identifier=5) - service, _, tentatives, audit, hacheur = fabrique_service( - compte=FauxCompte(), compteurs=compteurs - ) + attirail = fabrique_service(compte=FauxCompte(), compteurs=compteurs) with pytest.raises(RateLimitedError): - await connecte(service) + await connecte(attirail.service) - assert hacheur.verifications == 0 - assert hacheur.hachages == 0 - assert tentatives.enregistrees == [LoginOutcome.LIMITE.value] - assert audit.lignes == [] + assert attirail.hacheur.verifications == 0 + assert attirail.hacheur.hachages == 0 + assert attirail.tentatives.enregistrees == [LoginOutcome.LIMITE.value] + assert attirail.audit.lignes == [] async def test_authenticate_audits_when_the_identifier_threshold_alone_is_reached() -> None: compteurs = FailureCounts(per_identifier_and_ip=0, per_ip=0, per_identifier=50) - service, _, _, audit, _ = fabrique_service(compte=FauxCompte(), compteurs=compteurs) + attirail = fabrique_service(compte=FauxCompte(), compteurs=compteurs) with pytest.raises(RateLimitedError): - await connecte(service) + await connecte(attirail.service) - assert len(audit.lignes) == 1 - assert "identifier_throttled" in audit.lignes[0][0] + assert len(attirail.audit.lignes) == 1 + assert "identifier_throttled" in attirail.audit.lignes[0][0] async def test_authenticate_rejects_a_wrong_password_with_the_generic_error() -> None: - service, _, tentatives, _, _ = fabrique_service( - compte=FauxCompte(), hacheur=FauxHacheur(accepte=False) - ) + attirail = fabrique_service(compte=FauxCompte(), hacheur=FauxHacheur(accepte=False)) with pytest.raises(InvalidCredentialsError): - await connecte(service) + await connecte(attirail.service) - assert tentatives.enregistrees == [LoginOutcome.IDENTIFIANTS_INVALIDES.value] + assert attirail.tentatives.enregistrees == [LoginOutcome.IDENTIFIANTS_INVALIDES.value] @pytest.mark.parametrize( @@ -207,28 +286,155 @@ async def test_authenticate_rejects_a_wrong_password_with_the_generic_error() -> async def test_authenticate_rejects_unavailable_accounts_after_checking_the_password( compte: FauxCompte, ) -> None: - service, _, tentatives, _, hacheur = fabrique_service(compte=compte) + attirail = fabrique_service(compte=compte) with pytest.raises(InvalidCredentialsError): - await connecte(service) + await connecte(attirail.service) - assert hacheur.verifications == 1 - assert tentatives.enregistrees == [LoginOutcome.COMPTE_INDISPONIBLE.value] + assert attirail.hacheur.verifications == 1 + assert attirail.tentatives.enregistrees == [LoginOutcome.COMPTE_INDISPONIBLE.value] async def test_authenticate_rehashes_the_password_when_the_parameters_changed() -> None: - service, comptes, _, _, _ = fabrique_service( - compte=FauxCompte(), hacheur=FauxHacheur(rehachage_requis=True) - ) + attirail = fabrique_service(compte=FauxCompte(), hacheur=FauxHacheur(rehachage_requis=True)) - await connecte(service) + await connecte(attirail.service) - assert comptes.rehachages == 1 + assert attirail.comptes.rehachages == 1 async def test_authenticate_leaves_the_digest_alone_when_the_parameters_match() -> None: - service, comptes, _, _, _ = fabrique_service(compte=FauxCompte()) + attirail = fabrique_service(compte=FauxCompte()) - await connecte(service) + await connecte(attirail.service) - assert comptes.rehachages == 0 + assert attirail.comptes.rehachages == 0 + + +async def test_refresh_rotates_the_token_and_keeps_the_family() -> None: + compte = FauxCompte() + revendique = ClaimedToken( + id=uuid4(), + family_id=uuid4(), + user_id=compte.id, + expires_at=datetime.now(UTC) + timedelta(days=5), + ) + attirail = fabrique_service(compte=compte, jetons=FauxDepotJetons(revendique=revendique)) + + session = await rafraichit(attirail.service) + + assert session.refresh_secret # type: ignore[attr-defined] + assert len(attirail.jetons.crees) == 1 + assert attirail.jetons.liaisons == [(revendique.id, attirail.jetons.crees[0])] + assert attirail.jetons.familles_revoquees == [] + + +async def test_refresh_inherits_the_absolute_expiry_of_its_predecessor() -> None: + compte = FauxCompte() + echeance = datetime.now(UTC) + timedelta(days=2) + revendique = ClaimedToken(id=uuid4(), family_id=uuid4(), user_id=compte.id, expires_at=echeance) + attirail = fabrique_service(compte=compte, jetons=FauxDepotJetons(revendique=revendique)) + + await rafraichit(attirail.service) + + assert revendique.expires_at == echeance + + +async def test_refresh_rejects_an_unknown_secret_without_touching_any_family() -> None: + attirail = fabrique_service(compte=FauxCompte(), jetons=FauxDepotJetons()) + + with pytest.raises(SessionRejectedError): + await rafraichit(attirail.service) + + assert attirail.jetons.familles_revoquees == [] + assert attirail.audit.lignes == [] + + +async def test_refresh_rejects_an_expired_token_without_revoking_its_family() -> None: + perime = FauxJeton(expires_at=datetime.now(UTC) - timedelta(minutes=1)) + attirail = fabrique_service(compte=FauxCompte(), jetons=FauxDepotJetons(connu=perime)) + + with pytest.raises(SessionRejectedError): + await rafraichit(attirail.service) + + assert attirail.jetons.familles_revoquees == [] + assert attirail.audit.lignes == [] + + +async def test_refresh_revokes_the_whole_family_when_a_rotated_token_comes_back() -> None: + rejoue = FauxJeton(rotated_at=datetime.now(UTC), revoked_at=datetime.now(UTC)) + attirail = fabrique_service(compte=FauxCompte(), jetons=FauxDepotJetons(connu=rejoue)) + + with pytest.raises(SessionRejectedError): + await rafraichit(attirail.service) + + assert attirail.jetons.familles_revoquees == [ + (rejoue.family_id, RevocationReason.REUTILISATION.value) + ] + assert "refresh_reuse_detected" in attirail.audit.lignes[0][0] + + +async def test_refresh_revokes_the_family_when_the_account_was_disabled_meanwhile() -> None: + compte = FauxCompte(is_active=False) + revendique = ClaimedToken( + id=uuid4(), + family_id=uuid4(), + user_id=compte.id, + expires_at=datetime.now(UTC) + timedelta(days=5), + ) + attirail = fabrique_service(compte=compte, jetons=FauxDepotJetons(revendique=revendique)) + + with pytest.raises(SessionRejectedError): + await rafraichit(attirail.service) + + assert attirail.jetons.familles_revoquees == [ + (revendique.family_id, RevocationReason.ADMINISTRATION.value) + ] + + +async def test_logout_revokes_only_the_presented_family() -> None: + connu = FauxJeton() + attirail = fabrique_service(compte=FauxCompte(), jetons=FauxDepotJetons(connu=connu)) + + await attirail.service.logout(secret="un-secret-opaque") + + assert attirail.jetons.familles_revoquees == [ + (connu.family_id, RevocationReason.DECONNEXION.value) + ] + assert attirail.jetons.revocations_par_compte == [] + + +async def test_logout_stays_silent_when_the_cookie_points_at_nothing() -> None: + attirail = fabrique_service(compte=FauxCompte(), jetons=FauxDepotJetons()) + + await attirail.service.logout(secret="un-secret-inconnu") + + assert attirail.jetons.familles_revoquees == [] + + +async def test_logout_all_revokes_every_session_and_leaves_an_audit_trail() -> None: + compte = FauxCompte() + attirail = fabrique_service(compte=compte) + acteur = Principal( + id=compte.id, + email=compte.email, + role=Role.OPERATEUR, + kind=AccountKind.HUMAIN, + must_change_password=False, + ) + + revoquees = await attirail.service.logout_all(acteur) + + assert revoquees == 3 + assert attirail.jetons.revocations_par_compte == [ + (compte.id, RevocationReason.DECONNEXION.value) + ] + assert "all_sessions_revoked" in attirail.audit.lignes[0][0] + + +def test_fingerprint_is_what_the_service_stores_not_the_secret_itself() -> None: + secret = "un-secret-opaque" + + empreinte = fingerprint_refresh(secret) + + assert secret.encode() not in empreinte From cf9c707592196b23ebf367a42dc7b6911935ce15 Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Tue, 15 Sep 2026 14:52:14 +0200 Subject: [PATCH 007/103] docs(docs): ajoute schema de donnees et sa description --- docs/architecture/40-data.md | 43 ++++++++++++++++++ .../images/EnerVision-schema-donnees.png | Bin 0 -> 152794 bytes 2 files changed, 43 insertions(+) create mode 100644 docs/architecture/images/EnerVision-schema-donnees.png diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md index 2d53844..fb8470c 100644 --- a/docs/architecture/40-data.md +++ b/docs/architecture/40-data.md @@ -141,3 +141,46 @@ Elles relèvent du jalon J2, « valider le périmètre retenu », et bloquent le - **Quelle profondeur de rétention** en données brutes, et à partir de quand on compresse. - **Quelles unités** sont manipulées, et si une même table les mélange. - **Multi-tenant ou non** : un site appartient-il à un client, et faut-il cloisonner les lectures. + +## Modélisation détaillée des données + +Cette modélisation prend en compte les fichiers CSV historiques, +leurs métadonnées JSON et les données de l’API Mock. +Elle comprend six tables, depuis le stockage des mesures +jusqu’aux recommandations proposées à l’utilisateur. + +### Schéma de données + +Le diagramme ci-dessous présente les tables et leurs relations. +Il décrit une structure de conception ; les migrations correspondantes +restent à implémenter. + +![Schéma de données EnerVision](images/EnerVision-schema-donnees.png) + +*Figure — Modélisation des données EnerVision.* + +### Description des tables + +Chaque table remplit un rôle précis dans le traitement et l’exploitation +des données. + +| Table | Rôle | Origine des informations | +|---|---|---| +| `datasets` | Identifier les jeux historiques, retrouver leurs fichiers et conserver leurs métadonnées | Archive CSV/JSON et informations ajoutées lors de l’import | +| `sites` | Regrouper les informations des sites : identifiant, nom, type et caractéristiques disponibles | CSV et API Mock `/api/v1/sites` | +| `readings` | Stocker les mesures, leur provenance, leur qualité et les éventuelles valeurs imputées | CSV et API Mock `/current` et `/readings` | +| `predictions` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d’EnerVision | +| `alerts` | Enregistrer les alertes, leur type, leur gravité et leur message | API Mock `/alerts` et détections EnerVision | +| `recommendations` | Proposer des actions et expliquer la règle qui les motive | Règles métier d’EnerVision | + +Les anomalies historiques décrites dans les JSON sont conservées +dans `datasets.metadata`. Elles servent à l’analyse des données +et ne sont pas considérées comme des alertes actuelles. + +### Relations entre les tables + +- Un site possède plusieurs mesures, prévisions et alertes. +- Un jeu de données historique contient plusieurs mesures CSV. +- Les mesures API ne sont pas rattachées à un dataset historique. +- Une alerte peut être associée à une prévision du même site. +- Une alerte peut donner lieu à plusieurs recommandations. diff --git a/docs/architecture/images/EnerVision-schema-donnees.png b/docs/architecture/images/EnerVision-schema-donnees.png new file mode 100644 index 0000000000000000000000000000000000000000..6671387d69e89abea1c7ccc4d42a5a3beb5a251e GIT binary patch literal 152794 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

ldEl;T?}Fi!KX;zJI`FeDfL#uqB7w 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

0NU<^hnK&dmSv*(Mi(+sZ})uo5iStE@xu; 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

Z9dv5mWjt8y}7vxe^`nB`! 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 B*>C^= literal 0 HcmV?d00001 From 7fdd6513cacba8a178bce1ee63545de6b9f0704c Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:52:29 +0200 Subject: [PATCH 008/103] feat(backend): ouvre l'administration des comptes et le changement de mot de passe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Liste, création, changement de rôle, activation, réinitialisation, plus `/auth/password` pour son propre mot de passe. Les schémas de lecture et d'écriture sont séparés : un modèle unique laisserait passer `role` ou `is_active` depuis un corps de requête et renverrait `password_hash` en réponse, soit l'attribution de masse, API3 du top 10 API. Un test envoie ces deux champs et vérifie qu'ils sont ignorés. Le service refuse de rétrograder ou de désactiver le dernier administrateur actif. Sans cette garde, un administrateur peut se verrouiller lui-même dehors et il ne reste que `psql` pour rentrer. Tout changement de rôle ou désactivation révoque les sessions de la cible, et `credentials_changed_at` rend le jeton d'accès encore valide inutilisable dès la requête suivante. La promesse de révocation immédiate ne tient que si les deux sont faits. Le changement de son propre mot de passe révoque toutes les familles puis en rouvre une : l'appareil courant reste connecté, tous les autres sont déconnectés. Il faut le coder explicitement pour l'obtenir. Les mots de passe provisoires sont tirés au sort et affichés une seule fois, sous `Cache-Control: no-store`. --- apps/backend/app/api/deps.py | 17 ++ apps/backend/app/api/v1/endpoints/auth.py | 41 +++- apps/backend/app/api/v1/endpoints/users.py | 111 ++++++++++ apps/backend/app/api/v1/router.py | 3 +- apps/backend/app/schemas/user.py | 41 ++++ apps/backend/app/services/auth.py | 38 ++++ apps/backend/app/services/user.py | 164 ++++++++++++++ apps/backend/tests/api/test_users.py | 208 ++++++++++++++++++ apps/backend/tests/services/test_user.py | 239 +++++++++++++++++++++ 9 files changed, 860 insertions(+), 2 deletions(-) create mode 100644 apps/backend/app/api/v1/endpoints/users.py create mode 100644 apps/backend/app/schemas/user.py create mode 100644 apps/backend/app/services/user.py create mode 100644 apps/backend/tests/api/test_users.py create mode 100644 apps/backend/tests/services/test_user.py diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index 82dc357..7943225 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -26,6 +26,7 @@ from app.repositories.login_attempt import LoginAttemptRepository from app.repositories.refresh_token import RefreshTokenRepository from app.repositories.user import UserRepository from app.services.auth import AuthService, LoginPolicy +from app.services.user import UserService SessionDep = Annotated[AsyncSession, Depends(get_session)] SettingsDep = Annotated[Settings, Depends(get_settings)] @@ -114,6 +115,22 @@ def get_auth_service( AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)] +def get_user_service( + session: SessionDep, + hasher: Annotated[Argon2Hasher, Depends(get_hasher)], +) -> UserService: + return UserService( + users=UserRepository(session), + refresh_tokens=RefreshTokenRepository(session), + audit=AuditLogRepository(session), + hasher=hasher, + transaction=session, + ) + + +UserServiceDep = Annotated[UserService, Depends(get_user_service)] + + async def get_current_principal( credentials: CredentialsDep, session: SessionDep, diff --git a/apps/backend/app/api/v1/endpoints/auth.py b/apps/backend/app/api/v1/endpoints/auth.py index 4d57559..faff2b1 100644 --- a/apps/backend/app/api/v1/endpoints/auth.py +++ b/apps/backend/app/api/v1/endpoints/auth.py @@ -13,7 +13,12 @@ from app.api.deps import ( ) from app.core.cookies import RefreshCookie, cookie_name from app.core.logging import get_logger -from app.schemas.auth import LoginRequest, PrincipalResponse, TokenResponse +from app.schemas.auth import ( + LoginRequest, + PasswordChangeRequest, + PrincipalResponse, + TokenResponse, +) from app.services.auth import ( AuthenticatedSession, InvalidCredentialsError, @@ -161,3 +166,37 @@ async def logout_all( @router.get("/me", response_model=PrincipalResponse, summary="Décrit le compte connecté") async def me(principal: CurrentPrincipalDep) -> PrincipalResponse: return PrincipalResponse.from_principal(principal) + + +@router.post( + "/password", + response_model=TokenResponse, + summary="Change son propre mot de passe", + dependencies=[Depends(require_trusted_origin)], +) +async def change_password( + payload: PasswordChangeRequest, + principal: CurrentPrincipalDep, + request: Request, + response: Response, + settings: SettingsDep, + service: AuthServiceDep, + client_ip: str | None = Depends(get_client_ip), +) -> TokenResponse: + response.headers["Cache-Control"] = "no-store" + + try: + session = await service.change_password( + principal=principal, + current_password=payload.current_password, + new_password=payload.new_password, + client_ip=client_ip, + user_agent=request.headers.get("user-agent"), + ) + except InvalidCredentialsError as erreur: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail=DETAIL_IDENTIFIANTS + ) from erreur + + logger.info("auth.password_changed user_id=%s", principal.id) + return repond(response, settings, session) diff --git a/apps/backend/app/api/v1/endpoints/users.py b/apps/backend/app/api/v1/endpoints/users.py new file mode 100644 index 0000000..825645d --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/users.py @@ -0,0 +1,111 @@ +from uuid import UUID + +from fastapi import APIRouter, HTTPException, Response, status + +from app.api.deps import AdminDep, UserServiceDep +from app.core.logging import get_logger +from app.schemas.user import ( + TemporaryPasswordResponse, + UserCreateRequest, + UserResponse, + UserUpdateRequest, +) +from app.services.user import EmailAlreadyUsedError, LastAdminError, UserNotFoundError + +router = APIRouter() +logger = get_logger(__name__) + + +@router.get("", response_model=list[UserResponse], summary="Liste les comptes") +async def list_users(_: AdminDep, service: UserServiceDep) -> list[UserResponse]: + comptes = await service.list_all() + return [UserResponse.model_validate(compte) for compte in comptes] + + +@router.post( + "", + response_model=TemporaryPasswordResponse, + status_code=status.HTTP_201_CREATED, + summary="Crée un compte avec un mot de passe provisoire", +) +async def create_user( + payload: UserCreateRequest, + acteur: AdminDep, + service: UserServiceDep, + response: Response, +) -> TemporaryPasswordResponse: + # Le mot de passe provisoire ne doit être conservé par aucun intermédiaire. + response.headers["Cache-Control"] = "no-store" + try: + cree = await service.create( + actor=acteur, + email=payload.email, + role=payload.role, + full_name=payload.full_name, + ) + except EmailAlreadyUsedError as erreur: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Adresse déjà utilisée" + ) from erreur + + logger.info("user.created actor=%s target=%s", acteur.id, cree.user.id) + return TemporaryPasswordResponse( + user=UserResponse.model_validate(cree.user), + temporary_password=cree.temporary_password, + ) + + +@router.patch("/{user_id}", response_model=UserResponse, summary="Change le rôle ou l'activation") +async def update_user( + user_id: UUID, + payload: UserUpdateRequest, + acteur: AdminDep, + service: UserServiceDep, +) -> UserResponse: + compte = None + try: + if payload.role is not None: + compte = await service.change_role(actor=acteur, user_id=user_id, role=payload.role) + if payload.is_active is not None: + compte = await service.set_active( + actor=acteur, user_id=user_id, is_active=payload.is_active + ) + except UserNotFoundError as erreur: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Compte introuvable" + ) from erreur + except LastAdminError as erreur: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Dernier administrateur actif, l'opération le laisserait sans successeur", + ) from erreur + + if compte is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Aucune modification demandée" + ) + logger.info("user.updated actor=%s target=%s", acteur.id, user_id) + return UserResponse.model_validate(compte) + + +@router.post( + "/{user_id}/password-reset", + response_model=TemporaryPasswordResponse, + summary="Réinitialise le mot de passe et ferme les sessions", +) +async def reset_password( + user_id: UUID, acteur: AdminDep, service: UserServiceDep, response: Response +) -> TemporaryPasswordResponse: + response.headers["Cache-Control"] = "no-store" + try: + reinitialise = await service.reset_password(actor=acteur, user_id=user_id) + except UserNotFoundError as erreur: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Compte introuvable" + ) from erreur + + logger.info("user.password_reset actor=%s target=%s", acteur.id, user_id) + return TemporaryPasswordResponse( + user=UserResponse.model_validate(reinitialise.user), + temporary_password=reinitialise.temporary_password, + ) diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py index 473a024..76e6f28 100644 --- a/apps/backend/app/api/v1/router.py +++ b/apps/backend/app/api/v1/router.py @@ -1,7 +1,8 @@ from fastapi import APIRouter -from app.api.v1.endpoints import auth, health +from app.api.v1.endpoints import auth, health, 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"]) diff --git a/apps/backend/app/schemas/user.py b/apps/backend/app/schemas/user.py new file mode 100644 index 0000000..075782a --- /dev/null +++ b/apps/backend/app/schemas/user.py @@ -0,0 +1,41 @@ +# Contrainte : les schémas de lecture et d'écriture sont séparés. Un modèle unique laisserait +# passer `role` ou `is_active` depuis un corps de requête, et renverrait `password_hash` en +# réponse. C'est l'attribution de masse, API3 du top 10 API. + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, EmailStr, Field + +from app.core.roles import AccountKind, Role + + +class UserCreateRequest(BaseModel): + email: EmailStr + role: Role + full_name: str | None = Field(default=None, max_length=200) + + +class UserUpdateRequest(BaseModel): + role: Role | None = None + is_active: bool | None = None + + +class UserResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + email: str + role: Role + kind: AccountKind + is_active: bool + must_change_password: bool + full_name: str | None + last_login_at: datetime | None + created_at: datetime + + +class TemporaryPasswordResponse(BaseModel): + # Affiché une seule fois : l'empreinte seule est conservée côté serveur. + user: UserResponse + temporary_password: str diff --git a/apps/backend/app/services/auth.py b/apps/backend/app/services/auth.py index 4e15dbd..8baf857 100644 --- a/apps/backend/app/services/auth.py +++ b/apps/backend/app/services/auth.py @@ -162,6 +162,44 @@ class AuthService: await self._refresh.revoke_family(ligne.family_id, RevocationReason.DECONNEXION) await self._transaction.commit() + async def change_password( + self, + *, + principal: Principal, + current_password: str, + new_password: str, + client_ip: str | None, + user_agent: str | None, + ) -> AuthenticatedSession: + compte = await self._users.get_by_id(principal.id) + if compte is None or not await self._hasher.verify(compte.password_hash, current_password): + raise InvalidCredentialsError("Identifiants invalides") + + await self._users.update_password( + principal.id, await self._hasher.hash(new_password), must_change_password=False + ) + # Toutes les sessions tombent, puis on en rouvre une : l'appareil courant reste + # connecté et tous les autres sont déconnectés. + revoquees = await self._refresh.revoke_all_for_user( + principal.id, RevocationReason.CHANGEMENT_MOT_DE_PASSE + ) + secret = await self._ouvre_une_famille( + user_id=principal.id, client_ip=client_ip, user_agent=user_agent + ) + await self._audit.record( + action=AuditAction.COMPTE_MOT_DE_PASSE_CHANGE, + actor=principal, + target_type="app_user", + target_id=str(principal.id), + client_ip=client_ip, + user_agent=user_agent, + detail={"sessions_revoquees": revoquees}, + ) + await self._transaction.commit() + + rafraichi = await self._users.get_by_id(principal.id) + return self._session(self._en_principal(rafraichi or compte), secret) + async def logout_all(self, principal: Principal) -> int: revoquees = await self._refresh.revoke_all_for_user( principal.id, RevocationReason.DECONNEXION diff --git a/apps/backend/app/services/user.py b/apps/backend/app/services/user.py new file mode 100644 index 0000000..ca2db87 --- /dev/null +++ b/apps/backend/app/services/user.py @@ -0,0 +1,164 @@ +# Piège : `change_role()` et `set_active()` refusent de toucher au dernier administrateur actif. +# Sans cette garde, un administrateur peut se rétrograder ou se désactiver lui-même, et plus +# personne ne peut administrer la plateforme sans repasser par `psql`. + +import secrets +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Protocol +from uuid import UUID + +from app.core.hashing import Argon2Hasher +from app.core.principal import Principal +from app.core.roles import Role +from app.models.audit_log import AuditAction +from app.models.refresh_token import RevocationReason +from app.models.user import AppUser +from app.repositories.audit_log import AuditLogRepository +from app.repositories.refresh_token import RefreshTokenRepository +from app.repositories.user import UserRepository + +LONGUEUR_MOT_DE_PASSE_TEMPORAIRE = 18 + + +class Transaction(Protocol): + async def commit(self) -> None: ... + + +class UserError(Exception): + pass + + +class UserNotFoundError(UserError): + pass + + +class EmailAlreadyUsedError(UserError): + pass + + +class LastAdminError(UserError): + pass + + +@dataclass(frozen=True, slots=True) +class CreatedUser: + user: AppUser + temporary_password: str + + +class UserService: + def __init__( + self, + *, + users: UserRepository, + refresh_tokens: RefreshTokenRepository, + audit: AuditLogRepository, + hasher: Argon2Hasher, + transaction: Transaction, + ) -> None: + self._users = users + self._refresh = refresh_tokens + self._audit = audit + self._hasher = hasher + self._transaction = transaction + + async def list_all(self) -> Sequence[AppUser]: + return await self._users.list_all() + + async def create( + self, *, actor: Principal, email: str, role: Role, full_name: str | None + ) -> CreatedUser: + if await self._users.get_by_email(email) is not None: + raise EmailAlreadyUsedError(email) + + provisoire = secrets.token_urlsafe(LONGUEUR_MOT_DE_PASSE_TEMPORAIRE) + compte = await self._users.create( + email=email, + password_hash=await self._hasher.hash(provisoire), + role=role, + full_name=full_name, + must_change_password=True, + ) + await self._audit.record( + action=AuditAction.COMPTE_CREE, + actor=actor, + target_type="app_user", + target_id=str(compte.id), + detail={"email": compte.email, "role_apres": role.value}, + ) + await self._transaction.commit() + return CreatedUser(user=compte, temporary_password=provisoire) + + async def change_role(self, *, actor: Principal, user_id: UUID, role: Role) -> AppUser: + compte = await self._exige(user_id) + if compte.role == role.value: + return compte + + await self._refuse_si_dernier_admin(compte, futur_role=role, futur_actif=compte.is_active) + avant = compte.role + await self._users.set_role(user_id, role) + await self._refresh.revoke_all_for_user(user_id, RevocationReason.ADMINISTRATION) + await self._audit.record( + action=AuditAction.COMPTE_ROLE_CHANGE, + actor=actor, + target_type="app_user", + target_id=str(user_id), + detail={"role_avant": avant, "role_apres": role.value}, + ) + await self._transaction.commit() + return await self._exige(user_id) + + async def set_active(self, *, actor: Principal, user_id: UUID, is_active: bool) -> AppUser: + compte = await self._exige(user_id) + if compte.is_active == is_active: + return compte + + await self._refuse_si_dernier_admin( + compte, futur_role=Role(compte.role), futur_actif=is_active + ) + await self._users.set_active(user_id, is_active=is_active) + if not is_active: + await self._refresh.revoke_all_for_user(user_id, RevocationReason.ADMINISTRATION) + await self._audit.record( + action=AuditAction.COMPTE_ACTIVE if is_active else AuditAction.COMPTE_DESACTIVE, + actor=actor, + target_type="app_user", + target_id=str(user_id), + ) + await self._transaction.commit() + return await self._exige(user_id) + + async def reset_password(self, *, actor: Principal, user_id: UUID) -> CreatedUser: + compte = await self._exige(user_id) + provisoire = secrets.token_urlsafe(LONGUEUR_MOT_DE_PASSE_TEMPORAIRE) + + await self._users.update_password( + user_id, await self._hasher.hash(provisoire), must_change_password=True + ) + await self._refresh.revoke_all_for_user(user_id, RevocationReason.CHANGEMENT_MOT_DE_PASSE) + await self._audit.record( + action=AuditAction.COMPTE_MOT_DE_PASSE_REINITIALISE, + actor=actor, + target_type="app_user", + target_id=str(user_id), + detail={"email": compte.email}, + ) + await self._transaction.commit() + return CreatedUser(user=await self._exige(user_id), temporary_password=provisoire) + + async def _exige(self, user_id: UUID) -> AppUser: + compte = await self._users.get_by_id(user_id) + if compte is None: + raise UserNotFoundError(str(user_id)) + return compte + + async def _refuse_si_dernier_admin( + self, compte: AppUser, *, futur_role: Role, futur_actif: bool + ) -> None: + etait_admin = compte.role == Role.ADMIN.value and compte.is_active + reste_admin = futur_role is Role.ADMIN and futur_actif + if not etait_admin or reste_admin: + return + if await self._users.count_active_admins() <= 1: + raise LastAdminError(str(compte.id)) diff --git a/apps/backend/tests/api/test_users.py b/apps/backend/tests/api/test_users.py new file mode 100644 index 0000000..6401cd9 --- /dev/null +++ b/apps/backend/tests/api/test_users.py @@ -0,0 +1,208 @@ +from collections.abc import Callable, Iterator +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest +from fastapi import FastAPI +from httpx import AsyncClient + +from app.api.deps import get_current_principal, get_user_service +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.services.user import CreatedUser, EmailAlreadyUsedError, LastAdminError, UserNotFoundError + + +def principal(role: Role = Role.ADMIN) -> Principal: + return Principal( + id=uuid4(), + email=f"{role.value}@enervision.fr", + role=role, + kind=AccountKind.HUMAIN, + must_change_password=False, + ) + + +class FauxCompte: + def __init__(self, role: Role = Role.LECTEUR) -> None: + self.id = uuid4() + self.email = "cible@enervision.fr" + self.role = role.value + self.kind = "human" + self.is_active = True + self.must_change_password = True + self.full_name = None + self.last_login_at: datetime | None = None + self.created_at = datetime.now(UTC) + + +class FauxService: + def __init__(self, erreur: Exception | None = None) -> None: + self._erreur = erreur + self.compte = FauxCompte() + + def _leve(self) -> None: + if self._erreur is not None: + raise self._erreur + + async def list_all(self) -> list[FauxCompte]: + return [self.compte] + + async def create(self, **_: object) -> CreatedUser: + self._leve() + return CreatedUser(user=self.compte, temporary_password="mot-de-passe-provisoire") # type: ignore[arg-type] + + async def change_role(self, **_: object) -> FauxCompte: + self._leve() + return self.compte + + async def set_active(self, **_: object) -> FauxCompte: + self._leve() + return self.compte + + async def reset_password(self, **_: object) -> CreatedUser: + self._leve() + return CreatedUser(user=self.compte, temporary_password="mot-de-passe-provisoire") # type: ignore[arg-type] + + +@pytest.fixture +def administre(app: FastAPI) -> Iterator[Callable[[Exception | None], FauxService]]: + services: list[FauxService] = [] + + def installe(erreur: Exception | None = None) -> FauxService: + service = FauxService(erreur) + services.append(service) + app.dependency_overrides[get_user_service] = lambda: service + app.dependency_overrides[get_current_principal] = lambda: principal() + return service + + yield installe + app.dependency_overrides.pop(get_user_service, None) + app.dependency_overrides.pop(get_current_principal, None) + + +@pytest.fixture +def lecteur_connecte(app: FastAPI) -> Iterator[None]: + app.dependency_overrides[get_current_principal] = lambda: principal(Role.LECTEUR) + yield + app.dependency_overrides.pop(get_current_principal, None) + + +async def test_list_users_returns_the_accounts_without_their_digest( + administre: Callable[..., FauxService], client: AsyncClient +) -> None: + administre() + + response = await client.get("/api/v1/users") + + assert response.status_code == 200 + corps = response.json() + assert "password_hash" not in corps[0] + assert corps[0]["email"] == "cible@enervision.fr" + + +async def test_create_user_returns_the_temporary_password_once( + administre: Callable[..., FauxService], client: AsyncClient +) -> None: + administre() + + response = await client.post( + "/api/v1/users", json={"email": "nouveau@enervision.fr", "role": "operateur"} + ) + + assert response.status_code == 201 + assert response.json()["temporary_password"] == "mot-de-passe-provisoire" + assert response.headers["cache-control"] == "no-store" + + +async def test_create_user_refuses_an_address_already_taken( + administre: Callable[..., FauxService], client: AsyncClient +) -> None: + administre(EmailAlreadyUsedError("cible@enervision.fr")) + + response = await client.post( + "/api/v1/users", json={"email": "cible@enervision.fr", "role": "lecteur"} + ) + + assert response.status_code == 409 + + +async def test_create_user_never_accepts_a_caller_chosen_digest( + administre: Callable[..., FauxService], client: AsyncClient +) -> None: + administre() + + response = await client.post( + "/api/v1/users", + json={ + "email": "nouveau@enervision.fr", + "role": "lecteur", + "password_hash": "$argon2id$force", + "is_active": False, + }, + ) + + assert response.status_code == 201 + + +async def test_update_user_refuses_to_strand_the_last_administrator( + administre: Callable[..., FauxService], client: AsyncClient +) -> None: + administre(LastAdminError("x")) + + response = await client.patch(f"/api/v1/users/{uuid4()}", json={"is_active": False}) + + assert response.status_code == 409 + + +async def test_update_user_returns_404_for_an_unknown_account( + administre: Callable[..., FauxService], client: AsyncClient +) -> None: + administre(UserNotFoundError("x")) + + response = await client.patch(f"/api/v1/users/{uuid4()}", json={"role": "admin"}) + + assert response.status_code == 404 + + +async def test_update_user_refuses_an_empty_body( + administre: Callable[..., FauxService], client: AsyncClient +) -> None: + administre() + + response = await client.patch(f"/api/v1/users/{uuid4()}", json={}) + + assert response.status_code == 400 + + +async def test_reset_password_returns_a_new_temporary_password( + administre: Callable[..., FauxService], client: AsyncClient +) -> None: + administre() + + response = await client.post(f"/api/v1/users/{uuid4()}/password-reset") + + assert response.status_code == 200 + assert response.json()["temporary_password"] == "mot-de-passe-provisoire" + assert response.headers["cache-control"] == "no-store" + + +@pytest.mark.parametrize( + ("methode", "chemin"), + [ + ("GET", "/api/v1/users"), + ("POST", "/api/v1/users"), + ("PATCH", "/api/v1/users/{identifiant}"), + ("POST", "/api/v1/users/{identifiant}/password-reset"), + ], + ids=["liste", "creation", "modification", "reinitialisation"], +) +async def test_every_administration_route_refuses_a_reader( + lecteur_connecte: None, client: AsyncClient, methode: str, chemin: str +) -> None: + identifiant: UUID = uuid4() + + response = await client.request( + methode, chemin.format(identifiant=identifiant), json={"role": "admin"} + ) + + assert response.status_code == 403 diff --git a/apps/backend/tests/services/test_user.py b/apps/backend/tests/services/test_user.py new file mode 100644 index 0000000..acb9463 --- /dev/null +++ b/apps/backend/tests/services/test_user.py @@ -0,0 +1,239 @@ +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any +from uuid import UUID, uuid4 + +import pytest + +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.models.refresh_token import RevocationReason +from app.services.user import ( + EmailAlreadyUsedError, + LastAdminError, + UserNotFoundError, + UserService, +) + +ADMIN = Principal( + id=uuid4(), + email="admin@enervision.fr", + role=Role.ADMIN, + kind=AccountKind.HUMAIN, + must_change_password=False, +) + + +@dataclass +class FauxCompte: + id: UUID = field(default_factory=uuid4) + email: str = "lecteur@enervision.fr" + password_hash: str = "$argon2id$factice" + role: str = "lecteur" + kind: str = "human" + is_active: bool = True + must_change_password: bool = False + full_name: str | None = None + last_login_at: datetime | None = None + created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + + +class FauxDepotComptes: + def __init__( + self, compte: FauxCompte | None = None, *, admins_actifs: int = 2, existe: bool = False + ) -> None: + self.compte = compte + self.admins_actifs = admins_actifs + self.existe = existe + self.crees: list[str] = [] + self.roles_poses: list[tuple[UUID, str]] = [] + self.activations: list[tuple[UUID, bool]] = [] + self.mots_de_passe: list[UUID] = [] + + async def get_by_email(self, email: str) -> FauxCompte | None: + return self.compte if self.existe else None + + async def get_by_id(self, user_id: UUID) -> FauxCompte | None: + return self.compte + + async def count_active_admins(self) -> int: + return self.admins_actifs + + async def create(self, *, email: str, **_: object) -> FauxCompte: + self.crees.append(email) + return FauxCompte(email=email) + + async def set_role(self, user_id: UUID, role: Role) -> None: + self.roles_poses.append((user_id, role.value)) + + async def set_active(self, user_id: UUID, *, is_active: bool) -> None: + self.activations.append((user_id, is_active)) + + async def update_password(self, user_id: UUID, password_hash: str, **_: object) -> None: + self.mots_de_passe.append(user_id) + + +class FauxDepotJetons: + def __init__(self) -> None: + self.revocations: list[tuple[UUID, str]] = [] + + async def revoke_all_for_user(self, user_id: UUID, reason: RevocationReason) -> int: + self.revocations.append((user_id, reason.value)) + return 2 + + +class FauxDepotAudit: + def __init__(self) -> None: + self.lignes: list[tuple[str, Any]] = [] + + async def record(self, *, action: object, detail: Any = None, **_: object) -> None: + self.lignes.append((str(action), detail)) + + +class FauxHacheur: + async def hash(self, password: str) -> str: + return "$argon2id$nouvelle" + + +class FausseTransaction: + async def commit(self) -> None: + return None + + +@dataclass +class Attirail: + service: UserService + comptes: FauxDepotComptes + jetons: FauxDepotJetons + audit: FauxDepotAudit + + +def fabrique( + compte: FauxCompte | None = None, *, admins_actifs: int = 2, existe: bool = False +) -> Attirail: + comptes = FauxDepotComptes(compte, admins_actifs=admins_actifs, existe=existe) + jetons = FauxDepotJetons() + audit = FauxDepotAudit() + service = UserService( + users=comptes, # type: ignore[arg-type] + refresh_tokens=jetons, # type: ignore[arg-type] + audit=audit, # type: ignore[arg-type] + hasher=FauxHacheur(), # type: ignore[arg-type] + transaction=FausseTransaction(), + ) + return Attirail(service, comptes, jetons, audit) + + +async def test_create_returns_a_temporary_password_shown_once() -> None: + attirail = fabrique() + + cree = await attirail.service.create( + actor=ADMIN, email="nouveau@enervision.fr", role=Role.LECTEUR, full_name=None + ) + + assert len(cree.temporary_password) >= 18 + assert attirail.comptes.crees == ["nouveau@enervision.fr"] + assert "user.created" in attirail.audit.lignes[0][0] + + +async def test_create_refuses_an_address_already_taken() -> None: + attirail = fabrique(FauxCompte(), existe=True) + + with pytest.raises(EmailAlreadyUsedError): + await attirail.service.create( + actor=ADMIN, email="lecteur@enervision.fr", role=Role.LECTEUR, full_name=None + ) + + +async def test_change_role_revokes_every_session_of_the_target() -> None: + cible = FauxCompte() + attirail = fabrique(cible) + + await attirail.service.change_role(actor=ADMIN, user_id=cible.id, role=Role.OPERATEUR) + + assert attirail.comptes.roles_poses == [(cible.id, "operateur")] + assert attirail.jetons.revocations == [(cible.id, RevocationReason.ADMINISTRATION.value)] + + +async def test_change_role_does_nothing_when_the_role_is_already_the_right_one() -> None: + cible = FauxCompte(role="operateur") + attirail = fabrique(cible) + + await attirail.service.change_role(actor=ADMIN, user_id=cible.id, role=Role.OPERATEUR) + + assert attirail.comptes.roles_poses == [] + assert attirail.jetons.revocations == [] + + +async def test_change_role_refuses_to_demote_the_last_active_administrator() -> None: + dernier = FauxCompte(role="admin") + attirail = fabrique(dernier, admins_actifs=1) + + with pytest.raises(LastAdminError): + await attirail.service.change_role(actor=ADMIN, user_id=dernier.id, role=Role.LECTEUR) + + +async def test_change_role_accepts_a_demotion_when_another_administrator_remains() -> None: + admin = FauxCompte(role="admin") + attirail = fabrique(admin, admins_actifs=2) + + await attirail.service.change_role(actor=ADMIN, user_id=admin.id, role=Role.LECTEUR) + + assert attirail.comptes.roles_poses == [(admin.id, "lecteur")] + + +async def test_set_active_refuses_to_disable_the_last_active_administrator() -> None: + dernier = FauxCompte(role="admin") + attirail = fabrique(dernier, admins_actifs=1) + + with pytest.raises(LastAdminError): + await attirail.service.set_active(actor=ADMIN, user_id=dernier.id, is_active=False) + + +async def test_set_active_revokes_the_sessions_when_disabling() -> None: + cible = FauxCompte() + attirail = fabrique(cible) + + await attirail.service.set_active(actor=ADMIN, user_id=cible.id, is_active=False) + + assert attirail.comptes.activations == [(cible.id, False)] + assert attirail.jetons.revocations == [(cible.id, RevocationReason.ADMINISTRATION.value)] + + +async def test_set_active_leaves_the_sessions_alone_when_enabling() -> None: + cible = FauxCompte(is_active=False) + attirail = fabrique(cible) + + await attirail.service.set_active(actor=ADMIN, user_id=cible.id, is_active=True) + + assert attirail.jetons.revocations == [] + + +async def test_reset_password_closes_every_session_and_forces_a_change() -> None: + cible = FauxCompte() + attirail = fabrique(cible) + + reinitialise = await attirail.service.reset_password(actor=ADMIN, user_id=cible.id) + + assert len(reinitialise.temporary_password) >= 18 + assert attirail.comptes.mots_de_passe == [cible.id] + assert attirail.jetons.revocations == [ + (cible.id, RevocationReason.CHANGEMENT_MOT_DE_PASSE.value) + ] + + +@pytest.mark.parametrize( + "action", + ["change_role", "set_active", "reset_password"], + ids=["changement_de_role", "activation", "reinitialisation"], +) +async def test_every_operation_refuses_an_unknown_account(action: str) -> None: + attirail = fabrique(None) + arguments: dict[str, Any] = {"actor": ADMIN, "user_id": uuid4()} + if action == "change_role": + arguments["role"] = Role.ADMIN + if action == "set_active": + arguments["is_active"] = False + + with pytest.raises(UserNotFoundError): + await getattr(attirail.service, action)(**arguments) From e8f22bf42796d0e38ac617087955b761953998d0 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:56:17 +0200 Subject: [PATCH 009/103] =?UTF-8?q?feat(backend):=20durcit=20la=20surface?= =?UTF-8?q?=20expos=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En-têtes de sécurité, CORS resserré, caviardage des journaux, `/metrics` derrière un jeton facultatif, documentation fermée en préproduction, et la sonde de disponibilité cesse de publier la version de TimescaleDB. HSTS et CSP sont volontairement absents : l'application ignore si TLS termine devant elle, et une CSP sur une API JSON ne protège presque rien. Les deux appartiennent au terminateur TLS, celle qui compte protège la page Angular. `/metrics` est gardé par un jeton statique et non par un rôle : coupler la supervision au modèle d'utilisateurs casserait la collecte à chaque panne d'authentification, c'est-à-dire quand on en a le plus besoin. Le contrôle principal reste le réseau. Le caviardage est la troisième ligne de défense, pas la première. On ne passe aucun secret au logger et aucun jeton dans une URL ; le filtre rattrape ce que personne n'a relu, à commencer par l'écho SQL qui publiait les empreintes Argon2 quand `debug` est actif. Corrige un défaut que le test a révélé : `create_app(settings)` ne pilotait que la construction, les dépendances continuaient de lire `get_settings()` depuis l'environnement. Un test « en production » ne testait donc pas la production, et `TESTING.md` promet le contraire. --- apps/backend/app/api/middleware.py | 35 ++++++ apps/backend/app/api/security.py | 23 ++++ apps/backend/app/api/v1/endpoints/health.py | 3 +- apps/backend/app/core/logging.py | 44 ++++++++ apps/backend/app/main.py | 35 ++++-- apps/backend/app/schemas/health.py | 5 +- apps/backend/tests/api/test_hardening.py | 111 ++++++++++++++++++++ apps/backend/tests/api/test_health.py | 7 +- apps/backend/tests/core/test_logging.py | 72 +++++++++++++ 9 files changed, 323 insertions(+), 12 deletions(-) create mode 100644 apps/backend/app/api/middleware.py create mode 100644 apps/backend/app/api/security.py create mode 100644 apps/backend/tests/api/test_hardening.py create mode 100644 apps/backend/tests/core/test_logging.py diff --git a/apps/backend/app/api/middleware.py b/apps/backend/app/api/middleware.py new file mode 100644 index 0000000..0a01192 --- /dev/null +++ b/apps/backend/app/api/middleware.py @@ -0,0 +1,35 @@ +# Pourquoi : `SecurityHeadersMiddleware` ne pose ni HSTS ni CSP, et c'est délibéré. +# L'application ignore si TLS termine devant elle, donc elle ne peut pas décider d'un HSTS ; +# et une CSP sur une API JSON ne protège presque rien, celle qui compte protège la page +# Angular. Les deux appartiennent au terminateur TLS. +# Contrainte : `/docs` charge Swagger depuis un CDN, une CSP stricte ici casserait la +# documentation sans rien sécuriser. + +from collections.abc import Awaitable, Callable +from typing import Final + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +EN_TETES: Final[dict[str, str]] = { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Referrer-Policy": "no-referrer", +} + +PREFIXE_AUTHENTIFICATION: Final = "/auth" + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + async def dispatch( + self, request: Request, call_next: Callable[[Request], Awaitable[Response]] + ) -> Response: + response = await call_next(request) + for nom, valeur in EN_TETES.items(): + response.headers.setdefault(nom, valeur) + + # Une réponse d'authentification ne doit jamais être conservée par un intermédiaire. + if PREFIXE_AUTHENTIFICATION in request.url.path: + response.headers["Cache-Control"] = "no-store" + return response diff --git a/apps/backend/app/api/security.py b/apps/backend/app/api/security.py new file mode 100644 index 0000000..6b47646 --- /dev/null +++ b/apps/backend/app/api/security.py @@ -0,0 +1,23 @@ +# Pourquoi : `/metrics` est protégé par un jeton statique et non par un rôle applicatif. Coupler +# la supervision au modèle d'utilisateurs casserait la collecte à chaque panne +# d'authentification, c'est-à-dire précisément quand on a besoin des métriques. Le vrai contrôle +# reste le réseau : Prometheus scrute sur le réseau interne et `/metrics` ne sort pas. + +import secrets + +from fastapi import HTTPException, Request, status + +from app.api.deps import SettingsDep + + +def require_metrics_token(request: Request, settings: SettingsDep) -> None: + attendu = settings.metrics_token + if attendu is None: + return + + presente = request.headers.get("authorization", "") + prefixe = "Bearer " + if not presente.startswith(prefixe) or not secrets.compare_digest( + presente[len(prefixe) :], attendu.get_secret_value() + ): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Jeton requis") diff --git a/apps/backend/app/api/v1/endpoints/health.py b/apps/backend/app/api/v1/endpoints/health.py index f97caaf..bf6b2ee 100644 --- a/apps/backend/app/api/v1/endpoints/health.py +++ b/apps/backend/app/api/v1/endpoints/health.py @@ -40,4 +40,5 @@ async def readiness(session: SessionDep) -> ReadinessStatus: detail="Extension TimescaleDB absente", ) - return ReadinessStatus(status="ready", database="reachable", timescaledb=version) + logger.debug("Extension TimescaleDB en version %s", version) + return ReadinessStatus(status="ready", database="reachable", timescaledb="loaded") diff --git a/apps/backend/app/core/logging.py b/apps/backend/app/core/logging.py index c0cc9a6..14cac3e 100644 --- a/apps/backend/app/core/logging.py +++ b/apps/backend/app/core/logging.py @@ -1,8 +1,48 @@ +# Pourquoi : `RedactingFilter` est la troisième ligne de défense, pas la première. La première +# est de ne jamais passer un secret au logger, la deuxième de ne jamais mettre un jeton dans +# une URL, que le journal d'accès enregistrerait de toute façon. Le filtre rattrape l'erreur +# que personne n'a relue, notamment l'écho SQL quand `debug` est actif. + import logging +import re from logging.config import dictConfig +from typing import Final from app.core.config import Settings +CAVIARDAGE: Final = "[expurgé]" + +REMPLACEMENTS: Final[tuple[tuple[re.Pattern[str], str], ...]] = ( + (re.compile(r"Bearer\s+[A-Za-z0-9._~+/-]{20,}=*"), f"Bearer {CAVIARDAGE}"), + (re.compile(r"eyJ[A-Za-z0-9._-]{20,}"), CAVIARDAGE), + (re.compile(r"\$argon2[a-z0-9]*\$\S+"), CAVIARDAGE), + ( + re.compile(r'("?(?:password|mot_de_passe|secret|token)"?\s*[:=]\s*")[^"]*(")'), + rf"\1{CAVIARDAGE}\2", + ), + ( + re.compile(r"((?:password|mot_de_passe|secret|token)[A-Za-z_]*=)[^&\s;\"]+"), + rf"\1{CAVIARDAGE}", + ), + (re.compile(r"(ev_refresh=)[^;\s]+"), rf"\1{CAVIARDAGE}"), +) + + +def redact(message: str) -> str: + for motif, remplacement in REMPLACEMENTS: + message = motif.sub(remplacement, message) + return message + + +class RedactingFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + message = record.getMessage() + expurge = redact(message) + if expurge != message: + record.msg = expurge + record.args = () + return True + def configure_logging(settings: Settings) -> None: formatter = "json" if settings.is_production else "console" @@ -10,6 +50,9 @@ def configure_logging(settings: Settings) -> None: { "version": 1, "disable_existing_loggers": False, + "filters": { + "redaction": {"()": "app.core.logging.RedactingFilter"}, + }, "formatters": { "console": { "format": "%(asctime)s %(levelname)-8s %(name)s %(message)s", @@ -23,6 +66,7 @@ def configure_logging(settings: Settings) -> None: "default": { "class": "logging.StreamHandler", "formatter": formatter, + "filters": ["redaction"], "stream": "ext://sys.stdout", }, }, diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index 2ddf1dd..6c3c866 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -1,11 +1,13 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware from prometheus_fastapi_instrumentator import Instrumentator from app.api.errors import register_error_handlers +from app.api.middleware import SecurityHeadersMiddleware +from app.api.security import require_metrics_token from app.api.v1.router import api_router from app.core.config import Settings, get_settings from app.core.logging import configure_logging, get_logger @@ -13,6 +15,9 @@ from app.db.session import get_engine logger = get_logger(__name__) +METHODES_AUTORISEES = ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"] +EN_TETES_AUTORISES = ["Authorization", "Content-Type"] + @asynccontextmanager async def lifespan(_: FastAPI) -> AsyncIterator[None]: @@ -28,30 +33,46 @@ def create_app(settings: Settings | None = None) -> FastAPI: resolved = settings or get_settings() configure_logging(resolved) + documentee = resolved.api_docs_are_exposed application = FastAPI( title=resolved.name, version=resolved.version, debug=resolved.debug, lifespan=lifespan, - docs_url=None if resolved.is_production else "/docs", - redoc_url=None if resolved.is_production else "/redoc", - openapi_url=None if resolved.is_production else "/openapi.json", + docs_url="/docs" if documentee else None, + redoc_url="/redoc" if documentee else None, + openapi_url="/openapi.json" if documentee else None, ) + application.add_middleware(SecurityHeadersMiddleware) + if resolved.allowed_origins: + # Méthodes et en-têtes listés plutôt que joker : avec `allow_credentials`, la liste + # d'origines devient l'unique contrôle, autant documenter le contrat exact. application.add_middleware( CORSMiddleware, allow_origins=resolved.allowed_origins, allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=METHODES_AUTORISEES, + allow_headers=EN_TETES_AUTORISES, + expose_headers=["Retry-After"], + max_age=600, ) register_error_handlers(application) Instrumentator().instrument(application).expose( - application, endpoint="/metrics", include_in_schema=False + application, + endpoint="/metrics", + include_in_schema=False, + dependencies=[Depends(require_metrics_token)], ) application.include_router(api_router, prefix=resolved.api_prefix) + # Piège : sans cette surcharge, une configuration passée à `create_app()` ne piloterait + # que la construction, et les dépendances continueraient de lire `get_settings()` depuis + # l'environnement. Un test « en production » ne testerait alors pas la production. + if settings is not None: + application.dependency_overrides[get_settings] = lambda: resolved + return application diff --git a/apps/backend/app/schemas/health.py b/apps/backend/app/schemas/health.py index e4ec86e..e7ddd4c 100644 --- a/apps/backend/app/schemas/health.py +++ b/apps/backend/app/schemas/health.py @@ -10,7 +10,10 @@ class LivenessStatus(BaseModel): environment: str +# Contrainte : la sonde ne publie pas la version de TimescaleDB. Une version exacte de +# composant, servie sans authentification, est de la reconnaissance gratuite pour qui +# cherche une CVE. Elle part dans le journal, où elle sert au diagnostic. class ReadinessStatus(BaseModel): status: Literal["ready"] database: Literal["reachable"] - timescaledb: str + timescaledb: Literal["loaded"] diff --git a/apps/backend/tests/api/test_hardening.py b/apps/backend/tests/api/test_hardening.py new file mode 100644 index 0000000..3ee1170 --- /dev/null +++ b/apps/backend/tests/api/test_hardening.py @@ -0,0 +1,111 @@ +import pytest +from httpx import ASGITransport, AsyncClient +from httpx import Response as HttpResponse + +from app.main import create_app +from tests.factories import make_settings + +ORIGINE = "https://enervision.fr" + + +async def interroge( + settings_overrides: dict[str, object], chemin: str, **kwargs: object +) -> HttpResponse: + application = create_app(make_settings(**settings_overrides)) + transport = ASGITransport(app=application) + async with AsyncClient(transport=transport, base_url="http://test") as client: + return await client.get(chemin, **kwargs) # type: ignore[arg-type] + + +@pytest.mark.parametrize( + ("entete", "valeur"), + [ + ("x-content-type-options", "nosniff"), + ("x-frame-options", "DENY"), + ("referrer-policy", "no-referrer"), + ], + ids=["nosniff", "anti_iframe", "referrer"], +) +async def test_every_response_carries_the_security_headers( + client: AsyncClient, entete: str, valeur: str +) -> None: + response = await client.get("/api/v1/health/live") + + assert response.headers[entete] == valeur + + +async def test_the_application_never_sets_hsts_itself(client: AsyncClient) -> None: + response = await client.get("/api/v1/health/live") + + assert "strict-transport-security" not in response.headers + + +@pytest.mark.parametrize( + "env", + ["staging", "prod"], + ids=["preproduction", "production"], +) +async def test_the_documentation_disappears_outside_development(env: str) -> None: + surcharges = {"env": env, "cors_origins": ORIGINE} + + for chemin in ("/docs", "/openapi.json"): + assert (await interroge(surcharges, chemin)).status_code == 404 + + +@pytest.mark.parametrize("env", ["local", "dev"], ids=["local", "developpement"]) +async def test_the_documentation_stays_available_while_developing(env: str) -> None: + surcharges = {"env": env, "cors_origins": ORIGINE} + + assert (await interroge(surcharges, "/openapi.json")).status_code == 200 + + +async def test_an_explicit_override_can_reopen_the_documentation() -> None: + surcharges = {"env": "prod", "cors_origins": ORIGINE, "expose_api_docs": True} + + assert (await interroge(surcharges, "/openapi.json")).status_code == 200 + + +async def test_metrics_stay_open_when_no_token_is_configured(client: AsyncClient) -> None: + response = await client.get("/metrics") + + assert response.status_code == 200 + + +async def test_metrics_demand_the_token_once_one_is_configured() -> None: + surcharges = {"metrics_token": "un-jeton-de-supervision-assez-long"} + + assert (await interroge(surcharges, "/metrics")).status_code == 401 + + +async def test_metrics_answer_to_the_right_token() -> None: + surcharges = {"metrics_token": "un-jeton-de-supervision-assez-long"} + entetes = {"Authorization": "Bearer un-jeton-de-supervision-assez-long"} + + response = await interroge(surcharges, "/metrics", headers=entetes) + + assert response.status_code == 200 + + +async def test_metrics_refuse_a_token_that_is_almost_right() -> None: + surcharges = {"metrics_token": "un-jeton-de-supervision-assez-long"} + entetes = {"Authorization": "Bearer un-jeton-de-supervision-assez-lon"} + + response = await interroge(surcharges, "/metrics", headers=entetes) + + assert response.status_code == 401 + + +async def test_an_unhandled_error_returns_a_correlation_id_and_no_traceback() -> None: + application = create_app(make_settings()) + + @application.get("/api/v1/essai-panne") + async def _casse() -> None: + raise RuntimeError("secret interne de la pile") + + transport = ASGITransport(app=application, raise_app_exceptions=False) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/essai-panne") + + assert response.status_code == 500 + assert "secret interne de la pile" not in response.text + assert response.json()["correlation"] diff --git a/apps/backend/tests/api/test_health.py b/apps/backend/tests/api/test_health.py index d5ba9bc..b9f2f33 100644 --- a/apps/backend/tests/api/test_health.py +++ b/apps/backend/tests/api/test_health.py @@ -17,7 +17,7 @@ async def test_liveness_exposes_service_metadata(client: AsyncClient) -> None: } -async def test_readiness_reports_the_timescaledb_version( +async def test_readiness_confirms_the_extension_without_leaking_its_version( fake_session: Callable[..., None], client: AsyncClient ) -> None: fake_session(result="2.22.1") @@ -28,8 +28,9 @@ async def test_readiness_reports_the_timescaledb_version( assert response.json() == { "status": "ready", "database": "reachable", - "timescaledb": "2.22.1", + "timescaledb": "loaded", } + assert "2.22.1" not in response.text async def test_readiness_returns_503_when_the_extension_is_missing( @@ -75,4 +76,4 @@ async def test_readiness_reaches_the_real_database(client: AsyncClient) -> None: body = response.json() assert body["status"] == "ready" assert body["database"] == "reachable" - assert body["timescaledb"] + assert body["timescaledb"] == "loaded" diff --git a/apps/backend/tests/core/test_logging.py b/apps/backend/tests/core/test_logging.py new file mode 100644 index 0000000..191c0c8 --- /dev/null +++ b/apps/backend/tests/core/test_logging.py @@ -0,0 +1,72 @@ +import logging + +import pytest + +from app.core.logging import CAVIARDAGE, RedactingFilter, redact + + +@pytest.mark.parametrize( + "message", + [ + "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.charge-utile-assez-longue.signature", + "jeton brut eyJhbGciOiJIUzI1NiJ9abcdefghijklmnopqrstuvwxyz", + "INSERT ... ('$argon2id$v=19$m=19456,t=2,p=1$sel-en-clair$empreinte-en-clair')", + '{"password": "le-mot-de-passe-du-client"}', + "current_password=le-mot-de-passe", + "Cookie: ev_refresh=abcdefghijklmnopqrstuvwxyz0123456789", + ], + ids=[ + "en_tete_bearer", + "jeton_jwt_nu", + "empreinte_argon2", + "mot_de_passe_json", + "mot_de_passe_en_paire", + "cookie_de_rafraichissement", + ], +) +def test_redact_removes_every_known_secret_shape(message: str) -> None: + expurge = redact(message) + + assert CAVIARDAGE in expurge + for suspect in ("le-mot-de-passe", "empreinte-en-clair", "abcdefghijklmnopqrstuvwxyz"): + assert suspect not in expurge + + +def test_redact_leaves_an_innocent_message_untouched() -> None: + message = "auth.login.success user_id=3f2a ip=203.0.113.10" + + assert redact(message) == message + + +def test_the_filter_rewrites_the_record_before_it_reaches_the_handler() -> None: + enregistrement = logging.LogRecord( + name="app", + level=logging.INFO, + pathname=__file__, + lineno=1, + msg='requete {"password": "%s"}', + args=("secret-du-client",), + exc_info=None, + ) + + conserve = RedactingFilter().filter(enregistrement) + + assert conserve is True + assert "secret-du-client" not in enregistrement.getMessage() + + +def test_the_filter_keeps_a_record_that_holds_no_secret() -> None: + enregistrement = logging.LogRecord( + name="app", + level=logging.INFO, + pathname=__file__, + lineno=1, + msg="requete %s", + args=("/api/v1/health/live",), + exc_info=None, + ) + + conserve = RedactingFilter().filter(enregistrement) + + assert conserve is True + assert enregistrement.getMessage() == "requete /api/v1/health/live" From cb2ac8c2c2faa69c06941d828f2a95243c8b0d20 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:57:42 +0200 Subject: [PATCH 010/103] =?UTF-8?q?test(backend):=20=C3=A9prouve=20le=20pa?= =?UTF-8?q?rcours=20d'authentification=20contre=20la=20vraie=20base?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six scénarios bout en bout, sans serveur ni port ouvert : connexion, rotation, déconnexion, rejeu d'un cookie déjà tourné, révocation immédiate et enregistrement d'une tentative sur adresse inconnue. Le scénario du rejeu vérifie aussi que la session encore vivante tombe avec sa famille : c'est la propriété qui distingue la détection de la simple rotation, et elle ne se démontre pas sur un double. Corrige un défaut que ce parcours a révélé : `iat` est une date JWT, donc en secondes entières, et `datetime.fromtimestamp` tronque. Tout jeton émis dans la même seconde que `credentials_changed_at` était rejeté, ce qui aurait déconnecté l'appareil courant à chaque changement de mot de passe, exactement l'inverse de ce que `/auth/password` promet. --- apps/backend/app/api/deps.py | 5 +- .../api/test_parcours_authentification.py | 162 ++++++++++++++++++ 2 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 apps/backend/tests/api/test_parcours_authentification.py diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index 7943225..4dc32cb 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -149,7 +149,10 @@ async def get_current_principal( compte = await UserRepository(session).get_by_id(claims.subject) if compte is None or not compte.is_active: raise _non_authentifie("invalid_token") - if claims.issued_at < compte.credentials_changed_at: + # Piège : `iat` est une date JWT, donc en secondes entières. Comparer sans tronquer le + # marqueur rejetterait tout jeton émis dans la même seconde que le changement, c'est-à-dire + # celui que `/auth/password` vient de rendre pour garder l'appareil courant connecté. + if int(claims.issued_at.timestamp()) < int(compte.credentials_changed_at.timestamp()): raise _non_authentifie("token_stale") if claims.role != compte.role: raise _non_authentifie("token_stale") diff --git a/apps/backend/tests/api/test_parcours_authentification.py b/apps/backend/tests/api/test_parcours_authentification.py new file mode 100644 index 0000000..e5e4cce --- /dev/null +++ b/apps/backend/tests/api/test_parcours_authentification.py @@ -0,0 +1,162 @@ +# Parcours complet contre la vraie base, sans serveur ni port ouvert. C'est ce fichier qui +# prouve que le câblage tient : la connexion, la rotation, la détection de réutilisation et la +# révocation immédiate passent par les vrais dépôts, les vraies transactions et les vrais +# déclencheurs PostgreSQL. + +import uuid +from collections.abc import AsyncIterator + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy import text + +from app.core.hashing import build_hasher +from app.core.roles import Role +from app.db.session import get_session_factory +from app.repositories.user import UserRepository + +pytestmark = pytest.mark.integration + +MOT_DE_PASSE = "un-mot-de-passe-de-recette" + + +@pytest.fixture +async def compte_operateur() -> AsyncIterator[str]: + email = f"parcours-{uuid.uuid4().hex[:12]}@enervision.fr" + hacheur = build_hasher(time_cost=1, memory_cost_kib=8192, parallelism=1, max_concurrency=2) + empreinte = await hacheur.hash(MOT_DE_PASSE) + + async with get_session_factory()() as session: + await UserRepository(session).create( + email=email, password_hash=empreinte, role=Role.OPERATEUR + ) + await session.commit() + + yield email + + async with get_session_factory()() as session: + await session.execute(text("delete from app_user where email = :e"), {"e": email}) + await session.commit() + + +@pytest.fixture +async def navigateur(app: FastAPI) -> AsyncIterator[AsyncClient]: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + yield client + + +async def connecte(navigateur: AsyncClient, email: str) -> dict[str, str]: + reponse = await navigateur.post( + "/api/v1/auth/login", json={"email": email, "password": MOT_DE_PASSE} + ) + assert reponse.status_code == 200, reponse.text + return {"Authorization": f"Bearer {reponse.json()['access_token']}"} + + +async def test_a_full_session_runs_from_login_to_logout( + compte_operateur: str, navigateur: AsyncClient +) -> None: + entetes = await connecte(navigateur, compte_operateur) + + identite = await navigateur.get("/api/v1/auth/me", headers=entetes) + rotation = await navigateur.post("/api/v1/auth/refresh") + deconnexion = await navigateur.post("/api/v1/auth/logout") + + assert identite.status_code == 200 + assert identite.json()["role"] == "operateur" + assert rotation.status_code == 200 + assert deconnexion.status_code == 204 + + +async def test_replaying_a_rotated_cookie_kills_the_whole_family( + compte_operateur: str, navigateur: AsyncClient +) -> None: + await connecte(navigateur, compte_operateur) + vole = navigateur.cookies["ev_refresh"] + premiere_rotation = await navigateur.post("/api/v1/auth/refresh") + vivant = navigateur.cookies["ev_refresh"] + + navigateur.cookies.set("ev_refresh", vole) + rejeu = await navigateur.post("/api/v1/auth/refresh") + + navigateur.cookies.set("ev_refresh", vivant) + apres = await navigateur.post("/api/v1/auth/refresh") + + assert premiere_rotation.status_code == 200 + assert rejeu.status_code == 401 + assert apres.status_code == 401, "la session vivante doit tomber avec sa famille" + + +async def test_the_reuse_leaves_a_trace_in_the_append_only_audit_log( + compte_operateur: str, navigateur: AsyncClient +) -> None: + await connecte(navigateur, compte_operateur) + vole = navigateur.cookies["ev_refresh"] + await navigateur.post("/api/v1/auth/refresh") + + navigateur.cookies.set("ev_refresh", vole) + await navigateur.post("/api/v1/auth/refresh") + + async with get_session_factory()() as session: + traces = await session.scalar( + text("select count(*) from audit_log where action = 'auth.refresh_reuse_detected'") + ) + assert traces is not None + assert traces >= 1 + + +async def test_disabling_an_account_invalidates_its_access_token_at_once( + compte_operateur: str, navigateur: AsyncClient +) -> None: + entetes = await connecte(navigateur, compte_operateur) + avant = await navigateur.get("/api/v1/auth/me", headers=entetes) + + async with get_session_factory()() as session: + depot = UserRepository(session) + compte = await depot.get_by_email(compte_operateur) + assert compte is not None + await depot.set_active(compte.id, is_active=False) + await session.commit() + + apres = await navigateur.get("/api/v1/auth/me", headers=entetes) + + assert avant.status_code == 200 + assert apres.status_code == 401, "la révocation doit être immédiate, pas dans 15 minutes" + + +async def test_changing_a_role_invalidates_the_token_that_still_carries_the_old_one( + compte_operateur: str, navigateur: AsyncClient +) -> None: + entetes = await connecte(navigateur, compte_operateur) + + async with get_session_factory()() as session: + depot = UserRepository(session) + compte = await depot.get_by_email(compte_operateur) + assert compte is not None + await depot.set_role(compte.id, Role.LECTEUR) + await session.commit() + + apres = await navigateur.get("/api/v1/auth/me", headers=entetes) + + assert apres.status_code == 401 + assert "token_stale" in apres.headers["www-authenticate"] + + +async def test_a_failed_login_is_recorded_even_for_an_unknown_address( + navigateur: AsyncClient, +) -> None: + inconnu = f"inconnu-{uuid.uuid4().hex[:12]}@enervision.fr" + + reponse = await navigateur.post( + "/api/v1/auth/login", json={"email": inconnu, "password": "peu-importe-ici"} + ) + + async with get_session_factory()() as session: + tentatives = await session.scalar( + text("select count(*) from login_attempt where email_tried = :e"), {"e": inconnu} + ) + assert reponse.status_code == 401 + assert reponse.json() == {"detail": "Identifiants invalides"} + assert tentatives == 1, "sans cette ligne, le 429 deviendrait un oracle d'existence" From c60081a5ac4688f61dbb88bf29da617f124d37b4 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 14:58:15 +0200 Subject: [PATCH 011/103] test(backend): isole les tests d'audit par cible unique MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ils interrogeaient `audit_log` sans filtre, ce qui supposait une table vide. Le parcours d'authentification y écrit désormais de vraies lignes, et comme la table est en ajout seul, elles ne s'effacent pas entre deux exécutions. Chaque test filtre maintenant sur son propre `target_id`. --- .../tests/repositories/test_audit_log.py | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/apps/backend/tests/repositories/test_audit_log.py b/apps/backend/tests/repositories/test_audit_log.py index 1c6fc64..beacc8e 100644 --- a/apps/backend/tests/repositories/test_audit_log.py +++ b/apps/backend/tests/repositories/test_audit_log.py @@ -56,12 +56,17 @@ async def test_the_database_refuses_to_mutate_the_audit_log( async def test_record_keeps_a_snapshot_of_the_actor(session: AsyncSession) -> None: depot = AuditLogRepository(session) + cible = uuid.uuid4().hex - await depot.record(action=AuditAction.COMPTE_DESACTIVE, actor=ACTEUR) + await depot.record(action=AuditAction.COMPTE_DESACTIVE, actor=ACTEUR, target_id=cible) await session.flush() ligne = ( await session.execute( - text("select actor_id, actor_email, actor_role, outcome from audit_log") + text( + "select actor_id, actor_email, actor_role, outcome from audit_log " + "where target_id = :c" + ), + {"c": cible}, ) ).one() await session.rollback() @@ -77,9 +82,15 @@ async def test_record_accepts_a_label_when_there_is_no_authenticated_actor( ) -> None: depot = AuditLogRepository(session) - await depot.record(action=AuditAction.ADMIN_AMORCE, actor_label="cli") + cible = uuid.uuid4().hex + await depot.record(action=AuditAction.ADMIN_AMORCE, actor_label="cli", target_id=cible) await session.flush() - ligne = (await session.execute(text("select actor_id, actor_email from audit_log"))).one() + ligne = ( + await session.execute( + text("select actor_id, actor_email from audit_log where target_id = :c"), + {"c": cible}, + ) + ).one() await session.rollback() assert ligne.actor_id is None @@ -91,13 +102,19 @@ async def test_record_drops_the_detail_keys_outside_the_allow_list( ) -> None: depot = AuditLogRepository(session) + cible = uuid.uuid4().hex await depot.record( action=AuditAction.COMPTE_ROLE_CHANGE, actor=ACTEUR, + target_id=cible, detail={"role_avant": "lecteur", "mot_de_passe": "ne-doit-pas-passer"}, ) await session.flush() - detail = (await session.execute(text("select detail from audit_log"))).scalar_one() + detail = ( + await session.execute( + text("select detail from audit_log where target_id = :c"), {"c": cible} + ) + ).scalar_one() await session.rollback() assert detail == {"role_avant": "lecteur"} From 3b7383697e6574467b4d6ddd2bd02bfac57d2ca2 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 15:05:28 +0200 Subject: [PATCH 012/103] =?UTF-8?q?docs:=20acte=20les=20d=C3=A9cisions=20d?= =?UTF-8?q?'authentification=20et=20met=20=C3=A0=20jour=20les=20vues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois ADR : le jeton d'accès et le rafraîchissement opaque, le RBAC avec relecture du compte à chaque requête, et le journal d'audit en ajout seul. Chacun porte ses alternatives écartées et son critère de bascule, notamment celui vers OIDC. `31-contrat-authentification.md` est destiné au frontend : endpoints, codes d'erreur à traiter, et les quatre règles qui comptent. La troisième, un seul rafraîchissement en vol, est une exigence et non une optimisation : cinq rotations concurrentes seraient lues comme un rejeu et révoqueraient la session à chaque chargement de page. `owasp-traceabilite.md` remplace la revendication « couverture OWASP Top 10 et API Top 10 » de la NFR4, qui n'a pas de réponse honnête sur vingt items en deux semaines. Un contrôle par ligne, l'item adressé, et une section qui dit ce qui reste ouvert : portée par site, bornage des lectures de séries, transport, et la consommation de l'API Mock. Les vues 00, 20 et 40 suivent, comme l'impose leur propre règle de maintenance. La question ouverte « quel mécanisme d'authentification » est fermée ; trois autres la remplacent, dont la portée par site. --- README.md | 2 +- apps/backend/README.md | 56 +++++-- apps/backend/TESTING.md | 32 ++++ docs/README.md | 13 +- ...-authentification-jwt-et-refresh-opaque.md | 128 +++++++++++++++ .../0003-autorisation-rbac-a-trois-roles.md | 107 ++++++++++++ .../adr/0004-journal-d-audit-en-ajout-seul.md | 104 ++++++++++++ docs/architecture/00-vue-ensemble.md | 50 ++++-- docs/architecture/20-backend.md | 152 ++++++++++++++---- .../31-contrat-authentification.md | 144 +++++++++++++++++ docs/architecture/40-data.md | 74 ++++++++- docs/architecture/README.md | 13 +- docs/architecture/owasp-traceabilite.md | 70 ++++++++ 13 files changed, 883 insertions(+), 62 deletions(-) create mode 100644 docs/adr/0002-authentification-jwt-et-refresh-opaque.md create mode 100644 docs/adr/0003-autorisation-rbac-a-trois-roles.md create mode 100644 docs/adr/0004-journal-d-audit-en-ajout-seul.md create mode 100644 docs/architecture/31-contrat-authentification.md create mode 100644 docs/architecture/owasp-traceabilite.md diff --git a/README.md b/README.md index 20181cc..7a8147f 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m | Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise | | ETL | Apache Airflow | `etl/airflow` | A initialiser | | Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise | -| CI/CD | GitHub Actions | `.github/workflows` | A initialiser | +| CI/CD | GitHub Actions | `.github/workflows` | Backend en place | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser | Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend diff --git a/apps/backend/README.md b/apps/backend/README.md index 5d940b8..12fd9ba 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -57,13 +57,21 @@ independants de l'environnement. ``` app/ ├── api/ -│ ├── deps.py Dependances FastAPI partagees (session, settings) +│ ├── deps.py Dépendances partagées : session, settings, principal, gardes de rôle +│ ├── errors.py Gestionnaires 422 et 500 +│ ├── middleware.py En-têtes de sécurité +│ ├── security.py Garde du point /metrics │ └── v1/ -│ ├── router.py Agregation des routes de la version 1 -│ └── endpoints/ Un module par ressource exposee +│ ├── router.py Agrégation des routes de la version 1 +│ └── endpoints/ Un module par ressource exposée ├── core/ │ ├── config.py Settings Pydantic, source unique de configuration -│ └── logging.py Journalisation console en local, JSON en production +│ ├── cookies.py Attributs du cookie de rafraîchissement +│ ├── hashing.py Argon2id, poussé dans un fil sous limiteur +│ ├── logging.py Journalisation console en local, JSON en production +│ ├── principal.py L'identité que voit le code métier +│ ├── roles.py Rôles ordonnés +│ └── security.py Encodage et décodage des jetons d'accès ├── db/ │ ├── base.py Base declarative SQLAlchemy │ └── session.py Engine et sessions asynchrones @@ -71,6 +79,7 @@ app/ ├── schemas/ Modeles Pydantic d'entree et de sortie ├── repositories/ Acces aux donnees, une classe par agregat ├── services/ Regles metier, orchestrent les repositories +├── cli.py Commandes hors HTTP, dont l'amorcage du premier admin └── main.py Factory applicative tests/ Miroir de app/ alembic/ Migrations du schema applicatif @@ -81,12 +90,39 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie ## Routes -| Route | Role | -|------------------------|-------------------------------------------------| -| `/api/v1/health/live` | Sonde de vivacite, aucune dependance externe | -| `/api/v1/health/ready` | Sonde de disponibilite, verifie la base et TimescaleDB | -| `/metrics` | Metriques au format Prometheus | -| `/docs`, `/openapi.json` | Documentation, desactivee quand `APP_ENV=prod` | +| Route | Rôle | Accès | +|---|---|---| +| `/api/v1/health/live` | Sonde de vivacité, aucune dépendance externe | public | +| `/api/v1/health/ready` | Sonde de disponibilité, vérifie la base et TimescaleDB | public | +| `/api/v1/auth/login` | Ouvre une session | public | +| `/api/v1/auth/refresh` | Fait tourner la session | cookie | +| `/api/v1/auth/logout` | Ferme la session courante | cookie, idempotente | +| `/api/v1/auth/logout-all` | Ferme toutes les sessions du compte | jeton | +| `/api/v1/auth/password` | Change son propre mot de passe | jeton | +| `/api/v1/auth/me` | Décrit le compte connecté | jeton | +| `/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` | +| `/metrics` | Métriques au format Prometheus | jeton si `APP_METRICS_TOKEN` | +| `/docs`, `/openapi.json` | Documentation, fermée en `staging` et `prod` | public sinon | + +Le contrat détaillé pour le frontend est dans +[`docs/architecture/31-contrat-authentification.md`](../../docs/architecture/31-contrat-authentification.md). + +## Premier administrateur + +Aucun compte n'existe après les migrations. Il s'en crée un en ligne de commande : + +```bash +make bootstrap-admin EMAIL=prenom.nom@enervision.fr # mot de passe saisi au clavier +# ou, depuis apps/backend : +uv run python -m app.cli create-admin --email prenom.nom@enervision.fr --generate +``` + +Le compte est créé avec `must_change_password`, donc la première connexion ne donne accès qu'à +`/auth/me` et `/auth/password` jusqu'au changement. Le mot de passe ne transite jamais par +`argv`, visible de tout `ps`, et aucune révision Alembic n'insère de compte : son empreinte +resterait dans Git pour toujours. ## Migrations diff --git a/apps/backend/TESTING.md b/apps/backend/TESTING.md index f794421..e0daf47 100644 --- a/apps/backend/TESTING.md +++ b/apps/backend/TESTING.md @@ -141,3 +141,35 @@ make check # lint + typage + suite unitaire uv run pytest tests/api/test_health.py # un seul fichier uv run pytest -k readiness # par motif de nom ``` + +## Trois fichiers à connaître avant de toucher à l'authentification + +`tests/api/test_route_protection.py` interroge réellement chaque route sans identifiant et +échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Il n'inspecte pas l'arbre de +dépendances : celui-ci n'est accessible que par l'API privée de FastAPI, et surtout une route +peut porter la bonne dépendance tout en répondant quand même. **Rendre une route publique impose +donc de modifier la liste `ROUTES_PUBLIQUES` de ce fichier**, ce qui apparaît en clair dans la +diff d'une pull request. + +`tests/services/test_auth.py` donne au faux hacheur un **compteur d'appels**. C'est ce qui rend +possibles les deux assertions qui prouvent la conception, et qu'aucune autre forme de test +n'atteint : + +- adresse inconnue → le compteur vaut 1, donc le haché leurre a bien été vérifié et il n'y a pas + d'oracle temporel ; +- limite de débit atteinte → le compteur vaut 0, donc la limite est évaluée avant Argon2. + +`tests/api/test_parcours_authentification.py` joue six parcours complets contre la vraie base, +sous le marqueur `integration`, sans serveur ni port ouvert. C'est là que se démontrent +l'atomicité de la rotation, la mort de la famille au rejeu d'un cookie déjà tourné, et la +révocation immédiate d'un compte désactivé. + +## Deux pièges d'écriture de test + +**Lire les attributs avant le `rollback`.** Un `session.rollback()` périme les attributs chargés, +et les relire déclenche une entrée-sortie hors du contexte greenlet, donc un `MissingGreenlet`. +On capture la valeur dans une variable locale avant d'annuler. + +**`audit_log` ne se nettoie pas.** La table est en ajout seul, garanti par déclencheur : un test +ne peut pas effacer ce qu'il y écrit, et les lignes d'une exécution précédente sont encore là. +Chaque test filtre donc sur son propre `target_id` plutôt que de supposer une table vide. diff --git a/docs/README.md b/docs/README.md index 938a78a..17859f5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,4 +1,13 @@ # Documentation -- `adr` : decisions d'architecture, une par fichier, numerotees et immuables. -- `architecture` : les vues du systeme. Point d'entree : [architecture/README.md](architecture/README.md). +- `adr` : décisions d'architecture, une par fichier, numérotées et immuables. +- `architecture` : les vues du système. Point d'entrée : [architecture/README.md](architecture/README.md). + +## Décisions en vigueur + +| ADR | Sujet | +|---|---| +| [0001](adr/0001-postgresql-timescaledb.md) | PostgreSQL avec l'extension TimescaleDB | +| [0002](adr/0002-authentification-jwt-et-refresh-opaque.md) | Authentification par JWT d'accès et jeton de rafraîchissement opaque | +| [0003](adr/0003-autorisation-rbac-a-trois-roles.md) | Autorisation RBAC à trois rôles, relecture du compte à chaque requête | +| [0004](adr/0004-journal-d-audit-en-ajout-seul.md) | Journal d'audit en ajout seul, garanti par PostgreSQL | diff --git a/docs/adr/0002-authentification-jwt-et-refresh-opaque.md b/docs/adr/0002-authentification-jwt-et-refresh-opaque.md new file mode 100644 index 0000000..8e568f5 --- /dev/null +++ b/docs/adr/0002-authentification-jwt-et-refresh-opaque.md @@ -0,0 +1,128 @@ +# 0002 - Authentification par JWT d'accès et jeton de rafraîchissement opaque + +- Statut : accepté +- Date : 2026-09-15 + +## Contexte + +L'école n'impose aucun mécanisme d'authentification : les choix techniques sont libres et +doivent être justifiés. La contrainte réelle vient du dossier EC01, qui annonce un JWT d'accès +de 15 minutes, un rafraîchissement rotatif de 7 jours en cookie httpOnly et des mots de passe +hachés en Argon2id. + +L'API est consommée par une application Angular mono-page, servie par la même équipe, sur un +seul nœud et une seule base. Il n'y a ni second service à authentifier, ni fédération d'identité, +ni comptes externes. + +## Décision + +**Jeton d'accès : JWT signé en HS256**, 15 minutes, porté par l'en-tête `Authorization`, gardé +en mémoire JavaScript et jamais persisté côté navigateur. + +La signature asymétrique existe pour qu'une partie puisse vérifier sans pouvoir signer. Ici +l'émetteur et le vérificateur sont le même processus : le bénéfice est nul, et EdDSA imposerait +une génération de clés, un point JWKS et une histoire de rotation, c'est-à-dire du travail +d'exploitation pur. HS256 n'utilise par ailleurs que `hmac` et `hashlib` de la bibliothèque +standard, donc aucune dépendance native supplémentaire dans l'image. + +Le décodage porte trois barrières indépendantes : algorithme épinglé, audience et émetteur +vérifiés, et un claim `typ` comparé explicitement. + +**Jeton de rafraîchissement : chaîne opaque de 256 bits, jamais un JWT.** Il est stocké haché +en SHA-256 dans `refresh_token`, et transporté dans un cookie `HttpOnly`, `SameSite=Strict`, +`Path=/api/v1/auth`, `Secure` hors environnement local. + +Un rafraîchissement doit être révocable, donc sa ligne en base existe de toute façon ; un JWT +n'ajouterait qu'un cookie plus gros et un second chemin de signature. Surtout, la séparation +d'avec le jeton d'accès devient **structurelle et non conditionnelle** : un JWT ne figure dans +aucune ligne, une chaîne opaque échoue au décodage. La confusion refresh-vers-accès, qui +transforme silencieusement une fenêtre de 15 minutes en fenêtre de 7 jours, devient impossible +même si quelqu'un oublie le test. + +SHA-256 nu, sans sel ni HMAC : l'entrée fait 256 bits issus d'un générateur cryptographique, il +n'existe ni dictionnaire ni préimage atteignable. Une fonction de dérivation lente ajouterait +17 ms à chaque rafraîchissement, multipliés par le nombre d'onglets ouverts, pour aucun gain. + +**Mots de passe : Argon2id** via `argon2-cffi`, m=19456 KiB, t=2, p=1, soit environ 17 ms +mesurés sur un poste de développement. Le hachage est poussé dans un fil sous un limiteur de +capacité : appelé tel quel dans une coroutine, il figerait la boucle d'événements et gèlerait +toutes les requêtes en cours, pas seulement la connexion. + +**Rotation avec détection de réutilisation.** Présenter un jeton déjà tourné révoque toute la +famille et laisse une trace dans `audit_log`. Un jeton simplement expiré ne révoque rien : ce +n'est pas une preuve de compromission. + +**Pas de verrouillage de compte.** Une limitation de débit à fenêtre glissante le remplace, sur +trois clés : (identifiant, IP), IP seule, identifiant seul. + +## Pourquoi la rotation seule ne suffit pas + +Avec rotation sans détection, l'attaquant qui a volé le cookie le fait tourner en boucle. La +victime échoue à son tour, se reconnecte, ce qui ouvre une **nouvelle** famille, et celle de +l'attaquant continue de vivre. On a transformé un vol silencieux en un vol silencieux plus une +déconnexion inexpliquée, mise sur le compte d'un bug. + +La rotation ne protège de rien par elle-même : elle rend la réutilisation **détectable**, et +c'est la détection qui termine le vol, en moins d'un cycle de rafraîchissement. + +Résiduel assumé : l'attaquant conserve un jeton d'accès valide jusqu'à 15 minutes, et s'il +rafraîchit avant la victime, il garde la session jusqu'au prochain rafraîchissement de +celle-ci. Borné, pas nul. + +## Pourquoi pas de verrouillage de compte + +Le verrouillage est un vecteur de déni de service trivial : cinq mots de passe faux suffisent à +mettre un administrateur dehors, et la boucle se répète indéfiniment. Sur une plateforme de +supervision énergétique, verrouiller l'opérateur d'astreinte pendant un incident est un scénario +d'attaque, pas une hypothèse d'école. + +Il est par ailleurs inopérant contre le bourrage d'identifiants horizontal, un mot de passe +essayé sur des milliers de comptes, qui est l'attaque réelle. Le NIST SP 800-63B déconseille +explicitement le verrouillage fixe au profit de la limitation de débit. + +Le seuil par couple (identifiant, IP) garantit qu'un attaquant depuis une adresse ne peut pas +empêcher la victime de se connecter depuis la sienne. Le seuil par identifiant seul est le seul +cas où un compte est réellement bloqué : c'est la signature d'une attaque distribuée, c'est +temporaire et cela s'auto-guérit. + +## Conséquences + +- Le rechargement de page perd le jeton d'accès. L'application doit appeler `/auth/refresh` à + son démarrage : c'est exactement le rôle du cookie, porter la persistance que le JavaScript + ne porte pas. +- L'intercepteur HTTP doit garantir **un seul rafraîchissement en vol**. Cinq requêtes + parallèles prenant cinq fois 401 déclencheraient cinq rotations concurrentes, et la détection + révoquerait la session de l'utilisateur légitime à chaque chargement de page. Côté serveur, la + revendication est une instruction SQL unique avec `RETURNING`, sans fenêtre. +- `SameSite=Strict` ferme la surface CSRF à trois routes, qui portent en plus une vérification + d'`Origin`. Le jour où un flux OIDC arrive, il faudra repasser à `Lax`. +- Changer `APP_SECRET_KEY` n'invalide que les jetons d'accès, jamais les sessions, puisque + celles-ci sont des lignes opaques. La rotation de clé se fait donc sans cérémonie : les + clients prennent des 401, l'intercepteur rafraîchit, la perturbation dure moins de 15 minutes. +- La configuration refuse de démarrer si `APP_SECRET_KEY` fait moins de 32 caractères ou reste + une valeur d'exemple. + +## Alternatives écartées + +- **Keycloak ou un fournisseur OIDC** : un serveur d'identité se justifie par la **fédération**, + c'est-à-dire plusieurs applications, du SSO, des comptes externes. Il y a une application et + des comptes internes. Le coût n'est pas le conteneur mais la surface d'intégration : realm et + client à versionner, flux de redirection côté Angular, validation JWKS et rotation de clés + côté API, transposition des rôles. Deux à trois jours sur un budget de dix. + **Critère de bascule** : l'exigence de SSO d'un client pilote. La migration est contenue parce + que tout le code métier dépend d'un type `Principal` et jamais des claims, qu'un seul endroit + valide un jeton et qu'un seul vérifie un mot de passe. +- **Jeton de session opaque à la place du JWT d'accès** : puisqu'on relit le compte en base à + chaque requête (voir ADR 0003), l'argument « sans état » ne tient pas. Un jeton opaque serait + défendable. Le JWT est conservé pour son auto-description, qui évite une table de sessions + indexée par jeton, et pour la couture OIDC qu'il laisse intacte. +- **Rafraîchissement sous forme de JWT avec `typ: "refresh"`** : c'est le schéma le plus répandu, + et il fonctionne, mais la séparation y repose sur un `if` et l'expiration est dupliquée entre + le claim et la ligne, deux valeurs qui peuvent diverger. +- **Argon2id sur les jetons de rafraîchissement** : voir plus haut, coût sans gain. +- **Poivre applicatif sur les mots de passe** : sa perte rend tous les hachages invérifiables et + sa rotation impose un re-hachage de masse. Sur deux semaines, le risque dépasse le gain. +- **`passlib`** : sa dernière version date de 2020 et importe le module `crypt`, retiré de la + bibliothèque standard en Python 3.13. Éliminatoire sur Python 3.14. +- **`python-jose`** : maintenance erratique et CVE en 2024. `PyJWT` impose de passer + `algorithms=` explicitement au décodage, ce qui ferme nativement l'attaque `alg: none`. diff --git a/docs/adr/0003-autorisation-rbac-a-trois-roles.md b/docs/adr/0003-autorisation-rbac-a-trois-roles.md new file mode 100644 index 0000000..28de6f0 --- /dev/null +++ b/docs/adr/0003-autorisation-rbac-a-trois-roles.md @@ -0,0 +1,107 @@ +# 0003 - Autorisation RBAC à trois rôles, avec relecture du compte à chaque requête + +- Statut : accepté +- Date : 2026-09-15 + +## Contexte + +Le dossier EC01 annonce un RBAC à trois rôles, `admin`, `opérateur` et `lecteur`, et des comptes +machine à machine distincts pour l'ETL et le travail d'apprentissage. Il annonce aussi un jeton +d'accès de 15 minutes, ce qui pose la question de ce qui se passe pendant ces 15 minutes après +une désactivation ou un changement de rôle. + +## Décision + +**Trois rôles totalement ordonnés** : `lecteur < operateur < admin`. La garde est une fabrique +de dépendance, `require_role(minimum)`, et non une matrice de permissions. + +Les valeurs restent en ASCII (`operateur`) parce qu'elles voyagent en base, en JSON et dans les +jetons ; le libellé accentué appartient à l'interface. + +**Le `Principal` est construit depuis la ligne en base, jamais depuis les claims du jeton.** +`get_current_principal` valide la signature puis relit le compte par clé primaire, et refuse la +requête si le compte a disparu, s'il est désactivé, si le jeton est antérieur à +`credentials_changed_at`, ou si le rôle du claim ne correspond plus. + +**Les routes sont protégées explicitement, une par une**, et un test interroge réellement +chaque route sans jeton pour vérifier qu'elle refuse un appelant anonyme. + +**Les comptes machine à machine sont des rôles PostgreSQL, pas des comptes applicatifs.** La +colonne `kind` distingue déjà un compte de service d'un compte humain, et `/auth/login` les +refuse, mais aucun flux `client_credentials` n'est construit. + +## Pourquoi relire la base plutôt que rester sans état + +La propriété « sans état » achète la montée en charge horizontale entre des services qui ne +partagent pas de base. Il y a un service et une base : le bénéfice est nul. + +Tous les endpoints authentifiés ouvrent déjà une session et interrogent TimescaleDB. Une lecture +par clé primaire sur une table de quelques dizaines de lignes, résidente en mémoire partagée, +représente moins d'un pour cent du budget d'une requête. + +Ce qu'on achète en échange est la **révocation immédiate**. « Un opérateur licencié à 10h00 +garde-t-il ses droits jusqu'à 10h15 ? » est la question qu'un jury pose, et pouvoir répondre +« non, dès la requête suivante, et voici le test » vaut davantage qu'une propriété théorique +qu'on n'exploitera jamais. + +Le claim `role` reste présent mais **n'entre jamais dans une décision d'autorisation**. Un claim +obsolète ne peut donc pas provoquer d'élévation de privilège ; sa comparaison avec la ligne sert +la fraîcheur de l'interface, pas la sécurité. + +Les 15 minutes cessent dès lors d'être le paramètre de sécurité principal. Elles bornent +l'obsolescence du claim, elles bornent le dégât si la relecture était un jour retirée, et elles +coûtent un rafraîchissement par quart d'heure. C'est une marge, pas une garantie. + +## Pourquoi les comptes machine à machine sont des rôles PostgreSQL + +Un travail d'ingestion de séries temporelles insère en masse, par `COPY` ou par insertions +groupées sur une connexion PostgreSQL, pas par des allers-retours REST : c'est deux ordres de +grandeur d'écart, et TimescaleDB a précisément été choisi pour cette charge. + +Le chemin d'accès réel ne passe donc pas par l'application, et un compte applicatif +`etl-worker` ne cantonnerait rien du tout. La frontière qui compte est le rôle PostgreSQL : +`enervision_etl` insère dans les hypertables de mesures et rien d'autre, sans aucun accès à +`app_user`, `refresh_token` ni `audit_log`. + +Formulation à retenir : le compte applicatif porte l'identité et la traçabilité, le rôle +PostgreSQL porte le cantonnement. Le premier sans le second serait du théâtre. + +**Cette partie n'est pas encore livrée**, et c'est une dette assumée : elle impose que +l'application cesse de se connecter en propriétaire du schéma, donc un `DATABASE_URL` différent +et une réinitialisation de base pour chaque poste de l'équipe. À ouvrir en ticket avec l'équipe +chargée de l'ETL. + +## Conséquences + +- Un changement de rôle ou une désactivation révoque aussi les familles de jetons de la cible, + sans quoi la révocation ne serait immédiate que sur le jeton d'accès. +- `credentials_changed_at` est comparé à la seconde entière, parce que `iat` est une date JWT et + n'a pas de précision inférieure. Sans cette troncature, le jeton rendu par `/auth/password` + serait rejeté dans la seconde qui suit son émission. +- Rendre une route publique impose de modifier une liste dans un fichier de test, ce qui + apparaît en clair dans la diff d'une pull request et demande une justification au relecteur. + Le garde-fou est social autant que technique. +- Le service refuse de rétrograder ou de désactiver le dernier administrateur actif : sans cette + garde, un administrateur peut se verrouiller lui-même dehors, et il ne reste que `psql`. + +## Alternatives écartées + +- **Matrice de permissions explicites** (`measure.read`, `user.create`…) : c'est la bonne réponse + à partir d'une dizaine de rôles. Ici, trois rôles totalement ordonnés se lisent en une ligne. + **Critère de bascule** : le jour où un rôle doit posséder une capacité qu'un rôle supérieur ne + doit pas avoir, par exemple un auditeur qui lit `audit_log` et rien d'autre, l'ordre total + casse et il faut des permissions nommées. +- **Dépendance globale sur le routeur avec liste blanche de chemins** : le filtrage par chaîne + de caractères est fragile, la documentation OpenAPI afficherait un schéma de sécurité sur les + routes publiques, et surtout la liste blanche vivrait dans le code applicatif, où un + développeur peut y glisser sa route pour faire passer son problème. +- **Portée par site** : c'est la limite connue de cette conception. Les rôles sont globaux, or + l'axe naturel d'autorisation sur une plateforme multi-sites est le site : un opérateur du site + A ne devrait pas acquitter les alertes du site B. En l'état, le risque BOLA reste ouvert. Le + correctif est une table d'affectation compte-site et un contrôle d'appartenance dans la même + dépendance que le contrôle de rôle. +- **Flux OAuth2 `client_credentials`** : c'est une fonctionnalité de serveur d'autorisation, + avec enregistrement des clients, portées et point de terminaison conforme. Des jours de + travail pour zéro consommateur HTTP actuel. Son seul avantage réel, des jetons courts pour + qu'un justificatif long ne circule pas à chaque appel, compte quand le jeton traverse une + frontière de confiance. Ici il n'en traverse aucune. diff --git a/docs/adr/0004-journal-d-audit-en-ajout-seul.md b/docs/adr/0004-journal-d-audit-en-ajout-seul.md new file mode 100644 index 0000000..cfb6a95 --- /dev/null +++ b/docs/adr/0004-journal-d-audit-en-ajout-seul.md @@ -0,0 +1,104 @@ +# 0004 - Journal d'audit en ajout seul, garanti par PostgreSQL + +- Statut : accepté +- Date : 2026-09-15 + +## Contexte + +Le dossier EC01 annonce une table `audit_log` « en ajout seul pour toute action +d'administration ». Une table sans contrainte n'est pas en ajout seul : elle l'est par +convention de code, c'est-à-dire jusqu'au premier `UPDATE` écrit par erreur. + +La question qu'un jury pose immédiatement est « et si quelqu'un a les droits sur la base ? ». +Elle mérite une réponse honnête plutôt qu'une parade. + +## Décision + +Deux déclencheurs PL/pgSQL sur `audit_log`, posés par la révision Alembic qui crée la table : + +- `BEFORE UPDATE OR DELETE ... FOR EACH ROW` +- `BEFORE TRUNCATE ... FOR EACH STATEMENT` + +Le second n'est pas redondant : `TRUNCATE` ne passe pas par les déclencheurs de ligne. Et la +fonction lève une exception plutôt que de renvoyer `NULL`, qui annulerait l'opération +silencieusement. + +`actor_id` ne porte **aucune clé étrangère**, et `actor_email` comme `actor_role` sont +dénormalisés. + +Le champ `detail` passe par une fonction d'assemblage à **liste blanche de clés**, jamais par un +`dict(**kwargs)`. + +## Pourquoi pas de clé étrangère sur l'acteur + +Une contrainte `ON DELETE SET NULL` déclencherait un `UPDATE` que le déclencheur d'ajout seul +refuserait : la suppression d'un compte échouerait. Une contrainte `NO ACTION` interdirait +purement et simplement toute suppression de compte. + +Un journal doit survivre à la disparition de son acteur et ne jamais être muté par un effet de +bord. D'où la dénormalisation : **le journal dit ce qui était vrai au moment de l'acte, pas ce +qui est vrai aujourd'hui.** + +## Ce qui entre, et ce qui n'entre pas + +| | `audit_log` | `login_attempt` et journaux applicatifs | +|---|---|---| +| Question | qui a fait quoi, à qui, quand | que se passe-t-il en ce moment | +| Volume | faible | élevé | +| Rétention | longue, non purgeable par ligne | courte, purgeable | +| Piloté par l'attaquant | **jamais** | possiblement | + +Conséquence non négociable, et c'est le point où une contrainte technique dicte une décision de +conception : **on n'écrit jamais dans `audit_log` un volume que l'attaquant contrôle.** Une +force brute y inscrirait des millions de lignes indestructibles. Les échecs de connexion vont +donc dans `login_attempt`, qui est aussi le compteur de la limitation de débit et se purge. + +La seule exception est `auth.refresh_reuse_detected` : rare, à très fort signal, et c'est +l'événement qu'on voudra retrouver trois mois plus tard. + +Corollaire : `audit_log` n'est **pas** une hypertable. Une politique de rétention TimescaleDB +émettrait des `DELETE` que le déclencheur refuserait. Si une purge devient nécessaire, elle +passera par un `DROP` de partition, donc par du DDL, ce qui est la bonne sémantique : purge +administrative oui, altération de ligne non. + +## Ce que cette garantie couvre, et ce qu'elle ne couvre pas + +Le déclencheur défend contre le code de l'équipe et contre l'accident. Il ne défend pas contre +quelqu'un qui détient `ALTER TABLE` : ce compte peut désactiver le déclencheur. + +La réponse honnête à « et si quelqu'un a les droits sur la base ? » est donc : alors l'audit +local ne vaut plus rien, et c'est vrai de tout journal co-localisé avec ce qu'il journalise. Cet +audit sert la traçabilité opérationnelle, pas la non-répudiation contre un administrateur de +base. Prétendre le contraire serait faux, et un membre du jury avec une console PostgreSQL le +démontrerait en trente secondes. + +Le palier suivant est double, et il est assumé comme dette : + +1. **Séparation de privilèges** : `REVOKE UPDATE, DELETE, TRUNCATE ON audit_log FROM + enervision_app`. C'est le contrôle qui arrête une application compromise, là où le + déclencheur n'arrête que les bugs. Il exige que l'application cesse de se connecter en + propriétaire de la table, donc un rôle supplémentaire, un `DATABASE_URL` différent et une + réinitialisation de base pour chaque poste de l'équipe. Reporté pour cette raison. +2. **Export hors hôte** en ajout seul, ou chaînage par empreinte de chaque ligne sur la + précédente. C'est le seuil au-delà duquel on peut parler de non-répudiation. + +## Conséquences + +- Les tests d'intégration ne peuvent pas nettoyer `audit_log` derrière eux, et doivent donc + filtrer sur leur propre `target_id` plutôt que supposer une table vide. +- Trois tests d'intégration vérifient que `UPDATE`, `DELETE` et `TRUNCATE` lèvent tous les + trois. Ce sont les tests les plus rentables du lot, et la démonstration de trente secondes à + garder pour l'oral : `UPDATE audit_log SET action = 'x';` renvoie `permission denied`. +- L'adresse IP est une donnée personnelle. `login_attempt` se purge à 30 jours ; `audit_log`, qui + ne se purge pas par ligne, ne doit donc recevoir que des événements d'administration peu + nombreux. + +## Alternatives écartées + +- **Convention de code seule** : c'est la formulation du dossier EC01, et elle ne tient pas. Une + table sans contrainte est en ajout seul jusqu'au premier `UPDATE` écrit par mégarde. +- **Rôles PostgreSQL immédiatement** : meilleur contrôle, mais il impose une réinitialisation de + base à toute l'équipe en plein milieu du projet. Le déclencheur d'abord, les privilèges + ensuite. +- **`audit_log` en hypertable avec rétention** : incompatible avec l'ajout seul, et sans objet + au volume attendu. diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index 0794bfb..15a54c0 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -109,24 +109,52 @@ consolidée. ### En place +- **Authentification et autorisation.** JWT d'accès de 15 minutes, jeton de rafraîchissement + opaque en cookie `HttpOnly` avec rotation et détection de réutilisation, mots de passe en + Argon2id, RBAC à trois rôles. Détail dans [20-backend.md](20-backend.md), décisions dans les + [ADR 0002](../adr/0002-authentification-jwt-et-refresh-opaque.md) et + [0003](../adr/0003-autorisation-rbac-a-trois-roles.md). +- **Interdire par défaut.** Toute route exige un jeton, sauf quatre exceptions listées dans un + fichier de test qui interroge réellement chaque route sans identifiant. +- **Révocation immédiate.** Le compte est relu en base à chaque requête : une désactivation ou un + changement de rôle prend effet à la requête suivante, pas au bout de 15 minutes. +- **Limitation de débit à fenêtre glissante** sur trois clés, évaluée avant le hachage. Pas de + verrouillage de compte, qui serait un déni de service trivial. +- **Journal d'audit en ajout seul**, garanti par deux déclencheurs PostgreSQL + ([ADR 0004](../adr/0004-journal-d-audit-en-ajout-seul.md)). - **Les secrets n'ont pas de valeur par défaut.** `APP_SECRET_KEY` et `DATABASE_URL` sont requis - sans repli : l'application refuse de démarrer si l'un manque, plutôt que de tourner avec une - valeur de démonstration. `.env` reste hors dépôt, `.env.example` est versionné. -- **CORS conditionnel** : le middleware n'est ajouté que si `APP_CORS_ORIGINS` est renseigné. -- **Documentation interactive fermée en production** : `/docs`, `/redoc` et `/openapi.json` sont - désactivés dès que `APP_ENV=prod`. + sans repli, et la configuration refuse de démarrer sur cinq erreurs silencieuses : secret trop + court ou laissé à sa valeur d'exemple, `debug` en production, joker CORS, origines vides hors + local, cookie `SameSite=None` sans `Secure`. +- **CORS explicite** : origines listées, méthodes et en-têtes énumérés, jamais de joker. +- **En-têtes de sécurité** posés par l'application (`nosniff`, `DENY`, `no-referrer`) et + `Cache-Control: no-store` sur les routes d'authentification. +- **Caviardage des journaux** : jetons, empreintes Argon2, mots de passe et cookies sont + expurgés avant écriture. +- **Documentation interactive fermée** en préproduction et en production, `/metrics` derrière un + jeton facultatif, sonde de disponibilité qui ne publie plus la version de TimescaleDB. +- **CI backend bloquante** : format, lint, typage strict et tests avec seuil de couverture. - **Conteneur backend non-root**, déclaré dans `apps/backend/Dockerfile`. - **Côté infrastructure** : la clé SSH est marquée `sensitive`, le kubeconfig reste en `600/root` sur la machine cible et n'est lu que par `sudo`, `*.tfvars` est ignoré par git sauf les `.example`. -### Absent +### Absent, et assumé -- **Aucune authentification ni autorisation.** Les deux endpoints exposés sont publics. Rien - n'est encore décidé sur ce point. -- Pas de TLS, pas de limitation de débit, pas de journalisation des accès, pas de rotation des - secrets. -- Aucune analyse de dépendances ni de conteneur, faute de CI. +- **Rôles PostgreSQL cantonnés** pour l'ETL et le travail d'apprentissage. C'est la vraie + frontière pour ces deux consommateurs, qui écrivent en base et non par HTTP. Reporté parce que + cela impose une réinitialisation de base à toute l'équipe. Voir l'ADR 0003. +- **`REVOKE` sur `audit_log`** : les déclencheurs arrêtent les accidents, les privilèges + arrêteraient une application compromise. Même raison de report. +- **Portée par site** dans l'autorisation : les rôles sont globaux, un opérateur du site A peut + agir sur le site B. C'est la limite connue du modèle. +- **TLS, HSTS et CSP** : ils appartiennent au terminateur TLS, qui n'existe pas encore. +- **Limitation de débit au frontal** : celle de l'application protège les identifiants, pas + l'infrastructure. +- **Analyse de dépendances et de conteneurs** dans la CI, qui relève du chantier CI/CD. +- **Le fichier `environment.ts` de production** pointe encore sur `http://localhost:8000` en HTTP + simple : dans cet état, le cookie `Secure` ne sera pas posé. Voir + [31-contrat-authentification.md](31-contrat-authentification.md). ## Décisions structurantes diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index d975da6..8688a6a 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -8,23 +8,23 @@ La doctrine est posée dans [`apps/backend/README.md`](../../apps/backend/README [`TESTING.md`](../../apps/backend/TESTING.md) : `endpoints` appelle `services`, qui appelle `repositories`, qui seuls touchent les `models`. Le sens de dépendance ne s'inverse jamais. -Dans les faits, trois de ces couches sont des dossiers vides. +Les quatre couches existent désormais, portées par l'authentification. ```mermaid flowchart TB - ep["endpoints
2 routes"] - sc["schemas
2 modèles Pydantic"] - sv["services
vide"] - rp["repositories
vide"] - md["models
vide"] + ep["endpoints
health, auth, users"] + sc["schemas
Pydantic"] + sv["services
AuthService, UserService"] + rp["repositories
user, refresh_token,
login_attempt, audit_log"] + md["models
4 tables"] db[("PostgreSQL")] ep --> sc - ep -.-> sv - sv -.-> rp - rp -.-> md - ep -->|"SQL brut, état actuel"| db - rp -.-> db + ep --> sv + sv --> rp + rp --> md + ep -->|"SQL brut, sonde seulement"| db + rp --> db ``` Le trait plein de `endpoints` vers la base n'est pas une erreur de dessin : `/health/ready` @@ -32,9 +32,13 @@ exécute aujourd'hui son `SELECT` directement, sans repository. C'est acceptable d'infrastructure, qui vérifie la base elle-même et non une donnée métier. Ce raccourci ne doit pas servir de modèle au premier endpoint métier. -`app/models/__init__.py` ne contient qu'un avertissement, qui mérite d'être connu avant la -première migration : tout modèle absent de ce module reste invisible d'un -`alembic revision --autogenerate`, qui produirait alors un `drop` de sa table. +`app/models/__init__.py` porte un avertissement qui reste valable à chaque nouveau modèle : +tout modèle absent de ce module est invisible d'un `alembic revision --autogenerate`, qui +produirait alors un `drop` de sa table. L'export va dans le même commit que le modèle. + +`AuthService` et `UserService` ne connaissent ni `AsyncSession` ni `Request` : ils reçoivent +leurs dépôts et une `Transaction` réduite à `commit()`. C'est ce qui les rend testables sans +base, avec des doubles écrits à la main. ## Démarrage @@ -81,14 +85,41 @@ démarre ne prouve rien sur la base, la première connexion réelle a lieu au pr | `APP_API_PREFIX` | `/api/v1` | | | `APP_DATABASE_POOL_SIZE` | `5` | | | `APP_DATABASE_MAX_OVERFLOW` | `10` | | +| `APP_JWT_ISSUER` | `enervision-api` | Claim `iss`, vérifié au décodage | +| `APP_JWT_AUDIENCE` | `enervision-web` | Claim `aud`, vérifié au décodage | +| `APP_ACCESS_TOKEN_TTL_SECONDS` | `900` | Durée du jeton d'accès | +| `APP_REFRESH_TOKEN_TTL_SECONDS` | `604800` | Durée absolue d'une session, héritée à chaque rotation | +| `APP_REFRESH_COOKIE_NAME` | `ev_refresh` | Préfixé `__Secure-` dès que le cookie est `Secure` | +| `APP_COOKIE_PATH` | `/api/v1/auth` | Le cookie ne part que sur ces routes | +| `APP_COOKIE_SAMESITE` | `strict` | | +| `APP_COOKIE_SECURE` | déduit | Vrai hors `local` si non renseigné | +| `APP_ARGON2_TIME_COST` | `2` | | +| `APP_ARGON2_MEMORY_COST_KIB` | `19456` | Profil OWASP, environ 17 ms mesurés | +| `APP_ARGON2_PARALLELISM` | `1` | | +| `APP_ARGON2_MAX_CONCURRENCY` | `4` | Plafonne le pic mémoire du hachage | +| `APP_LOGIN_WINDOW_SECONDS` | `900` | Fenêtre glissante de la limitation | +| `APP_LOGIN_MAX_FAILURES_PER_IDENTIFIER_AND_IP` | `5` | Remplace le verrouillage de compte | +| `APP_LOGIN_MAX_FAILURES_PER_IP` | `20` | Arrête le balayage | +| `APP_LOGIN_MAX_FAILURES_PER_IDENTIFIER` | `50` | Signature d'une attaque distribuée | +| `APP_TRUST_PROXY_HEADERS` | `false` | À vrai derrière un proxy, sinon le compteur par IP devient global | +| `APP_EXPOSE_API_DOCS` | déduit | Faux en `staging` et `prod` si non renseigné | +| `APP_METRICS_TOKEN` | absent | Si présent, `/metrics` exige `Authorization: Bearer` | -Deux pièges : +Cinq gardes refusent de démarrer plutôt que de laisser passer une erreur silencieuse : +secret de moins de 32 caractères ou laissé à sa valeur d'exemple, `debug` en `staging` ou +`prod`, joker dans `APP_CORS_ORIGINS`, liste d'origines vide hors `local`, et cookie +`SameSite=None` sans `Secure`. + +Trois pièges : - **`DATABASE_URL` ne prend pas le préfixe `APP_`.** C'est le seul réglage dans ce cas, par `validation_alias`, pour rester compatible avec la convention d'Alembic et des hébergeurs. - **`APP_SECRET_KEY` et `DATABASE_URL` n'ont pas de valeur par défaut.** L'application refuse de démarrer si l'un manque. C'est délibéré : mieux vaut un échec au démarrage qu'un service qui tourne avec un secret de démonstration. +- **Une `Settings` passée à `create_app()` pilote aussi les dépendances.** La factory installe + une surcharge de `get_settings` ; sans elle, un test « en production » testerait la + configuration du poste. Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docker-compose.yml`, `apps/backend/.env` alimente l'API lancée sur le poste. @@ -99,16 +130,36 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke |---|---|---|---| | 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 | -| GET | `/metrics` | non | Format Prometheus, exposé par l'instrumentator | -| GET | `/docs`, `/redoc`, `/openapi.json` | non | Désactivés quand `APP_ENV=prod` | +| 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` | -Aucune route métier n'existe à ce jour. +**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 +é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 +[31-contrat-authentification.md](31-contrat-authentification.md). ### `/health/ready` Cette sonde porte une garde décrite dans l'[ADR 0001](../adr/0001-postgresql-timescaledb.md) : un bootstrap de base sauté ne se voit pas au démarrage de l'API, elle le rend visible. +Elle ne publie **pas** la version de l'extension, qui part dans le journal : une version exacte +de composant servie sans authentification est de la reconnaissance gratuite pour qui cherche +une CVE. + ```mermaid sequenceDiagram participant C as Client @@ -121,26 +172,54 @@ sequenceDiagram R->>D: SELECT extversion FROM pg_extension WHERE extname = 'timescaledb' alt base injoignable D--xR: SQLAlchemyError ou OSError - R-->>C: 503 Base de donnees injoignable + R-->>C: 503 Base de données injoignable else extension absente D-->>R: NULL R-->>C: 503 Extension TimescaleDB absente else D-->>R: version de l'extension - R-->>C: 200 status ready + R-->>C: 200 timescaledb loaded end ``` ## Sécurité -Voir la vue consolidée dans [00-vue-ensemble.md](00-vue-ensemble.md). Côté backend : +Voir la vue consolidée dans [00-vue-ensemble.md](00-vue-ensemble.md) et les décisions dans les +[ADR 0002](../adr/0002-authentification-jwt-et-refresh-opaque.md), +[0003](../adr/0003-autorisation-rbac-a-trois-roles.md) et +[0004](../adr/0004-journal-d-audit-en-ajout-seul.md). Côté backend, les ordres d'exécution qui +portent la sécurité, et qu'un refactor casserait sans rien faire échouer de visible : -- **Aucune authentification, aucune autorisation.** Les deux routes sont publiques. Le premier - endpoint métier imposera de trancher ce point. -- Le CORS n'autorise que les origines listées, et n'existe pas si la liste est vide. -- `/docs`, `/redoc` et `/openapi.json` disparaissent en production. +1. **Les compteurs de limitation sont lus avant le hachage Argon2.** Dans l'autre ordre, chaque + requête rejetée coûterait quand même 17 ms de processeur et 19 Mio de mémoire, et la + protection deviendrait l'amplificateur de déni de service qu'elle doit empêcher. +2. **Un haché leurre est vérifié quand l'adresse est inconnue.** Sans lui, l'écart entre 2 ms et + 17 ms est un oracle d'existence de compte, mesurable à distance. +3. **La tentative échouée est validée en base avant que l'erreur ne soit levée.** `get_session()` + ne valide pas de lui-même : la preuve disparaîtrait avec la transaction. +4. **Un jeton de rafraîchissement déjà tourné révoque toute sa famille ; un jeton expiré ne + révoque rien.** La rotation ne protège de rien par elle-même, elle rend la réutilisation + détectable. + +Le reste, par ordre de surface : + +- Le `Principal` est construit depuis la ligne en base, jamais depuis le claim `role` : un claim + périmé ne peut pas provoquer d'élévation de privilège. +- `credentials_changed_at` est comparé à la seconde entière, parce que `iat` est une date JWT et + n'a pas de précision inférieure. +- Le CORS liste ses origines, ses méthodes et ses en-têtes. Il n'est pas monté si la liste est + vide, et la configuration refuse de démarrer dans ce cas hors `local`. +- La 422 renvoie le champ fautif et le type d'erreur, **jamais la valeur rejetée** : la réponse + par défaut de FastAPI contient `input`, donc le mot de passe sur `/auth/login`. +- La 500 renvoie un identifiant de corrélation, la trace reste côté serveur. +- Un filtre de caviardage expurge jetons, empreintes Argon2, mots de passe et cookies avant + écriture des journaux. C'est la troisième ligne de défense : la première est de ne rien passer + de secret au logger, la deuxième de ne jamais mettre un jeton dans une URL. +- En-têtes posés par l'application : `X-Content-Type-Options`, `X-Frame-Options`, + `Referrer-Policy`, plus `Cache-Control: no-store` sur `/auth/*`. HSTS et CSP appartiennent au + terminateur TLS, que l'application ne connaît pas. - Le conteneur tourne en utilisateur non-root, avec un `HEALTHCHECK` sur `/api/v1/health/live`. -- Ni limitation de débit, ni journalisation des accès, ni en-têtes de sécurité. +- Ni limitation de débit au frontal, ni TLS, ni journalisation des accès applicative. ## Observabilité @@ -151,13 +230,24 @@ Voir la vue consolidée dans [00-vue-ensemble.md](00-vue-ensemble.md). Côté ba ## Tests Conventions, gabarits et arborescence : [`apps/backend/TESTING.md`](../../apps/backend/TESTING.md). -Deux points structurants y sont fixés : les doubles passent par `app.dependency_overrides` et -jamais par `unittest.mock`, et les tests qui touchent la vraie base portent le marqueur -`integration`, exclu par défaut. + +Trois fichiers méritent d'être connus avant de toucher à l'authentification : + +- `tests/api/test_route_protection.py` : le garde-fou de l'autorisation, décrit plus haut. +- `tests/services/test_auth.py` : le faux hacheur y porte un compteur d'appels, ce qui permet les + deux assertions qui prouvent le design, à savoir un appel quand l'adresse est inconnue et zéro + appel quand la limite est atteinte. +- `tests/api/test_parcours_authentification.py` : six parcours contre la vraie base, sous le + marqueur `integration`. C'est là que se démontrent l'atomicité de la rotation, la mort de la + famille au rejeu et la révocation immédiate. ## Questions ouvertes -- **Authentification et autorisation** : quel mécanisme, quelle granularité. +- **Portée par site dans l'autorisation** : les rôles sont globaux, un opérateur du site A peut + agir sur le site B. C'est la limite connue du modèle, et le risque BOLA du top 10 API. +- **Rôles PostgreSQL cantonnés** pour l'ETL et le travail d'apprentissage, plus le `REVOKE` sur + `audit_log`. Dette assumée, décrite dans les ADR 0003 et 0004. - **Pagination et fenêtrage** des lectures de séries temporelles, qui conditionnent la forme des - endpoints métier. + endpoints métier. Sans plafond dur, une requête sur dix ans d'historique suffit à faire tomber + l'API. - **Politique de versionnement de l'API** au-delà du préfixe `/api/v1`. diff --git a/docs/architecture/31-contrat-authentification.md b/docs/architecture/31-contrat-authentification.md new file mode 100644 index 0000000..f02fd1b --- /dev/null +++ b/docs/architecture/31-contrat-authentification.md @@ -0,0 +1,144 @@ +# Contrat d'authentification, côté frontend + +Ce que le frontend doit savoir pour coder la connexion, et rien de plus. Le raisonnement est +dans l'[ADR 0002](../adr/0002-authentification-jwt-et-refresh-opaque.md). + +Statut : `Fait` côté backend, `Cible` côté Angular. + +## En une phrase + +Le **jeton d'accès** vit en mémoire JavaScript et part dans l'en-tête `Authorization`. Le +**jeton de rafraîchissement** est un cookie `HttpOnly` que le code ne voit jamais et n'a pas à +gérer : il suffit d'envoyer les requêtes avec `withCredentials`. + +## Endpoints + +| Méthode | Chemin | Authentification | Réponse | +|---|---|---|---| +| POST | `/api/v1/auth/login` | aucune | `200` `TokenResponse` | +| POST | `/api/v1/auth/refresh` | cookie | `200` `TokenResponse` | +| POST | `/api/v1/auth/logout` | cookie | `204` | +| POST | `/api/v1/auth/logout-all` | jeton d'accès | `204` | +| POST | `/api/v1/auth/password` | jeton d'accès | `200` `TokenResponse` | +| GET | `/api/v1/auth/me` | jeton d'accès | `200` `PrincipalResponse` | +| GET | `/api/v1/users` | jeton d'accès, `admin` | `200` `UserResponse[]` | +| POST | `/api/v1/users` | jeton d'accès, `admin` | `201` `TemporaryPasswordResponse` | +| 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. + +## Charges utiles + +```jsonc +// POST /auth/login +{ "email": "operateur@enervision.fr", "password": "..." } + +// TokenResponse, rendu par login, refresh et password +{ + "access_token": "eyJ...", + "token_type": "bearer", + "expires_in": 900, + "principal": { + "id": "3f2a...", + "email": "operateur@enervision.fr", + "role": "lecteur | operateur | admin", + "kind": "human", + "must_change_password": false + } +} + +// POST /auth/password +{ "current_password": "...", "new_password": "..." } // 12 à 128 caractères +``` + +Le secret de rafraîchissement **n'apparaît jamais** dans le corps de la réponse. + +## Codes d'erreur à traiter + +| Code | Quand | Ce que fait le frontend | +|---|---|---| +| `401` sur `/auth/login` | identifiants faux, compte désactivé, compte inconnu | afficher le message générique tel quel, ne rien déduire de plus | +| `429` sur `/auth/login` | trop de tentatives | afficher l'attente, l'en-tête `Retry-After` donne les secondes | +| `401` avec `WWW-Authenticate: ... error="expired"` | jeton d'accès périmé | **rafraîchir**, puis rejouer la requête | +| `401` avec `error="token_stale"` | rôle changé ou compte désactivé pendant la session | **rafraîchir** ; si le rafraîchissement échoue, déconnecter | +| `401` avec `error="invalid_token"` | jeton illisible ou compte disparu | déconnecter | +| `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 | +| `422` | corps invalide | le détail donne `champ` et `type`, jamais la valeur envoyée | + +## Les quatre règles qui comptent + +**1. Le jeton d'accès ne se persiste jamais.** Ni `localStorage`, ni `sessionStorage`, ni +cookie : un signal dans un service racine. Un rechargement de page le perd, c'est voulu. + +**2. Au démarrage de l'application, appeler `/auth/refresh`.** C'est ce qui restaure la session +après un rechargement, via `provideAppInitializer`. Un `401` y est normal : il signifie +simplement qu'il n'y a pas de session, on affiche la page de connexion. + +**3. Un seul rafraîchissement en vol à la fois.** C'est une exigence, pas une optimisation. +Cinq requêtes parallèles qui prennent cinq fois `401` déclencheraient cinq rotations +concurrentes ; le serveur n'en accepte qu'une et considère les autres comme un rejeu, ce qui +**révoque toute la session**. L'utilisateur serait déconnecté à chaque chargement de page. + +```ts +// Dans l'intercepteur : une seule rotation partagée par tous les appelants. +private rotation$?: Observable; + +private rafraichir(): Observable { + this.rotation$ ??= this.http.post('/api/v1/auth/refresh', {}, { withCredentials: true }) + .pipe(finalize(() => (this.rotation$ = undefined)), shareReplay(1)); + return this.rotation$; +} +``` + +**4. Toutes les requêtes vers `/auth/*` portent `withCredentials: true`.** Sans quoi le cookie +n'est pas envoyé et le rafraîchissement échoue toujours. + +## Ce qu'il faut savoir sur le cookie + +- Nom `ev_refresh` en local, `__Secure-ev_refresh` ailleurs. Le code ne le lit jamais. +- `HttpOnly`, `SameSite=Strict`, `Path=/api/v1/auth`. Il n'est donc envoyé que sur ces routes. +- `Secure` dès que l'environnement n'est pas `local`, donc **HTTPS obligatoire hors poste de + développement**. +- `HttpOnly` empêche de voler le cookie, pas de s'en servir : une XSS peut appeler + `/auth/refresh` depuis l'origine de la victime. La vraie défense contre ce cas reste de ne pas + avoir de XSS. + +## Dev et production, le point à ne pas rater + +En développement, `proxy.conf.json` fait passer `/api` par `localhost:4200`, donc tout est +**même origine** et le cookie marche sans rien configurer. + +En production, `src/environments/environment.ts` contient encore le gabarit +`http://localhost:8000/api/v1`, en HTTP simple et sur une autre origine. **Dans cet état, aucun +cookie `Secure` ne sera posé et l'authentification ne fonctionnera pas.** + +Deux corrections, à faire avant la démonstration : + +1. passer `apiUrl` à `/api/v1` et servir le SPA et l'API sous la même origine, via un + `location /api` dans le `nginx.conf` du conteneur frontend ou via l'ingress ; +2. servir en HTTPS. + +Et au moins une fois avant la soutenance, lancer le front **sans le proxy**, en cross-origin +réel : c'est le seul moyen d'exercer le préflight CORS et `SameSite`, que le proxy masque. + +## Origines autorisées + +Le backend ne monte le middleware CORS que si `APP_CORS_ORIGINS` est renseigné, et refuse de +démarrer hors `local` si la liste est vide. Les routes portant le cookie vérifient en plus +l'en-tête `Origin` : une origine absente de la liste reçoit un `403`. + +Méthodes autorisées : `GET`, `POST`, `PATCH`, `PUT`, `DELETE`, `OPTIONS`. +En-têtes autorisés : `Authorization`, `Content-Type`. En-tête exposé : `Retry-After`. + +## Premier compte + +Créé en ligne de commande côté serveur (`make bootstrap-admin EMAIL=...`), avec +`must_change_password` à vrai. La première connexion renvoie donc `403 +password_change_required` sur toute route métier, et seuls `/auth/me` et `/auth/password` +répondent. L'écran de changement de mot de passe doit exister avant la démonstration. + +Idem pour tout compte créé par un administrateur : le mot de passe provisoire est affiché **une +seule fois** dans la réponse, il n'est plus jamais récupérable. diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md index 2d53844..86d65d1 100644 --- a/docs/architecture/40-data.md +++ b/docs/architecture/40-data.md @@ -35,8 +35,8 @@ Statut : `Fait`. - `db/init/100-extensions.sql` crée l'extension `timescaledb`. - `db/init/110-test-database.sql` crée `enervision_test`, dont le nom est attendu en dur par `apps/backend/tests/conftest.py`. -- Une révision Alembic, `5353c0e4f094`, qui **ne crée aucune table**. Elle établit - `alembic_version` et refuse de s'appliquer si l'extension manque : +- Quatre révisions Alembic. La première, `5353c0e4f094`, **ne crée aucune table** : elle + établit `alembic_version` et refuse de s'appliquer si l'extension manque : ```sql IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') THEN @@ -47,6 +47,9 @@ END IF; Cette garde forme paire avec le 503 de `/api/v1/health/ready`. Un bootstrap sauté ne se voit pas au démarrage de l'API : ces deux gardes le rendent visible tôt, des deux côtés. +Les trois suivantes créent les tables de l'authentification, décrites plus bas : `app_user`, +puis `login_attempt` et `audit_log`, puis `refresh_token`. + ## Cycle de vie d'une mesure Statut : `Cible`. Aucun de ces maillons n'existe. @@ -65,7 +68,72 @@ flowchart LR Les lectures de l'API et de Grafana visent l'agrégat continu, pas la table brute : c'est tout l'intérêt de TimescaleDB, et cela doit rester vrai quand les volumes augmenteront. -## Modèle +## Tables d'authentification + +Statut : `Fait`. Elles ne sont pas des séries temporelles et n'ont donc rien à voir avec les +hypertables ; elles vivent dans `apps/backend/alembic/`, qui porte le schéma exposé par l'API. + +```mermaid +erDiagram + APP_USER ||--o{ REFRESH_TOKEN : ouvre + APP_USER { + uuid id PK + string email UK + text password_hash + text role + text kind + bool is_active + bool must_change_password + timestamptz credentials_changed_at + } + REFRESH_TOKEN { + uuid id PK + uuid family_id + uuid user_id FK + bytea token_hash UK + timestamptz expires_at + timestamptz rotated_at + timestamptz revoked_at + text revoked_reason + uuid replaced_by + } + LOGIN_ATTEMPT { + bigint id PK + timestamptz occurred_at + string email_tried + inet client_ip + text outcome + } + AUDIT_LOG { + bigint id PK + timestamptz occurred_at + uuid actor_id + text actor_email + text action + jsonb detail + } +``` + +Quatre choix de modélisation portent une intention et se défendent seuls : + +- **`app_user` et non `user`** : `user` est un mot réservé PostgreSQL, raccourci de + `CURRENT_USER`. Le nom rappelle en prime qu'il s'agit d'un compte applicatif, par opposition + au rôle PostgreSQL qui portera le cantonnement de l'ETL. +- **`credentials_changed_at`, une seule colonne**, couvre le changement de mot de passe, le + changement de rôle et la désactivation. Un compteur de version ne dirait rien à un humain qui + lit un audit. +- **`refresh_token.expires_at` est absolu et hérité** du prédécesseur à chaque rotation. S'il + glissait, la promesse de sept jours serait fictive et une session active ne finirait jamais. +- **`audit_log.actor_id` n'a aucune clé étrangère**, et `actor_email` comme `actor_role` sont + dénormalisés. Une contrainte `ON DELETE SET NULL` déclencherait un `UPDATE` que le déclencheur + d'ajout seul refuserait. Voir l'[ADR 0004](../adr/0004-journal-d-audit-en-ajout-seul.md). + +`audit_log` porte deux déclencheurs qui refusent `UPDATE`, `DELETE` et `TRUNCATE`. Elle n'est +donc **pas** une hypertable : une politique de rétention émettrait des `DELETE` qu'ils +refuseraient. `login_attempt`, à l'inverse, est faite pour se purger, puisque son volume est +piloté par l'attaquant. + +## Modèle métier Statut : `Cible`. Les entités ci-dessous sont des **candidates**, à valider en J2. Elles s'appuient sur les gabarits de [`apps/backend/TESTING.md`](../../apps/backend/TESTING.md), qui diff --git a/docs/architecture/README.md b/docs/architecture/README.md index a23be40..c6b91f0 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -12,12 +12,17 @@ contredisent, c'est l'ADR qui fait foi et la vue qui est en retard. | [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 | | [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 | -L'observabilité, la sécurité et la CI/CD n'ont pas de document propre : ce sont des sections des -cinq ci-dessus, tant que `monitoring/`, `.github/workflows/` et `etl/airflow/` ne contiennent que -des `.gitkeep`. Elles en sortiront le jour où elles auront de la matière. Un fichier vide de plus -n'aide personne. +L'observabilité et la CI/CD n'ont pas de document propre : ce sont des sections des documents +ci-dessus, tant que `monitoring/` et `etl/airflow/` ne contiennent que des `.gitkeep`. Elles en +sortiront le jour où elles auront de la matière. Un fichier vide de plus n'aide personne. + +La sécurité applicative, elle, a désormais de la matière : la vue consolidée reste dans +[00-vue-ensemble.md](00-vue-ensemble.md), le détail dans [20-backend.md](20-backend.md), la +traçabilité OWASP dans [owasp-traceabilite.md](owasp-traceabilite.md), et les décisions dans les +ADR 0002 à 0004. ## Conventions diff --git a/docs/architecture/owasp-traceabilite.md b/docs/architecture/owasp-traceabilite.md new file mode 100644 index 0000000..ada1a45 --- /dev/null +++ b/docs/architecture/owasp-traceabilite.md @@ -0,0 +1,70 @@ +# Traçabilité OWASP + +Ce document remplace la revendication « couverture OWASP Top 10 et OWASP API Security Top 10 » +de la NFR4 du dossier EC01. Cette formulation est indéfendable telle quelle : vingt items, non +vérifiables en deux semaines, et « montrez-moi votre couverture de A04 Insecure Design » n'a pas +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. + +## Contrôles en place + +| Contrôle | Où | Item adressé | +|---|---|---| +| Interdire par défaut, liste blanche de routes publiques vérifiée par un test qui appelle réellement chaque route | `tests/api/test_route_protection.py` | API5 Broken Function Level Authorization, A01 Broken Access Control | +| RBAC à trois rôles ordonnés, décision prise sur la ligne en base et jamais sur le claim | `app/api/deps.py` | A01, API5 | +| Révocation immédiate : compte relu à chaque requête, `credentials_changed_at` invalide les jetons antérieurs | `app/api/deps.py`, `app/repositories/user.py` | A01, API2 Broken Authentication | +| Argon2id m=19456 t=2 p=1, re-hachage passif quand les paramètres changent | `app/core/hashing.py` | A02 Cryptographic Failures, A07 Identification and Authentication Failures | +| Message et temps de réponse identiques quelle que soit la cause de l'échec, haché leurre sur adresse inconnue | `app/services/auth.py` | A07, API2 | +| Limitation de débit à fenêtre glissante sur trois clés, évaluée avant le hachage | `app/services/auth.py`, `app/repositories/login_attempt.py` | A07, API4 Unrestricted Resource Consumption | +| Absence de verrouillage de compte, qui serait un déni de service | ADR 0002 | API4 | +| Jeton de rafraîchissement opaque, haché en base, rotation avec détection de réutilisation | `app/services/auth.py`, `app/repositories/refresh_token.py` | A07, API2 | +| Séparation structurelle accès / rafraîchissement, impossible à confondre | ADR 0002 | API2 | +| Algorithme épinglé, `aud`, `iss` et `typ` vérifiés, `alg: none` rejeté | `app/core/security.py` | A02, API2 | +| Cookie `HttpOnly`, `Secure`, `SameSite=Strict`, `Path` restreint, suppression symétrique | `app/core/cookies.py` | A05 Security Misconfiguration | +| Vérification d'`Origin` sur les trois routes portant le cookie | `app/api/deps.py` | A01 | +| Schémas de lecture et d'écriture séparés, aucun modèle ORM en réponse | `app/schemas/user.py` | API3 Broken Object Property Level Authorization | +| Validation stricte Pydantic en entrée, mot de passe borné à 128 caractères | `app/schemas/auth.py` | A03 Injection, API4 | +| Requêtes paramétrées par SQLAlchemy, aucune concaténation SQL | `app/repositories/` | A03 | +| Réponse 422 qui ne renvoie jamais la valeur rejetée | `app/api/errors.py` | A09 Security Logging and Monitoring Failures | +| Réponse 500 générique avec identifiant de corrélation, trace côté serveur seulement | `app/api/errors.py` | A05 | +| Journal d'audit en ajout seul garanti par déclencheurs, liste blanche des clés de détail | ADR 0004, `app/repositories/audit_log.py` | A09 | +| Caviardage des jetons, empreintes, mots de passe et cookies dans les journaux | `app/core/logging.py` | A09, A02 | +| Cinq gardes de configuration qui refusent le démarrage plutôt que de dégrader silencieusement | `app/core/config.py` | A05 | +| Documentation interactive fermée hors développement, `/metrics` derrière un jeton, sonde qui ne publie plus de version | `app/main.py`, `app/api/security.py` | A05 | +| En-têtes `nosniff`, `DENY`, `no-referrer`, et `no-store` sur les routes d'authentification | `app/api/middleware.py` | A05 | +| Refus de rétrograder ou désactiver le dernier administrateur actif | `app/services/user.py` | A04 Insecure Design | +| Amorçage du premier administrateur hors dépôt, mot de passe jamais dans `argv` ni dans Git | `app/cli.py` | A02, A05 | +| CI bloquante : format, lint avec règles Bandit, typage strict, tests avec seuil de couverture | `.github/workflows/backend.yml` | A06 Vulnerable and Outdated Components | + +Note sur A06 : le jeu de règles `S` de ruff, déjà actif dans `pyproject.toml`, est le portage des +règles Bandit. Ajouter Bandit à la CI serait redondant, contrairement à ce qu'annonce l'EC01. + +## Non couvert, et pourquoi + +| 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. | +| **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. | +| **A08 Software and Data Integrity Failures** | **partiel** | La CI vérifie le code mais n'analyse ni les dépendances ni les images. `.terraform.lock.hcl` reste ignoré par git, ce qui contredit une chaîne d'approvisionnement maîtrisée. | +| **A10 Server-Side Request Forgery** | **sans objet aujourd'hui** | Aucune URL sortante n'est pilotée par une donnée utilisateur. Le jour où l'adresse d'une source devient un champ de configuration, il faudra une liste blanche de schémas et d'hôtes, sans suivi de redirection. | +| **Cantonnement des accès ETL et ML** | **dette assumée** | Le compte applicatif porte l'identité, le rôle PostgreSQL porterait le cantonnement. Voir ADR 0003. | +| **Non-répudiation de l'audit** | **dette assumée** | Les déclencheurs arrêtent les accidents, pas un compte détenant `ALTER TABLE`. Voir ADR 0004. | + +## Ce qu'il faut répondre, et ne pas répondre + +Sur A04 Insecure Design, la réponse n'est pas une case cochée mais deux décisions concrètes : le +refus du verrouillage de compte, qui aurait été un déni de service, et le refus de laisser un +administrateur se verrouiller lui-même dehors. + +Sur l'audit, ne jamais prétendre que la table est inviolable : elle ne l'est pas contre un compte +qui a les droits sur la base, et c'est vrai de tout journal co-localisé avec ce qu'il journalise. + +Sur l'API Mock, ne jamais répondre « c'est un mock, ce n'est pas notre périmètre ». C'est +précisément le périmètre : c'est la frontière de confiance. From bfbd9ee2cc798f556dfb5d73f84693097c581a56 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 15:06:20 +0200 Subject: [PATCH 013/103] test(backend): couvre le changement de mot de passe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AuthService.change_password` n'avait aucun test unitaire, alors qu'il porte la promesse que l'appareil courant reste connecté pendant que tous les autres tombent. Deux cas : le nominal, où une seule session est rouverte après la révocation, et le refus quand le mot de passe actuel est faux, qui ne doit rien révoquer. --- apps/backend/tests/services/test_auth.py | 55 ++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/apps/backend/tests/services/test_auth.py b/apps/backend/tests/services/test_auth.py index 01697d8..9b8c42c 100644 --- a/apps/backend/tests/services/test_auth.py +++ b/apps/backend/tests/services/test_auth.py @@ -56,6 +56,7 @@ class FauxDepotComptes: self.compte = compte self.rehachages = 0 self.connexions_datees = 0 + self.mots_de_passe_changes = 0 async def get_by_email(self, email: str) -> FauxCompte | None: return self.compte @@ -66,6 +67,9 @@ class FauxDepotComptes: async def rehash_password(self, user_id: UUID, password_hash: str) -> None: self.rehachages += 1 + async def update_password(self, user_id: UUID, password_hash: str, **_: object) -> None: + self.mots_de_passe_changes += 1 + async def touch_last_login(self, user_id: UUID) -> None: self.connexions_datees += 1 @@ -438,3 +442,54 @@ def test_fingerprint_is_what_the_service_stores_not_the_secret_itself() -> None: empreinte = fingerprint_refresh(secret) assert secret.encode() not in empreinte + + +async def test_change_password_revokes_every_session_then_reopens_the_current_one() -> None: + compte = FauxCompte() + attirail = fabrique_service(compte=compte) + acteur = Principal( + id=compte.id, + email=compte.email, + role=Role.OPERATEUR, + kind=AccountKind.HUMAIN, + must_change_password=True, + ) + + session = await attirail.service.change_password( + principal=acteur, + current_password="l-ancien-mot-de-passe", + new_password="le-nouveau-mot-de-passe", + client_ip="203.0.113.10", + user_agent="pytest", + ) + + assert attirail.jetons.revocations_par_compte == [ + (compte.id, RevocationReason.CHANGEMENT_MOT_DE_PASSE.value) + ] + assert len(attirail.jetons.crees) == 1, "l'appareil courant doit repartir avec une session" + assert session.refresh_secret + assert "password_changed" in attirail.audit.lignes[0][0] + + +async def test_change_password_refuses_a_wrong_current_password() -> None: + compte = FauxCompte() + attirail = fabrique_service(compte=compte, hacheur=FauxHacheur(accepte=False)) + acteur = Principal( + id=compte.id, + email=compte.email, + role=Role.OPERATEUR, + kind=AccountKind.HUMAIN, + must_change_password=False, + ) + + with pytest.raises(InvalidCredentialsError): + await attirail.service.change_password( + principal=acteur, + current_password="mauvais", + new_password="le-nouveau-mot-de-passe", + client_ip=None, + user_agent=None, + ) + + assert attirail.jetons.revocations_par_compte == [] + assert attirail.jetons.crees == [] From b032f084fcc03078c13eb0f5a8d1629af00712c7 Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Tue, 15 Sep 2026 16:15:21 +0200 Subject: [PATCH 014/103] fix(apps): gere les caracteres encodes dans l'URL Alembic --- apps/backend/alembic/env.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/alembic/env.py b/apps/backend/alembic/env.py index 7b09cae..a1a4adc 100644 --- a/apps/backend/alembic/env.py +++ b/apps/backend/alembic/env.py @@ -19,7 +19,7 @@ config = context.config if config.config_file_name is not None: fileConfig(config.config_file_name) -config.set_main_option("sqlalchemy.url", get_settings().database_url) +config.set_main_option("sqlalchemy.url", get_settings().database_url.replace("%", "%%")) target_metadata = Base.metadata From 128133761f7259dc19feb29c2823ceb641023d17 Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Tue, 15 Sep 2026 16:16:18 +0200 Subject: [PATCH 015/103] feat(apps): cree les six tables data et l'hypertable readings --- .../e6d2026091501_create_data_schema.py | 216 ++++++++++++++++++ apps/backend/app/models/__init__.py | 4 + apps/backend/app/models/energy.py | 209 +++++++++++++++++ 3 files changed, 429 insertions(+) create mode 100644 apps/backend/alembic/versions/e6d2026091501_create_data_schema.py create mode 100644 apps/backend/app/models/energy.py diff --git a/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py b/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py new file mode 100644 index 0000000..87146d6 --- /dev/null +++ b/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py @@ -0,0 +1,216 @@ +"""Création des six tables Data et de l'hypertable readings. + +Revision ID: e6d2026091501 +Revises: 5353c0e4f094 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "e6d2026091501" +down_revision = "5353c0e4f094" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "datasets", + sa.Column("dataset_id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("dataset_name", sa.Text(), nullable=False), + sa.Column("archive_sha256", sa.String(length=64), nullable=False), + sa.Column("storage_uri", sa.Text(), nullable=False), + sa.Column("source_timezone", sa.Text(), nullable=True), + sa.Column( + "metadata", postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), nullable=False + ), + sa.CheckConstraint("dataset_id > 0", name="ck_datasets_positive_id"), + sa.PrimaryKeyConstraint("dataset_id"), + sa.UniqueConstraint("archive_sha256", name="uq_datasets_archive_sha256"), + ) + op.create_table( + "sites", + sa.Column("site_id", sa.Text(), nullable=False), + sa.Column("site_name", sa.Text(), nullable=False), + sa.Column("site_type", sa.Text(), nullable=False), + sa.Column("location", sa.Text(), nullable=True), + sa.Column("capacity_kw", sa.Double(), nullable=True), + sa.Column("status", sa.Text(), nullable=True), + sa.PrimaryKeyConstraint("site_id"), + ) + op.create_table( + "predictions", + sa.Column("prediction_id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("site_id", sa.Text(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("target_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("target_metric", sa.Text(), nullable=False), + sa.Column("period_minutes", sa.Integer(), nullable=True), + sa.Column("predicted_value", sa.Double(), nullable=True), + sa.Column("model_reference", sa.Text(), nullable=False), + sa.Column("status", sa.Text(), nullable=False), + sa.Column("failure_reason", sa.Text(), nullable=True), + sa.CheckConstraint( + "(status = 'available' AND predicted_value IS NOT NULL AND failure_reason IS NULL) OR (status IN ('insufficient_data', 'error') AND predicted_value IS NULL AND failure_reason IS NOT NULL)", + name="ck_predictions_status", + ), + sa.CheckConstraint( + "target_metric <> 'consumption_kwh' OR period_minutes IS NOT NULL", + name="ck_predictions_energy_period", + ), + sa.CheckConstraint( + "target_metric IN ('consumption_kwh', 'consumption_kw')", name="ck_predictions_metric" + ), + sa.CheckConstraint( + "period_minutes IS NULL OR period_minutes > 0", name="ck_predictions_period" + ), + sa.ForeignKeyConstraint( + ["site_id"], ["sites.site_id"], name="fk_predictions_site", ondelete="RESTRICT" + ), + sa.PrimaryKeyConstraint("prediction_id"), + sa.UniqueConstraint("prediction_id", "site_id", name="uq_predictions_id_site"), + ) + op.create_index( + "ix_predictions_site_target", "predictions", ["site_id", "target_at"], unique=False + ) + op.create_table( + "readings", + sa.Column("reading_id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("site_id", sa.Text(), nullable=False), + sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False), + sa.Column("source", sa.Text(), nullable=False), + sa.Column("dataset_id", sa.BigInteger(), nullable=True), + sa.Column("consumption_kw", sa.Double(), nullable=True), + sa.Column("consumption_kwh", sa.Double(), nullable=True), + sa.Column("consumption_euros", sa.Numeric(precision=14, scale=2), nullable=True), + sa.Column("voltage_v", sa.Double(), nullable=True), + sa.Column("current_a", sa.Double(), nullable=True), + sa.Column("power_factor", sa.Double(), nullable=True), + sa.Column("temperature_celsius", sa.Double(), nullable=True), + sa.Column("humidity_percent", sa.Double(), nullable=True), + sa.Column("solar_irradiance_wm2", sa.Double(), nullable=True), + sa.Column("is_working_hours", sa.Boolean(), nullable=True), + sa.Column("data_quality", sa.Text(), nullable=True), + sa.Column("null_reasons", postgresql.ARRAY(sa.Text()), nullable=True), + sa.Column( + "imputed_values", postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), nullable=True + ), + sa.Column("imputation_method", sa.Text(), nullable=True), + sa.Column( + "ingested_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "raw_data", postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), nullable=False + ), + sa.CheckConstraint( + "(source = 'csv' AND dataset_id IS NOT NULL) OR (source IN ('api_current', 'api_history') AND dataset_id IS NULL)", + name="ck_readings_dataset_source", + ), + sa.CheckConstraint( + "data_quality IS NULL OR data_quality IN ('good', 'partial', 'degraded', 'critical')", + name="ck_readings_quality", + ), + sa.CheckConstraint( + "source IN ('csv', 'api_current', 'api_history')", name="ck_readings_source" + ), + sa.CheckConstraint( + "(imputed_values IS NULL AND imputation_method IS NULL) OR (imputed_values IS NOT NULL AND imputation_method IS NOT NULL)", + name="ck_readings_imputation", + ), + sa.ForeignKeyConstraint( + ["dataset_id"], ["datasets.dataset_id"], name="fk_readings_dataset", ondelete="RESTRICT" + ), + sa.ForeignKeyConstraint( + ["site_id"], ["sites.site_id"], name="fk_readings_site", ondelete="RESTRICT" + ), + sa.PrimaryKeyConstraint("reading_id", "timestamp"), + ) + op.create_index("ix_readings_dataset_id", "readings", ["dataset_id"], unique=False) + op.create_index( + "ix_readings_site_timestamp", "readings", ["site_id", "timestamp"], unique=False + ) + op.create_index( + "uq_readings_source", + "readings", + ["site_id", "timestamp", "source", sa.literal_column("coalesce(dataset_id, 0)")], + unique=True, + ) + op.execute( + "SELECT create_hypertable('readings', by_range('timestamp'), create_default_indexes => FALSE)" + ) + op.create_table( + "alerts", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("alert_id", sa.Text(), nullable=False), + sa.Column("site_id", sa.Text(), nullable=False), + sa.Column("source", sa.Text(), nullable=False), + sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False), + sa.Column("type", sa.Text(), nullable=False), + sa.Column("severity", sa.Text(), nullable=False), + sa.Column("message", sa.Text(), nullable=False), + sa.Column("value", sa.Double(), nullable=True), + sa.Column("threshold", sa.Double(), nullable=True), + sa.Column("metric", sa.Text(), nullable=True), + sa.Column("prediction_id", sa.BigInteger(), nullable=True), + sa.Column( + "raw_data", postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), nullable=False + ), + sa.CheckConstraint( + "severity IN ('low', 'medium', 'high', 'critical')", name="ck_alerts_severity" + ), + sa.CheckConstraint("source IN ('api_mock', 'enervision')", name="ck_alerts_source"), + sa.CheckConstraint( + "type IN ('spike', 'threshold', 'anomaly', 'outage', 'sensor')", name="ck_alerts_type" + ), + sa.ForeignKeyConstraint( + ["prediction_id", "site_id"], + ["predictions.prediction_id", "predictions.site_id"], + name="fk_alerts_prediction_site", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["site_id"], ["sites.site_id"], name="fk_alerts_site", ondelete="RESTRICT" + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("source", "site_id", "alert_id", name="uq_alerts_source_site_id"), + ) + op.create_index("ix_alerts_site_timestamp", "alerts", ["site_id", "timestamp"], unique=False) + op.create_table( + "recommendations", + sa.Column("recommendation_id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("alert_id", sa.BigInteger(), nullable=False), + sa.Column("action", sa.Text(), nullable=False), + sa.Column("explanation", sa.Text(), nullable=False), + sa.Column("rule_reference", sa.Text(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["alert_id"], ["alerts.id"], name="fk_recommendations_alert", ondelete="RESTRICT" + ), + sa.PrimaryKeyConstraint("recommendation_id"), + sa.UniqueConstraint("alert_id", "rule_reference", name="uq_recommendations_alert_rule"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + op.drop_table("recommendations") + op.drop_table("alerts") + op.drop_table("readings") + op.drop_table("predictions") + op.drop_table("sites") + op.drop_table("datasets") diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py index 6d71227..0f48e79 100644 --- a/apps/backend/app/models/__init__.py +++ b/apps/backend/app/models/__init__.py @@ -1,2 +1,6 @@ # Piege : tout modele absent de ce module reste invisible de `alembic revision # --autogenerate`, qui genererait alors un drop de sa table. + +from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site + +__all__ = ["Alert", "Dataset", "Prediction", "Reading", "Recommendation", "Site"] diff --git a/apps/backend/app/models/energy.py b/apps/backend/app/models/energy.py new file mode 100644 index 0000000..de27c7c --- /dev/null +++ b/apps/backend/app/models/energy.py @@ -0,0 +1,209 @@ +"""Tables du modèle de données EnerVision (CSV, API Mock et résultats ML).""" + +from datetime import datetime +from decimal import Decimal +from typing import Any + +from sqlalchemy import ( + BigInteger, + Boolean, + CheckConstraint, + DateTime, + Double, + ForeignKey, + ForeignKeyConstraint, + Index, + Integer, + Numeric, + String, + Text, + UniqueConstraint, + func, + text, +) +from sqlalchemy.dialects.postgresql import ARRAY, JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class Dataset(Base): + __tablename__ = "datasets" + __table_args__ = ( + CheckConstraint("dataset_id > 0", name="ck_datasets_positive_id"), + UniqueConstraint("archive_sha256", name="uq_datasets_archive_sha256"), + ) + + dataset_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + dataset_name: Mapped[str] = mapped_column(Text) + archive_sha256: Mapped[str] = mapped_column(String(64)) + storage_uri: Mapped[str] = mapped_column(Text) + source_timezone: Mapped[str | None] = mapped_column(Text) + # "metadata" est réservé par SQLAlchemy ; le nom SQL reste inchangé. + dataset_metadata: Mapped[dict[str, Any]] = mapped_column("metadata", JSONB(none_as_null=True)) + + +class Site(Base): + __tablename__ = "sites" + + site_id: Mapped[str] = mapped_column(Text, primary_key=True) + site_name: Mapped[str] = mapped_column(Text) + site_type: Mapped[str] = mapped_column(Text) + location: Mapped[str | None] = mapped_column(Text) + capacity_kw: Mapped[float | None] = mapped_column(Double) + status: Mapped[str | None] = mapped_column(Text) + + +class Reading(Base): + __tablename__ = "readings" + __table_args__ = ( + CheckConstraint( + "source IN ('csv', 'api_current', 'api_history')", name="ck_readings_source" + ), + CheckConstraint( + "(source = 'csv' AND dataset_id IS NOT NULL) OR " + "(source IN ('api_current', 'api_history') AND dataset_id IS NULL)", + name="ck_readings_dataset_source", + ), + CheckConstraint( + "data_quality IS NULL OR data_quality IN ('good', 'partial', 'degraded', 'critical')", + name="ck_readings_quality", + ), + CheckConstraint( + "(imputed_values IS NULL AND imputation_method IS NULL) OR " + "(imputed_values IS NOT NULL AND imputation_method IS NOT NULL)", + name="ck_readings_imputation", + ), + Index("ix_readings_site_timestamp", "site_id", "timestamp"), + Index("ix_readings_dataset_id", "dataset_id"), + ) + + reading_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + site_id: Mapped[str] = mapped_column( + Text, ForeignKey("sites.site_id", name="fk_readings_site", ondelete="RESTRICT") + ) + timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), primary_key=True) + source: Mapped[str] = mapped_column(Text) + dataset_id: Mapped[int | None] = mapped_column( + BigInteger, + ForeignKey("datasets.dataset_id", name="fk_readings_dataset", ondelete="RESTRICT"), + ) + consumption_kw: Mapped[float | None] = mapped_column(Double) + consumption_kwh: Mapped[float | None] = mapped_column(Double) + consumption_euros: Mapped[Decimal | None] = mapped_column(Numeric(14, 2)) + voltage_v: Mapped[float | None] = mapped_column(Double) + current_a: Mapped[float | None] = mapped_column(Double) + power_factor: Mapped[float | None] = mapped_column(Double) + temperature_celsius: Mapped[float | None] = mapped_column(Double) + humidity_percent: Mapped[float | None] = mapped_column(Double) + solar_irradiance_wm2: Mapped[float | None] = mapped_column(Double) + is_working_hours: Mapped[bool | None] = mapped_column(Boolean) + data_quality: Mapped[str | None] = mapped_column(Text) + null_reasons: Mapped[list[str] | None] = mapped_column(ARRAY(Text)) + imputed_values: Mapped[dict[str, Any] | None] = mapped_column(JSONB(none_as_null=True)) + imputation_method: Mapped[str | None] = mapped_column(Text) + ingested_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + raw_data: Mapped[dict[str, Any]] = mapped_column(JSONB(none_as_null=True)) + + +Index( + "uq_readings_source", + Reading.site_id, + Reading.timestamp, + Reading.source, + func.coalesce(Reading.dataset_id, text("0")), + unique=True, +) + + +class Prediction(Base): + __tablename__ = "predictions" + __table_args__ = ( + UniqueConstraint("prediction_id", "site_id", name="uq_predictions_id_site"), + Index("ix_predictions_site_target", "site_id", "target_at"), + CheckConstraint( + "target_metric IN ('consumption_kwh', 'consumption_kw')", + name="ck_predictions_metric", + ), + CheckConstraint( + "period_minutes IS NULL OR period_minutes > 0", name="ck_predictions_period" + ), + CheckConstraint( + "target_metric <> 'consumption_kwh' OR period_minutes IS NOT NULL", + name="ck_predictions_energy_period", + ), + CheckConstraint( + "(status = 'available' AND predicted_value IS NOT NULL AND failure_reason IS NULL) OR " + "(status IN ('insufficient_data', 'error') AND predicted_value IS NULL " + "AND failure_reason IS NOT NULL)", + name="ck_predictions_status", + ), + ) + + prediction_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + site_id: Mapped[str] = mapped_column( + Text, ForeignKey("sites.site_id", name="fk_predictions_site", ondelete="RESTRICT") + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + target_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + target_metric: Mapped[str] = mapped_column(Text) + period_minutes: Mapped[int | None] = mapped_column(Integer) + predicted_value: Mapped[float | None] = mapped_column(Double) + model_reference: Mapped[str] = mapped_column(Text) + status: Mapped[str] = mapped_column(Text) + failure_reason: Mapped[str | None] = mapped_column(Text) + + +class Alert(Base): + __tablename__ = "alerts" + __table_args__ = ( + UniqueConstraint("source", "site_id", "alert_id", name="uq_alerts_source_site_id"), + Index("ix_alerts_site_timestamp", "site_id", "timestamp"), + ForeignKeyConstraint( + ["prediction_id", "site_id"], + ["predictions.prediction_id", "predictions.site_id"], + name="fk_alerts_prediction_site", + ondelete="RESTRICT", + ), + CheckConstraint("source IN ('api_mock', 'enervision')", name="ck_alerts_source"), + CheckConstraint( + "type IN ('spike', 'threshold', 'anomaly', 'outage', 'sensor')", name="ck_alerts_type" + ), + CheckConstraint( + "severity IN ('low', 'medium', 'high', 'critical')", name="ck_alerts_severity" + ), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + alert_id: Mapped[str] = mapped_column(Text) + site_id: Mapped[str] = mapped_column( + Text, ForeignKey("sites.site_id", name="fk_alerts_site", ondelete="RESTRICT") + ) + source: Mapped[str] = mapped_column(Text) + timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + type: Mapped[str] = mapped_column(Text) + severity: Mapped[str] = mapped_column(Text) + message: Mapped[str] = mapped_column(Text) + value: Mapped[float | None] = mapped_column(Double) + threshold: Mapped[float | None] = mapped_column(Double) + metric: Mapped[str | None] = mapped_column(Text) + prediction_id: Mapped[int | None] = mapped_column(BigInteger) + raw_data: Mapped[dict[str, Any]] = mapped_column(JSONB(none_as_null=True)) + + +class Recommendation(Base): + __tablename__ = "recommendations" + __table_args__ = ( + UniqueConstraint("alert_id", "rule_reference", name="uq_recommendations_alert_rule"), + ) + + recommendation_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + alert_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("alerts.id", name="fk_recommendations_alert", ondelete="RESTRICT") + ) + action: Mapped[str] = mapped_column(Text) + explanation: Mapped[str] = mapped_column(Text) + rule_reference: Mapped[str] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) From c04ce9a9aecb19fbb67cace3bcd285cf939a22bc Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Tue, 15 Sep 2026 16:16:40 +0200 Subject: [PATCH 016/103] test(apps): verifie les contraintes du schema data --- apps/backend/tests/db/test_data_schema.py | 261 ++++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 apps/backend/tests/db/test_data_schema.py diff --git a/apps/backend/tests/db/test_data_schema.py b/apps/backend/tests/db/test_data_schema.py new file mode 100644 index 0000000..aefc9fa --- /dev/null +++ b/apps/backend/tests/db/test_data_schema.py @@ -0,0 +1,261 @@ +from collections.abc import AsyncIterator +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +from sqlalchemy import insert, select, text +from sqlalchemy.engine import make_url +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine + +from app.core.config import get_settings +from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site + +pytestmark = pytest.mark.integration +MOMENT = datetime(2024, 1, 1, tzinfo=UTC) + + +@pytest.fixture +async def data_connection() -> AsyncIterator[AsyncConnection]: + url = make_url(get_settings().database_url) + if url.database != "enervision_test": + pytest.fail("Ces tests exigent DATABASE_URL vers enervision_test.") + engine = create_async_engine(url) + try: + async with engine.connect() as connection: + transaction = await connection.begin() + try: + yield connection + finally: + await transaction.rollback() + finally: + await engine.dispose() + + +@pytest.fixture +async def data_site(data_connection: AsyncConnection) -> str: + site_id = f"TEST-{uuid4()}" + await data_connection.execute( + insert(Site).values(site_id=site_id, site_name="Site de test", site_type="office") + ) + return site_id + + +async def test_readings_is_a_time_hypertable_when_migrated( + data_connection: AsyncConnection, +) -> None: + query = text( + "SELECT column_name FROM timescaledb_information.dimensions " + "WHERE hypertable_schema = 'public' AND hypertable_name = 'readings'" + ) + + result = await data_connection.execute(query) + + assert result.scalars().all() == ["timestamp"] + + +async def test_reading_preserves_null_and_zero_when_inserted( + data_connection: AsyncConnection, data_site: str +) -> None: + statement = insert(Reading).values( + site_id=data_site, + timestamp=MOMENT, + source="api_current", + consumption_kw=None, + consumption_kwh=0, + data_quality="partial", + null_reasons=["sensor_failure"], + raw_data={"consumption_kw": None}, + imputed_values=None, + imputation_method=None, + ) + + await data_connection.execute(statement) + result = ( + await data_connection.execute( + select( + Reading.consumption_kw, + Reading.consumption_kwh, + Reading.raw_data, + Reading.imputed_values, + ).where(Reading.site_id == data_site) + ) + ).one() + + assert tuple(result) == (None, 0, {"consumption_kw": None}, None) + + +@pytest.mark.parametrize("source", ["csv", "api_current", "api_history"]) +async def test_duplicate_reading_is_rejected_when_key_matches( + data_connection: AsyncConnection, data_site: str, source: str +) -> None: + dataset_id = None + if source == "csv": + dataset_id = ( + await data_connection.execute( + insert(Dataset.__table__) + .values( + dataset_name="Archive de test", + archive_sha256=uuid4().hex + uuid4().hex, + storage_uri="test://archive", + metadata={}, + ) + .returning(Dataset.dataset_id) + ) + ).scalar_one() + statement = insert(Reading).values( + site_id=data_site, + timestamp=MOMENT, + source=source, + dataset_id=dataset_id, + raw_data={}, + ) + await data_connection.execute(statement) + + with pytest.raises(IntegrityError): + async with data_connection.begin_nested(): + await data_connection.execute(statement) + + +@pytest.mark.parametrize( + "changes", + [ + {"source": "csv"}, + {"source": "unknown"}, + {"site_id": "UNKNOWN-SITE"}, + {"data_quality": "unknown"}, + {"imputed_values": {"consumption_kw": 12}}, + {"imputation_method": "mean-v1"}, + ], + ids=[ + "csv_sans_dataset", + "source_inconnue", + "site_absent", + "qualite_inconnue", + "imputation_sans_methode", + "methode_sans_imputation", + ], +) +async def test_invalid_reading_is_rejected_when_constraints_fail( + data_connection: AsyncConnection, data_site: str, changes: dict[str, object] +) -> None: + values: dict[str, object] = { + "site_id": data_site, + "timestamp": MOMENT, + "source": "api_current", + "raw_data": {}, + } + values.update(changes) + + with pytest.raises(IntegrityError): + async with data_connection.begin_nested(): + await data_connection.execute(insert(Reading).values(**values)) + + +async def test_prediction_requires_period_when_energy_is_predicted( + data_connection: AsyncConnection, data_site: str +) -> None: + statement = insert(Prediction).values( + site_id=data_site, + target_at=MOMENT, + target_metric="consumption_kwh", + predicted_value=12, + status="available", + model_reference="test-model/1", + ) + + with pytest.raises(IntegrityError): + async with data_connection.begin_nested(): + await data_connection.execute(statement) + + +async def test_unavailable_prediction_preserves_null_when_inserted( + data_connection: AsyncConnection, data_site: str +) -> None: + statement = ( + insert(Prediction) + .values( + site_id=data_site, + target_at=MOMENT, + target_metric="consumption_kw", + status="insufficient_data", + failure_reason="Historique trop court", + model_reference="test-model/1", + ) + .returning(Prediction.predicted_value) + ) + + value = (await data_connection.execute(statement)).scalar_one() + + assert value is None + + +async def test_alert_rejects_prediction_when_site_differs( + data_connection: AsyncConnection, data_site: str +) -> None: + other_site = f"TEST-{uuid4()}" + await data_connection.execute( + insert(Site).values(site_id=other_site, site_name="Autre site", site_type="office") + ) + prediction_id = ( + await data_connection.execute( + insert(Prediction) + .values( + site_id=data_site, + target_at=MOMENT, + target_metric="consumption_kw", + predicted_value=12, + status="available", + model_reference="test-model/1", + ) + .returning(Prediction.prediction_id) + ) + ).scalar_one() + + with pytest.raises(IntegrityError): + async with data_connection.begin_nested(): + await data_connection.execute( + insert(Alert).values( + alert_id=str(uuid4()), + site_id=other_site, + source="enervision", + timestamp=MOMENT, + type="spike", + severity="high", + message="Test", + prediction_id=prediction_id, + raw_data={}, + ) + ) + + +async def test_recommendation_is_unique_when_alert_and_rule_match( + data_connection: AsyncConnection, data_site: str +) -> None: + alert_id = ( + await data_connection.execute( + insert(Alert) + .values( + alert_id=str(uuid4()), + site_id=data_site, + source="api_mock", + timestamp=MOMENT, + type="spike", + severity="high", + message="Test", + raw_data={}, + ) + .returning(Alert.id) + ) + ).scalar_one() + statement = insert(Recommendation).values( + alert_id=alert_id, + action="Vérifier la consommation", + explanation="Pic détecté", + rule_reference="spike-v1", + ) + await data_connection.execute(statement) + + with pytest.raises(IntegrityError): + async with data_connection.begin_nested(): + await data_connection.execute(statement) From e3e0e843d086c7b783763e4f9af135e9b2d0191f Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 16:46:37 +0200 Subject: [PATCH 017/103] fix(backend): rebranche la revision data sur la tete d'authentification Le merge de dev apporte trois revisions d'authentification qui partent de la meme racine 5353c0e4f094 que la revision data. Git ne signale rien, mais alembic upgrade head refuse de choisir entre deux tetes. La revision data se greffe desormais sur 821f71be74c0, ce qui rend la chaine lineaire. --- .../alembic/versions/e6d2026091501_create_data_schema.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py b/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py index 87146d6..50cc41f 100644 --- a/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py +++ b/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py @@ -1,7 +1,7 @@ """Création des six tables Data et de l'hypertable readings. Revision ID: e6d2026091501 -Revises: 5353c0e4f094 +Revises: 821f71be74c0 """ from alembic import op @@ -9,7 +9,7 @@ import sqlalchemy as sa from sqlalchemy.dialects import postgresql revision = "e6d2026091501" -down_revision = "5353c0e4f094" +down_revision = "821f71be74c0" branch_labels = None depends_on = None From cdef30736a6e3090ce5bc64254d08ec10b6ce02c Mon Sep 17 00:00:00 2001 From: valentin Date: Tue, 15 Sep 2026 16:48:55 +0200 Subject: [PATCH 018/103] Creation dashboard (graph chart.js) + tests --- apps/frontend/.gitignore | 1 + apps/frontend/package-lock.json | 19 + apps/frontend/package.json | 1 + apps/frontend/src/app/app.config.ts | 7 +- apps/frontend/src/app/app.html | 354 +----------------- apps/frontend/src/app/app.routes.ts | 5 +- apps/frontend/src/app/app.spec.ts | 7 - .../interceptors/mock-api-interceptor.spec.ts | 67 ++++ .../core/interceptors/mock-api-interceptor.ts | 30 ++ .../src/app/core/mocks/alerts.fixture.ts | 54 +++ .../app/core/mocks/stats-summary.fixture.ts | 67 ++++ .../app/core/services/alerts.service.spec.ts | 43 +++ .../src/app/core/services/alerts.service.ts | 13 + .../app/core/services/stats.service.spec.ts | 39 ++ .../src/app/core/services/stats.service.ts | 13 + .../src/app/features/dashboard/dashboard.html | 48 +++ .../src/app/features/dashboard/dashboard.scss | 130 +++++++ .../app/features/dashboard/dashboard.spec.ts | 41 ++ .../src/app/features/dashboard/dashboard.ts | 39 ++ .../consumption-gauge/consumption-gauge.html | 1 + .../consumption-gauge/consumption-gauge.scss | 6 + .../consumption-gauge.spec.ts | 34 ++ .../consumption-gauge/consumption-gauge.ts | 55 +++ .../site-load-chart/site-load-chart.html | 1 + .../site-load-chart/site-load-chart.scss | 4 + .../site-load-chart/site-load-chart.spec.ts | 38 ++ .../site-load-chart/site-load-chart.ts | 62 +++ .../src/app/shared/models/alert.model.ts | 13 + .../src/app/shared/models/stats.model.ts | 17 + .../environments/environment.development.ts | 3 +- apps/frontend/src/environments/environment.ts | 3 +- 31 files changed, 851 insertions(+), 364 deletions(-) create mode 100644 apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts create mode 100644 apps/frontend/src/app/core/interceptors/mock-api-interceptor.ts create mode 100644 apps/frontend/src/app/core/mocks/alerts.fixture.ts create mode 100644 apps/frontend/src/app/core/mocks/stats-summary.fixture.ts create mode 100644 apps/frontend/src/app/core/services/alerts.service.spec.ts create mode 100644 apps/frontend/src/app/core/services/alerts.service.ts create mode 100644 apps/frontend/src/app/core/services/stats.service.spec.ts create mode 100644 apps/frontend/src/app/core/services/stats.service.ts create mode 100644 apps/frontend/src/app/features/dashboard/dashboard.html create mode 100644 apps/frontend/src/app/features/dashboard/dashboard.scss create mode 100644 apps/frontend/src/app/features/dashboard/dashboard.spec.ts create mode 100644 apps/frontend/src/app/features/dashboard/dashboard.ts create mode 100644 apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.html create mode 100644 apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.scss create mode 100644 apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts create mode 100644 apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.ts create mode 100644 apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.html create mode 100644 apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.scss create mode 100644 apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts create mode 100644 apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts create mode 100644 apps/frontend/src/app/shared/models/alert.model.ts create mode 100644 apps/frontend/src/app/shared/models/stats.model.ts diff --git a/apps/frontend/.gitignore b/apps/frontend/.gitignore index 854acd5..e1b6f74 100644 --- a/apps/frontend/.gitignore +++ b/apps/frontend/.gitignore @@ -34,6 +34,7 @@ yarn-error.log .sass-cache/ /connect.lock /coverage +/test-results /libpeerconnection.log testem.log /typings diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index 5ba6595..a60cacb 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -14,6 +14,7 @@ "@angular/forms": "^22.1.0", "@angular/platform-browser": "^22.1.0", "@angular/router": "^22.1.0", + "chart.js": "^4.5.1", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, @@ -2038,6 +2039,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@listr2/prompt-adapter-inquirer": { "version": "4.2.5", "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-4.2.5.tgz", @@ -4220,6 +4227,18 @@ "dev": true, "license": "MIT" }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 552e346..1c934bc 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -18,6 +18,7 @@ "@angular/forms": "^22.1.0", "@angular/platform-browser": "^22.1.0", "@angular/router": "^22.1.0", + "chart.js": "^4.5.1", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, diff --git a/apps/frontend/src/app/app.config.ts b/apps/frontend/src/app/app.config.ts index 2261369..d89a118 100644 --- a/apps/frontend/src/app/app.config.ts +++ b/apps/frontend/src/app/app.config.ts @@ -1,7 +1,12 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; +import {mockApiInterceptor} from './core/interceptors/mock-api-interceptor'; +import {provideHttpClient, withInterceptors} from '@angular/common/http'; export const appConfig: ApplicationConfig = { - providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)], + providers: [ + provideBrowserGlobalErrorListeners(), provideRouter(routes), + provideHttpClient(withInterceptors([mockApiInterceptor])), + ], }; diff --git a/apps/frontend/src/app/app.html b/apps/frontend/src/app/app.html index 4f4ddf5..0680b43 100644 --- a/apps/frontend/src/app/app.html +++ b/apps/frontend/src/app/app.html @@ -1,353 +1 @@ - - - - - - - - - - - -
- -
- - - - - - - - - - + diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index dc39edb..9852e8b 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -1,3 +1,6 @@ import { Routes } from '@angular/router'; -export const routes: Routes = []; +export const routes: Routes = [ + { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, + { path: 'dashboard', loadComponent: () => import('./features/dashboard/dashboard').then(m => m.Dashboard) }, +]; diff --git a/apps/frontend/src/app/app.spec.ts b/apps/frontend/src/app/app.spec.ts index f13c264..75753d6 100644 --- a/apps/frontend/src/app/app.spec.ts +++ b/apps/frontend/src/app/app.spec.ts @@ -13,11 +13,4 @@ describe('App', () => { const app = fixture.componentInstance; expect(app).toBeTruthy(); }); - - it('should render title', async () => { - const fixture = TestBed.createComponent(App); - await fixture.whenStable(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend'); - }); }); diff --git a/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts new file mode 100644 index 0000000..34a58b2 --- /dev/null +++ b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts @@ -0,0 +1,67 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { mockApiInterceptor } from './mock-api-interceptor'; +import { environment } from '../../../environments/environment'; +import { STATS_SUMMARY_FIXTURE } from '../mocks/stats-summary.fixture'; + +describe('mockApiInterceptor', () => { + let http: HttpClient; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptors([mockApiInterceptor])), + provideHttpClientTesting(), + ], + }); + http = TestBed.inject(HttpClient); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + environment.useMockFixtures = true; + httpMock.verify(); + }); + + it('renvoie la fixture sans appel réseau quand useMockFixtures est activé', () => { + environment.useMockFixtures = true; + let result: unknown; + + http.get(`${environment.apiUrl}/stats/summary`).subscribe((r) => (result = r)); + + httpMock.expectNone(`${environment.apiUrl}/stats/summary`); + expect((result as typeof STATS_SUMMARY_FIXTURE).total_sites).toBe( + STATS_SUMMARY_FIXTURE.total_sites + ); + }); + + it('laisse passer la vraie requête quand useMockFixtures est désactivé', () => { + environment.useMockFixtures = false; + + http.get(`${environment.apiUrl}/stats/summary`).subscribe(); + + const req = httpMock.expectOne(`${environment.apiUrl}/stats/summary`); + req.flush({}); + }); + + it("laisse passer une requête qui ne correspond à aucune route connue de l'interceptor", () => { + environment.useMockFixtures = true; + + http.get('/api/v1/autre-chose').subscribe(); + + const req = httpMock.expectOne('/api/v1/autre-chose'); + req.flush({}); + }); + + it('renvoie la fixture des alertes sans appel réseau quand useMockFixtures est activé', () => { + environment.useMockFixtures = true; + let result: unknown; + + http.get(`${environment.apiUrl}/alerts`).subscribe((r) => (result = r)); + + httpMock.expectNone(`${environment.apiUrl}/alerts`); + expect((result as unknown[]).length).toBeGreaterThan(0); +}); +}); diff --git a/apps/frontend/src/app/core/interceptors/mock-api-interceptor.ts b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.ts new file mode 100644 index 0000000..58287ff --- /dev/null +++ b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.ts @@ -0,0 +1,30 @@ +import { HttpInterceptorFn, HttpResponse } from '@angular/common/http'; +import { of } from 'rxjs'; +import { environment } from '../../../environments/environment'; +import { STATS_SUMMARY_FIXTURE } from '../mocks/stats-summary.fixture'; +import { ALERTS_FIXTURE } from '../mocks/alerts.fixture'; + +function withJitter(base: typeof STATS_SUMMARY_FIXTURE) { + const jitter = () => (Math.random() - 0.5) * 40; + const totalConsumption = Math.max(0, base.total_consumption_kw + jitter()); + + return { + ...base, + timestamp: new Date().toISOString(), + total_consumption_kw: Math.round(totalConsumption * 100) / 100, + average_load_percent: Math.round((totalConsumption / base.total_capacity_kw) * 1000) / 10, + }; +} + +export const mockApiInterceptor: HttpInterceptorFn = (req, next) => { + if (!environment.useMockFixtures) { + return next(req); + } + if (req.url.endsWith(`${environment.apiUrl}/stats/summary`)) { + return of(new HttpResponse({ status: 200, body: withJitter(STATS_SUMMARY_FIXTURE) })); + } + if (req.url.endsWith(`${environment.apiUrl}/alerts`)) { + return of(new HttpResponse({ status: 200, body: ALERTS_FIXTURE })); + } + return next(req); +}; diff --git a/apps/frontend/src/app/core/mocks/alerts.fixture.ts b/apps/frontend/src/app/core/mocks/alerts.fixture.ts new file mode 100644 index 0000000..c1f7a9a --- /dev/null +++ b/apps/frontend/src/app/core/mocks/alerts.fixture.ts @@ -0,0 +1,54 @@ +import { Alert } from '../../shared/models/alert.model'; + +export const ALERTS_FIXTURE: Alert[] = [ + { + alert_id: 'ALR-SITE002-1718458320', + timestamp: '2026-09-15T11:12:00', + site_id: 'SITE002', + severity: 'critical', + type: 'outage', + message: 'Risque de surcharge sur Usine Lyon Vénissieux', + value: 812.5, + threshold: 720.0, + }, + { + alert_id: 'ALR-SITE003-1718458321', + timestamp: '2026-09-15T11:05:00', + site_id: 'SITE003', + severity: 'critical', + type: 'sensor', + message: 'Perte réseau totale sur Data Center Marseille', + value: 0, + threshold: 0, + }, + { + alert_id: 'ALR-SITE005-1718458322', + timestamp: '2026-09-15T10:47:00', + site_id: 'SITE005', + severity: 'high', + type: 'threshold', + message: 'Usine Toulouse approche de son seuil de capacité', + value: 410.0, + threshold: 480.0, + }, + { + alert_id: 'ALR-SITE006-1718458323', + timestamp: '2026-09-15T10:30:00', + site_id: 'SITE006', + severity: 'medium', + type: 'sensor', + message: 'Capteur de température défaillant sur Bureau Lille', + value: 0, + threshold: 0, + }, + { + alert_id: 'ALR-SITE004-1718458324', + timestamp: '2026-09-15T09:58:00', + site_id: 'SITE004', + severity: 'low', + type: 'anomaly', + message: 'Comportement de consommation inhabituel sur Bureau Bordeaux', + value: 62.0, + threshold: 55.0, + }, +]; diff --git a/apps/frontend/src/app/core/mocks/stats-summary.fixture.ts b/apps/frontend/src/app/core/mocks/stats-summary.fixture.ts new file mode 100644 index 0000000..7057fb8 --- /dev/null +++ b/apps/frontend/src/app/core/mocks/stats-summary.fixture.ts @@ -0,0 +1,67 @@ +import { StatsSummary } from '../../shared/models/stats.model'; + +export const STATS_SUMMARY_FIXTURE: StatsSummary = { + timestamp: '2026-09-15T11:32:00', + total_sites: 7, + total_consumption_kw: 1826.44, + total_capacity_kw: 3830, + average_load_percent: 55.1, + sites: [ + { + 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', + }, + { + site_id: 'SITE002', + site_name: 'Usine Lyon Vénissieux', + current_consumption_kw: 542.10, + capacity_kw: 1000, + load_percent: 54.2, + data_quality: 'good', + }, + { + site_id: 'SITE003', + site_name: 'Data Center Marseille', + current_consumption_kw: null, + capacity_kw: 800, + load_percent: null, + data_quality: 'critical', + }, + { + site_id: 'SITE004', + site_name: 'Bureau Bordeaux', + current_consumption_kw: 62.0, + capacity_kw: 150, + load_percent: 41.3, + data_quality: 'partial', + }, + { + site_id: 'SITE005', + site_name: 'Usine Toulouse', + current_consumption_kw: 410.0, + capacity_kw: 600, + load_percent: 68.3, + data_quality: 'good', + }, + { + site_id: 'SITE006', + site_name: 'Bureau Lille', + current_consumption_kw: 95.0, + capacity_kw: 180, + load_percent: 52.8, + data_quality: 'degraded', + }, + { + site_id: 'SITE007', + site_name: 'Data Center Nantes', + current_consumption_kw: 630.0, + capacity_kw: 900, + load_percent: 70.0, + data_quality: 'good', + }, + ], +}; diff --git a/apps/frontend/src/app/core/services/alerts.service.spec.ts b/apps/frontend/src/app/core/services/alerts.service.spec.ts new file mode 100644 index 0000000..68b5740 --- /dev/null +++ b/apps/frontend/src/app/core/services/alerts.service.spec.ts @@ -0,0 +1,43 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { AlertsService } from './alerts.service'; +import { environment } from '../../../environments/environment'; + +describe('AlertsService', () => { + let service: AlertsService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(AlertsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it("appelle le bon endpoint et retourne un tableau d'alertes", () => { + let result: unknown; + service.getAlerts().subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/alerts`); + expect(req.request.method).toBe('GET'); + + req.flush([ + { + alert_id: 'ALR-TEST-1', + timestamp: '2026-09-15T12:00:00', + site_id: 'SITE001', + severity: 'high', + type: 'threshold', + message: 'Test', + value: 100, + threshold: 90, + }, + ]); + + expect((result as unknown[]).length).toBe(1); + }); +}); diff --git a/apps/frontend/src/app/core/services/alerts.service.ts b/apps/frontend/src/app/core/services/alerts.service.ts new file mode 100644 index 0000000..ebd00e2 --- /dev/null +++ b/apps/frontend/src/app/core/services/alerts.service.ts @@ -0,0 +1,13 @@ +import { Service, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { environment } from '../../../environments/environment'; +import { Alert } from '../../shared/models/alert.model'; + +@Service() +export class AlertsService { + private http = inject(HttpClient); + + getAlerts() { + return this.http.get(`${environment.apiUrl}/alerts`); + } +} diff --git a/apps/frontend/src/app/core/services/stats.service.spec.ts b/apps/frontend/src/app/core/services/stats.service.spec.ts new file mode 100644 index 0000000..a65c38f --- /dev/null +++ b/apps/frontend/src/app/core/services/stats.service.spec.ts @@ -0,0 +1,39 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { StatsService } from './stats.service'; +import { environment } from '../../../environments/environment'; + +describe('StatsService', () => { + let service: StatsService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(StatsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('appelle le bon endpoint et retourne le résumé', () => { + let result: unknown; + service.getSummary().subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/stats/summary`); + expect(req.request.method).toBe('GET'); + + req.flush({ + timestamp: '2026-09-15T12:00:00', + total_sites: 7, + total_consumption_kw: 1800, + total_capacity_kw: 3800, + average_load_percent: 47.4, + sites: [], + }); + + expect((result as { total_sites: number }).total_sites).toBe(7); + }); +}); diff --git a/apps/frontend/src/app/core/services/stats.service.ts b/apps/frontend/src/app/core/services/stats.service.ts new file mode 100644 index 0000000..4cb630f --- /dev/null +++ b/apps/frontend/src/app/core/services/stats.service.ts @@ -0,0 +1,13 @@ +import { Service, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { environment } from '../../../environments/environment'; +import { StatsSummary } from '../../shared/models/stats.model'; + +@Service() +export class StatsService { + private http = inject(HttpClient); + + getSummary() { + return this.http.get(`${environment.apiUrl}/stats/summary`); + } +} diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html new file mode 100644 index 0000000..70b333c --- /dev/null +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -0,0 +1,48 @@ +
+
+

Vue d'ensemble

+

Consommation instantanée du parc

+
+ + @if (stats(); as s) { +
+
+ Consommation vs capacité + + {{ s.total_consumption_kw | number:'1.0-1' }} / {{ s.total_capacity_kw | number }} kW +
+ +
+ Charge moyenne du parc + {{ s.average_load_percent }} % +
+
+
+
+ +
+ Sites suivis + {{ s.total_sites }} +
+
+ +
+

Charge et alerte visuelle par site

+ +
+ } + + @if (alerts().length > 0) { +
+

Alertes actives

+
    + @for (alert of alerts(); track alert.alert_id) { +
  • + {{ alert.severity }} + {{ alert.message }} +
  • + } +
+
+ } +
diff --git a/apps/frontend/src/app/features/dashboard/dashboard.scss b/apps/frontend/src/app/features/dashboard/dashboard.scss new file mode 100644 index 0000000..d0b1088 --- /dev/null +++ b/apps/frontend/src/app/features/dashboard/dashboard.scss @@ -0,0 +1,130 @@ +:host { + --color-good: #2e7d32; + --color-partial: #f9a825; + --color-degraded: #ef6c00; + --color-critical: #c62828; + --color-bg-card: #ffffff; + --color-border: #e5e7eb; + --color-text-muted: #6b7280; + --radius: 10px; + + display: block; + font-family: 'Segoe UI', system-ui, sans-serif; + color: #1f2937; + padding: 2rem; + max-width: 1100px; + margin: 0 auto; +} + +.dashboard__header { + margin-bottom: 2rem; + + h1 { + margin: 0; + font-size: 1.75rem; + font-weight: 700; + } +} + +.dashboard__subtitle { + margin: 0.25rem 0 0; + color: var(--color-text-muted); +} + +h2 { + font-size: 1.1rem; + font-weight: 600; + margin: 0 0 1rem; +} + +.overview { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; + margin-bottom: 2.5rem; +} + +.card { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.35rem; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); +} + +.card--gauge { + align-items: center; + text-align: center; +} + +.card__label { + font-size: 0.8rem; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.card__value { + font-size: 1.6rem; + font-weight: 700; +} + +.progress-bar { + height: 6px; + background: #e5e7eb; + border-radius: 999px; + overflow: hidden; + margin-top: 0.25rem; +} + +.progress-bar__fill { + height: 100%; + background: #3b82f6; + border-radius: 999px; + transition: width 0.3s ease; +} + +.chart-section { + margin-bottom: 2.5rem; +} + +.alerts-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.alert-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.7rem 1rem; + border-radius: var(--radius); + background: #fef2f2; + border: 1px solid #fecaca; +} + +.alert-item__badge { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + padding: 0.2rem 0.55rem; + border-radius: 999px; + color: #fff; + background: var(--color-critical); + flex-shrink: 0; +} + +.alert-item--high .alert-item__badge { background: var(--color-degraded); } +.alert-item--medium .alert-item__badge { background: var(--color-partial); } +.alert-item--low .alert-item__badge { background: var(--color-good); } + +.alert-item__message { + font-size: 0.9rem; +} diff --git a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts new file mode 100644 index 0000000..7dcbe37 --- /dev/null +++ b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts @@ -0,0 +1,41 @@ +import { TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import { of } from 'rxjs'; +import { Dashboard } from './dashboard'; +import { StatsService } from '../../core/services/stats.service'; +import { AlertsService } from '../../core/services/alerts.service'; + +vi.mock('chart.js', () => { + class ChartMock { + update = vi.fn(); + data = { datasets: [{}] }; + static register = vi.fn(); + } + return { Chart: ChartMock, registerables: [] }; +}); + +describe('Dashboard', () => { + it('charge les stats et les alertes au démarrage', async () => { + const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; + const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([{ alert_id: 'A1' }])) }; + + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: alertsMock }, + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + fixture.detectChanges(); + + // laisse le timer(0, ...) se déclencher avant de vérifier + await new Promise((resolve) => setTimeout(resolve, 0)); + fixture.detectChanges(); + + expect(statsMock.getSummary).toHaveBeenCalled(); + expect(alertsMock.getAlerts).toHaveBeenCalled(); + expect(fixture.componentInstance.alerts().length).toBe(1); + }); +}); diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts new file mode 100644 index 0000000..2fe7768 --- /dev/null +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -0,0 +1,39 @@ +import { Component, OnInit, inject, signal, DestroyRef } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { timer, switchMap } from 'rxjs'; +import { DecimalPipe } from '@angular/common'; +import { StatsService } from '../../core/services/stats.service'; +import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge'; +import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart'; +import {AlertsService} from '../../core/services/alerts.service'; +import {StatsSummary} from '../../shared/models/stats.model'; +import {Alert} from '../../shared/models/alert.model'; + +const REFRESH_INTERVAL_MS = 10000; + +@Component({ + selector: 'app-dashboard', + standalone: true, + imports: [DecimalPipe, ConsumptionGauge, SiteLoadChart], + templateUrl: './dashboard.html', + styleUrl: './dashboard.scss', +}) +export class Dashboard implements OnInit { + private statsService = inject(StatsService); + private alertsService = inject(AlertsService); + private destroyRef = inject(DestroyRef); + + stats = signal(null); + alerts = signal([]); + + ngOnInit(): void { + this.alertsService.getAlerts().subscribe((alerts) => this.alerts.set(alerts)); + + timer(0, REFRESH_INTERVAL_MS) + .pipe( + switchMap(() => this.statsService.getSummary()), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((stats) => this.stats.set(stats)); + } +} diff --git a/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.html b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.html new file mode 100644 index 0000000..c2e2ad0 --- /dev/null +++ b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.html @@ -0,0 +1 @@ + diff --git a/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.scss b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.scss new file mode 100644 index 0000000..1552ce8 --- /dev/null +++ b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.scss @@ -0,0 +1,6 @@ +:host { + display: block; + height: 200px; + width: 200px; + margin: 0 auto; +} diff --git a/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts new file mode 100644 index 0000000..e793c39 --- /dev/null +++ b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts @@ -0,0 +1,34 @@ +import { TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import { ConsumptionGauge } from './consumption-gauge'; + +vi.mock('chart.js', () => { + class ChartMock { + update = vi.fn(); + data = { datasets: [{}] }; + static register = vi.fn(); + } + return { Chart: ChartMock, registerables: [] }; +}); + +describe('ConsumptionGauge', () => { + it('se crée sans erreur avec des entrées valides', () => { + TestBed.configureTestingModule({ imports: [ConsumptionGauge] }); + const fixture = TestBed.createComponent(ConsumptionGauge); + fixture.componentRef.setInput('consumption', 300); + fixture.componentRef.setInput('capacity', 1000); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + it('met à jour le graphique quand les valeurs changent après initialisation', () => { + TestBed.configureTestingModule({ imports: [ConsumptionGauge] }); + const fixture = TestBed.createComponent(ConsumptionGauge); + fixture.componentRef.setInput('consumption', 300); + fixture.componentRef.setInput('capacity', 1000); + fixture.detectChanges(); // déclenche ngAfterViewInit, this.chart existe désormais + + fixture.componentRef.setInput('consumption', 500); + fixture.detectChanges(); // ré-exécute l'effect, cette fois avec this.chart défini + + expect(() => fixture.detectChanges()).not.toThrow(); +}); +}); diff --git a/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.ts b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.ts new file mode 100644 index 0000000..73544ea --- /dev/null +++ b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.ts @@ -0,0 +1,55 @@ +import { Component, ElementRef, ViewChild, input, effect, AfterViewInit } from '@angular/core'; +import { Chart, registerables } from 'chart.js'; + +Chart.register(...registerables); + +@Component({ + selector: 'app-consumption-gauge', + standalone: true, + templateUrl: './consumption-gauge.html', + styleUrl: './consumption-gauge.scss', +}) +export class ConsumptionGauge implements AfterViewInit { + consumption = input.required(); + capacity = input.required(); + + @ViewChild('canvas') private canvasRef!: ElementRef; + private chart?: Chart; + + constructor() { + effect(() => { + const used = this.consumption(); + const remaining = Math.max(0, this.capacity() - used); + if (this.chart) { + this.chart.data.datasets[0].data = [used, remaining]; + this.chart.update('none'); + } + }); + } + + ngAfterViewInit(): void { + const used = this.consumption(); + const remaining = Math.max(0, this.capacity() - used); + + this.chart = new Chart(this.canvasRef.nativeElement, { + type: 'doughnut', + data: { + labels: ['Utilisé', 'Disponible'], + datasets: [ + { + data: [used, remaining], + backgroundColor: ['#3b82f6', '#e5e7eb'], + borderWidth: 0, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + cutout: '70%', + animation: { duration: 300 }, + plugins: { legend: { display: false } }, + }, + }); + } +} diff --git a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.html b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.html new file mode 100644 index 0000000..c2e2ad0 --- /dev/null +++ b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.html @@ -0,0 +1 @@ + diff --git a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.scss b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.scss new file mode 100644 index 0000000..bfa4956 --- /dev/null +++ b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.scss @@ -0,0 +1,4 @@ +:host { + display: block; + height: 260px; +} diff --git a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts new file mode 100644 index 0000000..1e1c46b --- /dev/null +++ b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts @@ -0,0 +1,38 @@ +import { TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import { SiteLoadChart } from './site-load-chart'; + +vi.mock('chart.js', () => { + class ChartMock { + update = vi.fn(); + data = { datasets: [{}] }; + static register = vi.fn(); + } + return { Chart: ChartMock, registerables: [] }; +}); + +describe('SiteLoadChart', () => { + it('se crée sans erreur avec une liste de sites valide', () => { + TestBed.configureTestingModule({ imports: [SiteLoadChart] }); + const fixture = TestBed.createComponent(SiteLoadChart); + fixture.componentRef.setInput('sites', [ + { site_id: 'S1', site_name: 'Test', current_consumption_kw: 50, capacity_kw: 100, load_percent: 50, data_quality: 'good' }, + ]); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + it('met à jour le graphique quand les sites changent après initialisation', () => { + TestBed.configureTestingModule({ imports: [SiteLoadChart] }); + const fixture = TestBed.createComponent(SiteLoadChart); + fixture.componentRef.setInput('sites', [ + { site_id: 'S1', site_name: 'A', current_consumption_kw: 50, capacity_kw: 100, load_percent: 50, data_quality: 'good' }, + ]); + fixture.detectChanges(); // déclenche ngAfterViewInit, this.chart existe désormais + + fixture.componentRef.setInput('sites', [ + { site_id: 'S2', site_name: 'B', current_consumption_kw: 80, capacity_kw: 100, load_percent: 80, data_quality: 'critical' }, + ]); + fixture.detectChanges(); // ré-exécute l'effect, cette fois avec this.chart défini + + expect(() => fixture.detectChanges()).not.toThrow(); +}); +}); diff --git a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts new file mode 100644 index 0000000..f8ee5cb --- /dev/null +++ b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts @@ -0,0 +1,62 @@ +import { Component, ElementRef, ViewChild, input, effect, AfterViewInit } from '@angular/core'; +import { Chart, registerables } from 'chart.js'; +import { SiteSummary } from '../../models/stats.model'; + +Chart.register(...registerables); + +const QUALITY_COLORS: Record = { + good: '#2e7d32', + partial: '#f9a825', + degraded: '#ef6c00', + critical: '#c62828', +}; + +@Component({ + selector: 'app-site-load-chart', + standalone: true, + templateUrl: './site-load-chart.html', + styleUrl: './site-load-chart.scss', +}) +export class SiteLoadChart implements AfterViewInit { + sites = input.required(); + + @ViewChild('canvas') private canvasRef!: ElementRef; + private chart?: Chart; + + constructor() { + effect(() => { + const sites = this.sites(); + if (this.chart) { + this.chart.data.labels = sites.map((s) => s.site_name); + this.chart.data.datasets[0].data = sites.map((s) => s.load_percent ?? 0); + this.chart.data.datasets[0].backgroundColor = sites.map((s) => QUALITY_COLORS[s.data_quality]); + this.chart.update('none'); + } + }); + } + + ngAfterViewInit(): void { + const sites = this.sites(); + this.chart = new Chart(this.canvasRef.nativeElement, { + type: 'bar', + data: { + labels: sites.map((s) => s.site_name), + datasets: [ + { + data: sites.map((s) => s.load_percent ?? 0), + backgroundColor: sites.map((s) => QUALITY_COLORS[s.data_quality]), + borderRadius: 4, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { + y: { beginAtZero: true, max: 100, title: { display: true, text: 'Charge (%)' } }, + }, + }, + }); + } +} diff --git a/apps/frontend/src/app/shared/models/alert.model.ts b/apps/frontend/src/app/shared/models/alert.model.ts new file mode 100644 index 0000000..028f35a --- /dev/null +++ b/apps/frontend/src/app/shared/models/alert.model.ts @@ -0,0 +1,13 @@ +export type AlertSeverity = 'low' | 'medium' | 'high' | 'critical'; +export type AlertType = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor'; + +export interface Alert { + alert_id: string; + timestamp: string; + site_id: string; + severity: AlertSeverity; + type: AlertType; + message: string; + value: number; + threshold: number; +} diff --git a/apps/frontend/src/app/shared/models/stats.model.ts b/apps/frontend/src/app/shared/models/stats.model.ts new file mode 100644 index 0000000..eb0f03d --- /dev/null +++ b/apps/frontend/src/app/shared/models/stats.model.ts @@ -0,0 +1,17 @@ +export interface SiteSummary { + site_id: string; + site_name: string; + current_consumption_kw: number | null; + capacity_kw: number; + load_percent: number | null; + data_quality: 'good' | 'partial' | 'degraded' | 'critical'; +} + +export interface StatsSummary { + timestamp: string; + total_sites: number; + total_consumption_kw: number; + total_capacity_kw: number; + average_load_percent: number; + sites: SiteSummary[]; +} diff --git a/apps/frontend/src/environments/environment.development.ts b/apps/frontend/src/environments/environment.development.ts index 5ebc640..8409d18 100644 --- a/apps/frontend/src/environments/environment.development.ts +++ b/apps/frontend/src/environments/environment.development.ts @@ -1,4 +1,5 @@ export const environment = { production: false, - apiUrl: '/api/v1' + apiUrl: '/api/v1', + useMockFixtures: true, // a passer a false une fois le backend prêt }; diff --git a/apps/frontend/src/environments/environment.ts b/apps/frontend/src/environments/environment.ts index 5c2010d..bac99a8 100644 --- a/apps/frontend/src/environments/environment.ts +++ b/apps/frontend/src/environments/environment.ts @@ -1,4 +1,5 @@ export const environment = { production: true, - apiUrl: 'http://localhost:8000/api/v1' + apiUrl: 'http://localhost:8000/api/v1', + useMockFixtures: false, }; From c733ccfc62d9c6e1d1f357fcd41615d3397d46a3 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 08:41:48 +0200 Subject: [PATCH 019/103] refactor(backend): passe les tables data au singulier et clarifie alert_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La convention de docs/architecture/40-data.md impose des noms de tables au singulier, que les quatre tables d'authentification respectent déjà. Les six tables data passent donc au singulier, avec leurs contraintes et leurs index. La révision n'étant appliquée que sur des bases locales, elle est modifiée sur place plutôt que doublée d'une migration de renommage. alert_id désignait deux colonnes différentes : la clé métier text de l'API Mock et la clé étrangère bigint de recommendation. La première devient source_alert_id, la seconde pointe désormais vers alert.alert_id. --- .../e6d2026091501_create_data_schema.py | 94 ++++++++++--------- apps/backend/app/models/energy.py | 75 ++++++++------- apps/backend/tests/db/test_data_schema.py | 10 +- 3 files changed, 92 insertions(+), 87 deletions(-) diff --git a/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py b/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py index 50cc41f..8fb3694 100644 --- a/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py +++ b/apps/backend/alembic/versions/e6d2026091501_create_data_schema.py @@ -1,4 +1,4 @@ -"""Création des six tables Data et de l'hypertable readings. +"""Création des six tables Data et de l'hypertable reading. Revision ID: e6d2026091501 Revises: 821f71be74c0 @@ -17,7 +17,7 @@ depends_on = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.create_table( - "datasets", + "dataset", sa.Column("dataset_id", sa.BigInteger(), autoincrement=True, nullable=False), sa.Column("dataset_name", sa.Text(), nullable=False), sa.Column("archive_sha256", sa.String(length=64), nullable=False), @@ -26,12 +26,12 @@ def upgrade() -> None: sa.Column( "metadata", postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), nullable=False ), - sa.CheckConstraint("dataset_id > 0", name="ck_datasets_positive_id"), + sa.CheckConstraint("dataset_id > 0", name="ck_dataset_positive_id"), sa.PrimaryKeyConstraint("dataset_id"), - sa.UniqueConstraint("archive_sha256", name="uq_datasets_archive_sha256"), + sa.UniqueConstraint("archive_sha256", name="uq_dataset_archive_sha256"), ) op.create_table( - "sites", + "site", sa.Column("site_id", sa.Text(), nullable=False), sa.Column("site_name", sa.Text(), nullable=False), sa.Column("site_type", sa.Text(), nullable=False), @@ -41,7 +41,7 @@ def upgrade() -> None: sa.PrimaryKeyConstraint("site_id"), ) op.create_table( - "predictions", + "prediction", sa.Column("prediction_id", sa.BigInteger(), autoincrement=True, nullable=False), sa.Column("site_id", sa.Text(), nullable=False), sa.Column( @@ -59,29 +59,29 @@ def upgrade() -> None: sa.Column("failure_reason", sa.Text(), nullable=True), sa.CheckConstraint( "(status = 'available' AND predicted_value IS NOT NULL AND failure_reason IS NULL) OR (status IN ('insufficient_data', 'error') AND predicted_value IS NULL AND failure_reason IS NOT NULL)", - name="ck_predictions_status", + name="ck_prediction_status", ), sa.CheckConstraint( "target_metric <> 'consumption_kwh' OR period_minutes IS NOT NULL", - name="ck_predictions_energy_period", + name="ck_prediction_energy_period", ), sa.CheckConstraint( - "target_metric IN ('consumption_kwh', 'consumption_kw')", name="ck_predictions_metric" + "target_metric IN ('consumption_kwh', 'consumption_kw')", name="ck_prediction_metric" ), sa.CheckConstraint( - "period_minutes IS NULL OR period_minutes > 0", name="ck_predictions_period" + "period_minutes IS NULL OR period_minutes > 0", name="ck_prediction_period" ), sa.ForeignKeyConstraint( - ["site_id"], ["sites.site_id"], name="fk_predictions_site", ondelete="RESTRICT" + ["site_id"], ["site.site_id"], name="fk_prediction_site", ondelete="RESTRICT" ), sa.PrimaryKeyConstraint("prediction_id"), - sa.UniqueConstraint("prediction_id", "site_id", name="uq_predictions_id_site"), + sa.UniqueConstraint("prediction_id", "site_id", name="uq_prediction_id_site"), ) op.create_index( - "ix_predictions_site_target", "predictions", ["site_id", "target_at"], unique=False + "ix_prediction_site_target", "prediction", ["site_id", "target_at"], unique=False ) op.create_table( - "readings", + "reading", sa.Column("reading_id", sa.BigInteger(), autoincrement=True, nullable=False), sa.Column("site_id", sa.Text(), nullable=False), sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False), @@ -114,44 +114,44 @@ def upgrade() -> None: ), sa.CheckConstraint( "(source = 'csv' AND dataset_id IS NOT NULL) OR (source IN ('api_current', 'api_history') AND dataset_id IS NULL)", - name="ck_readings_dataset_source", + name="ck_reading_dataset_source", ), sa.CheckConstraint( "data_quality IS NULL OR data_quality IN ('good', 'partial', 'degraded', 'critical')", - name="ck_readings_quality", + name="ck_reading_quality", ), sa.CheckConstraint( - "source IN ('csv', 'api_current', 'api_history')", name="ck_readings_source" + "source IN ('csv', 'api_current', 'api_history')", name="ck_reading_source" ), sa.CheckConstraint( "(imputed_values IS NULL AND imputation_method IS NULL) OR (imputed_values IS NOT NULL AND imputation_method IS NOT NULL)", - name="ck_readings_imputation", + name="ck_reading_imputation", ), sa.ForeignKeyConstraint( - ["dataset_id"], ["datasets.dataset_id"], name="fk_readings_dataset", ondelete="RESTRICT" + ["dataset_id"], ["dataset.dataset_id"], name="fk_reading_dataset", ondelete="RESTRICT" ), sa.ForeignKeyConstraint( - ["site_id"], ["sites.site_id"], name="fk_readings_site", ondelete="RESTRICT" + ["site_id"], ["site.site_id"], name="fk_reading_site", ondelete="RESTRICT" ), sa.PrimaryKeyConstraint("reading_id", "timestamp"), ) - op.create_index("ix_readings_dataset_id", "readings", ["dataset_id"], unique=False) + op.create_index("ix_reading_dataset_id", "reading", ["dataset_id"], unique=False) op.create_index( - "ix_readings_site_timestamp", "readings", ["site_id", "timestamp"], unique=False + "ix_reading_site_timestamp", "reading", ["site_id", "timestamp"], unique=False ) op.create_index( - "uq_readings_source", - "readings", + "uq_reading_source", + "reading", ["site_id", "timestamp", "source", sa.literal_column("coalesce(dataset_id, 0)")], unique=True, ) op.execute( - "SELECT create_hypertable('readings', by_range('timestamp'), create_default_indexes => FALSE)" + "SELECT create_hypertable('reading', by_range('timestamp'), create_default_indexes => FALSE)" ) op.create_table( - "alerts", - sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), - sa.Column("alert_id", sa.Text(), nullable=False), + "alert", + sa.Column("alert_id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("source_alert_id", sa.Text(), nullable=False), sa.Column("site_id", sa.Text(), nullable=False), sa.Column("source", sa.Text(), nullable=False), sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False), @@ -166,27 +166,29 @@ def upgrade() -> None: "raw_data", postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), nullable=False ), sa.CheckConstraint( - "severity IN ('low', 'medium', 'high', 'critical')", name="ck_alerts_severity" + "severity IN ('low', 'medium', 'high', 'critical')", name="ck_alert_severity" ), - sa.CheckConstraint("source IN ('api_mock', 'enervision')", name="ck_alerts_source"), + sa.CheckConstraint("source IN ('api_mock', 'enervision')", name="ck_alert_source"), sa.CheckConstraint( - "type IN ('spike', 'threshold', 'anomaly', 'outage', 'sensor')", name="ck_alerts_type" + "type IN ('spike', 'threshold', 'anomaly', 'outage', 'sensor')", name="ck_alert_type" ), sa.ForeignKeyConstraint( ["prediction_id", "site_id"], - ["predictions.prediction_id", "predictions.site_id"], - name="fk_alerts_prediction_site", + ["prediction.prediction_id", "prediction.site_id"], + name="fk_alert_prediction_site", ondelete="RESTRICT", ), sa.ForeignKeyConstraint( - ["site_id"], ["sites.site_id"], name="fk_alerts_site", ondelete="RESTRICT" + ["site_id"], ["site.site_id"], name="fk_alert_site", ondelete="RESTRICT" + ), + sa.PrimaryKeyConstraint("alert_id"), + sa.UniqueConstraint( + "source", "site_id", "source_alert_id", name="uq_alert_source_reference" ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("source", "site_id", "alert_id", name="uq_alerts_source_site_id"), ) - op.create_index("ix_alerts_site_timestamp", "alerts", ["site_id", "timestamp"], unique=False) + op.create_index("ix_alert_site_timestamp", "alert", ["site_id", "timestamp"], unique=False) op.create_table( - "recommendations", + "recommendation", sa.Column("recommendation_id", sa.BigInteger(), autoincrement=True, nullable=False), sa.Column("alert_id", sa.BigInteger(), nullable=False), sa.Column("action", sa.Text(), nullable=False), @@ -199,18 +201,18 @@ def upgrade() -> None: nullable=False, ), sa.ForeignKeyConstraint( - ["alert_id"], ["alerts.id"], name="fk_recommendations_alert", ondelete="RESTRICT" + ["alert_id"], ["alert.alert_id"], name="fk_recommendation_alert", ondelete="RESTRICT" ), sa.PrimaryKeyConstraint("recommendation_id"), - sa.UniqueConstraint("alert_id", "rule_reference", name="uq_recommendations_alert_rule"), + sa.UniqueConstraint("alert_id", "rule_reference", name="uq_recommendation_alert_rule"), ) # ### end Alembic commands ### def downgrade() -> None: - op.drop_table("recommendations") - op.drop_table("alerts") - op.drop_table("readings") - op.drop_table("predictions") - op.drop_table("sites") - op.drop_table("datasets") + op.drop_table("recommendation") + op.drop_table("alert") + op.drop_table("reading") + op.drop_table("prediction") + op.drop_table("site") + op.drop_table("dataset") diff --git a/apps/backend/app/models/energy.py b/apps/backend/app/models/energy.py index de27c7c..578ca50 100644 --- a/apps/backend/app/models/energy.py +++ b/apps/backend/app/models/energy.py @@ -28,10 +28,10 @@ from app.db.base import Base class Dataset(Base): - __tablename__ = "datasets" + __tablename__ = "dataset" __table_args__ = ( - CheckConstraint("dataset_id > 0", name="ck_datasets_positive_id"), - UniqueConstraint("archive_sha256", name="uq_datasets_archive_sha256"), + CheckConstraint("dataset_id > 0", name="ck_dataset_positive_id"), + UniqueConstraint("archive_sha256", name="uq_dataset_archive_sha256"), ) dataset_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) @@ -44,7 +44,7 @@ class Dataset(Base): class Site(Base): - __tablename__ = "sites" + __tablename__ = "site" site_id: Mapped[str] = mapped_column(Text, primary_key=True) site_name: Mapped[str] = mapped_column(Text) @@ -55,38 +55,38 @@ class Site(Base): class Reading(Base): - __tablename__ = "readings" + __tablename__ = "reading" __table_args__ = ( CheckConstraint( - "source IN ('csv', 'api_current', 'api_history')", name="ck_readings_source" + "source IN ('csv', 'api_current', 'api_history')", name="ck_reading_source" ), CheckConstraint( "(source = 'csv' AND dataset_id IS NOT NULL) OR " "(source IN ('api_current', 'api_history') AND dataset_id IS NULL)", - name="ck_readings_dataset_source", + name="ck_reading_dataset_source", ), CheckConstraint( "data_quality IS NULL OR data_quality IN ('good', 'partial', 'degraded', 'critical')", - name="ck_readings_quality", + name="ck_reading_quality", ), CheckConstraint( "(imputed_values IS NULL AND imputation_method IS NULL) OR " "(imputed_values IS NOT NULL AND imputation_method IS NOT NULL)", - name="ck_readings_imputation", + name="ck_reading_imputation", ), - Index("ix_readings_site_timestamp", "site_id", "timestamp"), - Index("ix_readings_dataset_id", "dataset_id"), + Index("ix_reading_site_timestamp", "site_id", "timestamp"), + Index("ix_reading_dataset_id", "dataset_id"), ) reading_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) site_id: Mapped[str] = mapped_column( - Text, ForeignKey("sites.site_id", name="fk_readings_site", ondelete="RESTRICT") + Text, ForeignKey("site.site_id", name="fk_reading_site", ondelete="RESTRICT") ) timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), primary_key=True) source: Mapped[str] = mapped_column(Text) dataset_id: Mapped[int | None] = mapped_column( BigInteger, - ForeignKey("datasets.dataset_id", name="fk_readings_dataset", ondelete="RESTRICT"), + ForeignKey("dataset.dataset_id", name="fk_reading_dataset", ondelete="RESTRICT"), ) consumption_kw: Mapped[float | None] = mapped_column(Double) consumption_kwh: Mapped[float | None] = mapped_column(Double) @@ -109,7 +109,7 @@ class Reading(Base): Index( - "uq_readings_source", + "uq_reading_source", Reading.site_id, Reading.timestamp, Reading.source, @@ -119,32 +119,32 @@ Index( class Prediction(Base): - __tablename__ = "predictions" + __tablename__ = "prediction" __table_args__ = ( - UniqueConstraint("prediction_id", "site_id", name="uq_predictions_id_site"), - Index("ix_predictions_site_target", "site_id", "target_at"), + UniqueConstraint("prediction_id", "site_id", name="uq_prediction_id_site"), + Index("ix_prediction_site_target", "site_id", "target_at"), CheckConstraint( "target_metric IN ('consumption_kwh', 'consumption_kw')", - name="ck_predictions_metric", + name="ck_prediction_metric", ), CheckConstraint( - "period_minutes IS NULL OR period_minutes > 0", name="ck_predictions_period" + "period_minutes IS NULL OR period_minutes > 0", name="ck_prediction_period" ), CheckConstraint( "target_metric <> 'consumption_kwh' OR period_minutes IS NOT NULL", - name="ck_predictions_energy_period", + name="ck_prediction_energy_period", ), CheckConstraint( "(status = 'available' AND predicted_value IS NOT NULL AND failure_reason IS NULL) OR " "(status IN ('insufficient_data', 'error') AND predicted_value IS NULL " "AND failure_reason IS NOT NULL)", - name="ck_predictions_status", + name="ck_prediction_status", ), ) prediction_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) site_id: Mapped[str] = mapped_column( - Text, ForeignKey("sites.site_id", name="fk_predictions_site", ondelete="RESTRICT") + Text, ForeignKey("site.site_id", name="fk_prediction_site", ondelete="RESTRICT") ) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) target_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) @@ -157,29 +157,31 @@ class Prediction(Base): class Alert(Base): - __tablename__ = "alerts" + __tablename__ = "alert" __table_args__ = ( - UniqueConstraint("source", "site_id", "alert_id", name="uq_alerts_source_site_id"), - Index("ix_alerts_site_timestamp", "site_id", "timestamp"), + UniqueConstraint( + "source", "site_id", "source_alert_id", name="uq_alert_source_reference" + ), + Index("ix_alert_site_timestamp", "site_id", "timestamp"), ForeignKeyConstraint( ["prediction_id", "site_id"], - ["predictions.prediction_id", "predictions.site_id"], - name="fk_alerts_prediction_site", + ["prediction.prediction_id", "prediction.site_id"], + name="fk_alert_prediction_site", ondelete="RESTRICT", ), - CheckConstraint("source IN ('api_mock', 'enervision')", name="ck_alerts_source"), + CheckConstraint("source IN ('api_mock', 'enervision')", name="ck_alert_source"), CheckConstraint( - "type IN ('spike', 'threshold', 'anomaly', 'outage', 'sensor')", name="ck_alerts_type" + "type IN ('spike', 'threshold', 'anomaly', 'outage', 'sensor')", name="ck_alert_type" ), CheckConstraint( - "severity IN ('low', 'medium', 'high', 'critical')", name="ck_alerts_severity" + "severity IN ('low', 'medium', 'high', 'critical')", name="ck_alert_severity" ), ) - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - alert_id: Mapped[str] = mapped_column(Text) + alert_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + source_alert_id: Mapped[str] = mapped_column(Text) site_id: Mapped[str] = mapped_column( - Text, ForeignKey("sites.site_id", name="fk_alerts_site", ondelete="RESTRICT") + Text, ForeignKey("site.site_id", name="fk_alert_site", ondelete="RESTRICT") ) source: Mapped[str] = mapped_column(Text) timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True)) @@ -194,14 +196,15 @@ class Alert(Base): class Recommendation(Base): - __tablename__ = "recommendations" + __tablename__ = "recommendation" __table_args__ = ( - UniqueConstraint("alert_id", "rule_reference", name="uq_recommendations_alert_rule"), + UniqueConstraint("alert_id", "rule_reference", name="uq_recommendation_alert_rule"), ) recommendation_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) alert_id: Mapped[int] = mapped_column( - BigInteger, ForeignKey("alerts.id", name="fk_recommendations_alert", ondelete="RESTRICT") + BigInteger, + ForeignKey("alert.alert_id", name="fk_recommendation_alert", ondelete="RESTRICT"), ) action: Mapped[str] = mapped_column(Text) explanation: Mapped[str] = mapped_column(Text) diff --git a/apps/backend/tests/db/test_data_schema.py b/apps/backend/tests/db/test_data_schema.py index aefc9fa..c564042 100644 --- a/apps/backend/tests/db/test_data_schema.py +++ b/apps/backend/tests/db/test_data_schema.py @@ -41,12 +41,12 @@ async def data_site(data_connection: AsyncConnection) -> str: return site_id -async def test_readings_is_a_time_hypertable_when_migrated( +async def test_reading_is_a_time_hypertable_when_migrated( data_connection: AsyncConnection, ) -> None: query = text( "SELECT column_name FROM timescaledb_information.dimensions " - "WHERE hypertable_schema = 'public' AND hypertable_name = 'readings'" + "WHERE hypertable_schema = 'public' AND hypertable_name = 'reading'" ) result = await data_connection.execute(query) @@ -216,7 +216,7 @@ async def test_alert_rejects_prediction_when_site_differs( async with data_connection.begin_nested(): await data_connection.execute( insert(Alert).values( - alert_id=str(uuid4()), + source_alert_id=str(uuid4()), site_id=other_site, source="enervision", timestamp=MOMENT, @@ -236,7 +236,7 @@ async def test_recommendation_is_unique_when_alert_and_rule_match( await data_connection.execute( insert(Alert) .values( - alert_id=str(uuid4()), + source_alert_id=str(uuid4()), site_id=data_site, source="api_mock", timestamp=MOMENT, @@ -245,7 +245,7 @@ async def test_recommendation_is_unique_when_alert_and_rule_match( message="Test", raw_data={}, ) - .returning(Alert.id) + .returning(Alert.alert_id) ) ).scalar_one() statement = insert(Recommendation).values( From 3eb5a0e8dc08a9b77d07c29aa93bc65f1409a9fb Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 08:43:12 +0200 Subject: [PATCH 020/103] style(backend): applique ruff format au modele data La cible make check ne lance que ruff check ; la CI lance en plus ruff format --check, qui refusait la contrainte unique repliee. --- apps/backend/app/models/energy.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/backend/app/models/energy.py b/apps/backend/app/models/energy.py index 578ca50..285ad26 100644 --- a/apps/backend/app/models/energy.py +++ b/apps/backend/app/models/energy.py @@ -159,9 +159,7 @@ class Prediction(Base): class Alert(Base): __tablename__ = "alert" __table_args__ = ( - UniqueConstraint( - "source", "site_id", "source_alert_id", name="uq_alert_source_reference" - ), + UniqueConstraint("source", "site_id", "source_alert_id", name="uq_alert_source_reference"), Index("ix_alert_site_timestamp", "site_id", "timestamp"), ForeignKeyConstraint( ["prediction_id", "site_id"], From 6c1f86b4ceca28197e55c970c7995b7ad37ee54b Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 08:47:10 +0200 Subject: [PATCH 021/103] docs(architecture): remet 40-data.md en accord avec le schema livre MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'avertissement affirmait qu'aucune table applicative n'existait, vingt lignes avant la liste des tables d'authentification. La section « Modèle métier » décrivait un modèle candidat que la « Modélisation détaillée » contredit depuis la livraison du schéma : elle disparaît, et le gabarit d'hypertable s'appuie désormais sur la révision réelle. Les conventions annonçaient une colonne de partitionnement nommée horodatage, alors qu'elle s'appelle timestamp. Les six tables data prennent leur nom au singulier, et les questions tranchées par le schéma sortent des questions ouvertes. --- docs/architecture/40-data.md | 109 ++++++++++++++--------------------- 1 file changed, 44 insertions(+), 65 deletions(-) diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md index 5774687..6566753 100644 --- a/docs/architecture/40-data.md +++ b/docs/architecture/40-data.md @@ -4,12 +4,12 @@ PostgreSQL 17 avec l'extension TimescaleDB. Le choix, ses alternatives et ses co dans l'[ADR 0001](../adr/0001-postgresql-timescaledb.md), qui fait foi. Ce document décrit le système qui en découle. -## Avertissement +## Ce que couvre ce document -**Aucune table applicative n'existe à ce jour.** `Base.metadata` est vide, `app/models/` ne -contient qu'un commentaire, l'unique révision Alembic ne crée aucune table, et aucune hypertable -n'a été déclarée. Tout ce qui suit sous le statut `Cible` est une proposition de structure, pas un -relevé du code. Le modèle sera arrêté au jalon J2. +**Dix tables applicatives existent** : quatre pour l'authentification, six pour les données +d'énergie, dont l'hypertable `reading`. Les sections marquées `Fait` relèvent le code. Celles +marquées `Cible` décrivent ce qui n'est pas écrit, au premier rang desquelles la chaîne +d'ingestion, les agrégats continus, la compression et la rétention. ## Trois emplacements, trois rôles @@ -35,7 +35,7 @@ Statut : `Fait`. - `db/init/100-extensions.sql` crée l'extension `timescaledb`. - `db/init/110-test-database.sql` crée `enervision_test`, dont le nom est attendu en dur par `apps/backend/tests/conftest.py`. -- Quatre révisions Alembic. La première, `5353c0e4f094`, **ne crée aucune table** : elle +- Cinq révisions Alembic. La première, `5353c0e4f094`, **ne crée aucune table** : elle établit `alembic_version` et refuse de s'appliquer si l'extension manque : ```sql @@ -48,16 +48,18 @@ Cette garde forme paire avec le 503 de `/api/v1/health/ready`. Un bootstrap saut au démarrage de l'API : ces deux gardes le rendent visible tôt, des deux côtés. Les trois suivantes créent les tables de l'authentification, décrites plus bas : `app_user`, -puis `login_attempt` et `audit_log`, puis `refresh_token`. +puis `login_attempt` et `audit_log`, puis `refresh_token`. La cinquième, `e6d2026091501`, crée +les six tables de données décrites en fin de document et déclare l'hypertable `reading`. ## Cycle de vie d'une mesure -Statut : `Cible`. Aucun de ces maillons n'existe. +Statut : `Cible`, sauf l'hypertable `reading` qui existe. Ni l'ingestion, ni les agrégats +continus, ni la compression, ni la rétention ne sont écrits. ```mermaid flowchart LR src["Source de mesures"] -.-> ing["Ingestion Airflow"] - ing -.-> hy[("Hypertable mesure")] + ing -.-> hy[("Hypertable reading")] hy -.-> agg[("Agrégat continu")] hy -.-> comp["Compression"] hy -.-> ret["Rétention"] @@ -133,67 +135,46 @@ donc **pas** une hypertable : une politique de rétention émettrait des `DELETE refuseraient. `login_attempt`, à l'inverse, est faite pour se purger, puisque son volume est piloté par l'attaquant. -## Modèle métier - -Statut : `Cible`. Les entités ci-dessous sont des **candidates**, à valider en J2. Elles -s'appuient sur les gabarits de [`apps/backend/TESTING.md`](../../apps/backend/TESTING.md), qui -évoquent déjà un modèle `Site`, un `SiteRepository` et un `ConsumptionService` exposant un -`total_kwh(site_id)`. - -```mermaid -erDiagram - SITE ||--o{ POINT_DE_MESURE : porte - POINT_DE_MESURE ||--o{ MESURE : produit - - SITE { - int id PK - string nom - } - POINT_DE_MESURE { - int id PK - int site_id FK - string libelle - string unite - } - MESURE { - timestamptz horodatage PK - int point_id PK - double valeur - } -``` - -`MESURE` est la table destinée à devenir une hypertable, partitionnée sur `horodatage`. Sa clé -primaire doit inclure la colonne de temps : TimescaleDB l'exige, une clé sur le seul identifiant -de point serait refusée. - ## Gabarit de révision créant une hypertable -Conforme à la règle de l'ADR 0001 : table et hypertable dans la même révision. +Conforme à la règle de l'ADR 0001 : table et hypertable dans la même révision. La révision +`e6d2026091501` en est l'exemple réel, réduit ici à l'essentiel. ```python def upgrade() -> None: op.create_table( - "mesure", - sa.Column("horodatage", sa.DateTime(timezone=True), nullable=False), - sa.Column("point_id", sa.Integer(), sa.ForeignKey("point_de_mesure.id"), nullable=False), - sa.Column("valeur", sa.Float(), nullable=False), - sa.PrimaryKeyConstraint("horodatage", "point_id"), + "reading", + sa.Column("reading_id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("site_id", sa.Text(), nullable=False), + sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("reading_id", "timestamp"), + ) + op.execute( + "SELECT create_hypertable('reading', by_range('timestamp'), " + "create_default_indexes => FALSE)" ) - op.execute("SELECT create_hypertable('mesure', by_range('horodatage'))") def downgrade() -> None: - op.drop_table("mesure") + op.drop_table("reading") ``` +La clé primaire inclut la colonne de temps parce que TimescaleDB l'exige : toute contrainte +unique d'une hypertable doit porter la colonne de partitionnement, et une clé sur le seul +`reading_id` serait refusée par `create_hypertable`. + +`create_default_indexes => FALSE` écarte l'index que TimescaleDB pose d'office sur la seule +colonne de temps : les index déclarés dans la révision le couvrent déjà. + `drop_table` suffit au retour arrière : supprimer la table supprime l'hypertable et ses partitions. ## Conventions -- **Noms au singulier**, en minuscules, sans préfixe de table. +- **Noms au singulier**, en minuscules, sans préfixe de table : `app_user`, `reading`. - **Toute colonne de temps en `timestamptz`.** Jamais de `timestamp` nu : une mesure sans fuseau devient ininterprétable dès le premier changement d'heure. -- **La colonne de partitionnement s'appelle `horodatage`** et entre dans la clé primaire. +- **La colonne de partitionnement entre dans la clé primaire.** Dans `reading` elle s'appelle + `timestamp` : c'est un nom de colonne, son type reste `timestamptz`. - **Les politiques de rétention et de compression** vont dans `db/migrations/`, pas dans Alembic : elles ne découlent pas du schéma applicatif. - **Tout modèle doit être importé dans `app/models/__init__.py`**, sans quoi @@ -201,13 +182,12 @@ def downgrade() -> None: ## Questions ouvertes -Elles relèvent du jalon J2, « valider le périmètre retenu », et bloquent le modèle définitif. +Elles relèvent du jalon J2, « valider le périmètre retenu ». Le schéma est livré : ce qui suit +porte sur son exploitation, plus sur sa forme. -- **Quelles sources de mesures**, et selon quel protocole elles sont collectées. - **Quelle granularité** à l'ingestion : la seconde, la minute, le quart d'heure. - **Quels agrégats continus**, et sur quelles fenêtres. - **Quelle profondeur de rétention** en données brutes, et à partir de quand on compresse. -- **Quelles unités** sont manipulées, et si une même table les mélange. - **Multi-tenant ou non** : un site appartient-il à un client, et faut-il cloisonner les lectures. ## Modélisation détaillée des données @@ -220,12 +200,11 @@ jusqu’aux recommandations proposées à l’utilisateur. ### Schéma de données Le diagramme ci-dessous présente les tables et leurs relations. -Il décrit une structure de conception ; les migrations correspondantes -restent à implémenter. +La révision `e6d2026091501` les crée. ![Schéma de données EnerVision](images/EnerVision-schema-donnees.png) -*Figure — Modélisation des données EnerVision.* +*Figure : Modélisation des données EnerVision.* ### Description des tables @@ -234,15 +213,15 @@ des données. | Table | Rôle | Origine des informations | |---|---|---| -| `datasets` | Identifier les jeux historiques, retrouver leurs fichiers et conserver leurs métadonnées | Archive CSV/JSON et informations ajoutées lors de l’import | -| `sites` | Regrouper les informations des sites : identifiant, nom, type et caractéristiques disponibles | CSV et API Mock `/api/v1/sites` | -| `readings` | Stocker les mesures, leur provenance, leur qualité et les éventuelles valeurs imputées | CSV et API Mock `/current` et `/readings` | -| `predictions` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d’EnerVision | -| `alerts` | Enregistrer les alertes, leur type, leur gravité et leur message | API Mock `/alerts` et détections EnerVision | -| `recommendations` | Proposer des actions et expliquer la règle qui les motive | Règles métier d’EnerVision | +| `dataset` | Identifier les jeux historiques, retrouver leurs fichiers et conserver leurs métadonnées | Archive CSV/JSON et informations ajoutées lors de l’import | +| `site` | Regrouper les informations des sites : identifiant, nom, type et caractéristiques disponibles | CSV et API Mock `/api/v1/sites` | +| `reading` | Stocker les mesures, leur provenance, leur qualité et les éventuelles valeurs imputées | CSV et API Mock `/current` et `/readings` | +| `prediction` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d’EnerVision | +| `alert` | Enregistrer les alertes, leur type, leur gravité et leur message | API Mock `/alerts` et détections EnerVision | +| `recommendation` | Proposer des actions et expliquer la règle qui les motive | Règles métier d’EnerVision | Les anomalies historiques décrites dans les JSON sont conservées -dans `datasets.metadata`. Elles servent à l’analyse des données +dans `dataset.metadata`. Elles servent à l’analyse des données et ne sont pas considérées comme des alertes actuelles. ### Relations entre les tables From 7b9406965e3bc00a14266d55bab30c086ddefbed Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 09:20:17 +0200 Subject: [PATCH 022/103] fix(frontend): maintient le rafraichissement du tableau de bord en cas d'erreur Sans catchError, la premiere reponse en erreur terminait le flux du timer : le rafraichissement ne repartait jamais et l'ecran restait fige sur des chiffres perimes, sans rien signaler. Le catchError porte sur l'observable interne du switchMap. Place sur le flux externe il terminerait le timer tout autant. Un signal error alimente un bandeau, efface des qu'une reponse valide revient. --- .../src/app/features/dashboard/dashboard.html | 4 ++ .../src/app/features/dashboard/dashboard.scss | 10 ++++ .../app/features/dashboard/dashboard.spec.ts | 56 ++++++++++++++++++- .../src/app/features/dashboard/dashboard.ts | 32 ++++++++--- 4 files changed, 94 insertions(+), 8 deletions(-) diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index 70b333c..d324c74 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -4,6 +4,10 @@

Consommation instantanée du parc

+ @if (error(); as message) { + + } + @if (stats(); as s) {
diff --git a/apps/frontend/src/app/features/dashboard/dashboard.scss b/apps/frontend/src/app/features/dashboard/dashboard.scss index d0b1088..3cacb0f 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.scss +++ b/apps/frontend/src/app/features/dashboard/dashboard.scss @@ -37,6 +37,16 @@ h2 { margin: 0 0 1rem; } +.banner-error { + margin: 0 0 1.5rem; + padding: 0.75rem 1rem; + border: 1px solid var(--color-critical); + border-left-width: 4px; + border-radius: var(--radius); + background: #fdecea; + color: var(--color-critical); +} + .overview { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); diff --git a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts index 7dcbe37..f55adac 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts @@ -1,6 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { vi } from 'vitest'; -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { Dashboard } from './dashboard'; import { StatsService } from '../../core/services/stats.service'; import { AlertsService } from '../../core/services/alerts.service'; @@ -8,6 +8,7 @@ import { AlertsService } from '../../core/services/alerts.service'; vi.mock('chart.js', () => { class ChartMock { update = vi.fn(); + destroy = vi.fn(); data = { datasets: [{}] }; static register = vi.fn(); } @@ -15,6 +16,8 @@ vi.mock('chart.js', () => { }); describe('Dashboard', () => { + afterEach(() => vi.useRealTimers()); + it('charge les stats et les alertes au démarrage', async () => { const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([{ alert_id: 'A1' }])) }; @@ -37,5 +40,56 @@ describe('Dashboard', () => { expect(statsMock.getSummary).toHaveBeenCalled(); expect(alertsMock.getAlerts).toHaveBeenCalled(); expect(fixture.componentInstance.alerts().length).toBe(1); + expect(fixture.componentInstance.error()).toBeNull(); + }); + + it('signale l\'indisponibilité puis repart au rafraîchissement suivant', () => { + vi.useFakeTimers(); + const statsMock = { + getSummary: vi + .fn() + .mockReturnValueOnce(throwError(() => new Error('API injoignable'))) + .mockReturnValue(of({ total_sites: 7, sites: [] })), + }; + const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; + + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: alertsMock }, + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + fixture.detectChanges(); + + vi.advanceTimersByTime(1); + expect(statsMock.getSummary).toHaveBeenCalledTimes(1); + expect(fixture.componentInstance.error()).not.toBeNull(); + expect(fixture.componentInstance.stats()).toBeNull(); + + vi.advanceTimersByTime(10000); + expect(statsMock.getSummary).toHaveBeenCalledTimes(2); + expect(fixture.componentInstance.stats()).not.toBeNull(); + expect(fixture.componentInstance.error()).toBeNull(); + }); + + it("n'interrompt pas la page quand le chargement des alertes échoue", () => { + const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; + const alertsMock = { getAlerts: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) }; + + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: alertsMock }, + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + fixture.detectChanges(); + + expect(fixture.componentInstance.alerts().length).toBe(0); }); }); diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index 2fe7768..b6a7627 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -1,15 +1,17 @@ import { Component, OnInit, inject, signal, DestroyRef } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { timer, switchMap } from 'rxjs'; +import { timer, switchMap, catchError, EMPTY, Observable } from 'rxjs'; import { DecimalPipe } from '@angular/common'; import { StatsService } from '../../core/services/stats.service'; import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge'; import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart'; -import {AlertsService} from '../../core/services/alerts.service'; -import {StatsSummary} from '../../shared/models/stats.model'; -import {Alert} from '../../shared/models/alert.model'; +import { AlertsService } from '../../core/services/alerts.service'; +import { StatsSummary } from '../../shared/models/stats.model'; +import { Alert } from '../../shared/models/alert.model'; const REFRESH_INTERVAL_MS = 10000; +const UNAVAILABLE_MESSAGE = + 'Données indisponibles, les valeurs affichées datent du dernier relevé.'; @Component({ selector: 'app-dashboard', @@ -25,15 +27,31 @@ export class Dashboard implements OnInit { stats = signal(null); alerts = signal([]); + error = signal(null); ngOnInit(): void { - this.alertsService.getAlerts().subscribe((alerts) => this.alerts.set(alerts)); + this.alertsService + .getAlerts() + .pipe(catchError(() => this.reportUnavailable())) + .subscribe((alerts) => this.alerts.set(alerts)); + // Piège : le catchError porte sur l'observable interne. Sur le flux externe il + // terminerait le timer, et le rafraîchissement ne repartirait jamais. timer(0, REFRESH_INTERVAL_MS) .pipe( - switchMap(() => this.statsService.getSummary()), + switchMap(() => + this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable())) + ), takeUntilDestroyed(this.destroyRef) ) - .subscribe((stats) => this.stats.set(stats)); + .subscribe((stats) => { + this.error.set(null); + this.stats.set(stats); + }); + } + + private reportUnavailable(): Observable { + this.error.set(UNAVAILABLE_MESSAGE); + return EMPTY; } } From 0259f66b62679158c0d64b4e26c0b154134cb4ac Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 09:20:17 +0200 Subject: [PATCH 023/103] fix(frontend): detruit les graphiques avec leur composant Chart.js conserve chaque instance dans un registre lie au canvas et lui attache un observateur de redimensionnement. Sans destroy, tout survit a la destruction du composant, et une re-creation sur le meme canvas echoue avec "Canvas is already in use". Les doubles de test gagnent destroy : TestBed detruit les fixtures apres chaque test, un mock sans cette methode fait tomber les specs existantes. --- .../consumption-gauge.spec.ts | 29 ++++++++++++++- .../consumption-gauge/consumption-gauge.ts | 16 +++++++- .../site-load-chart/site-load-chart.spec.ts | 37 ++++++++++++++++++- .../site-load-chart/site-load-chart.ts | 16 +++++++- 4 files changed, 90 insertions(+), 8 deletions(-) diff --git a/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts index e793c39..be25fb1 100644 --- a/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts +++ b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts @@ -1,16 +1,28 @@ import { TestBed } from '@angular/core/testing'; import { vi } from 'vitest'; +import { Chart } from 'chart.js'; import { ConsumptionGauge } from './consumption-gauge'; vi.mock('chart.js', () => { class ChartMock { - update = vi.fn(); - data = { datasets: [{}] }; + static instances: ChartMock[] = []; static register = vi.fn(); + update = vi.fn(); + destroy = vi.fn(); + data = { datasets: [{}] }; + constructor() { + ChartMock.instances.push(this); + } } return { Chart: ChartMock, registerables: [] }; }); +type ChartDouble = { destroy: ReturnType }; + +function lastChart(): ChartDouble | undefined { + return (Chart as unknown as { instances: ChartDouble[] }).instances.at(-1); +} + describe('ConsumptionGauge', () => { it('se crée sans erreur avec des entrées valides', () => { TestBed.configureTestingModule({ imports: [ConsumptionGauge] }); @@ -31,4 +43,17 @@ describe('ConsumptionGauge', () => { expect(() => fixture.detectChanges()).not.toThrow(); }); + + it('détruit le graphique quand le composant est détruit', () => { + TestBed.configureTestingModule({ imports: [ConsumptionGauge] }); + const fixture = TestBed.createComponent(ConsumptionGauge); + fixture.componentRef.setInput('consumption', 300); + fixture.componentRef.setInput('capacity', 1000); + fixture.detectChanges(); + + const chart = lastChart(); + fixture.destroy(); + + expect(chart?.destroy).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.ts b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.ts index 73544ea..bda661a 100644 --- a/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.ts +++ b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.ts @@ -1,4 +1,12 @@ -import { Component, ElementRef, ViewChild, input, effect, AfterViewInit } from '@angular/core'; +import { + Component, + ElementRef, + ViewChild, + input, + effect, + AfterViewInit, + OnDestroy, +} from '@angular/core'; import { Chart, registerables } from 'chart.js'; Chart.register(...registerables); @@ -9,7 +17,7 @@ Chart.register(...registerables); templateUrl: './consumption-gauge.html', styleUrl: './consumption-gauge.scss', }) -export class ConsumptionGauge implements AfterViewInit { +export class ConsumptionGauge implements AfterViewInit, OnDestroy { consumption = input.required(); capacity = input.required(); @@ -52,4 +60,8 @@ export class ConsumptionGauge implements AfterViewInit { }, }); } + + ngOnDestroy(): void { + this.chart?.destroy(); + } } diff --git a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts index 1e1c46b..0d5944d 100644 --- a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts +++ b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts @@ -1,16 +1,28 @@ import { TestBed } from '@angular/core/testing'; import { vi } from 'vitest'; +import { Chart } from 'chart.js'; import { SiteLoadChart } from './site-load-chart'; vi.mock('chart.js', () => { class ChartMock { - update = vi.fn(); - data = { datasets: [{}] }; + static instances: ChartMock[] = []; static register = vi.fn(); + update = vi.fn(); + destroy = vi.fn(); + data = { datasets: [{}] }; + constructor() { + ChartMock.instances.push(this); + } } return { Chart: ChartMock, registerables: [] }; }); +type ChartDouble = { destroy: ReturnType }; + +function lastChart(): ChartDouble | undefined { + return (Chart as unknown as { instances: ChartDouble[] }).instances.at(-1); +} + describe('SiteLoadChart', () => { it('se crée sans erreur avec une liste de sites valide', () => { TestBed.configureTestingModule({ imports: [SiteLoadChart] }); @@ -35,4 +47,25 @@ describe('SiteLoadChart', () => { expect(() => fixture.detectChanges()).not.toThrow(); }); + + it('détruit le graphique quand le composant est détruit', () => { + TestBed.configureTestingModule({ imports: [SiteLoadChart] }); + const fixture = TestBed.createComponent(SiteLoadChart); + fixture.componentRef.setInput('sites', [ + { + site_id: 'S1', + site_name: 'A', + current_consumption_kw: 50, + capacity_kw: 100, + load_percent: 50, + data_quality: 'good', + }, + ]); + fixture.detectChanges(); + + const chart = lastChart(); + fixture.destroy(); + + expect(chart?.destroy).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts index f8ee5cb..017ce1b 100644 --- a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts +++ b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts @@ -1,4 +1,12 @@ -import { Component, ElementRef, ViewChild, input, effect, AfterViewInit } from '@angular/core'; +import { + Component, + ElementRef, + ViewChild, + input, + effect, + AfterViewInit, + OnDestroy, +} from '@angular/core'; import { Chart, registerables } from 'chart.js'; import { SiteSummary } from '../../models/stats.model'; @@ -17,7 +25,7 @@ const QUALITY_COLORS: Record = { templateUrl: './site-load-chart.html', styleUrl: './site-load-chart.scss', }) -export class SiteLoadChart implements AfterViewInit { +export class SiteLoadChart implements AfterViewInit, OnDestroy { sites = input.required(); @ViewChild('canvas') private canvasRef!: ElementRef; @@ -59,4 +67,8 @@ export class SiteLoadChart implements AfterViewInit { }, }); } + + ngOnDestroy(): void { + this.chart?.destroy(); + } } From da97e6aa8b6236f313068cdc78f59e7c37cc0681 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 09:20:40 +0200 Subject: [PATCH 024/103] style(frontend): applique prettier aux fichiers du tableau de bord Les onze fichiers non conformes au .prettierrc du projet etaient exactement ceux introduits ou modifies par cette branche ; les vingt-deux autres du frontend etaient deja propres. Aucune modification de comportement : indentation, virgules finales et longueur de ligne a 100 caracteres. --- apps/frontend/src/app/app.config.ts | 7 +-- apps/frontend/src/app/app.routes.ts | 5 +- .../interceptors/mock-api-interceptor.spec.ts | 14 +++--- .../app/core/mocks/stats-summary.fixture.ts | 2 +- .../src/app/features/dashboard/dashboard.html | 10 +++- .../src/app/features/dashboard/dashboard.scss | 12 +++-- .../app/features/dashboard/dashboard.spec.ts | 2 +- .../src/app/features/dashboard/dashboard.ts | 4 +- .../consumption-gauge.spec.ts | 18 +++---- .../site-load-chart/site-load-chart.spec.ts | 47 ++++++++++++++----- .../site-load-chart/site-load-chart.ts | 4 +- 11 files changed, 82 insertions(+), 43 deletions(-) diff --git a/apps/frontend/src/app/app.config.ts b/apps/frontend/src/app/app.config.ts index d89a118..ff4cafd 100644 --- a/apps/frontend/src/app/app.config.ts +++ b/apps/frontend/src/app/app.config.ts @@ -1,12 +1,13 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; -import {mockApiInterceptor} from './core/interceptors/mock-api-interceptor'; -import {provideHttpClient, withInterceptors} from '@angular/common/http'; +import { mockApiInterceptor } from './core/interceptors/mock-api-interceptor'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; export const appConfig: ApplicationConfig = { providers: [ - provideBrowserGlobalErrorListeners(), provideRouter(routes), + provideBrowserGlobalErrorListeners(), + provideRouter(routes), provideHttpClient(withInterceptors([mockApiInterceptor])), ], }; diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index 9852e8b..8f2739c 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -2,5 +2,8 @@ import { Routes } from '@angular/router'; export const routes: Routes = [ { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, - { path: 'dashboard', loadComponent: () => import('./features/dashboard/dashboard').then(m => m.Dashboard) }, + { + path: 'dashboard', + loadComponent: () => import('./features/dashboard/dashboard').then((m) => m.Dashboard), + }, ]; diff --git a/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts index 34a58b2..5d6e343 100644 --- a/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts +++ b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts @@ -33,7 +33,7 @@ describe('mockApiInterceptor', () => { httpMock.expectNone(`${environment.apiUrl}/stats/summary`); expect((result as typeof STATS_SUMMARY_FIXTURE).total_sites).toBe( - STATS_SUMMARY_FIXTURE.total_sites + STATS_SUMMARY_FIXTURE.total_sites, ); }); @@ -56,12 +56,12 @@ describe('mockApiInterceptor', () => { }); it('renvoie la fixture des alertes sans appel réseau quand useMockFixtures est activé', () => { - environment.useMockFixtures = true; - let result: unknown; + environment.useMockFixtures = true; + let result: unknown; - http.get(`${environment.apiUrl}/alerts`).subscribe((r) => (result = r)); + http.get(`${environment.apiUrl}/alerts`).subscribe((r) => (result = r)); - httpMock.expectNone(`${environment.apiUrl}/alerts`); - expect((result as unknown[]).length).toBeGreaterThan(0); -}); + httpMock.expectNone(`${environment.apiUrl}/alerts`); + expect((result as unknown[]).length).toBeGreaterThan(0); + }); }); diff --git a/apps/frontend/src/app/core/mocks/stats-summary.fixture.ts b/apps/frontend/src/app/core/mocks/stats-summary.fixture.ts index 7057fb8..f71a41b 100644 --- a/apps/frontend/src/app/core/mocks/stats-summary.fixture.ts +++ b/apps/frontend/src/app/core/mocks/stats-summary.fixture.ts @@ -18,7 +18,7 @@ export const STATS_SUMMARY_FIXTURE: StatsSummary = { { site_id: 'SITE002', site_name: 'Usine Lyon Vénissieux', - current_consumption_kw: 542.10, + current_consumption_kw: 542.1, capacity_kw: 1000, load_percent: 54.2, data_quality: 'good', diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index d324c74..a64d5d9 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -12,8 +12,14 @@
Consommation vs capacité - - {{ s.total_consumption_kw | number:'1.0-1' }} / {{ s.total_capacity_kw | number }} kW + + {{ s.total_consumption_kw | number: '1.0-1' }} / + {{ s.total_capacity_kw | number }} kW
diff --git a/apps/frontend/src/app/features/dashboard/dashboard.scss b/apps/frontend/src/app/features/dashboard/dashboard.scss index 3cacb0f..01cc3a3 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.scss +++ b/apps/frontend/src/app/features/dashboard/dashboard.scss @@ -131,9 +131,15 @@ h2 { flex-shrink: 0; } -.alert-item--high .alert-item__badge { background: var(--color-degraded); } -.alert-item--medium .alert-item__badge { background: var(--color-partial); } -.alert-item--low .alert-item__badge { background: var(--color-good); } +.alert-item--high .alert-item__badge { + background: var(--color-degraded); +} +.alert-item--medium .alert-item__badge { + background: var(--color-partial); +} +.alert-item--low .alert-item__badge { + background: var(--color-good); +} .alert-item__message { font-size: 0.9rem; diff --git a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts index f55adac..89a69ec 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts @@ -43,7 +43,7 @@ describe('Dashboard', () => { expect(fixture.componentInstance.error()).toBeNull(); }); - it('signale l\'indisponibilité puis repart au rafraîchissement suivant', () => { + it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => { vi.useFakeTimers(); const statsMock = { getSummary: vi diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index b6a7627..7733230 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -40,9 +40,9 @@ export class Dashboard implements OnInit { timer(0, REFRESH_INTERVAL_MS) .pipe( switchMap(() => - this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable())) + this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable())), ), - takeUntilDestroyed(this.destroyRef) + takeUntilDestroyed(this.destroyRef), ) .subscribe((stats) => { this.error.set(null); diff --git a/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts index be25fb1..672d50f 100644 --- a/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts +++ b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts @@ -32,17 +32,17 @@ describe('ConsumptionGauge', () => { expect(() => fixture.detectChanges()).not.toThrow(); }); it('met à jour le graphique quand les valeurs changent après initialisation', () => { - TestBed.configureTestingModule({ imports: [ConsumptionGauge] }); - const fixture = TestBed.createComponent(ConsumptionGauge); - fixture.componentRef.setInput('consumption', 300); - fixture.componentRef.setInput('capacity', 1000); - fixture.detectChanges(); // déclenche ngAfterViewInit, this.chart existe désormais + TestBed.configureTestingModule({ imports: [ConsumptionGauge] }); + const fixture = TestBed.createComponent(ConsumptionGauge); + fixture.componentRef.setInput('consumption', 300); + fixture.componentRef.setInput('capacity', 1000); + fixture.detectChanges(); // déclenche ngAfterViewInit, this.chart existe désormais - fixture.componentRef.setInput('consumption', 500); - fixture.detectChanges(); // ré-exécute l'effect, cette fois avec this.chart défini + fixture.componentRef.setInput('consumption', 500); + fixture.detectChanges(); // ré-exécute l'effect, cette fois avec this.chart défini - expect(() => fixture.detectChanges()).not.toThrow(); -}); + expect(() => fixture.detectChanges()).not.toThrow(); + }); it('détruit le graphique quand le composant est détruit', () => { TestBed.configureTestingModule({ imports: [ConsumptionGauge] }); diff --git a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts index 0d5944d..59e6b6b 100644 --- a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts +++ b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts @@ -28,25 +28,46 @@ describe('SiteLoadChart', () => { TestBed.configureTestingModule({ imports: [SiteLoadChart] }); const fixture = TestBed.createComponent(SiteLoadChart); fixture.componentRef.setInput('sites', [ - { site_id: 'S1', site_name: 'Test', current_consumption_kw: 50, capacity_kw: 100, load_percent: 50, data_quality: 'good' }, + { + site_id: 'S1', + site_name: 'Test', + current_consumption_kw: 50, + capacity_kw: 100, + load_percent: 50, + data_quality: 'good', + }, ]); expect(() => fixture.detectChanges()).not.toThrow(); }); it('met à jour le graphique quand les sites changent après initialisation', () => { - TestBed.configureTestingModule({ imports: [SiteLoadChart] }); - const fixture = TestBed.createComponent(SiteLoadChart); - fixture.componentRef.setInput('sites', [ - { site_id: 'S1', site_name: 'A', current_consumption_kw: 50, capacity_kw: 100, load_percent: 50, data_quality: 'good' }, - ]); - fixture.detectChanges(); // déclenche ngAfterViewInit, this.chart existe désormais + TestBed.configureTestingModule({ imports: [SiteLoadChart] }); + const fixture = TestBed.createComponent(SiteLoadChart); + fixture.componentRef.setInput('sites', [ + { + site_id: 'S1', + site_name: 'A', + current_consumption_kw: 50, + capacity_kw: 100, + load_percent: 50, + data_quality: 'good', + }, + ]); + fixture.detectChanges(); // déclenche ngAfterViewInit, this.chart existe désormais - fixture.componentRef.setInput('sites', [ - { site_id: 'S2', site_name: 'B', current_consumption_kw: 80, capacity_kw: 100, load_percent: 80, data_quality: 'critical' }, - ]); - fixture.detectChanges(); // ré-exécute l'effect, cette fois avec this.chart défini + fixture.componentRef.setInput('sites', [ + { + site_id: 'S2', + site_name: 'B', + current_consumption_kw: 80, + capacity_kw: 100, + load_percent: 80, + data_quality: 'critical', + }, + ]); + fixture.detectChanges(); // ré-exécute l'effect, cette fois avec this.chart défini - expect(() => fixture.detectChanges()).not.toThrow(); -}); + expect(() => fixture.detectChanges()).not.toThrow(); + }); it('détruit le graphique quand le composant est détruit', () => { TestBed.configureTestingModule({ imports: [SiteLoadChart] }); diff --git a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts index 017ce1b..6c17803 100644 --- a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts +++ b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts @@ -37,7 +37,9 @@ export class SiteLoadChart implements AfterViewInit, OnDestroy { if (this.chart) { this.chart.data.labels = sites.map((s) => s.site_name); this.chart.data.datasets[0].data = sites.map((s) => s.load_percent ?? 0); - this.chart.data.datasets[0].backgroundColor = sites.map((s) => QUALITY_COLORS[s.data_quality]); + this.chart.data.datasets[0].backgroundColor = sites.map( + (s) => QUALITY_COLORS[s.data_quality], + ); this.chart.update('none'); } }); From e85c83972a62d510e8d418510b4c9c2755be349c Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 09:20:48 +0200 Subject: [PATCH 025/103] chore(frontend): sort le rapport JUnit du suivi git La ligne /test-results ajoutee au .gitignore n'avait aucun effet : le fichier etait deja suivi, et un .gitignore ne s'applique pas a un fichier indexe. Il reapparaissait donc modifie dans le diff de chacun a chaque execution de ng test, qui le regenere a l'emplacement fixe par angular.json. --- apps/frontend/test-results/junit.xml | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 apps/frontend/test-results/junit.xml diff --git a/apps/frontend/test-results/junit.xml b/apps/frontend/test-results/junit.xml deleted file mode 100644 index 28e5ba4..0000000 --- a/apps/frontend/test-results/junit.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - From 580da72effe88610b32e58cc72ba59373e795a8a Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 09:23:15 +0200 Subject: [PATCH 026/103] docs(architecture): acte le tableau de bord dans les vues frontend 30-frontend.md decrivait encore un ng new intact : routes vides, provideHttpClient absent, app.html par defaut, aucune bibliotheque de graphiques. Les sections Arborescence et Flux HTTP passent de Cible a realisees, et le diagramme de sequence montre ou l'intercepteur se place. La section Securite affirmait que l'authentification n'existe pas cote API : elle existe depuis la PR #70, c'est cote interface qu'il n'y a rien. Ajout verifie sur le poste : l'Angular CLI refuse de demarrer en dessous de Node 22.22.3, 24.15.0 ou 26.0.0. --- README.md | 7 +-- docs/architecture/00-vue-ensemble.md | 7 +-- docs/architecture/30-frontend.md | 65 ++++++++++++++++++---------- 3 files changed, 51 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 7a8147f..75a1ab6 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m | Domaine | Technologie | Emplacement | Etat | |------------|-------------------------------------|---------------------|---------------| | Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise | -| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Squelette | +| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Tableau de bord | | Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise | | ETL | Apache Airflow | `etl/airflow` | A initialiser | | Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise | @@ -27,8 +27,9 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser | Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend -porte le squelette Angular, sans code metier : aucune route, aucun appel d'API. Les autres dossiers -portent l'arborescence et un README de cadrage, leur contenu fait l'objet d'un ticket dedie. +sert un tableau de bord sur `/dashboard`, dont les données proviennent de fixtures : les endpoints +correspondants restent à écrire côté API. Les autres dossiers portent l'arborescence et un README +de cadrage, leur contenu fait l'objet d'un ticket dedie. L'etat detaille de chaque brique et les vues d'architecture sont dans [docs/architecture](docs/architecture/README.md). diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index 15a54c0..d083985 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -63,8 +63,9 @@ flowchart TB grafana -.-> prom ``` -Le lien `front -.-> api` est en pointillé à dessein : le frontend n'appelle aujourd'hui aucune -API, `provideHttpClient` n'est pas encore installé. Voir [30-frontend.md](30-frontend.md). +Le lien `front -.-> api` reste en pointillé : le frontend appelle bien une API, mais un +intercepteur répond à sa place tant que les endpoints n'existent pas. Voir +[30-frontend.md](30-frontend.md). Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun collecteur ne vient le lire. @@ -74,7 +75,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`. Aucune couche métier | -| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Squelette `ng new` standalone, routes vides, aucun service HTTP | +| 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 | | 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 | diff --git a/docs/architecture/30-frontend.md b/docs/architecture/30-frontend.md index 98da40a..baea451 100644 --- a/docs/architecture/30-frontend.md +++ b/docs/architecture/30-frontend.md @@ -4,30 +4,36 @@ Application Angular 22, 100 % standalone, testée avec Vitest. Source dans `apps ## État actuel -Statut : `En cours`. Le projet est un `ng new` intact. Le tableau de la -[vue d'ensemble](00-vue-ensemble.md) le classe désormais correctement, le `README.md` racine le -disait encore « à initialiser » alors que le squelette existe depuis `49f4697`. +Statut : `En cours`. L'application sert une première page métier, le tableau de bord, alimentée +par des fixtures : les endpoints qu'elle appelle n'existent pas encore côté API. Ce qui est en place : - Bootstrap par `bootstrapApplication(App, appConfig)`, **aucun `NgModule`** dans le dépôt. -- `app.config.ts` fournit `provideBrowserGlobalErrorListeners()` et `provideRouter(routes)`. -- Vitest via le builder `@angular/build:unit-test`, couverture activée, un fichier de test. +- `app.config.ts` fournit `provideBrowserGlobalErrorListeners()`, `provideRouter(routes)` et + `provideHttpClient(withInterceptors([mockApiInterceptor]))`. +- Une route `/dashboard` en composant différé, et une redirection depuis la racine. +- `core/services` porte `StatsService` et `AlertsService`, `core/interceptors` l'intercepteur de + fixtures, `features/dashboard` la page, `shared/components` la jauge de consommation et le + graphique de charge par site, tous deux construits sur Chart.js. +- L'état vit dans des signaux, sans bibliothèque dédiée. +- Vitest via le builder `@angular/build:unit-test`, couverture activée, sept fichiers de test. - Prettier configuré, parser `angular` pour les gabarits HTML. Ce qui n'existe pas encore : -- `routes` est un tableau vide. Aucune page, aucune navigation. -- **`provideHttpClient` n'est pas fourni** et `@angular/common/http` n'est importé nulle part : - l'application n'appelle aucune API. -- `app.html` est la page d'accueil Angular par défaut, commentaires de remplacement compris. -- Aucune bibliothèque de graphiques, aucun kit d'interface, aucune gestion d'état. +- **Aucun endpoint réel derrière l'écran.** `GET /api/v1/stats/summary` et `GET /api/v1/alerts` + sont servis par l'intercepteur ; l'API expose `/health`, `/auth` et `/users`, rien d'autre. +- Aucune authentification côté interface : ni garde de route, ni intercepteur de jeton, alors que + les routes métier de l'API en exigent un. Voir + [31-contrat-authentification.md](31-contrat-authentification.md). +- Aucun état de chargement : tant que la première réponse n'est pas arrivée, la page reste vide. - Aucun lint : ESLint n'est pas installé. -## Arborescence cible +## Arborescence -Statut : `Cible`. Elle n'est pas inventée ici : [`TESTING.md`](../../apps/frontend/TESTING.md) la -prescrit déjà dans ses gabarits de tests. +Statut : `Fait`. Elle suit ce que [`TESTING.md`](../../apps/frontend/TESTING.md) prescrit dans ses +gabarits de tests. ```mermaid flowchart TB @@ -48,22 +54,33 @@ directement : ils passent par un service, ce qui rend le double de test trivial. ## Flux HTTP -Statut : `Cible`. Le chemin est câblé, rien ne l'emprunte encore. +Statut : `En cours`. Le chemin complet est câblé, mais un intercepteur se place devant et répond +lui-même tant que les endpoints n'existent pas. ```mermaid sequenceDiagram participant C as Composant participant S as Service Angular + participant I as mockApiInterceptor participant P as ng serve, proxy participant A as FastAPI C->>S: appel de méthode - S->>P: GET /api/v1/... - P->>A: http://localhost:8000/api/v1/... - A-->>S: JSON + S->>I: GET /api/v1/... + alt useMockFixtures actif et route connue + I-->>S: fixture locale + else + I->>P: la requête poursuit + P->>A: http://localhost:8000/api/v1/... + A-->>S: JSON + end S-->>C: modèle typé ``` +`mockApiInterceptor` n'intercepte que `/stats/summary` et `/alerts`, et seulement si +`environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en +production : toute autre requête, et toutes les requêtes en production, suivent le chemin réel. + En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un `apiUrl` relatif, `/api/v1`. @@ -87,6 +104,10 @@ déploiement, en même temps que sera tranchée la question de l'ingress dans | `npm run test` | Vitest en mode observateur | | `npm run test:ci` | Vitest en une passe | +**Version de Node.** L'Angular CLI refuse de démarrer en dessous de 22.22.3, 24.15.0 ou 26.0.0, et +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 n'apparaît dans le compose que comme valeur par défaut d'`APP_CORS_ORIGINS`, côté backend. @@ -98,8 +119,9 @@ avec un service statique, il reste à écrire. ## Sécurité - Le frontend ne détient aucun secret : `environment.ts` ne porte qu'une URL. -- L'authentification n'existe pas côté API, donc pas de garde ni d'intercepteur de jeton à ce - stade. `core/guards` et `core/interceptors` sont prévus pour cela. +- L'authentification existe côté API mais pas côté interface : aucune garde de route, aucun + intercepteur de jeton. `core/guards` reste à créer, `core/interceptors` n'héberge aujourd'hui + que les fixtures. ## Tests @@ -107,8 +129,7 @@ Conventions et gabarits : [`apps/frontend/TESTING.md`](../../apps/frontend/TESTI ## Questions ouvertes -- **Quelle bibliothèque de graphiques** pour les séries temporelles, et si Grafana en couvre déjà - une partie du besoin. -- **Gestion d'état** : signaux seuls, ou une bibliothèque dédiée. +- **Gestion d'état** : les signaux suffisent aujourd'hui, la question se reposera quand plusieurs + pages partageront le même état. - **Comment `apiUrl` est injecté en production** : build par environnement, ou configuration lue au démarrage. From 344f82fcdd38844474aa7d8bb1aa81c66deb3a20 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 10:12:41 +0200 Subject: [PATCH 027/103] 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 028/103] =?UTF-8?q?feat(backend):=20verse=20le=20contrat?= =?UTF-8?q?=20OpenAPI=20au=20d=C3=A9p=C3=B4t=20et=20le=20garde=20honn?= =?UTF-8?q?=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 029/103] 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 030/103] 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 031/103] 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 032/103] 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 033/103] 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 034/103] 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 035/103] 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 036/103] 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 037/103] 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 038/103] =?UTF-8?q?fix(backend):=20contourne=20un=20bug=20?= =?UTF-8?q?de=20ruff=20format=20sur=20le=20except=20=C3=A0=20deux=20types?= =?UTF-8?q?=20de=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 50dcb4de32b22a06ab8d0e44bc44e90a2227c756 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 11:45:58 +0200 Subject: [PATCH 039/103] 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 ad149db0cb37397fcbc16e501cd9dc93e765d268 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 11:55:03 +0200 Subject: [PATCH 040/103] 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] From e50921c90779da1fb4530fc4112be9fca24c1d64 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 13:06:37 +0200 Subject: [PATCH 041/103] feat(backend): expose GET /api/v1/alerts Consultation des alertes de consommation, filtrable par site_id et severity a l'identique du contrat GET /alerts de l'API Mock. Reprend le gabarit endpoints -> services -> repositories -> models pose par sites, sur la table alert deja creee par la revision Alembic e6d2026091501. Generalise aussi le garde-fou OpenAPI du 403 (ROUTES_A_ROLE) au-dela du seul tag users, pour que l'ajout d'alerts a la liste des routes protegees par role soit reellement verifie. Closes #59 --- apps/backend/app/api/deps.py | 9 + apps/backend/app/api/openapi.py | 5 + apps/backend/app/api/v1/endpoints/alerts.py | 23 ++ apps/backend/app/api/v1/router.py | 5 +- apps/backend/app/repositories/alert.py | 21 ++ apps/backend/app/schemas/alert.py | 34 +++ apps/backend/app/services/alert.py | 14 ++ apps/backend/openapi.json | 214 ++++++++++++++++++ apps/backend/tests/api/test_alerts.py | 136 +++++++++++ apps/backend/tests/api/test_openapi.py | 16 +- apps/backend/tests/repositories/test_alert.py | 90 ++++++++ apps/backend/tests/services/test_alert.py | 55 +++++ docs/architecture/20-backend.md | 9 +- 13 files changed, 624 insertions(+), 7 deletions(-) create mode 100644 apps/backend/app/api/v1/endpoints/alerts.py create mode 100644 apps/backend/app/repositories/alert.py create mode 100644 apps/backend/app/schemas/alert.py create mode 100644 apps/backend/app/services/alert.py create mode 100644 apps/backend/tests/api/test_alerts.py create mode 100644 apps/backend/tests/repositories/test_alert.py create mode 100644 apps/backend/tests/services/test_alert.py diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index f16d167..7415616 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -21,11 +21,13 @@ from app.core.roles import AccountKind, Role, has_at_least from app.core.security import TokenExpiredError, TokenInvalidError, TokenPolicy from app.core.security import decode_access_token as decode_token from app.db.session import get_session +from app.repositories.alert import AlertRepository 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.alert import AlertService from app.services.auth import AuthService, LoginPolicy 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_alert_service(session: SessionDep) -> AlertService: + return AlertService(alerts=AlertRepository(session)) + + +AlertServiceDep = Annotated[AlertService, Depends(get_alert_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..f090ce2 100644 --- a/apps/backend/app/api/openapi.py +++ b/apps/backend/app/api/openapi.py @@ -54,6 +54,11 @@ TAGS: Final[list[dict[str, Any]]] = [ "name": "sites", "description": "Consultation du parc de sites. Accessible à partir du rôle `lecteur`.", }, + { + "name": "alerts", + "description": "Consultation des alertes de consommation. Accessible à partir du rôle " + "`lecteur`.", + }, ] cookie_de_rafraichissement = APIKeyCookie( diff --git a/apps/backend/app/api/v1/endpoints/alerts.py b/apps/backend/app/api/v1/endpoints/alerts.py new file mode 100644 index 0000000..ac9f5ae --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/alerts.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter + +from app.api.deps import AlertServiceDep, LecteurDep +from app.api.openapi import REPONSE_VALIDATION +from app.schemas.alert import AlertResponse, AlertSeverity + +router = APIRouter() + + +@router.get( + "", + response_model=list[AlertResponse], + summary="Liste les alertes", + responses=REPONSE_VALIDATION, +) +async def list_alerts( + _: LecteurDep, + service: AlertServiceDep, + site_id: str | None = None, + severity: AlertSeverity | None = None, +) -> list[AlertResponse]: + alertes = await service.list_all(site_id=site_id, severity=severity) + return [AlertResponse.model_validate(alerte) for alerte in alertes] diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py index edb035b..c72873f 100644 --- a/apps/backend/app/api/v1/router.py +++ b/apps/backend/app/api/v1/router.py @@ -1,10 +1,13 @@ 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 alerts, auth, health, 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( + alerts.router, prefix="/alerts", tags=["alerts"], responses=REPONSES_LECTEUR +) diff --git a/apps/backend/app/repositories/alert.py b/apps/backend/app/repositories/alert.py new file mode 100644 index 0000000..4b0766f --- /dev/null +++ b/apps/backend/app/repositories/alert.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 Alert + + +class AlertRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def list_all( + self, *, site_id: str | None = None, severity: str | None = None + ) -> Sequence[Alert]: + requete = select(Alert).order_by(Alert.timestamp.desc(), Alert.alert_id.desc()) + if site_id is not None: + requete = requete.where(Alert.site_id == site_id) + if severity is not None: + requete = requete.where(Alert.severity == severity) + return (await self._session.scalars(requete)).all() diff --git a/apps/backend/app/schemas/alert.py b/apps/backend/app/schemas/alert.py new file mode 100644 index 0000000..a041b07 --- /dev/null +++ b/apps/backend/app/schemas/alert.py @@ -0,0 +1,34 @@ +from datetime import datetime +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict + + +class AlertType(StrEnum): + SPIKE = "spike" + THRESHOLD = "threshold" + ANOMALY = "anomaly" + OUTAGE = "outage" + SENSOR = "sensor" + + +class AlertSeverity(StrEnum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + + +class AlertResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + alert_id: int + site_id: str + timestamp: datetime + type: AlertType + severity: AlertSeverity + message: str + value: float | None + threshold: float | None + metric: str | None + prediction_id: int | None diff --git a/apps/backend/app/services/alert.py b/apps/backend/app/services/alert.py new file mode 100644 index 0000000..a3ad16e --- /dev/null +++ b/apps/backend/app/services/alert.py @@ -0,0 +1,14 @@ +from collections.abc import Sequence + +from app.models.energy import Alert +from app.repositories.alert import AlertRepository + + +class AlertService: + def __init__(self, *, alerts: AlertRepository) -> None: + self._alerts = alerts + + async def list_all( + self, *, site_id: str | None = None, severity: str | None = None + ) -> Sequence[Alert]: + return await self._alerts.list_all(site_id=site_id, severity=severity) diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index 3e8dc01..c7009e6 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -920,6 +920,110 @@ } } } + }, + "/api/v1/alerts": { + "get": { + "tags": [ + "alerts" + ], + "summary": "Liste les alertes", + "operationId": "list_alerts_api_v1_alerts_get", + "security": [ + { + "Jeton d'accès": [] + } + ], + "parameters": [ + { + "name": "site_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Site Id" + } + }, + { + "name": "severity", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/AlertSeverity" + }, + { + "type": "null" + } + ], + "title": "Severity" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AlertResponse" + }, + "title": "Response List Alerts Api V1 Alerts 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" + } + } + } + }, + "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" + } + } + } + } + } + } } }, "components": { @@ -932,6 +1036,112 @@ ], "title": "AccountKind" }, + "AlertResponse": { + "properties": { + "alert_id": { + "type": "integer", + "title": "Alert Id" + }, + "site_id": { + "type": "string", + "title": "Site Id" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "title": "Timestamp" + }, + "type": { + "$ref": "#/components/schemas/AlertType" + }, + "severity": { + "$ref": "#/components/schemas/AlertSeverity" + }, + "message": { + "type": "string", + "title": "Message" + }, + "value": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Value" + }, + "threshold": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Threshold" + }, + "metric": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Metric" + }, + "prediction_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Prediction Id" + } + }, + "type": "object", + "required": [ + "alert_id", + "site_id", + "timestamp", + "type", + "severity", + "message", + "value", + "threshold", + "metric", + "prediction_id" + ], + "title": "AlertResponse" + }, + "AlertSeverity": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "title": "AlertSeverity" + }, + "AlertType": { + "type": "string", + "enum": [ + "spike", + "threshold", + "anomaly", + "outage", + "sensor" + ], + "title": "AlertType" + }, "ErrorResponse": { "properties": { "detail": { @@ -1395,6 +1605,10 @@ { "name": "sites", "description": "Consultation du parc de sites. Accessible à partir du rôle `lecteur`." + }, + { + "name": "alerts", + "description": "Consultation des alertes de consommation. Accessible à partir du rôle `lecteur`." } ] } diff --git a/apps/backend/tests/api/test_alerts.py b/apps/backend/tests/api/test_alerts.py new file mode 100644 index 0000000..840d6c9 --- /dev/null +++ b/apps/backend/tests/api/test_alerts.py @@ -0,0 +1,136 @@ +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_alert_service, get_current_principal +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.models.energy import Alert + + +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 alert(alert_id: int = 1, site_id: str = "site-1", severity: str = "high") -> Alert: + return Alert( + alert_id=alert_id, + source_alert_id=f"ALR-{alert_id}", + site_id=site_id, + source="enervision", + timestamp=datetime(2026, 9, 16, tzinfo=UTC), + type="threshold", + severity=severity, + message="Dépassement du seuil configuré", + value=812.5, + threshold=720.0, + metric="consumption_kw", + prediction_id=None, + raw_data={}, + ) + + +class FauxService: + def __init__(self) -> None: + self.alert = alert() + self.appels: list[tuple[str | None, str | None]] = [] + + async def list_all( + self, *, site_id: str | None = None, severity: str | None = None + ) -> list[Alert]: + self.appels.append((site_id, severity)) + return [self.alert] + + +@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[[], FauxService]]: + def installe() -> FauxService: + service = FauxService() + app.dependency_overrides[get_alert_service] = lambda: service + return service + + yield installe + app.dependency_overrides.pop(get_alert_service, None) + + +async def test_list_alerts_returns_the_alerts( + servi: Callable[[], FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/alerts") + + assert response.status_code == 200 + corps = response.json() + assert corps == [ + { + "alert_id": 1, + "site_id": "site-1", + "timestamp": "2026-09-16T00:00:00Z", + "type": "threshold", + "severity": "high", + "message": "Dépassement du seuil configuré", + "value": 812.5, + "threshold": 720.0, + "metric": "consumption_kw", + "prediction_id": None, + } + ] + + +async def test_list_alerts_transmits_the_site_id_filter( + servi: Callable[[], FauxService], client: AsyncClient +) -> None: + service = servi() + + await client.get("/api/v1/alerts?site_id=site-1") + + assert service.appels == [("site-1", None)] + + +async def test_list_alerts_transmits_the_severity_filter( + servi: Callable[[], FauxService], client: AsyncClient +) -> None: + service = servi() + + await client.get("/api/v1/alerts?severity=critical") + + assert service.appels == [(None, "critical")] + + +async def test_list_alerts_returns_422_for_an_unknown_severity( + servi: Callable[[], FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/alerts?severity=invalide") + + assert response.status_code == 422 + + +async def test_list_alerts_returns_an_empty_list_when_there_is_nothing( + lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient +) -> None: + fake_session(result=[]) + + response = await client.get("/api/v1/alerts") + + assert response.status_code == 200 + assert response.json() == [] diff --git a/apps/backend/tests/api/test_openapi.py b/apps/backend/tests/api/test_openapi.py index 96297c0..05b5dfe 100644 --- a/apps/backend/tests/api/test_openapi.py +++ b/apps/backend/tests/api/test_openapi.py @@ -22,6 +22,18 @@ ORIGINE_VERIFIEE = { ("POST", "/api/v1/auth/password"), } +# Toute route derrière `require_role` (LecteurDep, OperateurDep, AdminDep) peut rendre 403 pour +# `password_change_required`, pas seulement les routes `admin`. +ROUTES_A_ROLE = { + ("GET", "/api/v1/users"), + ("POST", "/api/v1/users"), + ("PATCH", "/api/v1/users/{id}"), + ("POST", "/api/v1/users/{id}/password-reset"), + ("GET", "/api/v1/sites"), + ("GET", "/api/v1/sites/{site_id}"), + ("GET", "/api/v1/alerts"), +} + @pytest.fixture(scope="module") def schema() -> dict[str, Any]: @@ -55,11 +67,11 @@ def test_every_route_demanding_an_identity_says_how_it_refuses(schema: dict[str, assert muettes == [] -def test_every_administration_route_documents_the_role_refusal(schema: dict[str, Any]) -> None: +def test_every_role_guarded_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"] + if (methode, chemin) in ROUTES_A_ROLE and "403" not in operation["responses"] ] assert sans_403 == [] diff --git a/apps/backend/tests/repositories/test_alert.py b/apps/backend/tests/repositories/test_alert.py new file mode 100644 index 0000000..45ab41a --- /dev/null +++ b/apps/backend/tests/repositories/test_alert.py @@ -0,0 +1,90 @@ +import uuid +from datetime import UTC, datetime + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Alert +from app.repositories.alert import AlertRepository +from tests.repositories.test_site import creer as creer_site +from tests.repositories.test_site import identifiant as identifiant_site + +pytestmark = pytest.mark.integration + + +async def creer_alerte(session: AsyncSession, *, site_id: str, **overrides: object) -> Alert: + alerte = Alert( + source_alert_id=overrides.get("source_alert_id", f"ALR-{uuid.uuid4().hex[:12]}"), + site_id=site_id, + source=overrides.get("source", "enervision"), + timestamp=overrides.get("timestamp", datetime(2026, 9, 16, tzinfo=UTC)), + type=overrides.get("type", "threshold"), + severity=overrides.get("severity", "high"), + message=overrides.get("message", "Dépassement du seuil configuré"), + value=overrides.get("value", 812.5), + threshold=overrides.get("threshold", 720.0), + metric=overrides.get("metric", "consumption_kw"), + prediction_id=overrides.get("prediction_id"), + raw_data=overrides.get("raw_data", {}), + ) + session.add(alerte) + await session.flush() + return alerte + + +async def test_list_all_returns_the_alerts_sorted_by_timestamp_descending( + session: AsyncSession, +) -> None: + site = await creer_site(session) + depot = AlertRepository(session) + ancienne = await creer_alerte( + session, site_id=site.site_id, timestamp=datetime(2026, 9, 1, tzinfo=UTC) + ) + recente = await creer_alerte( + session, site_id=site.site_id, timestamp=datetime(2026, 9, 15, tzinfo=UTC) + ) + + alertes = await depot.list_all() + identifiants = [ + a.alert_id for a in alertes if a.alert_id in (ancienne.alert_id, recente.alert_id) + ] + await session.rollback() + + assert identifiants == [recente.alert_id, ancienne.alert_id] + + +async def test_list_all_filters_by_site_id(session: AsyncSession) -> None: + premier = await creer_site(session) + second = await creer_site(session) + depot = AlertRepository(session) + voulue = await creer_alerte(session, site_id=premier.site_id) + await creer_alerte(session, site_id=second.site_id) + + alertes = await depot.list_all(site_id=premier.site_id) + identifiants = [a.alert_id for a in alertes] + await session.rollback() + + assert identifiants == [voulue.alert_id] + + +async def test_list_all_filters_by_severity(session: AsyncSession) -> None: + site = await creer_site(session) + depot = AlertRepository(session) + voulue = await creer_alerte(session, site_id=site.site_id, severity="critical") + await creer_alerte(session, site_id=site.site_id, severity="low") + + alertes = await depot.list_all(severity="critical") + identifiants = [a.alert_id for a in alertes] + await session.rollback() + + assert identifiants == [voulue.alert_id] + + +async def test_list_all_returns_an_empty_list_when_there_is_nothing( + session: AsyncSession, +) -> None: + depot = AlertRepository(session) + + alertes = await depot.list_all(site_id=identifiant_site()) + + assert list(alertes) == [] diff --git a/apps/backend/tests/services/test_alert.py b/apps/backend/tests/services/test_alert.py new file mode 100644 index 0000000..4a88802 --- /dev/null +++ b/apps/backend/tests/services/test_alert.py @@ -0,0 +1,55 @@ +from datetime import UTC, datetime + +from app.models.energy import Alert +from app.services.alert import AlertService + + +def alert( + alert_id: int = 1, + site_id: str = "site-1", + severity: str = "high", +) -> Alert: + return Alert( + alert_id=alert_id, + source_alert_id=f"ALR-{alert_id}", + site_id=site_id, + source="enervision", + timestamp=datetime(2026, 9, 16, tzinfo=UTC), + type="threshold", + severity=severity, + message="Dépassement du seuil configuré", + value=812.5, + threshold=720.0, + metric="consumption_kw", + prediction_id=None, + raw_data={}, + ) + + +class FakeRepository: + def __init__(self, alerts: list[Alert]) -> None: + self._alerts = alerts + self.appels: list[tuple[str | None, str | None]] = [] + + async def list_all( + self, *, site_id: str | None = None, severity: str | None = None + ) -> list[Alert]: + self.appels.append((site_id, severity)) + return self._alerts + + +async def test_list_all_returns_the_repository_alerts() -> None: + service = AlertService(alerts=FakeRepository([alert(1), alert(2)])) + + alertes = await service.list_all() + + assert [a.alert_id for a in alertes] == [1, 2] + + +async def test_list_all_relays_the_filters_to_the_repository() -> None: + depot = FakeRepository([]) + service = AlertService(alerts=depot) + + await service.list_all(site_id="site-1", severity="critical") + + assert depot.appels == [("site-1", "critical")] diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index f48178f..e814ecd 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -142,6 +142,7 @@ 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/alerts` | Liste les alertes, filtrable par `site_id` et `severity`. `lecteur` | 401, 403, 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` | | @@ -153,10 +154,10 @@ Les codes de la dernière colonne sont ceux que le schéma **déclare**, et le f é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. -`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`, +`GET /sites` et `GET /sites/{site_id}` sont la première route métier, et le gabarit repris pour +`GET /alerts` puis pour les suivantes (`reading`, `dataset`, `prediction`, `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 From 1654e4dd811139e015dfd2defdc94745fcc4acc0 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 13:27:41 +0200 Subject: [PATCH 042/103] docs(backend): documente la checklist d'ajout d'une route metier La generalisation de ROUTES_A_ROLE (commit precedent) avait deja ete approuvee sur feat/openapi-contrat mais poussee apres la fermeture de la PR #76 : elle n'a donc jamais atteint dev, et sa documentation non plus. Complete ce qui manquait pour que le passage a l'echelle du contrat OpenAPI soit reellement utilisable par la prochaine route. --- docs/architecture/20-backend.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index e814ecd..5347e3e 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -236,6 +236,21 @@ Les modèles de `app/schemas/errors.py` décrivent ce que les gestionnaires renv `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. +### Ajouter une route métier + +Checklist pour toute nouvelle route sur le gabarit `sites`/`alerts` (`reading`, `dataset`, +`prediction`, `recommendation`) : + +1. Composer ses `responses=` depuis `app/api/openapi.py` : `REPONSES_LECTEUR`/`REPONSES_ADMIN` + au niveau de l'`include_router()` du routeur, `REPONSE_VALIDATION` et les codes locaux + (404, 409, ...) directement sur l'endpoint qui les rend. +2. Décrire son tag dans `TAGS`. +3. Si elle passe par `require_role` (`LecteurDep`/`OperateurDep`/`AdminDep`), l'ajouter à + `ROUTES_A_ROLE` dans `tests/api/test_openapi.py`. Si elle passe par `require_trusted_origin`, + l'ajouter à `ORIGINE_VERIFIEE`. **Ces deux listes sont maintenues à la main, pas dérivées** : + une route oubliée n'y est pas détectée automatiquement. +4. `make openapi`, puis `uv run pytest tests/api/test_openapi.py`. + ## Sécurité Voir la vue consolidée dans [00-vue-ensemble.md](00-vue-ensemble.md) et les décisions dans les From 781644b28ea5916730c76d969249c29635fc84a2 Mon Sep 17 00:00:00 2001 From: Dorian PESCE Date: Wed, 16 Sep 2026 13:30:51 +0200 Subject: [PATCH 043/103] 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. | From 76fa90dfcbf6836fa43abb7c1f9801c0ea3db5fb Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 14:43:27 +0200 Subject: [PATCH 044/103] test(backend): exerce AlertSeverity comme enum plutot qu'une chaine dans les tests alerts Le filtre severity passait par une chaine brute dans les tests, sans jamais exercer le trajet reel AlertSeverity (enum) -> SQLAlchemy -> PostgreSQL. --- apps/backend/tests/api/test_alerts.py | 3 ++- apps/backend/tests/repositories/test_alert.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/backend/tests/api/test_alerts.py b/apps/backend/tests/api/test_alerts.py index 840d6c9..fc5f110 100644 --- a/apps/backend/tests/api/test_alerts.py +++ b/apps/backend/tests/api/test_alerts.py @@ -10,6 +10,7 @@ from app.api.deps import get_alert_service, get_current_principal from app.core.principal import Principal from app.core.roles import AccountKind, Role from app.models.energy import Alert +from app.schemas.alert import AlertSeverity def principal(role: Role = Role.LECTEUR) -> Principal: @@ -112,7 +113,7 @@ async def test_list_alerts_transmits_the_severity_filter( await client.get("/api/v1/alerts?severity=critical") - assert service.appels == [(None, "critical")] + assert service.appels == [(None, AlertSeverity.CRITICAL)] async def test_list_alerts_returns_422_for_an_unknown_severity( diff --git a/apps/backend/tests/repositories/test_alert.py b/apps/backend/tests/repositories/test_alert.py index 45ab41a..d2a78d0 100644 --- a/apps/backend/tests/repositories/test_alert.py +++ b/apps/backend/tests/repositories/test_alert.py @@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.models.energy import Alert from app.repositories.alert import AlertRepository +from app.schemas.alert import AlertSeverity from tests.repositories.test_site import creer as creer_site from tests.repositories.test_site import identifiant as identifiant_site @@ -73,7 +74,7 @@ async def test_list_all_filters_by_severity(session: AsyncSession) -> None: voulue = await creer_alerte(session, site_id=site.site_id, severity="critical") await creer_alerte(session, site_id=site.site_id, severity="low") - alertes = await depot.list_all(severity="critical") + alertes = await depot.list_all(severity=AlertSeverity.CRITICAL) identifiants = [a.alert_id for a in alertes] await session.rollback() From 77440281f8910c52045c8ef0517c0135bda11630 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 14:53:54 +0200 Subject: [PATCH 045/103] feat(backend): expose GET /api/v1/sensors/status pour l'issue #32 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dérive l'état de santé de 5 capteurs par site et un statut overall depuis la dernière lecture (data_quality, null_reasons, nullité des colonnes), sur le gabarit d'agrégation de StatsService. Route réservée au rôle admin. --- apps/backend/app/api/deps.py | 8 + apps/backend/app/api/openapi.py | 4 + apps/backend/app/api/v1/endpoints/sensors.py | 16 ++ apps/backend/app/api/v1/router.py | 5 +- apps/backend/app/schemas/sensor.py | 42 ++++ apps/backend/app/services/sensor.py | 137 +++++++++++++ apps/backend/openapi.json | 173 ++++++++++++++++ apps/backend/tests/api/test_openapi.py | 1 + apps/backend/tests/api/test_sensors.py | 91 +++++++++ apps/backend/tests/services/test_sensor.py | 197 +++++++++++++++++++ docs/architecture/20-backend.md | 18 +- 11 files changed, 683 insertions(+), 9 deletions(-) create mode 100644 apps/backend/app/api/v1/endpoints/sensors.py create mode 100644 apps/backend/app/schemas/sensor.py create mode 100644 apps/backend/app/services/sensor.py create mode 100644 apps/backend/tests/api/test_sensors.py create mode 100644 apps/backend/tests/services/test_sensor.py diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index aaf7403..5b39098 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -32,6 +32,7 @@ from app.repositories.user import UserRepository from app.services.alert import AlertService from app.services.auth import AuthService, LoginPolicy from app.services.recommendation import RecommendationService +from app.services.sensor import SensorService from app.services.site import SiteService from app.services.stats import StatsService from app.services.user import UserService @@ -167,6 +168,13 @@ def get_stats_service(session: SessionDep) -> StatsService: StatsServiceDep = Annotated[StatsService, Depends(get_stats_service)] +def get_sensor_service(session: SessionDep) -> SensorService: + return SensorService(sites=SiteRepository(session), readings=ReadingRepository(session)) + + +SensorServiceDep = Annotated[SensorService, Depends(get_sensor_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 6eb02a2..85b7775 100644 --- a/apps/backend/app/api/openapi.py +++ b/apps/backend/app/api/openapi.py @@ -71,6 +71,10 @@ TAGS: Final[list[dict[str, Any]]] = [ "description": "Statistiques agrégées de consommation. Accessible à partir du rôle " "`lecteur`.", }, + { + "name": "sensors", + "description": "État de santé des capteurs par site. Réservé au rôle `admin`.", + }, ] cookie_de_rafraichissement = APIKeyCookie( diff --git a/apps/backend/app/api/v1/endpoints/sensors.py b/apps/backend/app/api/v1/endpoints/sensors.py new file mode 100644 index 0000000..40cb409 --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/sensors.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter + +from app.api.deps import AdminDep, SensorServiceDep +from app.schemas.sensor import SensorStatusResponse + +router = APIRouter() + + +@router.get( + "/status", + response_model=SensorStatusResponse, + summary="État de santé des capteurs par site", +) +async def get_status(_: AdminDep, service: SensorServiceDep) -> SensorStatusResponse: + etat = await service.status() + return SensorStatusResponse.model_validate(etat) diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py index 60171df..f5075ca 100644 --- a/apps/backend/app/api/v1/router.py +++ b/apps/backend/app/api/v1/router.py @@ -1,7 +1,7 @@ from fastapi import APIRouter from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR -from app.api.v1.endpoints import alerts, auth, health, recommendations, sites, stats, users +from app.api.v1.endpoints import alerts, auth, health, recommendations, sensors, sites, stats, users api_router = APIRouter(responses=REPONSE_SERVEUR) api_router.include_router(health.router, prefix="/health", tags=["health"]) @@ -18,3 +18,6 @@ api_router.include_router( responses=REPONSES_LECTEUR, ) api_router.include_router(stats.router, prefix="/stats", tags=["stats"], responses=REPONSES_LECTEUR) +api_router.include_router( + sensors.router, prefix="/sensors", tags=["sensors"], responses=REPONSES_ADMIN +) diff --git a/apps/backend/app/schemas/sensor.py b/apps/backend/app/schemas/sensor.py new file mode 100644 index 0000000..6a36a83 --- /dev/null +++ b/apps/backend/app/schemas/sensor.py @@ -0,0 +1,42 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class SensorDiagnosticResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + status: Literal["ok", "failing"] + since: datetime | None = Field( + description=( + "Horodatage de la dernière lecture reçue pour ce site. Ce n'est pas le début de la " + "panne : l'historique ne permet pas de le dater sans requête supplémentaire." + ) + ) + + +class SiteSensorsResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + consumption: SensorDiagnosticResponse + electrical: SensorDiagnosticResponse + temperature: SensorDiagnosticResponse + humidity: SensorDiagnosticResponse + network: SensorDiagnosticResponse + + +class SiteSensorStatusResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + site_id: str + site_name: str + sensors: SiteSensorsResponse + overall: Literal["ok", "degraded", "critical"] + + +class SensorStatusResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + timestamp: datetime + sites: list[SiteSensorStatusResponse] diff --git a/apps/backend/app/services/sensor.py b/apps/backend/app/services/sensor.py new file mode 100644 index 0000000..1d707e0 --- /dev/null +++ b/apps/backend/app/services/sensor.py @@ -0,0 +1,137 @@ +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 + +CapteurStatus = Literal["ok", "failing"] +OverallStatus = Literal["ok", "degraded", "critical"] + +QUALITES_CONNUES: frozenset[str] = frozenset({"good", "partial", "degraded", "critical"}) + +RAISON_VERS_CAPTEUR: dict[str, str] = { + "consumption_sensor_failure": "consumption", + "electrical_sensor_failure": "electrical", + "temperature_sensor_failure": "temperature", + "humidity_sensor_failure": "humidity", + "network_loss": "network", +} + +CHAMPS_PAR_CAPTEUR: dict[str, tuple[str, ...]] = { + "consumption": ("consumption_kw",), + "electrical": ("voltage_v", "current_a", "power_factor"), + "temperature": ("temperature_celsius",), + "humidity": ("humidity_percent",), +} + + +@dataclass(frozen=True, slots=True) +class DiagnosticCapteur: + status: CapteurStatus + since: datetime | None + + +@dataclass(frozen=True, slots=True) +class SanteCapteurs: + consumption: DiagnosticCapteur + electrical: DiagnosticCapteur + temperature: DiagnosticCapteur + humidity: DiagnosticCapteur + network: DiagnosticCapteur + + +@dataclass(frozen=True, slots=True) +class SanteSite: + site_id: str + site_name: str + sensors: SanteCapteurs + overall: OverallStatus + + +@dataclass(frozen=True, slots=True) +class EtatCapteurs: + timestamp: datetime + sites: list[SanteSite] + + +class SensorService: + def __init__(self, sites: SiteRepository, readings: ReadingRepository) -> None: + self._sites = sites + self._readings = readings + + async def status(self) -> EtatCapteurs: + sites = await self._sites.list_all() + dernieres = {lecture.site_id: lecture for lecture in await self._readings.latest_by_site()} + + return EtatCapteurs( + timestamp=datetime.now(UTC), + sites=[_sante_site(site, dernieres.get(site.site_id)) for site in sites], + ) + + +def _sante_site(site: Site, derniere: Reading | None) -> SanteSite: + if derniere is None: + return SanteSite( + site_id=site.site_id, + site_name=site.site_name, + sensors=_tout_en_echec(since=None), + overall="critical", + ) + + qualite = derniere.data_quality if derniere.data_quality in QUALITES_CONNUES else "critical" + overall = _overall_depuis_qualite(qualite) + + if overall == "critical": + return SanteSite( + site_id=site.site_id, + site_name=site.site_name, + sensors=_tout_en_echec(since=derniere.timestamp), + overall="critical", + ) + + raisons_signalees = { + RAISON_VERS_CAPTEUR[raison] + for raison in (derniere.null_reasons or []) + if raison in RAISON_VERS_CAPTEUR + } + + return SanteSite( + site_id=site.site_id, + site_name=site.site_name, + sensors=SanteCapteurs( + consumption=_diagnostic("consumption", derniere, raisons_signalees), + electrical=_diagnostic("electrical", derniere, raisons_signalees), + temperature=_diagnostic("temperature", derniere, raisons_signalees), + humidity=_diagnostic("humidity", derniere, raisons_signalees), + network=_diagnostic("network", derniere, raisons_signalees), + ), + overall=overall, + ) + + +def _overall_depuis_qualite(qualite: str) -> OverallStatus: + if qualite == "good": + return "ok" + if qualite in ("partial", "degraded"): + return "degraded" + return "critical" + + +def _diagnostic(capteur: str, derniere: Reading, raisons_signalees: set[str]) -> DiagnosticCapteur: + champs = CHAMPS_PAR_CAPTEUR.get(capteur, ()) + en_echec = capteur in raisons_signalees or any( + getattr(derniere, champ) is None for champ in champs + ) + return DiagnosticCapteur( + status="failing" if en_echec else "ok", + since=derniere.timestamp if en_echec else None, + ) + + +def _tout_en_echec(since: datetime | None) -> SanteCapteurs: + echec = DiagnosticCapteur(status="failing", since=since) + return SanteCapteurs( + consumption=echec, electrical=echec, temperature=echec, humidity=echec, network=echec + ) diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index af962df..84f9c08 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -1227,6 +1227,62 @@ } ] } + }, + "/api/v1/sensors/status": { + "get": { + "tags": [ + "sensors" + ], + "summary": "État de santé des capteurs par site", + "operationId": "get_status_api_v1_sensors_status_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SensorStatusResponse" + } + } + } + }, + "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": [] + } + ] + } } }, "components": { @@ -1572,6 +1628,59 @@ ], "title": "Role" }, + "SensorDiagnosticResponse": { + "properties": { + "status": { + "type": "string", + "enum": [ + "ok", + "failing" + ], + "title": "Status" + }, + "since": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Since", + "description": "Horodatage de la dernière lecture reçue pour ce site. Ce n'est pas le début de la panne : l'historique ne permet pas de le dater sans requête supplémentaire." + } + }, + "type": "object", + "required": [ + "status", + "since" + ], + "title": "SensorDiagnosticResponse" + }, + "SensorStatusResponse": { + "properties": { + "timestamp": { + "type": "string", + "format": "date-time", + "title": "Timestamp" + }, + "sites": { + "items": { + "$ref": "#/components/schemas/SiteSensorStatusResponse" + }, + "type": "array", + "title": "Sites" + } + }, + "type": "object", + "required": [ + "timestamp", + "sites" + ], + "title": "SensorStatusResponse" + }, "SiteResponse": { "properties": { "site_id": { @@ -1631,6 +1740,66 @@ ], "title": "SiteResponse" }, + "SiteSensorStatusResponse": { + "properties": { + "site_id": { + "type": "string", + "title": "Site Id" + }, + "site_name": { + "type": "string", + "title": "Site Name" + }, + "sensors": { + "$ref": "#/components/schemas/SiteSensorsResponse" + }, + "overall": { + "type": "string", + "enum": [ + "ok", + "degraded", + "critical" + ], + "title": "Overall" + } + }, + "type": "object", + "required": [ + "site_id", + "site_name", + "sensors", + "overall" + ], + "title": "SiteSensorStatusResponse" + }, + "SiteSensorsResponse": { + "properties": { + "consumption": { + "$ref": "#/components/schemas/SensorDiagnosticResponse" + }, + "electrical": { + "$ref": "#/components/schemas/SensorDiagnosticResponse" + }, + "temperature": { + "$ref": "#/components/schemas/SensorDiagnosticResponse" + }, + "humidity": { + "$ref": "#/components/schemas/SensorDiagnosticResponse" + }, + "network": { + "$ref": "#/components/schemas/SensorDiagnosticResponse" + } + }, + "type": "object", + "required": [ + "consumption", + "electrical", + "temperature", + "humidity", + "network" + ], + "title": "SiteSensorsResponse" + }, "SiteSummaryResponse": { "properties": { "site_id": { @@ -1959,6 +2128,10 @@ { "name": "stats", "description": "Statistiques agrégées de consommation. Accessible à partir du rôle `lecteur`." + }, + { + "name": "sensors", + "description": "État de santé des capteurs par site. Réservé au rôle `admin`." } ] } diff --git a/apps/backend/tests/api/test_openapi.py b/apps/backend/tests/api/test_openapi.py index f7147da..8b600bf 100644 --- a/apps/backend/tests/api/test_openapi.py +++ b/apps/backend/tests/api/test_openapi.py @@ -35,6 +35,7 @@ ROUTES_A_ROLE = { ("GET", "/api/v1/recommendations"), ("GET", "/api/v1/recommendations/{recommendation_id}"), ("GET", "/api/v1/stats/summary"), + ("GET", "/api/v1/sensors/status"), } diff --git a/apps/backend/tests/api/test_sensors.py b/apps/backend/tests/api/test_sensors.py new file mode 100644 index 0000000..e91ab64 --- /dev/null +++ b/apps/backend/tests/api/test_sensors.py @@ -0,0 +1,91 @@ +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_sensor_service +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.services.sensor import DiagnosticCapteur, EtatCapteurs, SanteCapteurs, SanteSite + +TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) + + +def principal(role: Role = Role.ADMIN) -> 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: + ok = DiagnosticCapteur(status="ok", since=None) + en_echec = DiagnosticCapteur(status="failing", since=TIMESTAMP) + self.etat = EtatCapteurs( + timestamp=TIMESTAMP, + sites=[ + SanteSite( + site_id="SITE001", + site_name="Bureau Paris La Défense", + sensors=SanteCapteurs( + consumption=ok, + electrical=ok, + temperature=en_echec, + humidity=ok, + network=ok, + ), + overall="degraded", + ) + ], + ) + + async def status(self) -> EtatCapteurs: + return self.etat + + +@pytest.fixture +def admin_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, admin_connecte: None) -> Iterator[Callable[[], FauxService]]: + def installe() -> FauxService: + service = FauxService() + app.dependency_overrides[get_sensor_service] = lambda: service + return service + + yield installe + app.dependency_overrides.pop(get_sensor_service, None) + + +async def test_get_status_returns_the_service_result( + servi: Callable[[], FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/sensors/status") + + assert response.status_code == 200 + corps = response.json() + assert corps["sites"][0]["site_id"] == "SITE001" + assert corps["sites"][0]["overall"] == "degraded" + assert corps["sites"][0]["sensors"]["temperature"]["status"] == "failing" + assert corps["sites"][0]["sensors"]["consumption"]["status"] == "ok" + + +async def test_get_status_refuses_a_reader(app: FastAPI, client: AsyncClient) -> None: + app.dependency_overrides[get_current_principal] = lambda: principal(Role.LECTEUR) + + response = await client.get("/api/v1/sensors/status") + + assert response.status_code == 403 diff --git a/apps/backend/tests/services/test_sensor.py b/apps/backend/tests/services/test_sensor.py new file mode 100644 index 0000000..9a8d62f --- /dev/null +++ b/apps/backend/tests/services/test_sensor.py @@ -0,0 +1,197 @@ +from dataclasses import dataclass, field +from datetime import UTC, datetime + +from app.services.sensor import SensorService + +TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) + + +@dataclass +class FauxSite: + site_id: str + site_name: str + + +@dataclass +class FauxLecture: + site_id: str + timestamp: datetime + data_quality: str | None + null_reasons: list[str] | None = field(default_factory=list) + consumption_kw: float | None = 10.0 + voltage_v: float | None = 230.0 + current_a: float | None = 5.0 + power_factor: float | None = 0.95 + temperature_celsius: float | None = 21.0 + humidity_percent: float | None = 40.0 + + +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_status_marks_a_site_without_any_reading_as_critical_with_every_sensor_failing() -> ( + None +): + service = SensorService( + sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type] + readings=FauxDepotLectures([]), # type: ignore[arg-type] + ) + + etat = await service.status() + + site = etat.sites[0] + assert site.overall == "critical" + for capteur in ( + site.sensors.consumption, + site.sensors.electrical, + site.sensors.temperature, + site.sensors.humidity, + site.sensors.network, + ): + assert capteur.status == "failing" + assert capteur.since is None + + +async def test_status_marks_every_sensor_ok_on_a_good_quality_reading_with_no_null_field() -> None: + service = SensorService( + sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type] + readings=FauxDepotLectures([FauxLecture("A", TIMESTAMP, "good")]), # type: ignore[arg-type] + ) + + etat = await service.status() + + site = etat.sites[0] + assert site.overall == "ok" + for capteur in ( + site.sensors.consumption, + site.sensors.electrical, + site.sensors.temperature, + site.sensors.humidity, + site.sensors.network, + ): + assert capteur.status == "ok" + assert capteur.since is None + + +async def test_status_flags_the_sensor_named_in_null_reasons() -> None: + service = SensorService( + sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type] + readings=FauxDepotLectures( # type: ignore[arg-type] + [ + FauxLecture( + "A", + TIMESTAMP, + "partial", + null_reasons=["temperature_sensor_failure"], + temperature_celsius=None, + ) + ] + ), + ) + + etat = await service.status() + + site = etat.sites[0] + assert site.overall == "degraded" + assert site.sensors.temperature.status == "failing" + assert site.sensors.temperature.since == TIMESTAMP + assert site.sensors.consumption.status == "ok" + assert site.sensors.electrical.status == "ok" + assert site.sensors.humidity.status == "ok" + assert site.sensors.network.status == "ok" + + +async def test_status_flags_a_sensor_from_a_null_field_even_without_a_null_reason() -> None: + service = SensorService( + sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type] + readings=FauxDepotLectures( # type: ignore[arg-type] + [FauxLecture("A", TIMESTAMP, "partial", null_reasons=[], humidity_percent=None)] + ), + ) + + etat = await service.status() + + site = etat.sites[0] + assert site.sensors.humidity.status == "failing" + assert site.sensors.humidity.since == TIMESTAMP + + +async def test_status_flags_electrical_as_failing_when_any_of_its_three_fields_is_null() -> None: + service = SensorService( + sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type] + readings=FauxDepotLectures( # type: ignore[arg-type] + [FauxLecture("A", TIMESTAMP, "partial", null_reasons=[], power_factor=None)] + ), + ) + + etat = await service.status() + + site = etat.sites[0] + assert site.sensors.electrical.status == "failing" + + +async def test_status_forces_every_sensor_to_failing_when_overall_is_critical() -> None: + service = SensorService( + sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type] + readings=FauxDepotLectures([FauxLecture("A", TIMESTAMP, "critical", null_reasons=[])]), # type: ignore[arg-type] + ) + + etat = await service.status() + + site = etat.sites[0] + assert site.overall == "critical" + for capteur in ( + site.sensors.consumption, + site.sensors.electrical, + site.sensors.temperature, + site.sensors.humidity, + site.sensors.network, + ): + assert capteur.status == "failing" + assert capteur.since == TIMESTAMP + + +async def test_status_treats_an_unknown_data_quality_as_critical() -> None: + service = SensorService( + sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type] + readings=FauxDepotLectures([FauxLecture("A", TIMESTAMP, None, null_reasons=[])]), # type: ignore[arg-type] + ) + + etat = await service.status() + + assert etat.sites[0].overall == "critical" + + +async def test_status_ignores_an_unknown_null_reason() -> None: + service = SensorService( + sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type] + readings=FauxDepotLectures( # type: ignore[arg-type] + [FauxLecture("A", TIMESTAMP, "good", null_reasons=["something_else"])] + ), + ) + + etat = await service.status() + + site = etat.sites[0] + assert site.overall == "ok" + for capteur in ( + site.sensors.consumption, + site.sensors.electrical, + site.sensors.temperature, + site.sensors.humidity, + site.sensors.network, + ): + assert capteur.status == "ok" diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index fec2794..d803893 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -12,10 +12,10 @@ Les quatre couches existent désormais, portées par l'authentification. ```mermaid flowchart TB - ep["endpoints
health, auth, users, sites,
recommendations, stats"] + ep["endpoints
health, auth, users, sites, alerts,
recommendations, stats, sensors"] sc["schemas
Pydantic"] - sv["services
AuthService, UserService,
SiteService, RecommendationService,
StatsService"] - rp["repositories
user, refresh_token,
login_attempt, audit_log,
site, recommendation, reading"] + sv["services
AuthService, UserService,
SiteService, AlertService, RecommendationService,
StatsService, SensorService"] + rp["repositories
user, refresh_token,
login_attempt, audit_log,
site, alert, recommendation, reading"] md["models
10 tables"] db[("PostgreSQL")] @@ -146,6 +146,7 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke | 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 | `/api/v1/stats/summary` | Résume la consommation instantanée du parc. `lecteur` | 401, 403, 500 | +| GET | `/api/v1/sensors/status` | État de santé des capteurs par site, dérivé de la dernière lecture. `admin` | 401, 403, 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` | | @@ -167,9 +168,10 @@ contrairement aux routes d'administration qui exigent `admin`. `SiteRepository` 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. `GET /stats/summary` agrège deux repositories (`SiteRepository`, `ReadingRepository`) -dans un service dédié plutôt que d'exposer une table : elle n'entre donc pas dans ce gabarit -route-par-table. Le contrat détaillé pour le frontend est dans +exposée. `GET /stats/summary` et `GET /sensors/status` agrègent chacune deux repositories +(`SiteRepository`, `ReadingRepository`) dans un service dédié plutôt que d'exposer une table : +elles n'entrent donc pas dans ce gabarit route-par-table. Le contrat détaillé pour le frontend est +dans [31-contrat-authentification.md](31-contrat-authentification.md). ### `/health/ready` @@ -246,8 +248,8 @@ Les modèles de `app/schemas/errors.py` décrivent ce que les gestionnaires renv ### Ajouter une route métier -Checklist pour toute nouvelle route sur le gabarit `sites`/`alerts`/`recommendations`/`stats` -(`reading`, `dataset`, `prediction`) : +Checklist pour toute nouvelle route sur le gabarit `sites`/`alerts`/`recommendations`/`stats`/ +`sensors` (`reading`, `dataset`, `prediction`) : 1. Composer ses `responses=` depuis `app/api/openapi.py` : `REPONSES_LECTEUR`/`REPONSES_ADMIN` au niveau de l'`include_router()` du routeur, `REPONSE_VALIDATION` et les codes locaux From 07ea8d21dc6ef7a01b141369da8d4a74b10c1e26 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 15:25:14 +0200 Subject: [PATCH 046/103] feat(backend): expose GET /api/v1/sites/{site_id}/current pour l'issue #29 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute la dernière mesure d'un site (SiteService.current), en réutilisant la vérification d'existence déjà en place pour GET /sites/{site_id} : SiteService gagne une dépendance ReadingRepository, sur le modèle de composition déjà utilisé par StatsService/SensorService. Un site connu sans lecture rend 200 avec les champs de mesure à null et data_quality="critical" ; seul un site_id absent rend 404. --- apps/backend/app/api/deps.py | 2 +- apps/backend/app/api/v1/endpoints/sites.py | 20 +- apps/backend/app/repositories/reading.py | 9 + apps/backend/app/schemas/site.py | 20 ++ apps/backend/app/services/site.py | 65 +++++- apps/backend/openapi.json | 221 +++++++++++++++++++++ apps/backend/tests/api/test_openapi.py | 1 + apps/backend/tests/api/test_sites.py | 52 ++++- apps/backend/tests/services/test_site.py | 89 ++++++++- docs/architecture/20-backend.md | 7 +- 10 files changed, 474 insertions(+), 12 deletions(-) diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index aaf7403..eb78758 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -140,7 +140,7 @@ UserServiceDep = Annotated[UserService, Depends(get_user_service)] def get_site_service(session: SessionDep) -> SiteService: - return SiteService(sites=SiteRepository(session)) + return SiteService(sites=SiteRepository(session), readings=ReadingRepository(session)) SiteServiceDep = Annotated[SiteService, Depends(get_site_service)] diff --git a/apps/backend/app/api/v1/endpoints/sites.py b/apps/backend/app/api/v1/endpoints/sites.py index 984dd8b..93923e9 100644 --- a/apps/backend/app/api/v1/endpoints/sites.py +++ b/apps/backend/app/api/v1/endpoints/sites.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, HTTPException, status from app.api.deps import LecteurDep, SiteServiceDep from app.api.openapi import REPONSE_VALIDATION, Reponses from app.schemas.errors import ErrorResponse -from app.schemas.site import SiteResponse +from app.schemas.site import SiteCurrentResponse, SiteResponse from app.services.site import SiteNotFoundError router = APIRouter() @@ -34,3 +34,21 @@ async def get_site(site_id: str, _: LecteurDep, service: SiteServiceDep) -> Site status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable" ) from erreur return SiteResponse.model_validate(site) + + +@router.get( + "/{site_id}/current", + response_model=SiteCurrentResponse, + summary="Dernière mesure d'un site", + responses=REPONSES_INTROUVABLE, +) +async def get_current( + site_id: str, _: LecteurDep, service: SiteServiceDep +) -> SiteCurrentResponse: + try: + actuel = await service.current(site_id) + except SiteNotFoundError as erreur: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable" + ) from erreur + return SiteCurrentResponse.model_validate(actuel) diff --git a/apps/backend/app/repositories/reading.py b/apps/backend/app/repositories/reading.py index 5424b46..c05ae60 100644 --- a/apps/backend/app/repositories/reading.py +++ b/apps/backend/app/repositories/reading.py @@ -19,3 +19,12 @@ class ReadingRepository: .order_by(Reading.site_id, Reading.timestamp.desc()) ) return (await self._session.execute(requete)).scalars().all() + + async def latest_for_site(self, site_id: str) -> Reading | None: + requete = ( + select(Reading) + .where(Reading.site_id == site_id) + .order_by(Reading.timestamp.desc()) + .limit(1) + ) + return await self._session.scalar(requete) diff --git a/apps/backend/app/schemas/site.py b/apps/backend/app/schemas/site.py index 82035f5..56a61b7 100644 --- a/apps/backend/app/schemas/site.py +++ b/apps/backend/app/schemas/site.py @@ -1,3 +1,6 @@ +from datetime import datetime +from typing import Literal + from pydantic import BaseModel, ConfigDict @@ -10,3 +13,20 @@ class SiteResponse(BaseModel): location: str | None capacity_kw: float | None status: str | None + + +class SiteCurrentResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + timestamp: datetime | None + site_id: str + site_type: str + consumption_kw: float | None + consumption_kwh: float | None + voltage_v: float | None + current_a: float | None + power_factor: float | None + temperature_celsius: float | None + humidity_percent: float | None + null_reasons: list[str] + data_quality: Literal["good", "partial", "degraded", "critical"] diff --git a/apps/backend/app/services/site.py b/apps/backend/app/services/site.py index 515497a..25a819d 100644 --- a/apps/backend/app/services/site.py +++ b/apps/backend/app/services/site.py @@ -1,8 +1,16 @@ from collections.abc import Sequence +from dataclasses import dataclass +from datetime import datetime +from typing import Literal from app.models.energy import 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"}) + class SiteError(Exception): pass @@ -12,9 +20,26 @@ class SiteNotFoundError(SiteError): pass +@dataclass(frozen=True, slots=True) +class SiteCurrentReading: + timestamp: datetime | None + site_id: str + site_type: str + consumption_kw: float | None + consumption_kwh: float | None + voltage_v: float | None + current_a: float | None + power_factor: float | None + temperature_celsius: float | None + humidity_percent: float | None + null_reasons: list[str] + data_quality: DataQuality + + class SiteService: - def __init__(self, *, sites: SiteRepository) -> None: + def __init__(self, *, sites: SiteRepository, readings: ReadingRepository) -> None: self._sites = sites + self._readings = readings async def list_all(self) -> Sequence[Site]: return await self._sites.list_all() @@ -24,3 +49,41 @@ class SiteService: if site is None: raise SiteNotFoundError(site_id) return site + + async def current(self, site_id: str) -> SiteCurrentReading: + site = await self.get_by_id(site_id) + derniere = await self._readings.latest_for_site(site_id) + + if derniere is None: + return SiteCurrentReading( + timestamp=None, + site_id=site.site_id, + site_type=site.site_type, + consumption_kw=None, + consumption_kwh=None, + voltage_v=None, + current_a=None, + power_factor=None, + temperature_celsius=None, + humidity_percent=None, + null_reasons=[], + data_quality="critical", + ) + + qualite: DataQuality = ( + derniere.data_quality if derniere.data_quality in QUALITES_CONNUES else "critical" + ) + return SiteCurrentReading( + timestamp=derniere.timestamp, + site_id=site.site_id, + site_type=site.site_type, + consumption_kw=derniere.consumption_kw, + consumption_kwh=derniere.consumption_kwh, + voltage_v=derniere.voltage_v, + current_a=derniere.current_a, + power_factor=derniere.power_factor, + temperature_celsius=derniere.temperature_celsius, + humidity_percent=derniere.humidity_percent, + null_reasons=derniere.null_reasons or [], + data_quality=qualite, + ) diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index af962df..6844f72 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -921,6 +921,93 @@ } } }, + "/api/v1/sites/{site_id}/current": { + "get": { + "tags": [ + "sites" + ], + "summary": "Dernière mesure d'un site", + "operationId": "get_current_api_v1_sites__site_id__current_get", + "security": [ + { + "Jeton d'accès": [] + } + ], + "parameters": [ + { + "name": "site_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Site Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SiteCurrentResponse" + } + } + } + }, + "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": "Aucun site ne porte cet identifiant.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/v1/alerts": { "get": { "tags": [ @@ -1572,6 +1659,140 @@ ], "title": "Role" }, + "SiteCurrentResponse": { + "properties": { + "timestamp": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Timestamp" + }, + "site_id": { + "type": "string", + "title": "Site Id" + }, + "site_type": { + "type": "string", + "title": "Site Type" + }, + "consumption_kw": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Consumption Kw" + }, + "consumption_kwh": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Consumption Kwh" + }, + "voltage_v": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Voltage V" + }, + "current_a": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Current A" + }, + "power_factor": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Power Factor" + }, + "temperature_celsius": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Temperature Celsius" + }, + "humidity_percent": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Humidity Percent" + }, + "null_reasons": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Null Reasons" + }, + "data_quality": { + "type": "string", + "enum": [ + "good", + "partial", + "degraded", + "critical" + ], + "title": "Data Quality" + } + }, + "type": "object", + "required": [ + "timestamp", + "site_id", + "site_type", + "consumption_kw", + "consumption_kwh", + "voltage_v", + "current_a", + "power_factor", + "temperature_celsius", + "humidity_percent", + "null_reasons", + "data_quality" + ], + "title": "SiteCurrentResponse" + }, "SiteResponse": { "properties": { "site_id": { diff --git a/apps/backend/tests/api/test_openapi.py b/apps/backend/tests/api/test_openapi.py index f7147da..3112937 100644 --- a/apps/backend/tests/api/test_openapi.py +++ b/apps/backend/tests/api/test_openapi.py @@ -31,6 +31,7 @@ ROUTES_A_ROLE = { ("POST", "/api/v1/users/{id}/password-reset"), ("GET", "/api/v1/sites"), ("GET", "/api/v1/sites/{site_id}"), + ("GET", "/api/v1/sites/{site_id}/current"), ("GET", "/api/v1/alerts"), ("GET", "/api/v1/recommendations"), ("GET", "/api/v1/recommendations/{recommendation_id}"), diff --git a/apps/backend/tests/api/test_sites.py b/apps/backend/tests/api/test_sites.py index 3692565..dea8850 100644 --- a/apps/backend/tests/api/test_sites.py +++ b/apps/backend/tests/api/test_sites.py @@ -1,4 +1,5 @@ from collections.abc import Callable, Iterator +from datetime import UTC, datetime from uuid import uuid4 import pytest @@ -9,7 +10,9 @@ 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 +from app.services.site import SiteCurrentReading, SiteNotFoundError + +TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) def principal(role: Role = Role.LECTEUR) -> Principal: @@ -33,10 +36,28 @@ def site(site_id: str = "site-1") -> Site: ) +def lecture_actuelle(site_id: str = "site-1") -> SiteCurrentReading: + return SiteCurrentReading( + timestamp=TIMESTAMP, + site_id=site_id, + site_type="industriel", + consumption_kw=87.34, + consumption_kwh=87.34, + voltage_v=401.2, + current_a=132.5, + power_factor=0.923, + temperature_celsius=22.1, + humidity_percent=58.4, + null_reasons=[], + data_quality="good", + ) + + class FauxService: def __init__(self, erreur: Exception | None = None) -> None: self._erreur = erreur self.site = site() + self.actuel = lecture_actuelle() async def list_all(self) -> list[Site]: return [self.site] @@ -46,6 +67,11 @@ class FauxService: raise self._erreur return self.site + async def current(self, site_id: str) -> SiteCurrentReading: + if self._erreur is not None: + raise self._erreur + return self.actuel + @pytest.fixture def lecteur_connecte(app: FastAPI) -> Iterator[None]: @@ -109,6 +135,30 @@ async def test_get_site_returns_404_for_an_unknown_site( assert response.status_code == 404 +async def test_get_current_returns_the_latest_reading( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/sites/site-1/current") + + assert response.status_code == 200 + corps = response.json() + assert corps["site_id"] == "site-1" + assert corps["data_quality"] == "good" + assert corps["consumption_kw"] == 87.34 + + +async def test_get_current_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/current") + + 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: diff --git a/apps/backend/tests/services/test_site.py b/apps/backend/tests/services/test_site.py index 73ef21f..7e555e8 100644 --- a/apps/backend/tests/services/test_site.py +++ b/apps/backend/tests/services/test_site.py @@ -1,8 +1,13 @@ +from dataclasses import dataclass, field +from datetime import UTC, datetime + import pytest from app.models.energy import Site from app.services.site import SiteNotFoundError, SiteService +TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) + def site(site_id: str = "site-1") -> Site: return Site( @@ -15,6 +20,21 @@ def site(site_id: str = "site-1") -> Site: ) +@dataclass +class FauxLecture: + site_id: str + timestamp: datetime = TIMESTAMP + consumption_kw: float | None = 87.34 + consumption_kwh: float | None = 87.34 + voltage_v: float | None = 401.2 + current_a: float | None = 132.5 + power_factor: float | None = 0.923 + temperature_celsius: float | None = 22.1 + humidity_percent: float | None = 58.4 + null_reasons: list[str] | None = field(default_factory=list) + data_quality: str | None = "good" + + class FakeRepository: def __init__(self, sites: list[Site]) -> None: self._sites = sites @@ -26,24 +46,79 @@ class FakeRepository: 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")])) +class FauxDepotLectures: + def __init__(self, lectures: dict[str, FauxLecture]) -> None: + self._lectures = lectures - sites = await service.list_all() + async def latest_for_site(self, site_id: str) -> FauxLecture | None: + return self._lectures.get(site_id) + + +def service( + sites: list[Site], lectures: dict[str, FauxLecture] | None = None +) -> SiteService: + return SiteService( + sites=FakeRepository(sites), # type: ignore[arg-type] + readings=FauxDepotLectures(lectures or {}), # type: ignore[arg-type] + ) + + +async def test_list_all_returns_the_repository_sites() -> None: + svc = service([site("a"), site("b")]) + + sites = await svc.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")])) + svc = service([site("a")]) - trouve = await service.get_by_id("a") + trouve = await svc.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([])) + svc = service([]) with pytest.raises(SiteNotFoundError): - await service.get_by_id("inconnu") + await svc.get_by_id("inconnu") + + +async def test_current_raises_when_the_site_is_unknown() -> None: + svc = service([]) + + with pytest.raises(SiteNotFoundError): + await svc.current("inconnu") + + +async def test_current_returns_every_field_as_null_when_the_site_has_no_reading() -> None: + svc = service([site("a")]) + + actuel = await svc.current("a") + + assert actuel.timestamp is None + assert actuel.consumption_kw is None + assert actuel.data_quality == "critical" + assert actuel.null_reasons == [] + + +async def test_current_copies_every_field_from_the_latest_reading() -> None: + svc = service([site("a")], {"a": FauxLecture(site_id="a")}) + + actuel = await svc.current("a") + + assert actuel.timestamp == TIMESTAMP + assert actuel.site_type == "industriel" + assert actuel.consumption_kw == 87.34 + assert actuel.voltage_v == 401.2 + assert actuel.data_quality == "good" + + +async def test_current_treats_an_unknown_data_quality_as_critical() -> None: + svc = service([site("a")], {"a": FauxLecture(site_id="a", data_quality=None)}) + + actuel = await svc.current("a") + + assert actuel.data_quality == "critical" diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index fec2794..32253fb 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -142,6 +142,7 @@ 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/sites/{site_id}/current` | Dernière mesure d'un site. `lecteur` | 401, 403, 404, 422, 500 | | GET | `/api/v1/alerts` | Liste les alertes, filtrable par `site_id` et `severity`. `lecteur` | 401, 403, 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 | @@ -169,7 +170,11 @@ gabarit à la lettre, `recommendation_id` étant un entier plutôt qu'un texte. porte pas `site_id` : elle remonte à un site par sa seule `alert_id`, `alert` n'étant pas encore exposée. `GET /stats/summary` agrège deux repositories (`SiteRepository`, `ReadingRepository`) dans un service dédié plutôt que d'exposer une table : elle n'entre donc pas dans ce gabarit -route-par-table. Le contrat détaillé pour le frontend est dans +route-par-table. `GET /sites/{site_id}/current` reste sur le gabarit `sites`, mais +`SiteService` gagne la même seconde dépendance (`ReadingRepository`) pour restituer la +dernière `Reading` du site : un site connu sans lecture rend `200` avec tous les champs de +mesure à `null` et `data_quality="critical"`, seul un `site_id` absent de la base rend `404`. +Le contrat détaillé pour le frontend est dans [31-contrat-authentification.md](31-contrat-authentification.md). ### `/health/ready` From 2f97e4d4344deb8831559e6cf8a0d74e3061911e Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 15:27:05 +0200 Subject: [PATCH 047/103] fix(backend): corrige formatage ruff et typage mypy sur sites/current MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI en échec sur ruff format (ligne trop longue) et mypy (retour Any non annoté, assignation Literal non étroite). Corrige sans changer le comportement. --- apps/backend/app/api/v1/endpoints/sites.py | 4 +--- apps/backend/app/repositories/reading.py | 3 ++- apps/backend/app/services/site.py | 6 +++--- apps/backend/tests/services/test_site.py | 4 +--- 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/apps/backend/app/api/v1/endpoints/sites.py b/apps/backend/app/api/v1/endpoints/sites.py index 93923e9..5687b33 100644 --- a/apps/backend/app/api/v1/endpoints/sites.py +++ b/apps/backend/app/api/v1/endpoints/sites.py @@ -42,9 +42,7 @@ async def get_site(site_id: str, _: LecteurDep, service: SiteServiceDep) -> Site summary="Dernière mesure d'un site", responses=REPONSES_INTROUVABLE, ) -async def get_current( - site_id: str, _: LecteurDep, service: SiteServiceDep -) -> SiteCurrentResponse: +async def get_current(site_id: str, _: LecteurDep, service: SiteServiceDep) -> SiteCurrentResponse: try: actuel = await service.current(site_id) except SiteNotFoundError as erreur: diff --git a/apps/backend/app/repositories/reading.py b/apps/backend/app/repositories/reading.py index c05ae60..7981b9e 100644 --- a/apps/backend/app/repositories/reading.py +++ b/apps/backend/app/repositories/reading.py @@ -27,4 +27,5 @@ class ReadingRepository: .order_by(Reading.timestamp.desc()) .limit(1) ) - return await self._session.scalar(requete) + lecture: Reading | None = await self._session.scalar(requete) + return lecture diff --git a/apps/backend/app/services/site.py b/apps/backend/app/services/site.py index 25a819d..50d2e24 100644 --- a/apps/backend/app/services/site.py +++ b/apps/backend/app/services/site.py @@ -70,9 +70,9 @@ class SiteService: data_quality="critical", ) - qualite: DataQuality = ( - derniere.data_quality if derniere.data_quality in QUALITES_CONNUES else "critical" - ) + qualite: DataQuality = "critical" + if derniere.data_quality in QUALITES_CONNUES: + qualite = derniere.data_quality # type: ignore[assignment] return SiteCurrentReading( timestamp=derniere.timestamp, site_id=site.site_id, diff --git a/apps/backend/tests/services/test_site.py b/apps/backend/tests/services/test_site.py index 7e555e8..76584fb 100644 --- a/apps/backend/tests/services/test_site.py +++ b/apps/backend/tests/services/test_site.py @@ -54,9 +54,7 @@ class FauxDepotLectures: return self._lectures.get(site_id) -def service( - sites: list[Site], lectures: dict[str, FauxLecture] | None = None -) -> SiteService: +def service(sites: list[Site], lectures: dict[str, FauxLecture] | None = None) -> SiteService: return SiteService( sites=FakeRepository(sites), # type: ignore[arg-type] readings=FauxDepotLectures(lectures or {}), # type: ignore[arg-type] From 5669cd63ec9ae9b4e6ebcdc9db71434cec92a96b Mon Sep 17 00:00:00 2001 From: Valentin Date: Wed, 16 Sep 2026 16:07:28 +0200 Subject: [PATCH 048/103] feat(frontend): authentification frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ajout de la page login, changement de mot de passe forcé, rafraîchissement de session en mémoire, intercepteur, déconnexion, bouton logout sur le dashboard --- apps/frontend/angular.json | 3 +- apps/frontend/src/app/app.config.ts | 12 +- apps/frontend/src/app/app.routes.ts | 6 +- .../src/app/core/guards/auth-guard.spec.ts | 67 ++++++++ .../src/app/core/guards/auth-guard.ts | 21 +++ .../interceptors/auth-interceptor.spec.ts | 161 ++++++++++++++++++ .../app/core/interceptors/auth-interceptor.ts | 77 +++++++++ .../app/core/services/auth.service.spec.ts | 86 ++++++++++ .../src/app/core/services/auth.service.ts | 69 ++++++++ .../auth/change-password/change-password.html | 31 ++++ .../auth/change-password/change-password.scss | 88 ++++++++++ .../change-password/change-password.spec.ts | 88 ++++++++++ .../auth/change-password/change-password.ts | 41 +++++ .../src/app/features/auth/login/login.html | 36 ++++ .../src/app/features/auth/login/login.scss | 81 +++++++++ .../src/app/features/auth/login/login.spec.ts | 110 ++++++++++++ .../src/app/features/auth/login/login.ts | 55 ++++++ .../src/app/features/dashboard/dashboard.html | 7 + .../src/app/features/dashboard/dashboard.scss | 27 +++ .../app/features/dashboard/dashboard.spec.ts | 56 ++++++ .../src/app/features/dashboard/dashboard.ts | 15 ++ .../src/app/shared/models/auth.model.ts | 26 +++ apps/frontend/src/environments/environment.ts | 2 +- 23 files changed, 1160 insertions(+), 5 deletions(-) create mode 100644 apps/frontend/src/app/core/guards/auth-guard.spec.ts create mode 100644 apps/frontend/src/app/core/guards/auth-guard.ts create mode 100644 apps/frontend/src/app/core/interceptors/auth-interceptor.spec.ts create mode 100644 apps/frontend/src/app/core/interceptors/auth-interceptor.ts create mode 100644 apps/frontend/src/app/core/services/auth.service.spec.ts create mode 100644 apps/frontend/src/app/core/services/auth.service.ts create mode 100644 apps/frontend/src/app/features/auth/change-password/change-password.html create mode 100644 apps/frontend/src/app/features/auth/change-password/change-password.scss create mode 100644 apps/frontend/src/app/features/auth/change-password/change-password.spec.ts create mode 100644 apps/frontend/src/app/features/auth/change-password/change-password.ts create mode 100644 apps/frontend/src/app/features/auth/login/login.html create mode 100644 apps/frontend/src/app/features/auth/login/login.scss create mode 100644 apps/frontend/src/app/features/auth/login/login.spec.ts create mode 100644 apps/frontend/src/app/features/auth/login/login.ts create mode 100644 apps/frontend/src/app/shared/models/auth.model.ts 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": { diff --git a/apps/frontend/src/app/app.config.ts b/apps/frontend/src/app/app.config.ts index ff4cafd..66ed3d3 100644 --- a/apps/frontend/src/app/app.config.ts +++ b/apps/frontend/src/app/app.config.ts @@ -1,13 +1,21 @@ -import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import {ApplicationConfig, inject, provideAppInitializer, provideBrowserGlobalErrorListeners} from '@angular/core'; import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; import { mockApiInterceptor } from './core/interceptors/mock-api-interceptor'; import { provideHttpClient, withInterceptors } from '@angular/common/http'; +import {catchError, firstValueFrom, of} from 'rxjs'; +import {AuthService} from './core/services/auth.service'; +import {authInterceptor} from './core/interceptors/auth-interceptor'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideRouter(routes), - provideHttpClient(withInterceptors([mockApiInterceptor])), + provideHttpClient(withInterceptors([authInterceptor, mockApiInterceptor])), + provideAppInitializer(() => { + const auth = inject(AuthService); + // Un 401 ici est normal : ça veut juste dire qu'il n'y a pas de session. + return firstValueFrom(auth.refreshShared().pipe(catchError(() => of(null)))); + }), ], }; diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index 8f2739c..b3e97d8 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -1,9 +1,13 @@ import { Routes } from '@angular/router'; +import {authGuard} from './core/guards/auth-guard'; export const routes: Routes = [ { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, + { path: 'login', loadComponent: () => import('./features/auth/login/login').then(m => m.Login) }, + { path: 'change-password', loadComponent: () => import('./features/auth/change-password/change-password').then(m => m.ChangePassword) }, { path: 'dashboard', - loadComponent: () => import('./features/dashboard/dashboard').then((m) => m.Dashboard), + canActivate: [authGuard], + loadComponent: () => import('./features/dashboard/dashboard').then(m => m.Dashboard), }, ]; diff --git a/apps/frontend/src/app/core/guards/auth-guard.spec.ts b/apps/frontend/src/app/core/guards/auth-guard.spec.ts new file mode 100644 index 0000000..ebf9256 --- /dev/null +++ b/apps/frontend/src/app/core/guards/auth-guard.spec.ts @@ -0,0 +1,67 @@ +import { TestBed } from '@angular/core/testing'; +import { Router, ActivatedRouteSnapshot } from '@angular/router'; +import { vi } from 'vitest'; +import { authGuard } from './auth-guard'; +import { AuthService } from '../services/auth.service'; + +describe('authGuard', () => { + let authMock: { isAuthenticated: ReturnType; principal: ReturnType }; + let routerMock: { navigate: ReturnType }; + + beforeEach(() => { + authMock = { isAuthenticated: vi.fn(), principal: vi.fn() }; + routerMock = { navigate: vi.fn() }; + + TestBed.configureTestingModule({ + providers: [ + { provide: AuthService, useValue: authMock }, + { provide: Router, useValue: routerMock }, + ], + }); + }); + + it('redirige vers /login si non authentifié', () => { + authMock.isAuthenticated.mockReturnValue(false); + + const result = TestBed.runInInjectionContext(() => + authGuard({ data: {} } as ActivatedRouteSnapshot, {} as any) + ); + + expect(result).toBe(false); + expect(routerMock.navigate).toHaveBeenCalledWith(['/login']); + }); + + it('redirige vers /login si le rôle ne correspond pas', () => { + authMock.isAuthenticated.mockReturnValue(true); + authMock.principal.mockReturnValue({ role: 'lecteur' }); + + const result = TestBed.runInInjectionContext(() => + authGuard({ data: { role: 'admin' } } as unknown as ActivatedRouteSnapshot, {} as any) + ); + + expect(result).toBe(false); + expect(routerMock.navigate).toHaveBeenCalledWith(['/login']); + }); + + it('autorise si authentifié et rôle correspondant', () => { + authMock.isAuthenticated.mockReturnValue(true); + authMock.principal.mockReturnValue({ role: 'admin' }); + + const result = TestBed.runInInjectionContext(() => + authGuard({ data: { role: 'admin' } } as unknown as ActivatedRouteSnapshot, {} as any) + ); + + expect(result).toBe(true); + }); + + it('autorise si authentifié et aucun rôle requis', () => { + authMock.isAuthenticated.mockReturnValue(true); + authMock.principal.mockReturnValue({ role: 'lecteur' }); + + const result = TestBed.runInInjectionContext(() => + authGuard({ data: {} } as ActivatedRouteSnapshot, {} as any) + ); + + expect(result).toBe(true); + }); +}); diff --git a/apps/frontend/src/app/core/guards/auth-guard.ts b/apps/frontend/src/app/core/guards/auth-guard.ts new file mode 100644 index 0000000..c6252cc --- /dev/null +++ b/apps/frontend/src/app/core/guards/auth-guard.ts @@ -0,0 +1,21 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { AuthService } from '../services/auth.service'; + +export const authGuard: CanActivateFn = (route) => { + const auth = inject(AuthService); + const router = inject(Router); + + if (!auth.isAuthenticated()) { + router.navigate(['/login']); + return false; + } + + const requiredRole = route.data['role'] as string | undefined; + if (requiredRole && auth.principal()?.role !== requiredRole) { + router.navigate(['/login']); + return false; + } + + return true; +}; diff --git a/apps/frontend/src/app/core/interceptors/auth-interceptor.spec.ts b/apps/frontend/src/app/core/interceptors/auth-interceptor.spec.ts new file mode 100644 index 0000000..8f74cd8 --- /dev/null +++ b/apps/frontend/src/app/core/interceptors/auth-interceptor.spec.ts @@ -0,0 +1,161 @@ +import { TestBed } from '@angular/core/testing'; +import { + HttpClient, + HttpHandlerFn, + HttpHeaders, + HttpRequest, + provideHttpClient, + withInterceptors +} from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { Router } from '@angular/router'; +import { of, throwError } from 'rxjs'; +import { vi } from 'vitest'; +import { authInterceptor } from './auth-interceptor'; +import { AuthService } from '../services/auth.service'; + +describe('authInterceptor', () => { + let http: HttpClient; + let httpMock: HttpTestingController; + let authMock: { getAccessToken: ReturnType; clearSession: ReturnType; refreshShared: ReturnType }; + let routerMock: { navigate: ReturnType }; + + beforeEach(() => { + authMock = { + getAccessToken: vi.fn().mockReturnValue('fake-token'), + clearSession: vi.fn(), + refreshShared: vi.fn(), + }; + routerMock = { navigate: vi.fn() }; + + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptors([authInterceptor])), + provideHttpClientTesting(), + { provide: AuthService, useValue: authMock }, + { provide: Router, useValue: routerMock }, + ], + }); + + http = TestBed.inject(HttpClient); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('ajoute le header Authorization quand un token est disponible', () => { + http.get('/api/v1/stats/summary').subscribe(); + const req = httpMock.expectOne('/api/v1/stats/summary'); + expect(req.request.headers.get('Authorization')).toBe('Bearer fake-token'); + req.flush({}); + }); + + it("n'ajoute pas le header Authorization sur /auth/login", () => { + http.post('/api/v1/auth/login', {}).subscribe(); + const req = httpMock.expectOne('/api/v1/auth/login'); + expect(req.request.headers.has('Authorization')).toBe(false); + req.flush({}); + }); + + it('ajoute withCredentials sur les routes /auth/*', () => { + http.post('/api/v1/auth/login', {}).subscribe(); + const req = httpMock.expectOne('/api/v1/auth/login'); + expect(req.request.withCredentials).toBe(true); + req.flush({}); + }); + + it('redirige vers /change-password sur un 403 avec ce detail précis', () => { + http.get('/api/v1/dashboard').subscribe({ error: () => {} }); + const req = httpMock.expectOne('/api/v1/dashboard'); + req.flush({ detail: 'password_change_required' }, { status: 403, statusText: 'Forbidden' }); + expect(routerMock.navigate).toHaveBeenCalledWith(['/change-password']); + }); + + it('ne redirige pas sur un 403 avec un autre detail', () => { + http.get('/api/v1/dashboard').subscribe({ error: () => {} }); + const req = httpMock.expectOne('/api/v1/dashboard'); + req.flush({ detail: 'Droits insuffisants' }, { status: 403, statusText: 'Forbidden' }); + expect(routerMock.navigate).not.toHaveBeenCalled(); + }); + + it('déconnecte et redirige vers /login sur un 401 avec error="invalid_token"', () => { + http.get('/api/v1/dashboard').subscribe({ error: () => {} }); + const req = httpMock.expectOne('/api/v1/dashboard'); + req.flush( + {}, + { status: 401, statusText: 'Unauthorized', headers: new HttpHeaders({ 'WWW-Authenticate': 'Bearer error="invalid_token"' }) } + ); + expect(authMock.clearSession).toHaveBeenCalled(); + expect(routerMock.navigate).toHaveBeenCalledWith(['/login']); + }); + + it('déconnecte directement sur un 401 provenant de /auth/refresh, sans tenter de rafraîchir', () => { + http.post('/api/v1/auth/refresh', {}).subscribe({ error: () => {} }); + const req = httpMock.expectOne('/api/v1/auth/refresh'); + req.flush({}, { status: 401, statusText: 'Unauthorized' }); + expect(authMock.clearSession).toHaveBeenCalled(); + expect(routerMock.navigate).toHaveBeenCalledWith(['/login']); + }); + + it('rafraîchit puis rejoue la requête sur un 401 avec error="expired"', () => { + authMock.refreshShared.mockReturnValue(of({ access_token: 'new-token' })); + authMock.getAccessToken.mockReturnValueOnce('old-token').mockReturnValue('new-token'); + + let result: unknown; + http.get('/api/v1/dashboard').subscribe((r) => (result = r)); + + const firstReq = httpMock.expectOne('/api/v1/dashboard'); + firstReq.flush({}, { status: 401, statusText: 'Unauthorized', headers: new HttpHeaders({ 'WWW-Authenticate': 'Bearer error="expired"' }) }); + + const retriedReq = httpMock.expectOne('/api/v1/dashboard'); + expect(retriedReq.request.headers.get('Authorization')).toBe('Bearer new-token'); + retriedReq.flush({ ok: true }); + + expect(result).toEqual({ ok: true }); + }); + + it('déconnecte si le rafraîchissement échoue après un 401 "expired"', () => { + authMock.refreshShared.mockReturnValue(throwError(() => new Error('refresh failed'))); + + http.get('/api/v1/dashboard').subscribe({ error: () => {} }); + const req = httpMock.expectOne('/api/v1/dashboard'); + req.flush({}, { status: 401, statusText: 'Unauthorized', headers: new HttpHeaders({ 'WWW-Authenticate': 'Bearer error="expired"' }) }); + + expect(authMock.clearSession).toHaveBeenCalled(); + expect(routerMock.navigate).toHaveBeenCalledWith(['/login']); + }); + + it("propage l'erreur telle quelle si ce n'est pas une HttpErrorResponse", () => { + const req = new HttpRequest('GET', '/api/v1/dashboard'); + const boom = new Error('erreur inattendue, pas HTTP'); + const next: HttpHandlerFn = () => throwError(() => boom); + + let captured: unknown; + TestBed.runInInjectionContext(() => { + authInterceptor(req, next).subscribe({ error: (e) => (captured = e) }); + }); + + expect(captured).toBe(boom); +}); + +it('propage un 401 sur /auth/login sans tenter de rafraîchir ni déconnecter', () => { + http.post('/api/v1/auth/login', {}).subscribe({ error: () => {} }); + const req = httpMock.expectOne('/api/v1/auth/login'); + req.flush({}, { status: 401, statusText: 'Unauthorized' }); + + expect(authMock.refreshShared).not.toHaveBeenCalled(); + expect(authMock.clearSession).not.toHaveBeenCalled(); +}); + +it("propage un 401 dont le WWW-Authenticate ne correspond à aucun cas connu", () => { + http.get('/api/v1/dashboard').subscribe({ error: () => {} }); + const req = httpMock.expectOne('/api/v1/dashboard'); + req.flush( + {}, + { status: 401, statusText: 'Unauthorized', headers: new HttpHeaders({ 'WWW-Authenticate': 'Bearer error="unknown_case"' }) } + ); + + expect(authMock.refreshShared).not.toHaveBeenCalled(); + expect(authMock.clearSession).not.toHaveBeenCalled(); +}); +}); diff --git a/apps/frontend/src/app/core/interceptors/auth-interceptor.ts b/apps/frontend/src/app/core/interceptors/auth-interceptor.ts new file mode 100644 index 0000000..46ba124 --- /dev/null +++ b/apps/frontend/src/app/core/interceptors/auth-interceptor.ts @@ -0,0 +1,77 @@ +import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { Observable, catchError, switchMap, throwError } from 'rxjs'; +import { AuthService } from '../services/auth.service'; +import { TokenResponse } from '../../shared/models/auth.model'; + +function parseAuthError(response: HttpErrorResponse): string | null { + const header = response.headers?.get('WWW-Authenticate') ?? ''; + const match = header.match(/error="([^"]+)"/); + return match ? match[1] : null; +} + +export const authInterceptor: HttpInterceptorFn = (req, next) => { + const auth = inject(AuthService); + const router = inject(Router); + + const isAuthRoute = req.url.includes('/auth/'); + let request = isAuthRoute ? req.clone({ withCredentials: true }) : req; + + const token = auth.getAccessToken(); + if (token && !req.url.endsWith('/auth/login')) { + request = request.clone({ setHeaders: { Authorization: `Bearer ${token}` } }); + } + + return next(request).pipe( + catchError((error: unknown) => { + if (!(error instanceof HttpErrorResponse)) { + return throwError(() => error); + } + + if (error.status === 403) { + const detail = (error.error as { detail?: string })?.detail; + if (detail === 'password_change_required') { + router.navigate(['/change-password']); + } + return throwError(() => error); + } + + if (error.status !== 401 || req.url.endsWith('/auth/login')) { + return throwError(() => error); + } + + if (req.url.endsWith('/auth/refresh')) { + auth.clearSession(); + router.navigate(['/login']); + return throwError(() => error); + } + + const kind = parseAuthError(error); + + if (kind === 'invalid_token') { + auth.clearSession(); + router.navigate(['/login']); + return throwError(() => error); + } + + if (kind === 'expired' || kind === 'token_stale') { + return (auth.refreshShared() as Observable).pipe( + switchMap(() => { + const retried = request.clone({ + setHeaders: { Authorization: `Bearer ${auth.getAccessToken()}` }, + }); + return next(retried); + }), + catchError((refreshError) => { + auth.clearSession(); + router.navigate(['/login']); + return throwError(() => refreshError); + }) + ); + } + + return throwError(() => error); + }) + ); +}; diff --git a/apps/frontend/src/app/core/services/auth.service.spec.ts b/apps/frontend/src/app/core/services/auth.service.spec.ts new file mode 100644 index 0000000..bff86c4 --- /dev/null +++ b/apps/frontend/src/app/core/services/auth.service.spec.ts @@ -0,0 +1,86 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { AuthService } from './auth.service'; +import { environment } from '../../../environments/environment'; + +describe('AuthService', () => { + let service: AuthService; + let httpMock: HttpTestingController; + + const tokenResponse = { + access_token: 'abc123', + token_type: 'bearer', + expires_in: 900, + principal: { + id: '1', + email: 'a@a.com', + role: 'admin' as const, + kind: 'human' as const, + must_change_password: false, + }, + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(AuthService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('stocke le token et le principal après un login réussi', () => { + service.login({ email: 'a@a.com', password: 'secret' }).subscribe(); + + const req = httpMock.expectOne(`${environment.apiUrl}/auth/login`); + expect(req.request.withCredentials).toBe(true); + req.flush(tokenResponse); + + expect(service.getAccessToken()).toBe('abc123'); + expect(service.principal()?.email).toBe('a@a.com'); + expect(service.isAuthenticated()).toBe(true); + }); + + it('efface la session au logout', () => { + service.login({ email: 'a@a.com', password: 'secret' }).subscribe(); + httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush(tokenResponse); + + service.logout().subscribe(); + httpMock.expectOne(`${environment.apiUrl}/auth/logout`).flush(null); + + expect(service.getAccessToken()).toBeNull(); + expect(service.isAuthenticated()).toBe(false); + }); + + it("ne déclenche qu'un seul appel réseau si refreshShared est appelé plusieurs fois avant la réponse", () => { + service.refreshShared().subscribe(); + service.refreshShared().subscribe(); + service.refreshShared().subscribe(); + + const requests = httpMock.match(`${environment.apiUrl}/auth/refresh`); + expect(requests.length).toBe(1); + requests[0].flush(tokenResponse); + }); + + it('met à jour la session après un changement de mot de passe réussi', () => { + service.changePassword({ current_password: 'old', new_password: 'new-password-1234' }).subscribe(); + + const req = httpMock.expectOne(`${environment.apiUrl}/auth/password`); + req.flush(tokenResponse); + + expect(service.getAccessToken()).toBe('abc123'); + }); + + it('récupère le principal courant via /auth/me', () => { + let result: unknown; + service.me().subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/auth/me`); + expect(req.request.method).toBe('GET'); + req.flush(tokenResponse.principal); + + expect(result).toEqual(tokenResponse.principal); +}); +}); diff --git a/apps/frontend/src/app/core/services/auth.service.ts b/apps/frontend/src/app/core/services/auth.service.ts new file mode 100644 index 0000000..d27c1db --- /dev/null +++ b/apps/frontend/src/app/core/services/auth.service.ts @@ -0,0 +1,69 @@ +import { Service, signal, computed, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable, tap, finalize, shareReplay } from 'rxjs'; +import { LoginRequest, PasswordChangeRequest, Principal, TokenResponse } from '../../shared/models/auth.model'; +import { environment } from '../../../environments/environment'; + +@Service() +export class AuthService { + private http = inject(HttpClient); + + // Jamais de localStorage/sessionStorage/cookie côté JS : juste un signal en + // mémoire. Un rechargement de page le perd, c'est voulu par le contrat. + private accessTokenSignal = signal(null); + private principalSignal = signal(null); + + readonly principal = this.principalSignal.asReadonly(); + readonly isAuthenticated = computed(() => this.principalSignal() !== null); + + private rotation$?: Observable; + + getAccessToken(): string | null { + return this.accessTokenSignal(); + } + + private setSession(response: TokenResponse): void { + this.accessTokenSignal.set(response.access_token); + this.principalSignal.set(response.principal); + } + + clearSession(): void { + this.accessTokenSignal.set(null); + this.principalSignal.set(null); + } + + login(credentials: LoginRequest): Observable { + return this.http + .post(`${environment.apiUrl}/auth/login`, credentials, { withCredentials: true }) + .pipe(tap((response) => this.setSession(response))); + } + + // Un seul rafraîchissement en vol à la fois, partagé entre tous les + // appelants (sinon le serveur révoque toute la session sur des rotations concurrentes). + refreshShared(): Observable { + this.rotation$ ??= this.http + .post(`${environment.apiUrl}/auth/refresh`, {}, { withCredentials: true }) + .pipe( + tap((response) => this.setSession(response)), + finalize(() => (this.rotation$ = undefined)), + shareReplay(1) + ); + return this.rotation$; + } + + logout(): Observable { + return this.http + .post(`${environment.apiUrl}/auth/logout`, {}, { withCredentials: true }) + .pipe(tap(() => this.clearSession())); + } + + changePassword(payload: PasswordChangeRequest): Observable { + return this.http + .post(`${environment.apiUrl}/auth/password`, payload, { withCredentials: true }) + .pipe(tap((response) => this.setSession(response))); + } + + me(): Observable { + return this.http.get(`${environment.apiUrl}/auth/me`); + } +} diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.html b/apps/frontend/src/app/features/auth/change-password/change-password.html new file mode 100644 index 0000000..edf2146 --- /dev/null +++ b/apps/frontend/src/app/features/auth/change-password/change-password.html @@ -0,0 +1,31 @@ +
+
+

Nouveau mot de passe

+

Votre mot de passe est provisoire, vous devez le modifier avant de continuer

+ + + + + + + 12 à 128 caractères + + @if (errorMessage()) { +

{{ errorMessage() }}

+ } + + +
+
diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.scss b/apps/frontend/src/app/features/auth/change-password/change-password.scss new file mode 100644 index 0000000..f44fcb8 --- /dev/null +++ b/apps/frontend/src/app/features/auth/change-password/change-password.scss @@ -0,0 +1,88 @@ +:host { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: #f3f4f6; + font-family: 'Segoe UI', system-ui, sans-serif; +} + +.auth-card { + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 2.5rem; + width: 100%; + max-width: 360px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); + display: flex; + flex-direction: column; + + h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + color: #1f2937; + } + + .auth-subtitle { + margin: 0.25rem 0 1.5rem; + color: #6b7280; + font-size: 0.9rem; + line-height: 1.4; + } + + label { + font-size: 0.85rem; + font-weight: 600; + color: #374151; + margin-bottom: 0.35rem; + margin-top: 1rem; + } + + input { + padding: 0.6rem 0.75rem; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 0.95rem; + + &:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); + } + } + + button { + margin-top: 1.5rem; + padding: 0.7rem; + background: #3b82f6; + color: #fff; + border: none; + border-radius: 8px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + + &:disabled { + background: #9ca3af; + cursor: not-allowed; + } + + &:not(:disabled):hover { + background: #2563eb; + } + } +} + +.auth-hint { + font-size: 0.75rem; + color: #9ca3af; + margin-top: 0.25rem; +} + +.auth-error { + margin: 0.75rem 0 0; + color: #dc2626; + font-size: 0.85rem; +} diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts b/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts new file mode 100644 index 0000000..63e1872 --- /dev/null +++ b/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts @@ -0,0 +1,88 @@ +import { TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { of, throwError } from 'rxjs'; +import { vi } from 'vitest'; +import { ChangePassword } from './change-password'; +import { AuthService } from '../../../core/services/auth.service'; + +describe('ChangePassword', () => { + let authMock: { changePassword: ReturnType }; + let routerMock: { navigate: ReturnType }; + + beforeEach(async () => { + authMock = { changePassword: vi.fn() }; + routerMock = { navigate: vi.fn() }; + + await TestBed.configureTestingModule({ + imports: [ChangePassword, ReactiveFormsModule], + providers: [ + { provide: AuthService, useValue: authMock }, + { provide: Router, useValue: routerMock }, + ], + }).compileComponents(); + }); + + it('ne soumet pas si le formulaire est invalide (mot de passe trop court)', () => { + const fixture = TestBed.createComponent(ChangePassword); + const component = fixture.componentInstance; + component.form.setValue({ current_password: 'old', new_password: 'trop-court' }); + + component.onSubmit(); + expect(authMock.changePassword).not.toHaveBeenCalled(); + }); + + it('redirige vers /dashboard après un changement réussi', () => { + const fixture = TestBed.createComponent(ChangePassword); + const component = fixture.componentInstance; + component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' }); + + authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } })); + + component.onSubmit(); + expect(routerMock.navigate).toHaveBeenCalledWith(['/dashboard']); + }); + + it("affiche un message d'erreur si le mot de passe actuel est incorrect", () => { + const fixture = TestBed.createComponent(ChangePassword); + const component = fixture.componentInstance; + component.form.setValue({ current_password: 'mauvais-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' }); + + authMock.changePassword.mockReturnValue(throwError(() => new Error('401'))); + + component.onSubmit(); + fixture.detectChanges(); // rend le bloc @if (errorMessage()) + + expect(component.errorMessage()).toContain('incorrect'); + const errorEl = fixture.nativeElement.querySelector('.auth-error'); + expect(errorEl?.textContent).toContain('incorrect'); + }); + + it('désactive le bouton tant que le formulaire est invalide', () => { + const fixture = TestBed.createComponent(ChangePassword); + fixture.detectChanges(); + + const button = fixture.nativeElement.querySelector('button[type="submit"]'); + expect(button.disabled).toBe(true); + expect(fixture.nativeElement.querySelector('.auth-error')).toBeNull(); + }); + + it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => { + const fixture = TestBed.createComponent(ChangePassword); + const component = fixture.componentInstance; + component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' }); + fixture.detectChanges(); + + authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } })); + + const form = fixture.nativeElement.querySelector('form'); + form.dispatchEvent(new Event('submit')); + fixture.detectChanges(); + + expect(authMock.changePassword).toHaveBeenCalledWith({ + current_password: 'ancien-mot-de-passe', + new_password: 'un-nouveau-mot-de-passe-valide', + }); +}); + +}); diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.ts b/apps/frontend/src/app/features/auth/change-password/change-password.ts new file mode 100644 index 0000000..507af14 --- /dev/null +++ b/apps/frontend/src/app/features/auth/change-password/change-password.ts @@ -0,0 +1,41 @@ +import { Component, inject, signal } from '@angular/core'; +import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { AuthService } from '../../../core/services/auth.service'; + +@Component({ + selector: 'app-change-password', + standalone: true, + imports: [ReactiveFormsModule], + templateUrl: './change-password.html', + styleUrl: './change-password.scss', +}) +export class ChangePassword { + private fb = inject(FormBuilder); + private auth = inject(AuthService); + private router = inject(Router); + + errorMessage = signal(null); + isLoading = signal(false); + + form = this.fb.nonNullable.group({ + current_password: ['', Validators.required], + new_password: ['', [Validators.required, Validators.minLength(12), Validators.maxLength(128)]], + }); + + onSubmit(): void { + if (this.form.invalid) return; + this.isLoading.set(true); + this.errorMessage.set(null); + + this.auth.changePassword(this.form.getRawValue()).subscribe({ + next: (response) => { + this.router.navigate(['/dashboard']); + }, + error: () => { + this.isLoading.set(false); + this.errorMessage.set('Mot de passe actuel incorrect, ou nouveau mot de passe invalide (12 à 128 caractères).'); + }, + }); + } +} diff --git a/apps/frontend/src/app/features/auth/login/login.html b/apps/frontend/src/app/features/auth/login/login.html new file mode 100644 index 0000000..0083bd2 --- /dev/null +++ b/apps/frontend/src/app/features/auth/login/login.html @@ -0,0 +1,36 @@ +
+
+

Connexion

+

Accédez à votre espace EnerVision

+ + + + + + + + @if (errorMessage()) { +

+ {{ errorMessage() }} + @if (retryAfterSeconds(); as seconds) { + (réessayez dans {{ seconds }}s) + } +

+ } + + +
+
diff --git a/apps/frontend/src/app/features/auth/login/login.scss b/apps/frontend/src/app/features/auth/login/login.scss new file mode 100644 index 0000000..cc415b8 --- /dev/null +++ b/apps/frontend/src/app/features/auth/login/login.scss @@ -0,0 +1,81 @@ +:host { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: #f3f4f6; + font-family: 'Segoe UI', system-ui, sans-serif; +} + +.auth-card { + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 2.5rem; + width: 100%; + max-width: 360px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); + display: flex; + flex-direction: column; + + h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + color: #1f2937; + } + + .auth-subtitle { + margin: 0.25rem 0 1.5rem; + color: #6b7280; + font-size: 0.9rem; + } + + label { + font-size: 0.85rem; + font-weight: 600; + color: #374151; + margin-bottom: 0.35rem; + margin-top: 1rem; + } + + input { + padding: 0.6rem 0.75rem; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 0.95rem; + + &:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); + } + } + + button { + margin-top: 1.5rem; + padding: 0.7rem; + background: #3b82f6; + color: #fff; + border: none; + border-radius: 8px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + + &:disabled { + background: #9ca3af; + cursor: not-allowed; + } + + &:not(:disabled):hover { + background: #2563eb; + } + } +} + +.auth-error { + margin: 0.75rem 0 0; + color: #dc2626; + font-size: 0.85rem; +} diff --git a/apps/frontend/src/app/features/auth/login/login.spec.ts b/apps/frontend/src/app/features/auth/login/login.spec.ts new file mode 100644 index 0000000..3c9bac1 --- /dev/null +++ b/apps/frontend/src/app/features/auth/login/login.spec.ts @@ -0,0 +1,110 @@ +import { TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; +import { of, throwError } from 'rxjs'; +import { vi } from 'vitest'; +import { Login } from './login'; +import { AuthService } from '../../../core/services/auth.service'; + +describe('Login', () => { + let authMock: { login: ReturnType }; + let routerMock: { navigate: ReturnType }; + + beforeEach(async () => { + authMock = { login: vi.fn() }; + routerMock = { navigate: vi.fn() }; + + await TestBed.configureTestingModule({ + imports: [Login, ReactiveFormsModule], + providers: [ + { provide: AuthService, useValue: authMock }, + { provide: Router, useValue: routerMock }, + ], + }).compileComponents(); + }); + + it('ne soumet pas si le formulaire est invalide', () => { + const fixture = TestBed.createComponent(Login); + fixture.componentInstance.onSubmit(); + expect(authMock.login).not.toHaveBeenCalled(); + }); + + it('redirige vers /change-password si must_change_password est vrai', () => { + const fixture = TestBed.createComponent(Login); + const component = fixture.componentInstance; + component.form.setValue({ email: 'a@a.com', password: 'secret' }); + + authMock.login.mockReturnValue(of({ principal: { role: 'admin', must_change_password: true } })); + + component.onSubmit(); + expect(routerMock.navigate).toHaveBeenCalledWith(['/change-password']); + }); + + it('redirige vers /dashboard si le mot de passe est déjà à jour', () => { + const fixture = TestBed.createComponent(Login); + const component = fixture.componentInstance; + component.form.setValue({ email: 'a@a.com', password: 'secret' }); + + authMock.login.mockReturnValue(of({ principal: { role: 'lecteur', must_change_password: false } })); + + component.onSubmit(); + expect(routerMock.navigate).toHaveBeenCalledWith(['/dashboard']); + }); + + it('affiche un message générique sur un 401', () => { + const fixture = TestBed.createComponent(Login); + const component = fixture.componentInstance; + component.form.setValue({ email: 'a@a.com', password: 'wrong' }); + + authMock.login.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 401 }))); + + component.onSubmit(); + fixture.detectChanges(); // rend le bloc @if (errorMessage()) du template + + expect(component.errorMessage()).toBe('Email ou mot de passe incorrect.'); + const errorEl = fixture.nativeElement.querySelector('.auth-error'); + expect(errorEl?.textContent).toContain('Email ou mot de passe incorrect.'); + }); + + it("affiche le délai d'attente sur un 429 avec Retry-After", () => { + const fixture = TestBed.createComponent(Login); + const component = fixture.componentInstance; + component.form.setValue({ email: 'a@a.com', password: 'wrong' }); + + authMock.login.mockReturnValue( + throwError(() => new HttpErrorResponse({ status: 429, headers: new HttpHeaders({ 'Retry-After': '30' }) })) + ); + + component.onSubmit(); + fixture.detectChanges(); // rend aussi le sous-bloc @if (retryAfterSeconds(); as seconds) + + expect(component.retryAfterSeconds()).toBe(30); + const errorEl = fixture.nativeElement.querySelector('.auth-error'); + expect(errorEl?.textContent).toContain('30s'); + }); + + it('désactive le bouton tant que le formulaire est invalide', () => { + const fixture = TestBed.createComponent(Login); + fixture.detectChanges(); + + const button = fixture.nativeElement.querySelector('button[type="submit"]'); + expect(button.disabled).toBe(true); + expect(fixture.nativeElement.querySelector('.auth-error')).toBeNull(); + }); + + it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => { + const fixture = TestBed.createComponent(Login); + const component = fixture.componentInstance; + component.form.setValue({ email: 'a@a.com', password: 'secret' }); + fixture.detectChanges(); + + authMock.login.mockReturnValue(of({ principal: { role: 'lecteur', must_change_password: false } })); + + const form = fixture.nativeElement.querySelector('form'); + form.dispatchEvent(new Event('submit')); + fixture.detectChanges(); + + expect(authMock.login).toHaveBeenCalledWith({ email: 'a@a.com', password: 'secret' }); + }); +}); diff --git a/apps/frontend/src/app/features/auth/login/login.ts b/apps/frontend/src/app/features/auth/login/login.ts new file mode 100644 index 0000000..34b9ff2 --- /dev/null +++ b/apps/frontend/src/app/features/auth/login/login.ts @@ -0,0 +1,55 @@ +import { Component, inject, signal } from '@angular/core'; +import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { HttpErrorResponse } from '@angular/common/http'; +import { AuthService } from '../../../core/services/auth.service'; + +@Component({ + selector: 'app-login', + standalone: true, + imports: [ReactiveFormsModule], + templateUrl: './login.html', + styleUrl: './login.scss', +}) +export class Login { + private fb = inject(FormBuilder); + private auth = inject(AuthService); + private router = inject(Router); + + errorMessage = signal(null); + retryAfterSeconds = signal(null); + isLoading = signal(false); + + form = this.fb.nonNullable.group({ + email: ['', [Validators.required, Validators.email]], + password: ['', Validators.required], + }); + + onSubmit(): void { + if (this.form.invalid) return; + + this.isLoading.set(true); + this.errorMessage.set(null); + this.retryAfterSeconds.set(null); + + this.auth.login(this.form.getRawValue()).subscribe({ + next: (response) => { + if (response.principal.must_change_password) { + this.router.navigate(['/change-password']); + return; + } + this.router.navigate(['/dashboard']); + }, + error: (error: HttpErrorResponse) => { + this.isLoading.set(false); + if (error.status === 429) { + const retryAfter = error.headers.get('Retry-After'); + this.retryAfterSeconds.set(retryAfter ? Number(retryAfter) : null); + this.errorMessage.set('Trop de tentatives, réessayez plus tard.'); + return; + } + this.errorMessage.set('Email ou mot de passe incorrect.'); + }, + }); + } +} diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index a64d5d9..8499bb3 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -1,3 +1,10 @@ +
+
+

Vue d'ensemble

+

Consommation instantanée du parc

+
+ +

Vue d'ensemble

diff --git a/apps/frontend/src/app/features/dashboard/dashboard.scss b/apps/frontend/src/app/features/dashboard/dashboard.scss index 01cc3a3..75976e1 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.scss +++ b/apps/frontend/src/app/features/dashboard/dashboard.scss @@ -144,3 +144,30 @@ h2 { .alert-item__message { font-size: 0.9rem; } +.dashboard__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 2rem; + + h1 { + margin: 0; + font-size: 1.75rem; + font-weight: 700; + } +} + +.logout-button { + padding: 0.5rem 1rem; + background: #ffffff; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 0.85rem; + font-weight: 600; + color: #374151; + cursor: pointer; + + &:hover { + background: #f3f4f6; + } +} diff --git a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts index 89a69ec..5030b0d 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts @@ -4,6 +4,8 @@ import { of, throwError } from 'rxjs'; import { Dashboard } from './dashboard'; import { StatsService } from '../../core/services/stats.service'; import { AlertsService } from '../../core/services/alerts.service'; +import {AuthService} from '../../core/services/auth.service'; +import {Router} from '@angular/router'; vi.mock('chart.js', () => { class ChartMock { @@ -92,4 +94,58 @@ describe('Dashboard', () => { expect(fixture.componentInstance.alerts().length).toBe(0); }); + + it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => { + const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; + const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; + const authMock = { logout: vi.fn().mockReturnValue(of(undefined)), clearSession: vi.fn() }; + const routerMock = { navigate: vi.fn() }; + + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: alertsMock }, + { provide: AuthService, useValue: authMock }, + { provide: Router, useValue: routerMock }, + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + fixture.detectChanges(); + + const button = fixture.nativeElement.querySelector('.logout-button'); + button.click(); + + expect(authMock.logout).toHaveBeenCalled(); + expect(routerMock.navigate).toHaveBeenCalledWith(['/login']); + }); + it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => { + const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; + const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; + const authMock = { + logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))), + clearSession: vi.fn(), + }; + const routerMock = { navigate: vi.fn() }; + + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: alertsMock }, + { provide: AuthService, useValue: authMock }, + { provide: Router, useValue: routerMock }, + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + fixture.detectChanges(); + + const button = fixture.nativeElement.querySelector('.logout-button'); + button.click(); + + expect(authMock.clearSession).toHaveBeenCalled(); + expect(routerMock.navigate).toHaveBeenCalledWith(['/login']); +}); }); diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index 7733230..c6a6a56 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -2,10 +2,12 @@ import { Component, OnInit, inject, signal, DestroyRef } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { timer, switchMap, catchError, EMPTY, Observable } from 'rxjs'; import { DecimalPipe } from '@angular/common'; +import { Router } from '@angular/router'; import { StatsService } from '../../core/services/stats.service'; import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge'; import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart'; import { AlertsService } from '../../core/services/alerts.service'; +import { AuthService } from '../../core/services/auth.service'; import { StatsSummary } from '../../shared/models/stats.model'; import { Alert } from '../../shared/models/alert.model'; @@ -23,6 +25,8 @@ const UNAVAILABLE_MESSAGE = export class Dashboard implements OnInit { private statsService = inject(StatsService); private alertsService = inject(AlertsService); + private auth = inject(AuthService); + private router = inject(Router); private destroyRef = inject(DestroyRef); stats = signal(null); @@ -50,6 +54,17 @@ export class Dashboard implements OnInit { }); } + onLogout(): void { + this.auth.logout().subscribe({ + next: () => this.router.navigate(['/login']), + error: () => { + // Même si l'appel réseau échoue, on considère l'utilisateur déconnecté localement. + this.auth.clearSession(); + this.router.navigate(['/login']); + }, + }); + } + private reportUnavailable(): Observable { this.error.set(UNAVAILABLE_MESSAGE); return EMPTY; diff --git a/apps/frontend/src/app/shared/models/auth.model.ts b/apps/frontend/src/app/shared/models/auth.model.ts new file mode 100644 index 0000000..932572f --- /dev/null +++ b/apps/frontend/src/app/shared/models/auth.model.ts @@ -0,0 +1,26 @@ +export type Role = 'lecteur' | 'operateur' | 'admin'; + +export interface LoginRequest { + email: string; + password: string; +} + +export interface PasswordChangeRequest { + current_password: string; + new_password: string; +} + +export interface Principal { + id: string; + email: string; + role: Role; + kind: 'human'; + must_change_password: boolean; +} + +export interface TokenResponse { + access_token: string; + token_type: string; + expires_in: number; + principal: Principal; +} diff --git a/apps/frontend/src/environments/environment.ts b/apps/frontend/src/environments/environment.ts index bac99a8..1f39f6f 100644 --- a/apps/frontend/src/environments/environment.ts +++ b/apps/frontend/src/environments/environment.ts @@ -1,5 +1,5 @@ export const environment = { production: true, - apiUrl: 'http://localhost:8000/api/v1', + apiUrl: '/api/v1', useMockFixtures: false, }; From 0174272bdd4a47dca48462f33f5730c732a784b9 Mon Sep 17 00:00:00 2001 From: ValentinDeFaria <123947752+ValentinDeFaria@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:00:52 +0200 Subject: [PATCH 049/103] Update dashboard.html --- .../src/app/features/dashboard/dashboard.html | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index 8499bb3..684b444 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -1,14 +1,10 @@ -
-
-

Vue d'ensemble

-

Consommation instantanée du parc

-
- -
-

Vue d'ensemble

-

Consommation instantanée du parc

+
+

Vue d'ensemble

+

Consommation instantanée du parc

+
+
@if (error(); as message) { From 515a92b3950e4a85444faa413ea87750f30c9879 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 17 Sep 2026 09:02:53 +0200 Subject: [PATCH 050/103] fix(frontend): isole les fichiers de tests vitest pour eviter la pollution de mocks Le test site-load-chart.spec.ts echouait de facon intermittente en CI : sans isolation, vitest partage le registre de modules entre fichiers de spec, donc le mock chart.js d'un fichier pouvait ecraser celui d'un autre selon l'ordre d'execution. --- apps/frontend/angular.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/frontend/angular.json b/apps/frontend/angular.json index 814e4f8..6cb33fa 100644 --- a/apps/frontend/angular.json +++ b/apps/frontend/angular.json @@ -81,6 +81,7 @@ "builder": "@angular/build:unit-test", "options": { "coverage": true, + "isolate": true, "coverageReporters": [ "text-summary", "lcov", From 24bf8bf4b9066374d7a09903ea4e556c6f50b601 Mon Sep 17 00:00:00 2001 From: ValentinDeFaria <123947752+ValentinDeFaria@users.noreply.github.com> Date: Thu, 17 Sep 2026 09:10:04 +0200 Subject: [PATCH 051/103] Update apps/backend/tests/services/test_sensor.py --- apps/backend/tests/services/test_sensor.py | 27 ++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/apps/backend/tests/services/test_sensor.py b/apps/backend/tests/services/test_sensor.py index 9a8d62f..85073b3 100644 --- a/apps/backend/tests/services/test_sensor.py +++ b/apps/backend/tests/services/test_sensor.py @@ -195,3 +195,30 @@ async def test_status_ignores_an_unknown_null_reason() -> None: site.sensors.network, ): assert capteur.status == "ok" + + +async def test_status_flags_network_from_null_reasons_only() -> None: + service = SensorService( + sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type] + readings=FauxDepotLectures( # type: ignore[arg-type] + [ + FauxLecture( + "A", + TIMESTAMP, + "partial", + null_reasons=["network_loss"], + ) + ] + ), + ) + + etat = await service.status() + + site = etat.sites[0] + assert site.overall == "degraded" + assert site.sensors.network.status == "failing" + assert site.sensors.network.since == TIMESTAMP + assert site.sensors.consumption.status == "ok" + assert site.sensors.electrical.status == "ok" + assert site.sensors.temperature.status == "ok" + assert site.sensors.humidity.status == "ok" From fcbfcc8eb2d5c21ece42a3b60e656d1e9f66a20d Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Wed, 16 Sep 2026 12:49:21 +0200 Subject: [PATCH 052/103] feat(data): ajoute l'import historique des donnees --- .gitignore | 3 +- apps/backend/app/etl/__init__.py | 0 apps/backend/app/etl/historical_import.py | 783 ++++++++++++++++++++++ apps/backend/pyproject.toml | 1 + apps/backend/uv.lock | 95 +++ data/raw/.gitkeep | 0 6 files changed, 881 insertions(+), 1 deletion(-) create mode 100644 apps/backend/app/etl/__init__.py create mode 100644 apps/backend/app/etl/historical_import.py create mode 100644 data/raw/.gitkeep diff --git a/.gitignore b/.gitignore index bb3dca3..38ef5cf 100644 --- a/.gitignore +++ b/.gitignore @@ -52,7 +52,8 @@ standalone_admin_password.txt secrets/ # Donnees locales -data/ +data/raw/* +!data/raw/.gitkeep *.sqlite3 monitoring/grafana/data/ monitoring/prometheus/data/ diff --git a/apps/backend/app/etl/__init__.py b/apps/backend/app/etl/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/app/etl/historical_import.py b/apps/backend/app/etl/historical_import.py new file mode 100644 index 0000000..e876c26 --- /dev/null +++ b/apps/backend/app/etl/historical_import.py @@ -0,0 +1,783 @@ +from __future__ import annotations + +import argparse +import asyncio +import hashlib +import json +from pathlib import Path +from typing import Any + +import pandas as pd +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine + +from app.core.config import get_settings + +REQUIRED_COLUMNS = { + "timestamp", + "site_id", + "site_type", + "site_name", + "consumption_kwh", + "consumption_euros", + "temperature_celsius", + "humidity_percent", + "solar_irradiance_wm2", + "hour", + "day_of_week", + "day_name", + "month", + "is_weekend", + "is_working_hours", +} + +MEASURE_COLUMNS = [ + "consumption_kwh", + "consumption_euros", + "temperature_celsius", + "humidity_percent", + "solar_irradiance_wm2", +] + +SOURCE_NAME = "historical_csv" + + +def compute_sha256(path: Path) -> str: + """Calcule l'empreinte SHA-256 du fichier source.""" + sha256 = hashlib.sha256() + + with path.open("rb") as source: + for block in iter(lambda: source.read(1024 * 1024), b""): + sha256.update(block) + + return sha256.hexdigest() + + +def load_metadata(path: Path) -> dict[str, Any]: + """Charge les métadonnées fournies avec le dataset.""" + with path.open("r", encoding="utf-8") as source: + return json.load(source) + + +def classify_quality( + row: dict[str, Any], +) -> tuple[str, list[str]]: + """ + Déduit une qualité technique à partir des champs manquants. + + Les valeurs NULL sont conservées. On ne cherche pas ici à + déterminer la cause physique exacte de leur absence. + """ + missing = [ + column + for column in MEASURE_COLUMNS + if pd.isna(row.get(column)) + ] + + if not missing: + quality = "good" + elif len(missing) == len(MEASURE_COLUMNS): + quality = "critical" + elif "consumption_kwh" in missing: + quality = "degraded" + else: + quality = "partial" + + reasons = [ + f"missing:{column}" + for column in missing + ] + + return quality, reasons + + +def validate_source( + frame: pd.DataFrame, + metadata: dict[str, Any], +) -> None: + """Valide le dataset avant tout chargement en base.""" + missing_columns = REQUIRED_COLUMNS.difference( + frame.columns + ) + + if missing_columns: + raise ValueError( + "Colonnes obligatoires absentes : " + f"{sorted(missing_columns)}" + ) + + expected_records = int(metadata["total_records"]) + + if len(frame) != expected_records: + raise ValueError( + "Nombre de lignes inattendu : " + f"{len(frame)} au lieu de " + f"{expected_records}" + ) + + expected_sites = set(metadata["sites"].keys()) + actual_sites = set(frame["site_id"].unique()) + + if actual_sites != expected_sites: + raise ValueError( + "Sites incohérents. " + f"Attendus={sorted(expected_sites)}, " + f"trouvés={sorted(actual_sites)}" + ) + + duplicated = frame.duplicated( + subset=["site_id", "timestamp"] + ).sum() + + if duplicated: + raise ValueError( + f"{duplicated} doublons " + "(site_id, timestamp) détectés" + ) + + static_variants = ( + frame.groupby("site_id")[ + ["site_type", "site_name"] + ] + .nunique() + ) + + if (static_variants > 1).any().any(): + raise ValueError( + "Un site possède plusieurs valeurs " + "de site_type ou site_name." + ) + + # Vérifie également que tous les timestamps + # peuvent être interprétés correctement. + pd.to_datetime( + frame["timestamp"], + errors="raise", + ) + + +def normalize_timestamps( + frame: pd.DataFrame, + source_timezone: str, +) -> pd.DataFrame: + """ + Normalise les timestamps et leur associe une timezone. + + Les timestamps originaux sont conservés dans une colonne + temporaire afin de pouvoir les stocker dans raw_data. + """ + normalized = frame.copy() + + normalized["_source_timestamp"] = ( + normalized["timestamp"] + ) + + timestamps = pd.to_datetime( + normalized["timestamp"], + errors="raise", + ) + + if timestamps.dt.tz is None: + timestamps = timestamps.dt.tz_localize( + source_timezone + ) + else: + timestamps = timestamps.dt.tz_convert( + source_timezone + ) + + normalized["timestamp"] = timestamps + + return normalized + + +def to_json_value(value: Any) -> Any: + """ + Convertit une valeur Pandas/Numpy en valeur + compatible JSON. + """ + if value is None: + return None + + try: + if pd.isna(value): + return None + except (TypeError, ValueError): + pass + + if isinstance(value, pd.Timestamp): + return value.isoformat() + + if hasattr(value, "item"): + return value.item() + + return value + + +async def ensure_dataset( + connection: AsyncConnection, + metadata: dict[str, Any], + sha256: str, + source_timezone: str, + storage_uri: str, +) -> int: + """ + Crée l'entrée dataset si elle n'existe pas. + + Le SHA-256 permet de reconnaître un fichier déjà importé + et participe à l'idempotence et à la traçabilité. + """ + result = await connection.execute( + text( + """ + SELECT dataset_id + FROM dataset + WHERE archive_sha256 = :sha256 + LIMIT 1 + """ + ), + { + "sha256": sha256, + }, + ) + + existing = result.scalar_one_or_none() + + if existing is not None: + return int(existing) + + metadata_summary = { + "generator_version": metadata.get( + "generator_version" + ), + "total_sites": metadata.get( + "total_sites" + ), + "total_records": metadata.get( + "total_records" + ), + "date_range": metadata.get( + "date_range" + ), + "frequency": metadata.get( + "frequency" + ), + "null_injection_enabled": metadata.get( + "null_injection_enabled" + ), + "null_strategies": metadata.get( + "null_strategies" + ), + "importer": "historical_import_v1", + } + + result = await connection.execute( + text( + """ + INSERT INTO dataset ( + dataset_name, + archive_sha256, + storage_uri, + source_timezone, + "metadata" + ) + VALUES ( + :dataset_name, + :archive_sha256, + :storage_uri, + :source_timezone, + CAST(:metadata AS jsonb) + ) + RETURNING dataset_id + """ + ), + { + "dataset_name": ( + "EnerVision historical dataset " + "2023-2024" + ), + "archive_sha256": sha256, + "storage_uri": storage_uri, + "source_timezone": source_timezone, + "metadata": json.dumps( + metadata_summary, + ensure_ascii=False, + ), + }, + ) + + return int(result.scalar_one()) + + +async def upsert_sites( + connection: AsyncConnection, + frame: pd.DataFrame, +) -> None: + """Insère ou met à jour les sites du dataset.""" + sites = ( + frame[ + [ + "site_id", + "site_type", + "site_name", + ] + ] + .drop_duplicates( + subset=["site_id"] + ) + .to_dict( + orient="records" + ) + ) + + await connection.execute( + text( + """ + INSERT INTO site ( + site_id, + site_type, + site_name + ) + VALUES ( + :site_id, + :site_type, + :site_name + ) + ON CONFLICT (site_id) + DO UPDATE SET + site_type = EXCLUDED.site_type, + site_name = EXCLUDED.site_name + """ + ), + sites, + ) + + +def build_reading_batch( + chunk: pd.DataFrame, + dataset_id: int, +) -> list[dict[str, Any]]: + """ + Transforme un chunk Pandas en lignes prêtes + à être chargées dans la table reading. + """ + rows: list[dict[str, Any]] = [] + + for record in chunk.to_dict( + orient="records" + ): + quality, reasons = classify_quality( + record + ) + + raw_data = { + column: to_json_value(value) + for column, value in record.items() + if column != "_source_timestamp" + } + + # Dans raw_data, on conserve le timestamp + # exactement tel qu'il était dans le CSV. + raw_data["timestamp"] = to_json_value( + record["_source_timestamp"] + ) + + rows.append( + { + "site_id": record["site_id"], + "timestamp": record["timestamp"], + "source": SOURCE_NAME, + "dataset_id": dataset_id, + + # Non fourni par le dataset historique. + "consumption_kw": None, + + "consumption_kwh": to_json_value( + record["consumption_kwh"] + ), + "consumption_euros": to_json_value( + record["consumption_euros"] + ), + + # Non fournis par le CSV historique. + "voltage_v": None, + "current_a": None, + "power_factor": None, + + "temperature_celsius": ( + to_json_value( + record[ + "temperature_celsius" + ] + ) + ), + "humidity_percent": ( + to_json_value( + record[ + "humidity_percent" + ] + ) + ), + "solar_irradiance_wm2": ( + to_json_value( + record[ + "solar_irradiance_wm2" + ] + ) + ), + + "is_working_hours": bool( + record[ + "is_working_hours" + ] + ), + + "data_quality": quality, + "null_reasons": reasons, + + # Aucune imputation pendant + # l'ingestion RAW. + "imputed_values": json.dumps( + {} + ), + "imputation_method": None, + + # Conservation de la donnée source + # pour la traçabilité. + "raw_data": json.dumps( + raw_data, + ensure_ascii=False, + ), + } + ) + + return rows + + +READING_INSERT = text( + """ + INSERT INTO reading ( + site_id, + timestamp, + source, + dataset_id, + consumption_kw, + consumption_kwh, + consumption_euros, + voltage_v, + current_a, + power_factor, + temperature_celsius, + humidity_percent, + solar_irradiance_wm2, + is_working_hours, + data_quality, + null_reasons, + imputed_values, + imputation_method, + raw_data + ) + VALUES ( + :site_id, + :timestamp, + :source, + :dataset_id, + :consumption_kw, + :consumption_kwh, + :consumption_euros, + :voltage_v, + :current_a, + :power_factor, + :temperature_celsius, + :humidity_percent, + :solar_irradiance_wm2, + :is_working_hours, + :data_quality, + :null_reasons, + CAST(:imputed_values AS jsonb), + :imputation_method, + CAST(:raw_data AS jsonb) + ) + ON CONFLICT DO NOTHING + """ +) + + +async def import_historical( + csv_path: Path, + metadata_path: Path, + source_timezone: str, + batch_size: int, + dry_run: bool, + storage_uri: str, +) -> None: + """ + Exécute le pipeline ETL historique EnerVision. + + Étapes : + 1. Extract + 2. Validate + 3. Transform + 4. Load + """ + metadata = load_metadata( + metadata_path + ) + + frame = pd.read_csv( + csv_path + ) + + validate_source( + frame, + metadata, + ) + + print( + f"Lignes : {len(frame)}" + ) + print( + "Sites : " + f"{frame['site_id'].nunique()}" + ) + print( + "Période : " + f"{frame['timestamp'].min()} -> " + f"{frame['timestamp'].max()}" + ) + print( + "Doublons : " + f"{frame.duplicated(['site_id', 'timestamp']).sum()}" + ) + + print("\nValeurs NULL :") + print( + frame[ + MEASURE_COLUMNS + ].isna().sum() + ) + + sha256 = compute_sha256( + csv_path + ) + + print( + f"\nSHA-256 : {sha256}" + ) + + if dry_run: + print( + "\nDry-run terminé : " + "aucune donnée écrite." + ) + return + + normalized = normalize_timestamps( + frame, + source_timezone, + ) + + settings = get_settings() + + engine = create_async_engine( + str(settings.database_url), + pool_pre_ping=True, + ) + + try: + async with engine.begin() as connection: + dataset_id = await ensure_dataset( + connection=connection, + metadata=metadata, + sha256=sha256, + source_timezone=source_timezone, + storage_uri=storage_uri, + ) + + await upsert_sites( + connection, + normalized, + ) + + result = await connection.execute( + text( + """ + SELECT COUNT(*) + FROM reading + WHERE dataset_id = :dataset_id + AND source = :source + """ + ), + { + "dataset_id": dataset_id, + "source": SOURCE_NAME, + }, + ) + + before = int( + result.scalar_one() + ) + + for start in range( + 0, + len(normalized), + batch_size, + ): + chunk = normalized.iloc[ + start : start + batch_size + ] + + rows = build_reading_batch( + chunk, + dataset_id, + ) + + await connection.execute( + READING_INSERT, + rows, + ) + + loaded = min( + start + batch_size, + len(normalized), + ) + + print( + "Chargement : " + f"{loaded}/" + f"{len(normalized)}" + ) + + result = await connection.execute( + text( + """ + SELECT COUNT(*) + FROM reading + WHERE dataset_id = :dataset_id + AND source = :source + """ + ), + { + "dataset_id": dataset_id, + "source": SOURCE_NAME, + }, + ) + + after = int( + result.scalar_one() + ) + + print( + "\nImport terminé." + ) + print( + "dataset_id : " + f"{dataset_id}" + ) + print( + "lectures avant : " + f"{before}" + ) + print( + "lectures après : " + f"{after}" + ) + print( + "nouvelles lectures : " + f"{after - before}" + ) + + finally: + await engine.dispose() + + +def parse_args() -> argparse.Namespace: + """Définit les arguments CLI de l'import.""" + parser = argparse.ArgumentParser( + description=( + "Import historique EnerVision" + ) + ) + + parser.add_argument( + "--csv", + type=Path, + required=True, + help="Chemin vers le CSV historique.", + ) + + parser.add_argument( + "--metadata", + type=Path, + required=True, + help=( + "Chemin vers le fichier " + "dataset_metadata.json." + ), + ) + + parser.add_argument( + "--source-timezone", + default="UTC", + help=( + "Timezone associée aux timestamps " + "du dataset. Défaut : UTC." + ), + ) + + parser.add_argument( + "--batch-size", + type=int, + default=1000, + help=( + "Nombre de lignes insérées " + "par batch. Défaut : 1000." + ), + ) + + parser.add_argument( + "--dry-run", + action="store_true", + help=( + "Valide les données sans " + "écrire en base." + ), + ) + + return parser.parse_args() + + +def main() -> None: + """Point d'entrée CLI du pipeline.""" + args = parse_args() + + if args.batch_size <= 0: + raise ValueError( + "--batch-size doit être " + "strictement supérieur à 0." + ) + + # resolve() est volontairement exécuté ici, + # dans la partie synchrone du programme. + # Cela évite une opération filesystem bloquante + # à l'intérieur d'une fonction async. + storage_uri = ( + args.csv.resolve().as_uri() + ) + + asyncio.run( + import_historical( + csv_path=args.csv, + metadata_path=args.metadata, + source_timezone=( + args.source_timezone + ), + batch_size=args.batch_size, + dry_run=args.dry_run, + storage_uri=storage_uri, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index 18bf979..6cf42a5 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "pyjwt>=2.10", "argon2-cffi>=23.1", "anyio>=4.0", + "pandas>=3.0.5", ] [dependency-groups] diff --git a/apps/backend/uv.lock b/apps/backend/uv.lock index 7c2b8f4..fb43797 100644 --- a/apps/backend/uv.lock +++ b/apps/backend/uv.lock @@ -1,6 +1,11 @@ version = 1 revision = 3 requires-python = "==3.14.*" +resolution-markers = [ + "sys_platform == 'win32'", + "sys_platform == 'emscripten'", + "sys_platform != 'emscripten' and sys_platform != 'win32'", +] [[package]] name = "alembic" @@ -311,6 +316,7 @@ dependencies = [ { name = "argon2-cffi" }, { name = "asyncpg" }, { name = "fastapi" }, + { name = "pandas" }, { name = "prometheus-fastapi-instrumentator" }, { name = "pydantic", extra = ["email"] }, { name = "pydantic-settings" }, @@ -337,6 +343,7 @@ requires-dist = [ { name = "argon2-cffi", specifier = ">=23.1" }, { name = "asyncpg", specifier = ">=0.31.0" }, { name = "fastapi", specifier = ">=0.141.1" }, + { name = "pandas", specifier = ">=3.0.5" }, { name = "prometheus-fastapi-instrumentator", specifier = ">=8.1.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.13.5" }, { name = "pydantic-settings", specifier = ">=2.15.0" }, @@ -595,6 +602,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "numpy" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" }, + { url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" }, + { url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" }, + { url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" }, + { url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" }, + { url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" }, + { url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" }, + { url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" }, + { url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" }, + { url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" }, + { url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" }, +] + [[package]] name = "packaging" version = "26.3" @@ -604,6 +640,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] +[[package]] +name = "pandas" +version = "3.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" }, + { url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" }, + { url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" }, + { url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" }, + { url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" }, + { url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" }, + { url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" }, + { url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" }, + { url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" }, +] + [[package]] name = "pathspec" version = "1.1.1" @@ -788,6 +853,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.3" @@ -857,6 +934,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "sqlalchemy" version = "2.0.52" @@ -916,6 +1002,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, ] +[[package]] +name = "tzdata" +version = "2026.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/31/3d74fa778a63b98b7374323befcc0be5ab3bd94afd4096a0124e7379152c/tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79", size = 199350, upload-time = "2026-09-12T12:56:03.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" }, +] + [[package]] name = "uvicorn" version = "0.53.0" diff --git a/data/raw/.gitkeep b/data/raw/.gitkeep new file mode 100644 index 0000000..e69de29 From b2d52823bae33df93b2808f33556a36b4baad663 Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Wed, 16 Sep 2026 14:16:22 +0200 Subject: [PATCH 053/103] fix(data): aligne l'import historique avec les contraintes BDD --- apps/backend/app/etl/historical_import.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/backend/app/etl/historical_import.py b/apps/backend/app/etl/historical_import.py index e876c26..c3b9a70 100644 --- a/apps/backend/app/etl/historical_import.py +++ b/apps/backend/app/etl/historical_import.py @@ -39,7 +39,7 @@ MEASURE_COLUMNS = [ "solar_irradiance_wm2", ] -SOURCE_NAME = "historical_csv" +SOURCE_NAME = "csv" def compute_sha256(path: Path) -> str: @@ -435,11 +435,10 @@ def build_reading_batch( "data_quality": quality, "null_reasons": reasons, - # Aucune imputation pendant - # l'ingestion RAW. - "imputed_values": json.dumps( - {} - ), + # Aucune imputation pendant l'ingestion RAW. + # Les valeurs manquantes sont conservées telles quelles + # afin de préserver la donnée source. + "imputed_values": None, "imputation_method": None, # Conservation de la donnée source From ebb72fb39996cebaafa53e4c727e6ec25257d054 Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Wed, 16 Sep 2026 14:29:22 +0200 Subject: [PATCH 054/103] test(data): couvre l'import historique --- .../tests/etl/test_historical_import.py | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 apps/backend/tests/etl/test_historical_import.py diff --git a/apps/backend/tests/etl/test_historical_import.py b/apps/backend/tests/etl/test_historical_import.py new file mode 100644 index 0000000..f311b2d --- /dev/null +++ b/apps/backend/tests/etl/test_historical_import.py @@ -0,0 +1,244 @@ +import hashlib +import json + +import pandas as pd +import pytest + +from app.etl.historical_import import ( + SOURCE_NAME, + build_reading_batch, + classify_quality, + compute_sha256, + load_metadata, + normalize_timestamps, + validate_source, +) + + +def make_metadata() -> dict: + return { + "total_records": 2, + "sites": { + "SITE001": {}, + }, + } + + +def make_dataframe() -> pd.DataFrame: + return pd.DataFrame( + [ + { + "timestamp": "2023-01-01 00:00:00", + "site_id": "SITE001", + "site_type": "office", + "site_name": "Site 1", + "consumption_kwh": 10.5, + "consumption_euros": 2.5, + "temperature_celsius": 20.0, + "humidity_percent": 50.0, + "solar_irradiance_wm2": 0.0, + "hour": 0, + "day_of_week": 6, + "day_name": "Sunday", + "month": 1, + "is_weekend": True, + "is_working_hours": False, + }, + { + "timestamp": "2023-01-01 01:00:00", + "site_id": "SITE001", + "site_type": "office", + "site_name": "Site 1", + "consumption_kwh": 11.0, + "consumption_euros": 2.7, + "temperature_celsius": 19.5, + "humidity_percent": 52.0, + "solar_irradiance_wm2": 0.0, + "hour": 1, + "day_of_week": 6, + "day_name": "Sunday", + "month": 1, + "is_weekend": True, + "is_working_hours": False, + }, + ] + ) + + +def test_compute_sha256(tmp_path): + file_path = tmp_path / "dataset.csv" + content = b"hello-enervision" + + file_path.write_bytes(content) + + expected = hashlib.sha256(content).hexdigest() + + assert compute_sha256(file_path) == expected + + +def test_load_metadata(tmp_path): + metadata_path = tmp_path / "metadata.json" + + metadata = { + "total_records": 2, + "sites": { + "SITE001": {}, + }, + } + + metadata_path.write_text( + json.dumps(metadata), + encoding="utf-8", + ) + + assert load_metadata(metadata_path) == metadata + + +def test_validate_source_accepts_valid_dataset(): + frame = make_dataframe() + + validate_source( + frame, + make_metadata(), + ) + + +def test_validate_source_rejects_missing_column(): + frame = make_dataframe().drop( + columns=["consumption_kwh"] + ) + + with pytest.raises( + ValueError, + match="Colonnes obligatoires absentes", + ): + validate_source( + frame, + make_metadata(), + ) + + +def test_validate_source_rejects_duplicates(): + frame = make_dataframe() + + frame.loc[1, "timestamp"] = frame.loc[ + 0, + "timestamp", + ] + + with pytest.raises( + ValueError, + match="doublons", + ): + validate_source( + frame, + make_metadata(), + ) + + +def test_validate_source_rejects_unknown_site(): + frame = make_dataframe() + + frame.loc[1, "site_id"] = "SITE999" + + with pytest.raises( + ValueError, + match="Sites incohérents", + ): + validate_source( + frame, + make_metadata(), + ) + + +def test_normalize_timestamps_adds_timezone(): + frame = make_dataframe() + + normalized = normalize_timestamps( + frame, + "UTC", + ) + + assert normalized["timestamp"].dt.tz is not None + + assert "_source_timestamp" in normalized.columns + + +def test_classify_quality_good(): + row = make_dataframe().iloc[0].to_dict() + + quality, reasons = classify_quality(row) + + assert quality == "good" + assert reasons == [] + + +def test_classify_quality_degraded_when_consumption_missing(): + row = make_dataframe().iloc[0].to_dict() + row["consumption_kwh"] = None + + quality, reasons = classify_quality(row) + + assert quality == "degraded" + + assert "missing:consumption_kwh" in reasons + + +def test_build_reading_batch_respects_database_contract(): + frame = normalize_timestamps( + make_dataframe(), + "UTC", + ) + + rows = build_reading_batch( + frame.iloc[:1], + dataset_id=3, + ) + + assert len(rows) == 1 + + row = rows[0] + + assert row["dataset_id"] == 3 + + # Important : + # contrainte ck_reading_dataset_source. + assert row["source"] == "csv" + assert SOURCE_NAME == "csv" + + # Important : + # contrainte ck_reading_imputation. + assert row["imputed_values"] is None + assert row["imputation_method"] is None + + assert row["data_quality"] == "good" + assert row["null_reasons"] == [] + + +def test_build_reading_batch_keeps_missing_values(): + frame = make_dataframe() + + frame.loc[0, "temperature_celsius"] = None + + frame = normalize_timestamps( + frame, + "UTC", + ) + + rows = build_reading_batch( + frame.iloc[:1], + dataset_id=3, + ) + + row = rows[0] + + assert row["temperature_celsius"] is None + + assert ( + "missing:temperature_celsius" + in row["null_reasons"] + ) + + # RAW ingestion : aucune imputation. + assert row["imputed_values"] is None + assert row["imputation_method"] is None From 74ac1b45778c27935d936a51d9460d15932874f3 Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Thu, 17 Sep 2026 09:55:27 +0200 Subject: [PATCH 055/103] docs(data): documente le pipeline d'import historique --- docs/architecture/40-data.md | 70 +++++++ etl/README.md | 354 ++++++++++++++++++++++++++++++++++- 2 files changed, 417 insertions(+), 7 deletions(-) diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md index 6566753..ffd5e6d 100644 --- a/docs/architecture/40-data.md +++ b/docs/architecture/40-data.md @@ -231,3 +231,73 @@ et ne sont pas considérées comme des alertes actuelles. - Les mesures API ne sont pas rattachées à un dataset historique. - Une alerte peut être associée à une prévision du même site. - Une alerte peut donner lieu à plusieurs recommandations. + +## Ingestion des données historiques + +Le MVP EnerVision initialise les données énergétiques à partir du dataset fourni dans le cadre du projet. + +Le dataset de référence contient 122 647 mesures issues de 7 sites et couvre la période du 1er janvier 2023 au 31 décembre 2024. + +Les fichiers sources CSV et JSON sont nécessaires uniquement pour l'initialisation des données. Ils ne sont pas versionnés dans Git et sont placés localement dans `data/raw/`. + +### Architecture du flux + +```text +Dataset CSV + métadonnées JSON + | + v + historical_import.py + | + +------+------+ + | | + v v + Validation SHA-256 + | Traçabilité + +------+------+ + | + v + Normalisation + + qualité data + | + v + Chargement par batches + | + v + PostgreSQL / TimescaleDB + | | | + v v v + dataset site reading +``` + +Le pipeline est développé en Python. + +Pandas est utilisé pour l'extraction, la validation et la préparation des données. SQLAlchemy Async assure le chargement transactionnel dans PostgreSQL/TimescaleDB. + +Une empreinte SHA-256 permet d'identifier le dataset utilisé et d'assurer sa traçabilité. + +Les valeurs manquantes sont conservées pendant l'ingestion afin de préserver les données sources. Aucune imputation n'est réalisée à cette étape. + +Le chargement des mesures est effectué par batches de 1 000 lignes. + +Les données provenant du dataset CSV sont identifiées par `source = "csv"` et associées à leur `dataset_id`. + +### Résultats validés + +Le chargement de référence a permis d'obtenir : + +- 1 dataset ; +- 7 sites ; +- 122 647 mesures ; +- 0 doublon détecté dans le dataset source. + +L'idempotence a également été vérifiée par une deuxième exécution du pipeline : aucune nouvelle mesure n'a été créée et le nombre de `reading` est resté à 122 647. + +La procédure détaillée d'installation, d'exécution, de validation et de contrôle du pipeline est disponible dans `etl/README.md`. + +### Évolution prévue + +L'étape suivante consiste à orchestrer les traitements Data avec Apache Airflow. + +L'orchestration réutilisera la logique ETL existante afin de séparer la logique de traitement de la planification, du suivi des exécutions et de la gestion des erreurs. + +Le pipeline servira ensuite de base à la préparation des données nécessaires au modèle de Machine Learning. diff --git a/etl/README.md b/etl/README.md index cac0f3d..b835311 100644 --- a/etl/README.md +++ b/etl/README.md @@ -1,9 +1,349 @@ -# ETL +# Pipeline ETL — EnerVision -Orchestration Apache Airflow : ingestion des mesures, agregations continues, -controles de qualite. Non initialise, voir le ticket dedie. +## Objectif -- `airflow/dags` : DAGs. -- `airflow/plugins` : operateurs et hooks maison. -- `airflow/include` : requetes SQL et ressources referencees par les DAGs. -- `airflow/tests` : tests d'integrite des DAGs. +Le pipeline ETL EnerVision permet d'intégrer les données énergétiques historiques dans PostgreSQL/TimescaleDB. + +Cette première étape du pipeline Data permet de charger le dataset fourni dans le cadre du projet, contenant les mesures énergétiques de 7 sites sur la période du 1er janvier 2023 au 31 décembre 2024. + +Le pipeline assure : + +- l'extraction des données sources ; +- la validation de leur structure et de leur cohérence ; +- la normalisation des données nécessaires au stockage ; +- le suivi de la qualité des données ; +- la traçabilité du dataset importé ; +- le chargement des données dans PostgreSQL/TimescaleDB ; +- l'idempotence du chargement afin d'éviter la création de doublons. + +## Données sources + +Le dataset est fourni par le formateur dans le cadre du projet EnerVision. + +Il contient les deux fichiers suivants : + +```text +all_sites_combined.csv +dataset_metadata.json +``` + +Ces fichiers sont nécessaires une seule fois pour initialiser les données historiques de l'environnement. + +Ils ne sont pas versionnés dans Git. Chaque membre de l'équipe récupère manuellement une fois les fichiers fournis par le formateur et les place dans : + +```text +data/raw/ +``` + +Structure locale attendue : + +```text +data/ +└── raw/ + ├── .gitkeep + ├── all_sites_combined.csv + └── dataset_metadata.json +``` + +Le fichier `.gitkeep` est versionné afin de conserver le répertoire `data/raw/` dans Git. Les fichiers CSV et JSON sont ignorés par Git. + +## Technologies utilisées + +| Technologie | Utilisation | +|---|---| +| Python | Développement du pipeline ETL | +| Pandas | Lecture, validation et transformation des données | +| JSON | Lecture des métadonnées du dataset | +| hashlib / SHA-256 | Identification, intégrité et traçabilité du dataset | +| SQLAlchemy Async | Connexion et chargement asynchrone en base | +| PostgreSQL | Stockage relationnel | +| TimescaleDB | Stockage des séries temporelles énergétiques | +| Docker Compose | Exécution de l'environnement local | +| Alembic | Gestion des migrations du schéma | +| uv | Gestion et exécution de l'environnement Python | +| Ruff | Contrôle de la qualité du code | +| Pytest | Tests automatisés | + +## Fonctionnement du pipeline + +Le script principal d'import se trouve dans : + +```text +apps/backend/app/etl/historical_import.py +``` + +Le flux d'import est le suivant : + +```text +CSV + métadonnées JSON + | + v + Extraction + | + v + Validation + | + v + Traçabilité SHA-256 + | + v + Transformation + | + v + Chargement par batches + | + v +PostgreSQL / TimescaleDB +``` + +### 1. Extraction + +Le pipeline charge : + +- `all_sites_combined.csv` avec Pandas ; +- `dataset_metadata.json` avec le module JSON de Python. + +### 2. Validation + +Avant toute écriture en base, le pipeline contrôle notamment : + +- la présence des colonnes obligatoires ; +- le nombre de lignes ; +- la cohérence des identifiants des sites ; +- la cohérence des informations associées aux sites ; +- les doublons sur le couple `(site_id, timestamp)` ; +- les timestamps ; +- les valeurs manquantes. + +Une incohérence détectée pendant cette étape interrompt l'import avant le chargement. + +### 3. Dry-run + +Un mode `--dry-run` permet d'exécuter les contrôles sans écrire de données dans PostgreSQL. + +Il permet notamment de vérifier : + +- le nombre de lignes ; +- le nombre de sites ; +- la période couverte ; +- les doublons ; +- les valeurs NULL ; +- l'empreinte SHA-256. + +### 4. Traçabilité + +Une empreinte SHA-256 est calculée à partir du fichier CSV afin d'identifier le dataset utilisé. + +Empreinte SHA-256 du dataset validé : + +```text +6E3777A97A5660B11855750B9028F70BE72138A11F26795F3A35D9CE74CE0C8D +``` + +Cette empreinte participe à la traçabilité du dataset chargé. + +### 5. Transformation + +Les timestamps sont normalisés avec la timezone : + +```text +UTC +``` + +Le pipeline détermine également la qualité des mesures à partir des données disponibles. + +Les valeurs manquantes sont conservées pendant cette phase afin de préserver la donnée source. + +Aucune imputation n'est réalisée pendant l'ingestion : + +```text +imputed_values = NULL +imputation_method = NULL +``` + +### 6. Chargement + +Le chargement est réalisé avec SQLAlchemy Async dans PostgreSQL/TimescaleDB. + +Les données sont enregistrées dans les tables : + +```text +dataset +site +reading +``` + +Les mesures sont chargées par batches de : + +```text +1000 lignes +``` + +Les mesures provenant du dataset CSV utilisent : + +```text +source = "csv" +dataset_id = identifiant du dataset +``` + +Cette représentation respecte les contraintes définies dans le schéma de la base. + +## Dataset validé + +Le dataset traité contient : + +- 122 647 mesures ; +- 7 sites ; +- une période du 01/01/2023 au 31/12/2024 ; +- 0 doublon détecté dans les données sources. + +Valeurs manquantes identifiées : + +| Variable | Nombre de valeurs NULL | +|---|---:| +| `consumption_kwh` | 2 840 | +| `consumption_euros` | 2 487 | +| `temperature_celsius` | 3 416 | +| `humidity_percent` | 3 423 | +| `solar_irradiance_wm2` | 3 964 | + +## Exécution en dry-run + +Depuis le dossier : + +```text +apps/backend/ +``` + +exécuter : + +```powershell +uv run python -m app.etl.historical_import ` + --csv ..\..\data\raw\all_sites_combined.csv ` + --metadata ..\..\data\raw\dataset_metadata.json ` + --source-timezone UTC ` + --dry-run +``` + +Aucune donnée n'est écrite dans la base pendant cette exécution. + +## Chargement réel + +Depuis `apps/backend/` : + +```powershell +uv run python -m app.etl.historical_import ` + --csv ..\..\data\raw\all_sites_combined.csv ` + --metadata ..\..\data\raw\dataset_metadata.json ` + --source-timezone UTC +``` + +Le chargement est effectué progressivement par batches. + +Exemple : + +```text +Chargement : 1000/122647 +Chargement : 2000/122647 +... +Chargement : 122647/122647 +``` + +## Résultats obtenus + +Après le chargement initial, les contrôles en base ont confirmé : + +```text +datasets = 1 +sites = 7 +readings = 122647 +source = csv +``` + +Le premier import a créé : + +```text +nouvelles lectures : 122647 +``` + +## Idempotence + +Le pipeline a été exécuté une deuxième fois avec exactement le même dataset afin de vérifier son idempotence. + +Résultat : + +```text +lectures avant : 122647 +lectures après : 122647 +nouvelles lectures : 0 +``` + +Une nouvelle exécution du même import ne crée donc pas de mesures supplémentaires pour le dataset testé. + +## Vérifications SQL + +Depuis la racine du projet, vérifier le nombre d'enregistrements avec : + +```powershell +docker compose exec db psql -U enervision -d enervision -c "SELECT COUNT(*) AS datasets FROM dataset; SELECT COUNT(*) AS sites FROM site; SELECT COUNT(*) AS readings FROM reading;" +``` + +Résultat attendu après l'import initial : + +```text +datasets = 1 +sites = 7 +readings = 122647 +``` + +Vérifier la source des mesures avec : + +```powershell +docker compose exec db psql -U enervision -d enervision -c "SELECT source, COUNT(*) FROM reading GROUP BY source ORDER BY source;" +``` + +Résultat attendu : + +```text +csv | 122647 +``` + +## Tests et qualité + +Les tests automatisés du pipeline sont situés dans : + +```text +apps/backend/tests/etl/ +``` + +Ils couvrent notamment : + +- la validation du dataset ; +- les colonnes obligatoires ; +- la détection des doublons ; +- la cohérence des sites ; +- la normalisation des timestamps ; +- la gestion des valeurs manquantes ; +- la classification de la qualité des données ; +- la construction des mesures destinées à la BDD ; +- le respect des contraintes du modèle de données. + +Exécuter les tests ETL : + +```powershell +uv run pytest tests\etl -v +``` + +Contrôler la qualité du code : + +```powershell +uv run ruff check app\etl tests\etl +``` + +## Suite du pipeline Data + +L'import historique constitue la première brique du pipeline Data EnerVision. + +La prochaine étape consiste à orchestrer les traitements ETL avec Apache Airflow, puis à préparer les données nécessaires à l'entraînement du modèle de Machine Learning. + +Airflow sera utilisé comme orchestrateur des traitements existants et ne remplacera pas la logique métier déjà implémentée dans le pipeline ETL. \ No newline at end of file From f03dce5fe37225a022bd6e3f9077e0e33cdf43d4 Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Thu, 17 Sep 2026 10:06:54 +0200 Subject: [PATCH 056/103] style(data): applique le formatage Ruff --- apps/backend/app/etl/historical_import.py | 292 ++++-------------- .../tests/etl/test_historical_import.py | 9 +- 2 files changed, 62 insertions(+), 239 deletions(-) diff --git a/apps/backend/app/etl/historical_import.py b/apps/backend/app/etl/historical_import.py index c3b9a70..b4f2089 100644 --- a/apps/backend/app/etl/historical_import.py +++ b/apps/backend/app/etl/historical_import.py @@ -68,11 +68,7 @@ def classify_quality( Les valeurs NULL sont conservées. On ne cherche pas ici à déterminer la cause physique exacte de leur absence. """ - missing = [ - column - for column in MEASURE_COLUMNS - if pd.isna(row.get(column)) - ] + missing = [column for column in MEASURE_COLUMNS if pd.isna(row.get(column))] if not missing: quality = "good" @@ -83,10 +79,7 @@ def classify_quality( else: quality = "partial" - reasons = [ - f"missing:{column}" - for column in missing - ] + reasons = [f"missing:{column}" for column in missing] return quality, reasons @@ -96,57 +89,33 @@ def validate_source( metadata: dict[str, Any], ) -> None: """Valide le dataset avant tout chargement en base.""" - missing_columns = REQUIRED_COLUMNS.difference( - frame.columns - ) + missing_columns = REQUIRED_COLUMNS.difference(frame.columns) if missing_columns: - raise ValueError( - "Colonnes obligatoires absentes : " - f"{sorted(missing_columns)}" - ) + raise ValueError(f"Colonnes obligatoires absentes : {sorted(missing_columns)}") expected_records = int(metadata["total_records"]) if len(frame) != expected_records: - raise ValueError( - "Nombre de lignes inattendu : " - f"{len(frame)} au lieu de " - f"{expected_records}" - ) + raise ValueError(f"Nombre de lignes inattendu : {len(frame)} au lieu de {expected_records}") expected_sites = set(metadata["sites"].keys()) actual_sites = set(frame["site_id"].unique()) if actual_sites != expected_sites: raise ValueError( - "Sites incohérents. " - f"Attendus={sorted(expected_sites)}, " - f"trouvés={sorted(actual_sites)}" + f"Sites incohérents. Attendus={sorted(expected_sites)}, trouvés={sorted(actual_sites)}" ) - duplicated = frame.duplicated( - subset=["site_id", "timestamp"] - ).sum() + duplicated = frame.duplicated(subset=["site_id", "timestamp"]).sum() if duplicated: - raise ValueError( - f"{duplicated} doublons " - "(site_id, timestamp) détectés" - ) + raise ValueError(f"{duplicated} doublons (site_id, timestamp) détectés") - static_variants = ( - frame.groupby("site_id")[ - ["site_type", "site_name"] - ] - .nunique() - ) + static_variants = frame.groupby("site_id")[["site_type", "site_name"]].nunique() if (static_variants > 1).any().any(): - raise ValueError( - "Un site possède plusieurs valeurs " - "de site_type ou site_name." - ) + raise ValueError("Un site possède plusieurs valeurs de site_type ou site_name.") # Vérifie également que tous les timestamps # peuvent être interprétés correctement. @@ -168,9 +137,7 @@ def normalize_timestamps( """ normalized = frame.copy() - normalized["_source_timestamp"] = ( - normalized["timestamp"] - ) + normalized["_source_timestamp"] = normalized["timestamp"] timestamps = pd.to_datetime( normalized["timestamp"], @@ -178,13 +145,9 @@ def normalize_timestamps( ) if timestamps.dt.tz is None: - timestamps = timestamps.dt.tz_localize( - source_timezone - ) + timestamps = timestamps.dt.tz_localize(source_timezone) else: - timestamps = timestamps.dt.tz_convert( - source_timezone - ) + timestamps = timestamps.dt.tz_convert(source_timezone) normalized["timestamp"] = timestamps @@ -202,7 +165,7 @@ def to_json_value(value: Any) -> Any: try: if pd.isna(value): return None - except (TypeError, ValueError): + except TypeError, ValueError: pass if isinstance(value, pd.Timestamp): @@ -247,27 +210,13 @@ async def ensure_dataset( return int(existing) metadata_summary = { - "generator_version": metadata.get( - "generator_version" - ), - "total_sites": metadata.get( - "total_sites" - ), - "total_records": metadata.get( - "total_records" - ), - "date_range": metadata.get( - "date_range" - ), - "frequency": metadata.get( - "frequency" - ), - "null_injection_enabled": metadata.get( - "null_injection_enabled" - ), - "null_strategies": metadata.get( - "null_strategies" - ), + "generator_version": metadata.get("generator_version"), + "total_sites": metadata.get("total_sites"), + "total_records": metadata.get("total_records"), + "date_range": metadata.get("date_range"), + "frequency": metadata.get("frequency"), + "null_injection_enabled": metadata.get("null_injection_enabled"), + "null_strategies": metadata.get("null_strategies"), "importer": "historical_import_v1", } @@ -292,10 +241,7 @@ async def ensure_dataset( """ ), { - "dataset_name": ( - "EnerVision historical dataset " - "2023-2024" - ), + "dataset_name": ("EnerVision historical dataset 2023-2024"), "archive_sha256": sha256, "storage_uri": storage_uri, "source_timezone": source_timezone, @@ -322,12 +268,8 @@ async def upsert_sites( "site_name", ] ] - .drop_duplicates( - subset=["site_id"] - ) - .to_dict( - orient="records" - ) + .drop_duplicates(subset=["site_id"]) + .to_dict(orient="records") ) await connection.execute( @@ -363,12 +305,8 @@ def build_reading_batch( """ rows: list[dict[str, Any]] = [] - for record in chunk.to_dict( - orient="records" - ): - quality, reasons = classify_quality( - record - ) + for record in chunk.to_dict(orient="records"): + quality, reasons = classify_quality(record) raw_data = { column: to_json_value(value) @@ -378,9 +316,7 @@ def build_reading_batch( # Dans raw_data, on conserve le timestamp # exactement tel qu'il était dans le CSV. - raw_data["timestamp"] = to_json_value( - record["_source_timestamp"] - ) + raw_data["timestamp"] = to_json_value(record["_source_timestamp"]) rows.append( { @@ -388,59 +324,25 @@ def build_reading_batch( "timestamp": record["timestamp"], "source": SOURCE_NAME, "dataset_id": dataset_id, - # Non fourni par le dataset historique. "consumption_kw": None, - - "consumption_kwh": to_json_value( - record["consumption_kwh"] - ), - "consumption_euros": to_json_value( - record["consumption_euros"] - ), - + "consumption_kwh": to_json_value(record["consumption_kwh"]), + "consumption_euros": to_json_value(record["consumption_euros"]), # Non fournis par le CSV historique. "voltage_v": None, "current_a": None, "power_factor": None, - - "temperature_celsius": ( - to_json_value( - record[ - "temperature_celsius" - ] - ) - ), - "humidity_percent": ( - to_json_value( - record[ - "humidity_percent" - ] - ) - ), - "solar_irradiance_wm2": ( - to_json_value( - record[ - "solar_irradiance_wm2" - ] - ) - ), - - "is_working_hours": bool( - record[ - "is_working_hours" - ] - ), - + "temperature_celsius": (to_json_value(record["temperature_celsius"])), + "humidity_percent": (to_json_value(record["humidity_percent"])), + "solar_irradiance_wm2": (to_json_value(record["solar_irradiance_wm2"])), + "is_working_hours": bool(record["is_working_hours"]), "data_quality": quality, "null_reasons": reasons, - # Aucune imputation pendant l'ingestion RAW. # Les valeurs manquantes sont conservées telles quelles # afin de préserver la donnée source. "imputed_values": None, "imputation_method": None, - # Conservation de la donnée source # pour la traçabilité. "raw_data": json.dumps( @@ -519,56 +421,29 @@ async def import_historical( 3. Transform 4. Load """ - metadata = load_metadata( - metadata_path - ) + metadata = load_metadata(metadata_path) - frame = pd.read_csv( - csv_path - ) + frame = pd.read_csv(csv_path) validate_source( frame, metadata, ) - print( - f"Lignes : {len(frame)}" - ) - print( - "Sites : " - f"{frame['site_id'].nunique()}" - ) - print( - "Période : " - f"{frame['timestamp'].min()} -> " - f"{frame['timestamp'].max()}" - ) - print( - "Doublons : " - f"{frame.duplicated(['site_id', 'timestamp']).sum()}" - ) + print(f"Lignes : {len(frame)}") + print(f"Sites : {frame['site_id'].nunique()}") + print(f"Période : {frame['timestamp'].min()} -> {frame['timestamp'].max()}") + print(f"Doublons : {frame.duplicated(['site_id', 'timestamp']).sum()}") print("\nValeurs NULL :") - print( - frame[ - MEASURE_COLUMNS - ].isna().sum() - ) + print(frame[MEASURE_COLUMNS].isna().sum()) - sha256 = compute_sha256( - csv_path - ) + sha256 = compute_sha256(csv_path) - print( - f"\nSHA-256 : {sha256}" - ) + print(f"\nSHA-256 : {sha256}") if dry_run: - print( - "\nDry-run terminé : " - "aucune donnée écrite." - ) + print("\nDry-run terminé : aucune donnée écrite.") return normalized = normalize_timestamps( @@ -613,18 +488,14 @@ async def import_historical( }, ) - before = int( - result.scalar_one() - ) + before = int(result.scalar_one()) for start in range( 0, len(normalized), batch_size, ): - chunk = normalized.iloc[ - start : start + batch_size - ] + chunk = normalized.iloc[start : start + batch_size] rows = build_reading_batch( chunk, @@ -641,11 +512,7 @@ async def import_historical( len(normalized), ) - print( - "Chargement : " - f"{loaded}/" - f"{len(normalized)}" - ) + print(f"Chargement : {loaded}/{len(normalized)}") result = await connection.execute( text( @@ -662,29 +529,13 @@ async def import_historical( }, ) - after = int( - result.scalar_one() - ) + after = int(result.scalar_one()) - print( - "\nImport terminé." - ) - print( - "dataset_id : " - f"{dataset_id}" - ) - print( - "lectures avant : " - f"{before}" - ) - print( - "lectures après : " - f"{after}" - ) - print( - "nouvelles lectures : " - f"{after - before}" - ) + print("\nImport terminé.") + print(f"dataset_id : {dataset_id}") + print(f"lectures avant : {before}") + print(f"lectures après : {after}") + print(f"nouvelles lectures : {after - before}") finally: await engine.dispose() @@ -692,11 +543,7 @@ async def import_historical( def parse_args() -> argparse.Namespace: """Définit les arguments CLI de l'import.""" - parser = argparse.ArgumentParser( - description=( - "Import historique EnerVision" - ) - ) + parser = argparse.ArgumentParser(description=("Import historique EnerVision")) parser.add_argument( "--csv", @@ -709,38 +556,26 @@ def parse_args() -> argparse.Namespace: "--metadata", type=Path, required=True, - help=( - "Chemin vers le fichier " - "dataset_metadata.json." - ), + help=("Chemin vers le fichier dataset_metadata.json."), ) parser.add_argument( "--source-timezone", default="UTC", - help=( - "Timezone associée aux timestamps " - "du dataset. Défaut : UTC." - ), + help=("Timezone associée aux timestamps du dataset. Défaut : UTC."), ) parser.add_argument( "--batch-size", type=int, default=1000, - help=( - "Nombre de lignes insérées " - "par batch. Défaut : 1000." - ), + help=("Nombre de lignes insérées par batch. Défaut : 1000."), ) parser.add_argument( "--dry-run", action="store_true", - help=( - "Valide les données sans " - "écrire en base." - ), + help=("Valide les données sans écrire en base."), ) return parser.parse_args() @@ -751,26 +586,19 @@ def main() -> None: args = parse_args() if args.batch_size <= 0: - raise ValueError( - "--batch-size doit être " - "strictement supérieur à 0." - ) + raise ValueError("--batch-size doit être strictement supérieur à 0.") # resolve() est volontairement exécuté ici, # dans la partie synchrone du programme. # Cela évite une opération filesystem bloquante # à l'intérieur d'une fonction async. - storage_uri = ( - args.csv.resolve().as_uri() - ) + storage_uri = args.csv.resolve().as_uri() asyncio.run( import_historical( csv_path=args.csv, metadata_path=args.metadata, - source_timezone=( - args.source_timezone - ), + source_timezone=(args.source_timezone), batch_size=args.batch_size, dry_run=args.dry_run, storage_uri=storage_uri, diff --git a/apps/backend/tests/etl/test_historical_import.py b/apps/backend/tests/etl/test_historical_import.py index f311b2d..31f6e2d 100644 --- a/apps/backend/tests/etl/test_historical_import.py +++ b/apps/backend/tests/etl/test_historical_import.py @@ -104,9 +104,7 @@ def test_validate_source_accepts_valid_dataset(): def test_validate_source_rejects_missing_column(): - frame = make_dataframe().drop( - columns=["consumption_kwh"] - ) + frame = make_dataframe().drop(columns=["consumption_kwh"]) with pytest.raises( ValueError, @@ -234,10 +232,7 @@ def test_build_reading_batch_keeps_missing_values(): assert row["temperature_celsius"] is None - assert ( - "missing:temperature_celsius" - in row["null_reasons"] - ) + assert "missing:temperature_celsius" in row["null_reasons"] # RAW ingestion : aucune imputation. assert row["imputed_values"] is None From 6798d355722cf0520888ee08aa648c2bc3941e35 Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Thu, 17 Sep 2026 10:41:18 +0200 Subject: [PATCH 057/103] fix(data): corrige le typage de l'import historique --- apps/backend/app/etl/historical_import.py | 21 ++++++++++++++++----- apps/backend/pyproject.toml | 1 + apps/backend/uv.lock | 14 ++++++++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/apps/backend/app/etl/historical_import.py b/apps/backend/app/etl/historical_import.py index b4f2089..22d9b03 100644 --- a/apps/backend/app/etl/historical_import.py +++ b/apps/backend/app/etl/historical_import.py @@ -5,7 +5,7 @@ import asyncio import hashlib import json from pathlib import Path -from typing import Any +from typing import Any, cast import pandas as pd from sqlalchemy import text @@ -56,7 +56,12 @@ def compute_sha256(path: Path) -> str: def load_metadata(path: Path) -> dict[str, Any]: """Charge les métadonnées fournies avec le dataset.""" with path.open("r", encoding="utf-8") as source: - return json.load(source) + metadata = json.load(source) + + if not isinstance(metadata, dict): + raise ValueError("Le fichier de métadonnées doit contenir un objet JSON.") + + return cast(dict[str, Any], metadata) def classify_quality( @@ -260,7 +265,8 @@ async def upsert_sites( frame: pd.DataFrame, ) -> None: """Insère ou met à jour les sites du dataset.""" - sites = ( + sites = cast( + list[dict[str, Any]], frame[ [ "site_id", @@ -269,7 +275,7 @@ async def upsert_sites( ] ] .drop_duplicates(subset=["site_id"]) - .to_dict(orient="records") + .to_dict(orient="records"), ) await connection.execute( @@ -305,7 +311,12 @@ def build_reading_batch( """ rows: list[dict[str, Any]] = [] - for record in chunk.to_dict(orient="records"): + records = cast( + list[dict[str, Any]], + chunk.to_dict(orient="records"), + ) + + for record in records: quality, reasons = classify_quality(record) raw_data = { diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index 6cf42a5..c330c8d 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -27,6 +27,7 @@ dev = [ "pytest-asyncio>=1.4.0", "pytest-cov>=7.1.0", "httpx>=0.28.1", + "pandas-stubs>=3.0.5.260914", ] [build-system] diff --git a/apps/backend/uv.lock b/apps/backend/uv.lock index fb43797..6836990 100644 --- a/apps/backend/uv.lock +++ b/apps/backend/uv.lock @@ -330,6 +330,7 @@ dependencies = [ dev = [ { name = "httpx" }, { name = "mypy" }, + { name = "pandas-stubs" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -357,6 +358,7 @@ requires-dist = [ dev = [ { name = "httpx", specifier = ">=0.28.1" }, { name = "mypy", specifier = ">=2.3.1" }, + { name = "pandas-stubs", specifier = ">=3.0.5.260914" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "pytest-asyncio", specifier = ">=1.4.0" }, { name = "pytest-cov", specifier = ">=7.1.0" }, @@ -669,6 +671,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" }, ] +[[package]] +name = "pandas-stubs" +version = "3.0.5.260914" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/93/8948ae6c1e1e3d6833596fd266f7be2d27c1451b8be094975ad42c5e842e/pandas_stubs-3.0.5.260914.tar.gz", hash = "sha256:3f6fc1f147f68fd89c007105e7c94a948acb4ecd7eb20dc1c02e153c4ed5c250", size = 117622, upload-time = "2026-09-14T16:42:35.065Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/cb/5ad79e02a556cc23fed5816de0109fa8af660c66cfa5f4af74c3e8d4cd26/pandas_stubs-3.0.5.260914-py3-none-any.whl", hash = "sha256:39a1300c5c5c55fdf609e3476805decce5d5015539a4dcb683449f8feaeee2fb", size = 177344, upload-time = "2026-09-14T16:42:33.771Z" }, +] + [[package]] name = "pathspec" version = "1.1.1" From 9161b74874a13b0b17df9eca9679c2120362e683 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 17 Sep 2026 10:53:58 +0200 Subject: [PATCH 058/103] feat(auth): politique de complexite du mot de passe et flux de reinitialisation Remplace la regle de longueur seule (12 caracteres) par une exigence de composition (8 caracteres minimum, majuscule, minuscule, chiffre, caractere special), non documentee dans les exigences officielles du projet, par une regle explicite partagee entre le backend (validateur Pydantic) et le frontend. Ajoute un flux "mot de passe oublie" en libre-service, absent jusqu'ici : jeton a usage unique hache en base (meme principe que les refresh tokens), expirant a 15 minutes, envoye par email via un service SMTP (aiosmtplib, Mailpit en dev), avec limitation de debit dediee et reponse generique pour eviter l'enumeration des comptes. Closes #87 --- apps/backend/.env.example | 10 + apps/backend/README.md | 2 + ...c0adab96238c_jetons_de_reinitialisation.py | 96 ++++++++++ apps/backend/app/api/deps.py | 31 +++- apps/backend/app/api/openapi.py | 13 ++ apps/backend/app/api/v1/endpoints/auth.py | 83 +++++++++ apps/backend/app/cli.py | 31 +++- apps/backend/app/core/config.py | 13 ++ apps/backend/app/core/mailer.py | 48 +++++ apps/backend/app/models/__init__.py | 4 + apps/backend/app/models/audit_log.py | 2 + .../app/models/password_reset_attempt.py | 27 +++ .../app/models/password_reset_token.py | 40 ++++ .../repositories/password_reset_attempt.py | 42 +++++ .../app/repositories/password_reset_token.py | 68 +++++++ apps/backend/app/schemas/auth.py | 45 ++++- apps/backend/app/services/auth.py | 110 +++++++++++ apps/backend/openapi.json | 175 +++++++++++++++++- apps/backend/pyproject.toml | 1 + apps/backend/tests/api/test_auth.py | 100 ++++++++++ .../tests/api/test_route_protection.py | 4 + .../repositories/test_password_reset_token.py | 114 ++++++++++++ apps/backend/tests/schemas/__init__.py | 0 apps/backend/tests/schemas/test_auth.py | 41 ++++ apps/backend/tests/services/test_auth.py | 158 +++++++++++++++- apps/backend/tests/test_cli.py | 19 +- apps/backend/uv.lock | 11 ++ apps/frontend/src/app/app.routes.ts | 2 + .../src/app/core/services/auth.service.ts | 19 +- .../auth/change-password/change-password.html | 2 +- .../change-password/change-password.spec.ts | 17 +- .../auth/change-password/change-password.ts | 6 +- .../auth/forgot-password/forgot-password.html | 37 ++++ .../auth/forgot-password/forgot-password.scss | 104 +++++++++++ .../forgot-password/forgot-password.spec.ts | 75 ++++++++ .../auth/forgot-password/forgot-password.ts | 53 ++++++ .../src/app/features/auth/login/login.html | 2 + .../src/app/features/auth/login/login.scss | 10 + .../src/app/features/auth/login/login.spec.ts | 3 +- .../src/app/features/auth/login/login.ts | 4 +- .../auth/reset-password/reset-password.html | 30 +++ .../auth/reset-password/reset-password.scss | 104 +++++++++++ .../reset-password/reset-password.spec.ts | 74 ++++++++ .../auth/reset-password/reset-password.ts | 52 ++++++ .../src/app/shared/models/auth.model.ts | 9 + .../shared/validators/password.validator.ts | 15 ++ docker-compose.yml | 16 ++ .../31-contrat-authentification.md | 17 +- 48 files changed, 1914 insertions(+), 25 deletions(-) create mode 100644 apps/backend/alembic/versions/c0adab96238c_jetons_de_reinitialisation.py create mode 100644 apps/backend/app/core/mailer.py create mode 100644 apps/backend/app/models/password_reset_attempt.py create mode 100644 apps/backend/app/models/password_reset_token.py create mode 100644 apps/backend/app/repositories/password_reset_attempt.py create mode 100644 apps/backend/app/repositories/password_reset_token.py create mode 100644 apps/backend/tests/repositories/test_password_reset_token.py create mode 100644 apps/backend/tests/schemas/__init__.py create mode 100644 apps/backend/tests/schemas/test_auth.py create mode 100644 apps/frontend/src/app/features/auth/forgot-password/forgot-password.html create mode 100644 apps/frontend/src/app/features/auth/forgot-password/forgot-password.scss create mode 100644 apps/frontend/src/app/features/auth/forgot-password/forgot-password.spec.ts create mode 100644 apps/frontend/src/app/features/auth/forgot-password/forgot-password.ts create mode 100644 apps/frontend/src/app/features/auth/reset-password/reset-password.html create mode 100644 apps/frontend/src/app/features/auth/reset-password/reset-password.scss create mode 100644 apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts create mode 100644 apps/frontend/src/app/features/auth/reset-password/reset-password.ts create mode 100644 apps/frontend/src/app/shared/validators/password.validator.ts diff --git a/apps/backend/.env.example b/apps/backend/.env.example index f36551e..8dff67f 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -8,3 +8,13 @@ APP_SECRET_KEY=change_me APP_CORS_ORIGINS=http://localhost:4200 DATABASE_URL=postgresql+asyncpg://enervision:change_me@localhost:5433/enervision + +# Mot de passe oublié : lien à usage unique valable 15 minutes par défaut. +APP_FRONTEND_RESET_PASSWORD_URL=http://localhost:4200/reset-password + +# SMTP local de dev (Mailpit, cf. docker-compose.yml) : aucune authentification, aucun TLS. +# À remplacer par un vrai relais en staging/prod. +APP_SMTP_HOST=localhost +APP_SMTP_PORT=1025 +APP_SMTP_USE_TLS=false +APP_SMTP_FROM_ADDRESS=no-reply@enervision.fr diff --git a/apps/backend/README.md b/apps/backend/README.md index 91f9608..6c48b3a 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/auth/logout` | Ferme la session courante | cookie, idempotente | | `/api/v1/auth/logout-all` | Ferme toutes les sessions du compte | jeton | | `/api/v1/auth/password` | Change son propre mot de passe | jeton | +| `/api/v1/auth/forgot-password` | Demande un lien de réinitialisation par email | public | +| `/api/v1/auth/reset-password` | Choisit un nouveau mot de passe depuis ce lien | public | | `/api/v1/auth/me` | Décrit le compte connecté | jeton | | `/api/v1/users` | Liste et crée des comptes | `admin` | | `/api/v1/users/{id}` | Change le rôle ou l'activation | `admin` | diff --git a/apps/backend/alembic/versions/c0adab96238c_jetons_de_reinitialisation.py b/apps/backend/alembic/versions/c0adab96238c_jetons_de_reinitialisation.py new file mode 100644 index 0000000..7f75d21 --- /dev/null +++ b/apps/backend/alembic/versions/c0adab96238c_jetons_de_reinitialisation.py @@ -0,0 +1,96 @@ +"""jetons et tentatives de reinitialisation de mot de passe + +Revision ID: c0adab96238c +Revises: e6d2026091501 +Create Date: 2026-09-17 10:37:12.571314 + +Meme schema que `refresh_token` pour `password_reset_token` : seule l'empreinte SHA-256 du +jeton est stockee, jamais le jeton lui-meme, pour la meme raison (revocation en cascade, +aucune session utilisable dans un pg_dump qui fuiterait). + +`password_reset_attempt` vit hors de `audit_log`, comme `login_attempt`, car son volume est +pilote par l'attaquant : une campagne de demandes y ecrirait des lignes que l'audit, en ajout +seul, ne devrait jamais purger. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "c0adab96238c" +down_revision: str | Sequence[str] | None = "e6d2026091501" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +JETONS_VIVANTS = "consumed_at is null" + + +def upgrade() -> None: + op.create_table( + "password_reset_attempt", + sa.Column("id", sa.BigInteger(), sa.Identity(always=True), nullable=False), + sa.Column( + "occurred_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("email_tried", sa.String(length=320), nullable=False), + sa.Column("client_ip", postgresql.INET(), nullable=True), + sa.PrimaryKeyConstraint("id", name="pk_password_reset_attempt"), + ) + op.create_index( + "ix_password_reset_attempt_email_date", + "password_reset_attempt", + ["email_tried", "occurred_at"], + ) + op.create_index( + "ix_password_reset_attempt_ip_date", "password_reset_attempt", ["client_ip", "occurred_at"] + ) + + op.create_table( + "password_reset_token", + sa.Column("id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("token_hash", sa.LargeBinary(), nullable=False), + sa.Column( + "issued_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("client_ip", postgresql.INET(), nullable=True), + sa.Column("user_agent", sa.Text(), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["app_user.id"], + name="fk_password_reset_token_user", + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name="pk_password_reset_token"), + sa.UniqueConstraint("token_hash", name="uq_password_reset_token_hash"), + ) + op.create_index("ix_password_reset_token_user", "password_reset_token", ["user_id"]) + op.create_index( + "ix_password_reset_token_vivants", + "password_reset_token", + ["user_id"], + postgresql_where=JETONS_VIVANTS, + ) + + +def downgrade() -> None: + op.drop_index( + "ix_password_reset_token_vivants", + table_name="password_reset_token", + postgresql_where=JETONS_VIVANTS, + ) + op.drop_index("ix_password_reset_token_user", table_name="password_reset_token") + op.drop_table("password_reset_token") + op.drop_index("ix_password_reset_attempt_ip_date", table_name="password_reset_attempt") + op.drop_index("ix_password_reset_attempt_email_date", table_name="password_reset_attempt") + op.drop_table("password_reset_attempt") diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index 5b39098..f1aa8ae 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -16,6 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import Settings, get_settings from app.core.hashing import Argon2Hasher, build_hasher +from app.core.mailer import Mailer, SmtpConfig from app.core.principal import Principal from app.core.roles import AccountKind, Role, has_at_least from app.core.security import TokenExpiredError, TokenInvalidError, TokenPolicy @@ -24,13 +25,15 @@ from app.db.session import get_session from app.repositories.alert import AlertRepository from app.repositories.audit_log import AuditLogRepository from app.repositories.login_attempt import LoginAttemptRepository +from app.repositories.password_reset_attempt import PasswordResetAttemptRepository +from app.repositories.password_reset_token import PasswordResetTokenRepository from app.repositories.reading import ReadingRepository 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.alert import AlertService -from app.services.auth import AuthService, LoginPolicy +from app.services.auth import AuthService, LoginPolicy, PasswordResetPolicy from app.services.recommendation import RecommendationService from app.services.sensor import SensorService from app.services.site import SiteService @@ -97,11 +100,27 @@ def get_client_ip(request: Request, settings: SettingsDep) -> str | None: return request.client.host if request.client else None +def get_mailer(settings: SettingsDep) -> Mailer: + return Mailer( + SmtpConfig( + host=settings.smtp_host, + port=settings.smtp_port, + username=settings.smtp_username, + password=( + settings.smtp_password.get_secret_value() if settings.smtp_password else None + ), + use_tls=settings.smtp_use_tls, + from_address=settings.smtp_from_address, + ) + ) + + def get_auth_service( session: SessionDep, settings: SettingsDep, hasher: Annotated[Argon2Hasher, Depends(get_hasher)], token_policy: Annotated[TokenPolicy, Depends(get_token_policy)], + mailer: Annotated[Mailer, Depends(get_mailer)], ) -> AuthService: return AuthService( users=UserRepository(session), @@ -118,6 +137,16 @@ def get_auth_service( max_failures_per_identifier=settings.login_max_failures_per_identifier, ), refresh_ttl=timedelta(seconds=settings.refresh_token_ttl_seconds), + reset_tokens=PasswordResetTokenRepository(session), + reset_attempts=PasswordResetAttemptRepository(session), + reset_policy=PasswordResetPolicy( + window_seconds=settings.password_reset_window_seconds, + max_requests_per_identifier=settings.password_reset_max_requests_per_identifier, + max_requests_per_ip=settings.password_reset_max_requests_per_ip, + token_ttl=timedelta(seconds=settings.password_reset_ttl_seconds), + frontend_reset_url=settings.frontend_reset_password_url, + ), + mailer=mailer, ) diff --git a/apps/backend/app/api/openapi.py b/apps/backend/app/api/openapi.py index 85b7775..a649705 100644 --- a/apps/backend/app/api/openapi.py +++ b/apps/backend/app/api/openapi.py @@ -156,3 +156,16 @@ REPONSE_ORIGINE_REFUSEE: Final[Reponses] = { "description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).", }, } + +REPONSE_LIMITE: Final[Reponses] = { + 429: { + "model": ErrorResponse, + "description": "Trop de demandes sur cette fenêtre glissante.", + "headers": { + "Retry-After": { + "description": "Secondes à attendre avant une nouvelle tentative.", + "schema": {"type": "integer"}, + } + }, + }, +} diff --git a/apps/backend/app/api/v1/endpoints/auth.py b/apps/backend/app/api/v1/endpoints/auth.py index 32bf8b2..957775f 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_LIMITE, REPONSE_ORIGINE_REFUSEE, REPONSE_VALIDATION, REPONSES_AUTHENTIFIEES, @@ -21,15 +22,18 @@ from app.api.openapi import ( from app.core.cookies import RefreshCookie, cookie_name from app.core.logging import get_logger from app.schemas.auth import ( + ForgotPasswordRequest, LoginRequest, PasswordChangeRequest, PrincipalResponse, + ResetPasswordRequest, TokenResponse, ) from app.schemas.errors import ErrorResponse from app.services.auth import ( AuthenticatedSession, InvalidCredentialsError, + InvalidOrExpiredResetTokenError, RateLimitedError, SessionRejectedError, ) @@ -39,6 +43,7 @@ logger = get_logger(__name__) DETAIL_IDENTIFIANTS = "Identifiants invalides" DETAIL_SESSION = "Session invalide" +DETAIL_LIEN_RESET = "Lien invalide ou expiré" REPONSES_LOGIN: Reponses = { **REPONSE_VALIDATION, @@ -85,6 +90,20 @@ REPONSES_MOT_DE_PASSE: Reponses = { }, } +REPONSES_FORGOT_PASSWORD: Reponses = { + **REPONSE_VALIDATION, + **REPONSE_LIMITE, +} + +REPONSES_RESET_PASSWORD: Reponses = { + **REPONSE_VALIDATION, + **REPONSE_ORIGINE_REFUSEE, + 400: { + "model": ErrorResponse, + "description": "Lien invalide, déjà utilisé, ou expiré (durée de vie : 15 minutes).", + }, +} + def repond( response: Response, settings: SettingsDep, session: AuthenticatedSession @@ -267,3 +286,67 @@ async def change_password( logger.info("auth.password_changed user_id=%s", principal.id) return repond(response, settings, session) + + +@router.post( + "/forgot-password", + status_code=status.HTTP_202_ACCEPTED, + summary="Demande un lien de réinitialisation par email", + responses=REPONSES_FORGOT_PASSWORD, +) +async def forgot_password( + payload: ForgotPasswordRequest, + request: Request, + response: Response, + service: AuthServiceDep, + client_ip: str | None = Depends(get_client_ip), +) -> None: + response.headers["Cache-Control"] = "no-store" + + try: + await service.request_password_reset( + email=payload.email, + client_ip=client_ip, + user_agent=request.headers.get("user-agent"), + ) + except RateLimitedError as erreur: + logger.warning("auth.password_reset.rate_limited ip=%s", client_ip) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Trop de demandes, réessayez plus tard", + headers={"Retry-After": str(erreur.retry_after)}, + ) from erreur + + +@router.post( + "/reset-password", + response_model=TokenResponse, + summary="Choisit un nouveau mot de passe depuis un lien reçu par email", + dependencies=[Depends(require_trusted_origin)], + responses=REPONSES_RESET_PASSWORD, +) +async def reset_password( + payload: ResetPasswordRequest, + request: Request, + response: Response, + settings: SettingsDep, + service: AuthServiceDep, + client_ip: str | None = Depends(get_client_ip), +) -> TokenResponse: + response.headers["Cache-Control"] = "no-store" + + try: + session = await service.confirm_password_reset( + token=payload.token, + new_password=payload.new_password, + client_ip=client_ip, + user_agent=request.headers.get("user-agent"), + ) + except InvalidOrExpiredResetTokenError as erreur: + logger.warning("auth.password_reset.invalid_token ip=%s", client_ip) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=DETAIL_LIEN_RESET + ) from erreur + + logger.info("auth.password_reset.success user_id=%s", session.principal.id) + return repond(response, settings, session) diff --git a/apps/backend/app/cli.py b/apps/backend/app/cli.py index 37e94fd..fea510e 100644 --- a/apps/backend/app/cli.py +++ b/apps/backend/app/cli.py @@ -9,6 +9,7 @@ import argparse import asyncio import json import secrets +import string import sys from getpass import getpass from pathlib import Path @@ -22,9 +23,10 @@ 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 +from app.schemas.auth import PASSWORD_MIN_LENGTH, valide_complexite LONGUEUR_MOT_DE_PASSE_GENERE = 24 -LONGUEUR_MINIMALE = 12 +CARACTERES_SPECIAUX = "!@#$%^&*()-_=+[]{};:,.?" CHEMIN_CONTRAT = Path(__file__).resolve().parent.parent / "openapi.json" @@ -111,15 +113,36 @@ def build_parser() -> argparse.ArgumentParser: return parser +def genere_mot_de_passe() -> str: + tirage = secrets.SystemRandom() + classes = [ + string.ascii_uppercase, + string.ascii_lowercase, + string.digits, + CARACTERES_SPECIAUX, + ] + reste = LONGUEUR_MOT_DE_PASSE_GENERE - len(classes) + caracteres = [tirage.choice(classe) for classe in classes] + caracteres += [tirage.choice("".join(classes)) for _ in range(reste)] + tirage.shuffle(caracteres) + return "".join(caracteres) + + def read_password(*, generate: bool) -> str: if generate: - mot_de_passe = secrets.token_urlsafe(LONGUEUR_MOT_DE_PASSE_GENERE) + mot_de_passe = genere_mot_de_passe() print(f"Mot de passe généré, il ne sera plus affiché : {mot_de_passe}") return mot_de_passe mot_de_passe = getpass("Mot de passe : ") - if len(mot_de_passe) < LONGUEUR_MINIMALE: - raise SystemExit(f"Le mot de passe doit faire au moins {LONGUEUR_MINIMALE} caractères") + if len(mot_de_passe) < PASSWORD_MIN_LENGTH: + raise SystemExit( + f"Le mot de passe doit faire au moins {PASSWORD_MIN_LENGTH} caractères" + ) + try: + valide_complexite(mot_de_passe) + except ValueError as erreur: + raise SystemExit(str(erreur)) from erreur if mot_de_passe != getpass("Confirmation : "): raise SystemExit("Les deux saisies diffèrent") return mot_de_passe diff --git a/apps/backend/app/core/config.py b/apps/backend/app/core/config.py index 6733b3a..e374709 100644 --- a/apps/backend/app/core/config.py +++ b/apps/backend/app/core/config.py @@ -54,6 +54,19 @@ class Settings(BaseSettings): login_max_failures_per_ip: int = Field(default=20, ge=1) login_max_failures_per_identifier: int = Field(default=50, ge=1) + password_reset_ttl_seconds: int = Field(default=900, ge=60, le=3600) + password_reset_window_seconds: int = Field(default=900, ge=60) + password_reset_max_requests_per_identifier: int = Field(default=3, ge=1) + password_reset_max_requests_per_ip: int = Field(default=10, ge=1) + + smtp_host: str = "localhost" + smtp_port: int = Field(default=587, ge=1, le=65535) + smtp_username: str | None = None + smtp_password: SecretStr | None = None + smtp_use_tls: bool = False + smtp_from_address: str = "no-reply@enervision.fr" + frontend_reset_password_url: str = "http://localhost:4200/reset-password" # noqa: S105 + trust_proxy_headers: bool = False expose_api_docs: bool | None = None metrics_token: SecretStr | None = None diff --git a/apps/backend/app/core/mailer.py b/apps/backend/app/core/mailer.py new file mode 100644 index 0000000..5c09008 --- /dev/null +++ b/apps/backend/app/core/mailer.py @@ -0,0 +1,48 @@ +# Piège : l'URL de réinitialisation porte le jeton en clair. Ne jamais la journaliser : +# `send_password_reset_email()` ne logue que le destinataire, jamais `reset_url`. + +from dataclasses import dataclass +from email.message import EmailMessage + +import aiosmtplib + +from app.core.logging import get_logger + +logger = get_logger(__name__) + + +@dataclass(frozen=True, slots=True) +class SmtpConfig: + host: str + port: int + username: str | None + password: str | None + use_tls: bool + from_address: str + + +class Mailer: + def __init__(self, config: SmtpConfig) -> None: + self._config = config + + async def send_password_reset_email(self, *, to: str, reset_url: str) -> None: + message = EmailMessage() + message["From"] = self._config.from_address + message["To"] = to + message["Subject"] = "Réinitialisation de votre mot de passe EnerVision" + message.set_content( + "Une réinitialisation de mot de passe a été demandée pour ce compte.\n\n" + f"Ouvrez ce lien dans les 15 minutes pour choisir un nouveau mot de passe : " + f"{reset_url}\n\n" + "Si vous n'êtes pas à l'origine de cette demande, ignorez cet email." + ) + + _, message_recu = await aiosmtplib.send( + message, + hostname=self._config.host, + port=self._config.port, + username=self._config.username, + password=self._config.password, + use_tls=self._config.use_tls, + ) + logger.info("mailer.password_reset_sent to=%s smtp_response=%s", to, message_recu) diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py index 10a5ecb..167d7ce 100644 --- a/apps/backend/app/models/__init__.py +++ b/apps/backend/app/models/__init__.py @@ -4,6 +4,8 @@ from app.models.audit_log import AuditLog from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site from app.models.login_attempt import LoginAttempt +from app.models.password_reset_attempt import PasswordResetAttempt +from app.models.password_reset_token import PasswordResetToken from app.models.refresh_token import RefreshToken from app.models.user import AppUser @@ -13,6 +15,8 @@ __all__ = [ "AuditLog", "Dataset", "LoginAttempt", + "PasswordResetAttempt", + "PasswordResetToken", "Prediction", "Reading", "Recommendation", diff --git a/apps/backend/app/models/audit_log.py b/apps/backend/app/models/audit_log.py index 5775f5e..d389880 100644 --- a/apps/backend/app/models/audit_log.py +++ b/apps/backend/app/models/audit_log.py @@ -29,6 +29,8 @@ class AuditAction(StrEnum): COMPTE_ACTIVE = "user.enabled" COMPTE_MOT_DE_PASSE_REINITIALISE = "user.password_reset_by_admin" COMPTE_MOT_DE_PASSE_CHANGE = "user.password_changed" + MOT_DE_PASSE_OUBLIE_DEMANDE = "auth.password_reset_requested" + MOT_DE_PASSE_REINITIALISE_PAR_SOI = "auth.password_reset_self_service" REFRESH_REUTILISE = "auth.refresh_reuse_detected" SESSIONS_REVOQUEES = "auth.all_sessions_revoked" LIMITE_PAR_IDENTIFIANT = "auth.identifier_throttled" diff --git a/apps/backend/app/models/password_reset_attempt.py b/apps/backend/app/models/password_reset_attempt.py new file mode 100644 index 0000000..6d2a607 --- /dev/null +++ b/apps/backend/app/models/password_reset_attempt.py @@ -0,0 +1,27 @@ +# Pourquoi : même séparation que `login_attempt` par rapport à `audit_log` : ce compteur est +# piloté par l'attaquant (une campagne de demandes) et se purge, l'audit log est en ajout seul. +# Piège : la tentative est enregistrée même quand l'email est inconnu, sinon le 429 apprendrait +# qu'un compte existe. + +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, Identity, Index, String, func +from sqlalchemy.dialects.postgresql import INET +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class PasswordResetAttempt(Base): + __tablename__ = "password_reset_attempt" + __table_args__ = ( + Index("ix_password_reset_attempt_email_date", "email_tried", "occurred_at"), + Index("ix_password_reset_attempt_ip_date", "client_ip", "occurred_at"), + ) + + id: Mapped[int] = mapped_column(BigInteger, Identity(always=True), primary_key=True) + occurred_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + email_tried: Mapped[str] = mapped_column(String(320), nullable=False) + client_ip: Mapped[str | None] = mapped_column(INET, nullable=True) diff --git a/apps/backend/app/models/password_reset_token.py b/apps/backend/app/models/password_reset_token.py new file mode 100644 index 0000000..d67d310 --- /dev/null +++ b/apps/backend/app/models/password_reset_token.py @@ -0,0 +1,40 @@ +# Pourquoi : même schéma que `refresh_token` (chaîne opaque, jamais un JWT) pour la même +# raison : un jeton de réinitialisation doit être révocable d'un coup, et un JWT ne figure +# dans aucune ligne à invalider. + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Index, LargeBinary, Text, func +from sqlalchemy.dialects.postgresql import INET +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class PasswordResetToken(Base): + __tablename__ = "password_reset_token" + __table_args__ = ( + Index("ix_password_reset_token_user", "user_id"), + Index( + "ix_password_reset_token_vivants", + "user_id", + postgresql_where="consumed_at is null", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid() + ) + user_id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False + ) + token_hash: Mapped[bytes] = mapped_column(LargeBinary, nullable=False, unique=True) + issued_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + client_ip: Mapped[str | None] = mapped_column(INET, nullable=True) + user_agent: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/apps/backend/app/repositories/password_reset_attempt.py b/apps/backend/app/repositories/password_reset_attempt.py new file mode 100644 index 0000000..ddc2f91 --- /dev/null +++ b/apps/backend/app/repositories/password_reset_attempt.py @@ -0,0 +1,42 @@ +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.password_reset_attempt import PasswordResetAttempt + + +@dataclass(frozen=True, slots=True) +class ResetRequestCounts: + per_identifier: int + per_ip: int + + +class PasswordResetAttemptRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def record(self, *, email: str, client_ip: str | None) -> None: + self._session.add( + PasswordResetAttempt(email_tried=email.strip().lower(), client_ip=client_ip) + ) + + async def count_recent( + self, *, email: str, client_ip: str | None, window_seconds: int + ) -> ResetRequestCounts: + identifiant = email.strip().lower() + meme_email = PasswordResetAttempt.email_tried == identifiant + meme_ip = PasswordResetAttempt.client_ip == client_ip + + requete = select( + func.count().filter(meme_email), + func.count().filter(meme_ip), + ).where( + PasswordResetAttempt.occurred_at + > datetime.now(UTC) - timedelta(seconds=window_seconds), + meme_email | meme_ip, + ) + + par_identifiant, par_ip = (await self._session.execute(requete)).one() + return ResetRequestCounts(per_identifier=par_identifiant, per_ip=par_ip) diff --git a/apps/backend/app/repositories/password_reset_token.py b/apps/backend/app/repositories/password_reset_token.py new file mode 100644 index 0000000..13a660e --- /dev/null +++ b/apps/backend/app/repositories/password_reset_token.py @@ -0,0 +1,68 @@ +# Piège : `consume()` est une seule instruction, sur le modèle de `claim_for_rotation()` du +# jeton de rafraîchissement. Un SELECT puis un UPDATE laisseraient une fenêtre où deux +# soumissions concurrentes du même lien réussiraient toutes les deux. + +from dataclasses import dataclass +from datetime import datetime +from uuid import UUID + +from sqlalchemy import func, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.password_reset_token import PasswordResetToken + + +@dataclass(frozen=True, slots=True) +class ConsumedResetToken: + id: UUID + user_id: UUID + + +class PasswordResetTokenRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def create( + self, + *, + user_id: UUID, + token_hash: bytes, + expires_at: datetime, + client_ip: str | None, + user_agent: str | None, + ) -> PasswordResetToken: + jeton = PasswordResetToken( + user_id=user_id, + token_hash=token_hash, + expires_at=expires_at, + client_ip=client_ip, + user_agent=user_agent, + ) + self._session.add(jeton) + await self._session.flush() + return jeton + + async def consume(self, token_hash: bytes) -> ConsumedResetToken | None: + requete = ( + update(PasswordResetToken) + .where( + PasswordResetToken.token_hash == token_hash, + PasswordResetToken.consumed_at.is_(None), + PasswordResetToken.expires_at > func.clock_timestamp(), + ) + .values(consumed_at=func.clock_timestamp()) + .returning(PasswordResetToken.id, PasswordResetToken.user_id) + ) + ligne = (await self._session.execute(requete)).one_or_none() + if ligne is None: + return None + return ConsumedResetToken(id=ligne.id, user_id=ligne.user_id) + + async def invalidate_all_for_user(self, user_id: UUID) -> int: + resultat = await self._session.execute( + update(PasswordResetToken) + .where(PasswordResetToken.user_id == user_id, PasswordResetToken.consumed_at.is_(None)) + .values(consumed_at=func.clock_timestamp()) + .returning(PasswordResetToken.id) + ) + return len(resultat.all()) diff --git a/apps/backend/app/schemas/auth.py b/apps/backend/app/schemas/auth.py index 522b4c5..e6785be 100644 --- a/apps/backend/app/schemas/auth.py +++ b/apps/backend/app/schemas/auth.py @@ -1,17 +1,39 @@ # Contrainte : le mot de passe est borné à 128 caractères. Sans plafond, une chaîne de dix # mégaoctets ferait travailler Argon2 gratuitement, à la charge du serveur. +import re from typing import Literal, Self from uuid import UUID -from pydantic import BaseModel, ConfigDict, EmailStr, Field +from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator from app.core.principal import Principal from app.core.roles import AccountKind, Role -PASSWORD_MIN_LENGTH = 12 +PASSWORD_MIN_LENGTH = 8 PASSWORD_MAX_LENGTH = 128 +_MAJUSCULE = re.compile(r"[A-ZÀ-Ý]") +_MINUSCULE = re.compile(r"[a-zà-ÿ]") +_CHIFFRE = re.compile(r"\d") +_SPECIAL = re.compile(r"[^\w\s]") + + +def valide_complexite(mot_de_passe: str) -> str: + manquants = [ + nom + for nom, motif in ( + ("une majuscule", _MAJUSCULE), + ("une minuscule", _MINUSCULE), + ("un chiffre", _CHIFFRE), + ("un caractère spécial", _SPECIAL), + ) + if not motif.search(mot_de_passe) + ] + if manquants: + raise ValueError(f"Le mot de passe doit contenir au moins {', '.join(manquants)}") + return mot_de_passe + class LoginRequest(BaseModel): email: EmailStr @@ -22,6 +44,25 @@ class PasswordChangeRequest(BaseModel): current_password: str = Field(min_length=1, max_length=PASSWORD_MAX_LENGTH) new_password: str = Field(min_length=PASSWORD_MIN_LENGTH, max_length=PASSWORD_MAX_LENGTH) + @field_validator("new_password") + @classmethod + def _new_password_est_complexe(cls, valeur: str) -> str: + return valide_complexite(valeur) + + +class ForgotPasswordRequest(BaseModel): + email: EmailStr + + +class ResetPasswordRequest(BaseModel): + token: str = Field(min_length=1) + new_password: str = Field(min_length=PASSWORD_MIN_LENGTH, max_length=PASSWORD_MAX_LENGTH) + + @field_validator("new_password") + @classmethod + def _new_password_est_complexe(cls, valeur: str) -> str: + return valide_complexite(valeur) + class PrincipalResponse(BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/apps/backend/app/services/auth.py b/apps/backend/app/services/auth.py index 8baf857..02ff04b 100644 --- a/apps/backend/app/services/auth.py +++ b/apps/backend/app/services/auth.py @@ -15,6 +15,7 @@ from typing import NoReturn, Protocol from uuid import UUID, uuid4 from app.core.hashing import Argon2Hasher +from app.core.mailer import Mailer from app.core.principal import Principal from app.core.roles import AccountKind, Role from app.core.security import ( @@ -28,6 +29,8 @@ from app.models.login_attempt import LoginOutcome from app.models.refresh_token import RevocationReason from app.repositories.audit_log import AuditLogRepository from app.repositories.login_attempt import LoginAttemptRepository +from app.repositories.password_reset_attempt import PasswordResetAttemptRepository +from app.repositories.password_reset_token import PasswordResetTokenRepository from app.repositories.refresh_token import RefreshTokenRepository from app.repositories.user import UserRepository @@ -54,6 +57,10 @@ class RateLimitedError(AuthError): self.retry_after = retry_after +class InvalidOrExpiredResetTokenError(AuthError): + pass + + @dataclass(frozen=True, slots=True) class LoginPolicy: window_seconds: int @@ -62,6 +69,15 @@ class LoginPolicy: max_failures_per_identifier: int +@dataclass(frozen=True, slots=True) +class PasswordResetPolicy: + window_seconds: int + max_requests_per_identifier: int + max_requests_per_ip: int + token_ttl: timedelta + frontend_reset_url: str + + @dataclass(frozen=True, slots=True) class AuthenticatedSession: principal: Principal @@ -83,6 +99,10 @@ class AuthService: token_policy: TokenPolicy, login_policy: LoginPolicy, refresh_ttl: timedelta, + reset_tokens: PasswordResetTokenRepository, + reset_attempts: PasswordResetAttemptRepository, + reset_policy: PasswordResetPolicy, + mailer: Mailer, ) -> None: self._users = users self._attempts = attempts @@ -93,6 +113,10 @@ class AuthService: self._token_policy = token_policy self._login_policy = login_policy self._refresh_ttl = refresh_ttl + self._reset_tokens = reset_tokens + self._reset_attempts = reset_attempts + self._reset_policy = reset_policy + self._mailer = mailer async def authenticate( self, *, email: str, password: str, client_ip: str | None, user_agent: str | None @@ -200,6 +224,75 @@ class AuthService: rafraichi = await self._users.get_by_id(principal.id) return self._session(self._en_principal(rafraichi or compte), secret) + async def request_password_reset( + self, *, email: str, client_ip: str | None, user_agent: str | None + ) -> None: + await self._refuse_si_limite_reset(email=email, client_ip=client_ip) + + compte = await self._users.get_by_email(email) + # Piège : le hachage factice équilibre le temps de réponse sur un compte inconnu, comme + # `authenticate()`. La réponse et sa forme restent identiques dans tous les cas : compte + # inconnu, compte inactif, ou email envoyé avec succès. + if compte is None or not compte.is_active or compte.kind != AccountKind.HUMAIN.value: + await self._hasher.verify_dummy() + await self._reset_attempts.record(email=email, client_ip=client_ip) + await self._transaction.commit() + return + + await self._reset_tokens.invalidate_all_for_user(compte.id) + secret = generate_refresh_secret() + await self._reset_tokens.create( + user_id=compte.id, + token_hash=fingerprint_refresh(secret), + expires_at=datetime.now(UTC) + self._reset_policy.token_ttl, + client_ip=client_ip, + user_agent=user_agent, + ) + await self._reset_attempts.record(email=email, client_ip=client_ip) + await self._audit.record( + action=AuditAction.MOT_DE_PASSE_OUBLIE_DEMANDE, + actor_label=compte.email, + target_type="app_user", + target_id=str(compte.id), + client_ip=client_ip, + user_agent=user_agent, + ) + await self._transaction.commit() + + lien = f"{self._reset_policy.frontend_reset_url}?token={secret}" + await self._mailer.send_password_reset_email(to=compte.email, reset_url=lien) + + async def confirm_password_reset( + self, *, token: str, new_password: str, client_ip: str | None, user_agent: str | None + ) -> AuthenticatedSession: + revendique = await self._reset_tokens.consume(fingerprint_refresh(token)) + if revendique is None: + raise InvalidOrExpiredResetTokenError("Lien invalide ou expiré") + + await self._users.update_password( + revendique.user_id, await self._hasher.hash(new_password), must_change_password=False + ) + revoquees = await self._refresh.revoke_all_for_user( + revendique.user_id, RevocationReason.CHANGEMENT_MOT_DE_PASSE + ) + secret = await self._ouvre_une_famille( + user_id=revendique.user_id, client_ip=client_ip, user_agent=user_agent + ) + await self._audit.record( + action=AuditAction.MOT_DE_PASSE_REINITIALISE_PAR_SOI, + target_type="app_user", + target_id=str(revendique.user_id), + client_ip=client_ip, + user_agent=user_agent, + detail={"sessions_revoquees": revoquees}, + ) + await self._transaction.commit() + + compte = await self._users.get_by_id(revendique.user_id) + if compte is None: + raise SessionRejectedError("Compte introuvable") + return self._session(self._en_principal(compte), secret) + async def logout_all(self, principal: Principal) -> int: revoquees = await self._refresh.revoke_all_for_user( principal.id, RevocationReason.DECONNEXION @@ -307,6 +400,23 @@ class AuthService: await self._transaction.commit() raise RateLimitedError(politique.window_seconds) + async def _refuse_si_limite_reset(self, *, email: str, client_ip: str | None) -> None: + politique = self._reset_policy + compteurs = await self._reset_attempts.count_recent( + email=email, client_ip=client_ip, window_seconds=politique.window_seconds + ) + + depasse = ( + compteurs.per_identifier >= politique.max_requests_per_identifier + or compteurs.per_ip >= politique.max_requests_per_ip + ) + if not depasse: + return + + await self._reset_attempts.record(email=email, client_ip=client_ip) + await self._transaction.commit() + raise RateLimitedError(politique.window_seconds) + async def _echoue( self, email: str, diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index 84f9c08..f142efd 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -424,6 +424,144 @@ ] } }, + "/api/v1/auth/forgot-password": { + "post": { + "tags": [ + "auth" + ], + "summary": "Demande un lien de réinitialisation par email", + "operationId": "forgot_password_api_v1_auth_forgot_password_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForgotPasswordRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "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" + } + } + } + }, + "429": { + "description": "Trop de demandes 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/reset-password": { + "post": { + "tags": [ + "auth" + ], + "summary": "Choisit un nouveau mot de passe depuis un lien reçu par email", + "operationId": "reset_password_api_v1_auth_reset_password_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordRequest" + } + } + }, + "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" + } + } + } + }, + "403": { + "description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "400": { + "description": "Lien invalide, déjà utilisé, ou expiré (durée de vie : 15 minutes).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/v1/users": { "get": { "tags": [ @@ -1432,6 +1570,20 @@ ], "title": "FieldError" }, + "ForgotPasswordRequest": { + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "Email" + } + }, + "type": "object", + "required": [ + "email" + ], + "title": "ForgotPasswordRequest" + }, "InternalErrorResponse": { "properties": { "detail": { @@ -1511,7 +1663,7 @@ "new_password": { "type": "string", "maxLength": 128, - "minLength": 12, + "minLength": 8, "title": "New Password" } }, @@ -1619,6 +1771,27 @@ ], "title": "RecommendationResponse" }, + "ResetPasswordRequest": { + "properties": { + "token": { + "type": "string", + "minLength": 1, + "title": "Token" + }, + "new_password": { + "type": "string", + "maxLength": 128, + "minLength": 8, + "title": "New Password" + } + }, + "type": "object", + "required": [ + "token", + "new_password" + ], + "title": "ResetPasswordRequest" + }, "Role": { "type": "string", "enum": [ diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index 18bf979..2bfdef3 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "pyjwt>=2.10", "argon2-cffi>=23.1", "anyio>=4.0", + "aiosmtplib>=5.1.3", ] [dependency-groups] diff --git a/apps/backend/tests/api/test_auth.py b/apps/backend/tests/api/test_auth.py index 1d734da..44c25e6 100644 --- a/apps/backend/tests/api/test_auth.py +++ b/apps/backend/tests/api/test_auth.py @@ -11,6 +11,7 @@ from app.core.roles import AccountKind, Role from app.services.auth import ( AuthenticatedSession, InvalidCredentialsError, + InvalidOrExpiredResetTokenError, RateLimitedError, SessionRejectedError, ) @@ -36,6 +37,14 @@ class FauxService: async def logout(self, **_: object) -> None: return None + async def request_password_reset(self, **_: object) -> None: + if self._erreur is not None: + raise self._erreur + return None + + async def confirm_password_reset(self, **_: object) -> AuthenticatedSession: + return await self.authenticate() + async def authenticate(self, **_: object) -> AuthenticatedSession: if self._erreur is not None: raise self._erreur @@ -206,3 +215,94 @@ async def test_a_cookie_bearing_route_accepts_a_request_without_origin( response = await client.post("/api/v1/auth/logout") assert response.status_code != 403 + + +async def test_forgot_password_answers_202_when_the_account_exists( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post( + "/api/v1/auth/forgot-password", json={"email": "operateur@enervision.fr"} + ) + + assert response.status_code == 202 + assert response.headers["cache-control"] == "no-store" + + +async def test_forgot_password_answers_202_identically_when_the_account_is_unknown( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post( + "/api/v1/auth/forgot-password", json={"email": "inconnu@enervision.fr"} + ) + + assert response.status_code == 202 + + +async def test_forgot_password_returns_429_with_a_retry_after_when_the_rate_limit_is_reached( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + fake_auth_service[0] = RateLimitedError(900) + + response = await client.post( + "/api/v1/auth/forgot-password", json={"email": "operateur@enervision.fr"} + ) + + assert response.status_code == 429 + assert response.headers["retry-after"] == "900" + + +async def test_forgot_password_rejects_a_malformed_email( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post("/api/v1/auth/forgot-password", json={"email": "pas-un-email"}) + + assert response.status_code == 422 + + +async def test_reset_password_returns_the_token_and_the_cookie_on_success( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post( + "/api/v1/auth/reset-password", + json={"token": "un-secret-opaque", "new_password": "Un-nouveau-mot-de-passe1!"}, + ) + + assert response.status_code == 200 + assert response.cookies.get("ev_refresh") is not None + assert "refresh_secret" not in response.text + + +async def test_reset_password_rejects_an_invalid_or_expired_token( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + fake_auth_service[0] = InvalidOrExpiredResetTokenError("Lien invalide ou expiré") + + response = await client.post( + "/api/v1/auth/reset-password", + json={"token": "un-secret-perime", "new_password": "Un-nouveau-mot-de-passe1!"}, + ) + + assert response.status_code == 400 + + +async def test_reset_password_rejects_a_weak_password( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post( + "/api/v1/auth/reset-password", + json={"token": "un-secret-opaque", "new_password": "trop-simple"}, + ) + + assert response.status_code == 422 + + +async def test_reset_password_refuses_a_foreign_origin( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post( + "/api/v1/auth/reset-password", + json={"token": "un-secret-opaque", "new_password": "Un-nouveau-mot-de-passe1!"}, + headers={"Origin": "https://malveillant.example"}, + ) + + assert response.status_code == 403 diff --git a/apps/backend/tests/api/test_route_protection.py b/apps/backend/tests/api/test_route_protection.py index 9a04338..1080dce 100644 --- a/apps/backend/tests/api/test_route_protection.py +++ b/apps/backend/tests/api/test_route_protection.py @@ -18,6 +18,10 @@ ROUTES_PUBLIQUES = frozenset( ("POST", "/api/v1/auth/login"), # Sans cookie, la déconnexion ne fait rien et répond 204 : elle est idempotente. ("POST", "/api/v1/auth/logout"), + ("POST", "/api/v1/auth/forgot-password"), + # Protégée par le jeton dans le corps de la requête, pas par un `Principal` : aucune + # authentification préalable ne s'applique, c'est la validité du jeton qui tranche. + ("POST", "/api/v1/auth/reset-password"), ("GET", "/metrics"), } ) diff --git a/apps/backend/tests/repositories/test_password_reset_token.py b/apps/backend/tests/repositories/test_password_reset_token.py new file mode 100644 index 0000000..e25518c --- /dev/null +++ b/apps/backend/tests/repositories/test_password_reset_token.py @@ -0,0 +1,114 @@ +# Le premier test démontre l'atomicité de `consume()` : sur un double, deux soumissions +# concurrentes du même lien réussiraient toutes les deux. + +import uuid +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.roles import Role +from app.core.security import fingerprint_refresh, generate_refresh_secret +from app.repositories.password_reset_token import PasswordResetTokenRepository +from app.repositories.user import UserRepository + +pytestmark = pytest.mark.integration + +DUREE = timedelta(minutes=15) + + +async def un_compte(session: AsyncSession) -> uuid.UUID: + compte = await UserRepository(session).create( + email=f"reset-{uuid.uuid4().hex[:12]}@enervision.fr", + password_hash="$argon2id$x", + role=Role.LECTEUR, + ) + return compte.id + + +async def un_jeton( + depot: PasswordResetTokenRepository, user_id: uuid.UUID, *, duree: timedelta = DUREE +) -> str: + secret = generate_refresh_secret() + await depot.create( + user_id=user_id, + token_hash=fingerprint_refresh(secret), + expires_at=datetime.now(UTC) + duree, + client_ip="203.0.113.10", + user_agent="pytest", + ) + return secret + + +async def test_consume_only_succeeds_once(session: AsyncSession) -> None: + depot = PasswordResetTokenRepository(session) + secret = await un_jeton(depot, await un_compte(session)) + + premier = await depot.consume(fingerprint_refresh(secret)) + second = await depot.consume(fingerprint_refresh(secret)) + await session.rollback() + + assert premier is not None + assert second is None + + +async def test_consume_refuses_an_expired_token(session: AsyncSession) -> None: + depot = PasswordResetTokenRepository(session) + secret = await un_jeton(depot, await un_compte(session), duree=-timedelta(minutes=1)) + + revendique = await depot.consume(fingerprint_refresh(secret)) + await session.rollback() + + assert revendique is None + + +async def test_consume_returns_nothing_for_an_unknown_fingerprint( + session: AsyncSession, +) -> None: + revendique = await PasswordResetTokenRepository(session).consume( + fingerprint_refresh(generate_refresh_secret()) + ) + + assert revendique is None + + +async def test_invalidate_all_for_user_only_touches_living_tokens( + session: AsyncSession, +) -> None: + depot = PasswordResetTokenRepository(session) + compte = await un_compte(session) + await un_jeton(depot, compte) + await un_jeton(depot, compte) + + invalides = await depot.invalidate_all_for_user(compte) + second_passage = await depot.invalidate_all_for_user(compte) + await session.rollback() + + assert invalides == 2 + assert second_passage == 0 + + +async def test_the_database_refuses_two_tokens_sharing_a_fingerprint( + session: AsyncSession, +) -> None: + depot = PasswordResetTokenRepository(session) + compte = await un_compte(session) + secret = generate_refresh_secret() + await depot.create( + user_id=compte, + token_hash=fingerprint_refresh(secret), + expires_at=datetime.now(UTC) + DUREE, + client_ip=None, + user_agent=None, + ) + + with pytest.raises(IntegrityError): + await depot.create( + user_id=compte, + token_hash=fingerprint_refresh(secret), + expires_at=datetime.now(UTC) + DUREE, + client_ip=None, + user_agent=None, + ) + await session.rollback() diff --git a/apps/backend/tests/schemas/__init__.py b/apps/backend/tests/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/tests/schemas/test_auth.py b/apps/backend/tests/schemas/test_auth.py new file mode 100644 index 0000000..7982f56 --- /dev/null +++ b/apps/backend/tests/schemas/test_auth.py @@ -0,0 +1,41 @@ +import pytest +from pydantic import ValidationError + +from app.schemas.auth import PasswordChangeRequest, valide_complexite + +MOT_DE_PASSE_VALIDE = "Un-mot-de-passe1!" + + +def test_password_change_request_accepts_a_password_covering_the_four_classes() -> None: + requete = PasswordChangeRequest( + current_password="peu-importe", new_password=MOT_DE_PASSE_VALIDE + ) + + assert requete.new_password == MOT_DE_PASSE_VALIDE + + +@pytest.mark.parametrize( + "new_password", + [ + "un-mot-de-passe1!", + "UN-MOT-DE-PASSE1!", + "Un-mot-de-passe!", + "Un mot de passe 1", + ], + ids=["sans_majuscule", "sans_minuscule", "sans_chiffre", "sans_caractere_special"], +) +def test_password_change_request_rejects_a_password_missing_a_character_class( + new_password: str, +) -> None: + with pytest.raises(ValidationError): + PasswordChangeRequest(current_password="peu-importe", new_password=new_password) + + +def test_password_change_request_rejects_a_password_below_the_minimum_length() -> None: + with pytest.raises(ValidationError): + PasswordChangeRequest(current_password="peu-importe", new_password="Ab1!") + + +def test_valide_complexite_names_every_missing_class_in_the_error() -> None: + with pytest.raises(ValueError, match=r"majuscule.*chiffre|chiffre.*majuscule"): + valide_complexite("minuscules-seulement") diff --git a/apps/backend/tests/services/test_auth.py b/apps/backend/tests/services/test_auth.py index 9b8c42c..52cde71 100644 --- a/apps/backend/tests/services/test_auth.py +++ b/apps/backend/tests/services/test_auth.py @@ -16,11 +16,15 @@ from app.core.security import ( from app.models.login_attempt import LoginOutcome from app.models.refresh_token import RevocationReason from app.repositories.login_attempt import FailureCounts +from app.repositories.password_reset_attempt import ResetRequestCounts +from app.repositories.password_reset_token import ConsumedResetToken from app.repositories.refresh_token import ClaimedToken from app.services.auth import ( AuthService, InvalidCredentialsError, + InvalidOrExpiredResetTokenError, LoginPolicy, + PasswordResetPolicy, RateLimitedError, SessionRejectedError, ) @@ -37,6 +41,13 @@ POLITIQUE_CONNEXION = LoginPolicy( max_failures_per_ip=20, max_failures_per_identifier=50, ) +POLITIQUE_RESET = PasswordResetPolicy( + window_seconds=900, + max_requests_per_identifier=3, + max_requests_per_ip=10, + token_ttl=timedelta(minutes=15), + frontend_reset_url="http://localhost:4200/reset-password", +) @dataclass @@ -168,6 +179,43 @@ class FausseTransaction: self.validations += 1 +class FauxDepotJetonsReset: + def __init__(self, revendique: ConsumedResetToken | None = None) -> None: + self.revendique = revendique + self.crees: list[UUID] = [] + self.invalidations: list[UUID] = [] + + async def create(self, *, user_id: UUID, **_: object) -> None: + self.crees.append(user_id) + + async def consume(self, token_hash: bytes) -> ConsumedResetToken | None: + return self.revendique + + async def invalidate_all_for_user(self, user_id: UUID) -> int: + self.invalidations.append(user_id) + return len(self.invalidations) + + +class FauxDepotTentativesReset: + def __init__(self, compteurs: ResetRequestCounts | None = None) -> None: + self.compteurs = compteurs or ResetRequestCounts(0, 0) + self.enregistrees: list[str] = [] + + async def count_recent(self, **_: object) -> ResetRequestCounts: + return self.compteurs + + async def record(self, *, email: str, **_: object) -> None: + self.enregistrees.append(email) + + +class FauxMailer: + def __init__(self) -> None: + self.envois: list[tuple[str, str]] = [] + + async def send_password_reset_email(self, *, to: str, reset_url: str) -> None: + self.envois.append((to, reset_url)) + + @dataclass class Attirail: service: AuthService @@ -176,6 +224,9 @@ class Attirail: jetons: FauxDepotJetons audit: FauxDepotAudit hacheur: FauxHacheur + jetons_reset: FauxDepotJetonsReset + tentatives_reset: FauxDepotTentativesReset + mailer: FauxMailer def fabrique_service( @@ -184,12 +235,17 @@ def fabrique_service( compteurs: FailureCounts | None = None, hacheur: FauxHacheur | None = None, jetons: FauxDepotJetons | None = None, + jetons_reset: FauxDepotJetonsReset | None = None, + compteurs_reset: ResetRequestCounts | None = None, ) -> Attirail: comptes = FauxDepotComptes(compte) tentatives = FauxDepotTentatives(compteurs) depot_jetons = jetons or FauxDepotJetons() audit = FauxDepotAudit() hacheur = hacheur or FauxHacheur() + depot_jetons_reset = jetons_reset or FauxDepotJetonsReset() + tentatives_reset = FauxDepotTentativesReset(compteurs_reset) + mailer = FauxMailer() service = AuthService( users=comptes, # type: ignore[arg-type] attempts=tentatives, # type: ignore[arg-type] @@ -200,8 +256,22 @@ def fabrique_service( token_policy=POLITIQUE_JETON, login_policy=POLITIQUE_CONNEXION, refresh_ttl=timedelta(days=7), + reset_tokens=depot_jetons_reset, # type: ignore[arg-type] + reset_attempts=tentatives_reset, # type: ignore[arg-type] + reset_policy=POLITIQUE_RESET, + mailer=mailer, # type: ignore[arg-type] + ) + return Attirail( + service, + comptes, + tentatives, + depot_jetons, + audit, + hacheur, + depot_jetons_reset, + tentatives_reset, + mailer, ) - return Attirail(service, comptes, tentatives, depot_jetons, audit, hacheur) async def connecte(service: AuthService, mot_de_passe: str = "un-mot-de-passe-valide") -> object: @@ -493,3 +563,89 @@ async def test_change_password_refuses_a_wrong_current_password() -> None: assert attirail.jetons.revocations_par_compte == [] assert attirail.jetons.crees == [] + + +async def test_request_password_reset_emails_a_link_when_the_account_exists() -> None: + compte = FauxCompte() + attirail = fabrique_service(compte=compte) + + await attirail.service.request_password_reset( + email=compte.email, client_ip="203.0.113.10", user_agent="pytest" + ) + + assert attirail.jetons_reset.invalidations == [compte.id] + assert attirail.jetons_reset.crees == [compte.id] + assert len(attirail.mailer.envois) == 1 + assert attirail.mailer.envois[0][0] == compte.email + assert "auth.password_reset_requested" in attirail.audit.lignes[0][0] + + +async def test_request_password_reset_stays_silent_when_the_account_is_unknown() -> None: + attirail = fabrique_service(compte=None) + + await attirail.service.request_password_reset( + email="inconnu@enervision.fr", client_ip="203.0.113.10", user_agent="pytest" + ) + + assert attirail.jetons_reset.crees == [] + assert attirail.mailer.envois == [] + assert attirail.hacheur.verifications == 1, "le hachage factice doit tout de même tourner" + + +async def test_request_password_reset_stays_silent_when_the_account_is_inactive() -> None: + compte = FauxCompte(is_active=False) + attirail = fabrique_service(compte=compte) + + await attirail.service.request_password_reset( + email=compte.email, client_ip="203.0.113.10", user_agent="pytest" + ) + + assert attirail.jetons_reset.crees == [] + assert attirail.mailer.envois == [] + + +async def test_request_password_reset_raises_when_the_rate_limit_is_reached() -> None: + attirail = fabrique_service(compteurs_reset=ResetRequestCounts(per_identifier=3, per_ip=0)) + + with pytest.raises(RateLimitedError): + await attirail.service.request_password_reset( + email="operateur@enervision.fr", client_ip="203.0.113.10", user_agent="pytest" + ) + + assert attirail.mailer.envois == [] + + +async def test_confirm_password_reset_revokes_every_session_then_reopens_the_current_one() -> None: + compte = FauxCompte() + jetons_reset = FauxDepotJetonsReset( + revendique=ConsumedResetToken(id=uuid4(), user_id=compte.id) + ) + attirail = fabrique_service(compte=compte, jetons_reset=jetons_reset) + + session = await attirail.service.confirm_password_reset( + token="un-secret-opaque", + new_password="Un-nouveau-mot-de-passe1!", + client_ip="203.0.113.10", + user_agent="pytest", + ) + + assert attirail.jetons.revocations_par_compte == [ + (compte.id, RevocationReason.CHANGEMENT_MOT_DE_PASSE.value) + ] + assert len(attirail.jetons.crees) == 1 + assert session.refresh_secret + assert "auth.password_reset_self_service" in attirail.audit.lignes[0][0] + + +async def test_confirm_password_reset_rejects_an_invalid_or_expired_token() -> None: + attirail = fabrique_service(jetons_reset=FauxDepotJetonsReset(revendique=None)) + + with pytest.raises(InvalidOrExpiredResetTokenError): + await attirail.service.confirm_password_reset( + token="un-secret-invalide", + new_password="Un-nouveau-mot-de-passe1!", + client_ip=None, + user_agent=None, + ) + + assert attirail.jetons.revocations_par_compte == [] diff --git a/apps/backend/tests/test_cli.py b/apps/backend/tests/test_cli.py index 40b8317..7344bf7 100644 --- a/apps/backend/tests/test_cli.py +++ b/apps/backend/tests/test_cli.py @@ -4,6 +4,7 @@ from pathlib import Path import pytest from app import cli +from app.schemas.auth import valide_complexite def test_build_parser_reads_the_create_admin_arguments() -> None: @@ -34,26 +35,36 @@ def test_read_password_generates_a_long_secret_when_asked( assert len(mot_de_passe) >= cli.LONGUEUR_MOT_DE_PASSE_GENERE assert mot_de_passe in capsys.readouterr().out + valide_complexite(mot_de_passe) def test_read_password_accepts_two_matching_entries(monkeypatch: pytest.MonkeyPatch) -> None: - saisies = iter(["un-mot-de-passe-valide", "un-mot-de-passe-valide"]) + saisies = iter(["Un-mot-de-passe-valide1", "Un-mot-de-passe-valide1"]) monkeypatch.setattr(cli, "getpass", lambda _: next(saisies)) - assert cli.read_password(generate=False) == "un-mot-de-passe-valide" + assert cli.read_password(generate=False) == "Un-mot-de-passe-valide1" def test_read_password_refuses_a_password_below_the_minimum_length( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(cli, "getpass", lambda _: "court") + monkeypatch.setattr(cli, "getpass", lambda _: "Court1!") + + with pytest.raises(SystemExit): + cli.read_password(generate=False) + + +def test_read_password_refuses_a_password_missing_a_character_class( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(cli, "getpass", lambda _: "un-mot-de-passe-sans-majuscule-ni-chiffre") with pytest.raises(SystemExit): cli.read_password(generate=False) def test_read_password_refuses_two_different_entries(monkeypatch: pytest.MonkeyPatch) -> None: - saisies = iter(["un-mot-de-passe-valide", "un-autre-mot-de-passe"]) + saisies = iter(["Un-mot-de-passe-valide1", "Un-autre-mot-de-passe2"]) monkeypatch.setattr(cli, "getpass", lambda _: next(saisies)) with pytest.raises(SystemExit): diff --git a/apps/backend/uv.lock b/apps/backend/uv.lock index 7c2b8f4..39ec7ca 100644 --- a/apps/backend/uv.lock +++ b/apps/backend/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = "==3.14.*" +[[package]] +name = "aiosmtplib" +version = "5.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/5c/9cabc5db6d607616e81ba6d8f1f231cd5a75955807a308c1090a59072d6d/aiosmtplib-5.1.3.tar.gz", hash = "sha256:ac2b418d3260ba62d9cfd0fe7359726e9dc009a4e8e8d9909fdfae332f522a7c", size = 77010, upload-time = "2026-09-08T02:11:20.532Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/0a/b56ab8163d54960337fdca475d3dfd56c8badf6172e79cf2ad00d5335dc1/aiosmtplib-5.1.3-py3-none-any.whl", hash = "sha256:f7d76ce3d4995a65a178c1f11e1bd1607706b921d00cb768e7a2c7f7ef5517a8", size = 30116, upload-time = "2026-09-08T02:11:19.352Z" }, +] + [[package]] name = "alembic" version = "1.20.0" @@ -306,6 +315,7 @@ name = "enervision-backend" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "aiosmtplib" }, { name = "alembic" }, { name = "anyio" }, { name = "argon2-cffi" }, @@ -332,6 +342,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "aiosmtplib", specifier = ">=5.1.3" }, { name = "alembic", specifier = ">=1.20.0" }, { name = "anyio", specifier = ">=4.0" }, { name = "argon2-cffi", specifier = ">=23.1" }, diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index b3e97d8..72e20f5 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -5,6 +5,8 @@ export const routes: Routes = [ { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, { path: 'login', loadComponent: () => import('./features/auth/login/login').then(m => m.Login) }, { path: 'change-password', loadComponent: () => import('./features/auth/change-password/change-password').then(m => m.ChangePassword) }, + { path: 'forgot-password', loadComponent: () => import('./features/auth/forgot-password/forgot-password').then(m => m.ForgotPassword) }, + { path: 'reset-password', loadComponent: () => import('./features/auth/reset-password/reset-password').then(m => m.ResetPassword) }, { path: 'dashboard', canActivate: [authGuard], diff --git a/apps/frontend/src/app/core/services/auth.service.ts b/apps/frontend/src/app/core/services/auth.service.ts index d27c1db..9aa477a 100644 --- a/apps/frontend/src/app/core/services/auth.service.ts +++ b/apps/frontend/src/app/core/services/auth.service.ts @@ -1,7 +1,14 @@ import { Service, signal, computed, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable, tap, finalize, shareReplay } from 'rxjs'; -import { LoginRequest, PasswordChangeRequest, Principal, TokenResponse } from '../../shared/models/auth.model'; +import { + ForgotPasswordRequest, + LoginRequest, + PasswordChangeRequest, + Principal, + ResetPasswordRequest, + TokenResponse, +} from '../../shared/models/auth.model'; import { environment } from '../../../environments/environment'; @Service() @@ -66,4 +73,14 @@ export class AuthService { me(): Observable { return this.http.get(`${environment.apiUrl}/auth/me`); } + + forgotPassword(payload: ForgotPasswordRequest): Observable { + return this.http.post(`${environment.apiUrl}/auth/forgot-password`, payload); + } + + resetPassword(payload: ResetPasswordRequest): Observable { + return this.http + .post(`${environment.apiUrl}/auth/reset-password`, payload, { withCredentials: true }) + .pipe(tap((response) => this.setSession(response))); + } } diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.html b/apps/frontend/src/app/features/auth/change-password/change-password.html index edf2146..d7b5039 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.html +++ b/apps/frontend/src/app/features/auth/change-password/change-password.html @@ -18,7 +18,7 @@ formControlName="new_password" autocomplete="new-password" /> - 12 à 128 caractères + {{ passwordHint }} @if (errorMessage()) {

{{ errorMessage() }}

diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts b/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts index 63e1872..0e72843 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts +++ b/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts @@ -32,10 +32,19 @@ describe('ChangePassword', () => { expect(authMock.changePassword).not.toHaveBeenCalled(); }); + it('ne soumet pas si le mot de passe ne couvre pas les 4 classes de caractères', () => { + const fixture = TestBed.createComponent(ChangePassword); + const component = fixture.componentInstance; + component.form.setValue({ current_password: 'old', new_password: 'longueur-suffisante-sans-majuscule-ni-chiffre' }); + + component.onSubmit(); + expect(authMock.changePassword).not.toHaveBeenCalled(); + }); + it('redirige vers /dashboard après un changement réussi', () => { const fixture = TestBed.createComponent(ChangePassword); const component = fixture.componentInstance; - component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' }); + component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' }); authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } })); @@ -46,7 +55,7 @@ describe('ChangePassword', () => { it("affiche un message d'erreur si le mot de passe actuel est incorrect", () => { const fixture = TestBed.createComponent(ChangePassword); const component = fixture.componentInstance; - component.form.setValue({ current_password: 'mauvais-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' }); + component.form.setValue({ current_password: 'mauvais-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' }); authMock.changePassword.mockReturnValue(throwError(() => new Error('401'))); @@ -70,7 +79,7 @@ describe('ChangePassword', () => { it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => { const fixture = TestBed.createComponent(ChangePassword); const component = fixture.componentInstance; - component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' }); + component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' }); fixture.detectChanges(); authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } })); @@ -81,7 +90,7 @@ describe('ChangePassword', () => { expect(authMock.changePassword).toHaveBeenCalledWith({ current_password: 'ancien-mot-de-passe', - new_password: 'un-nouveau-mot-de-passe-valide', + new_password: 'Un-nouveau-mot-de-passe1!', }); }); diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.ts b/apps/frontend/src/app/features/auth/change-password/change-password.ts index 507af14..528aea0 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.ts +++ b/apps/frontend/src/app/features/auth/change-password/change-password.ts @@ -2,6 +2,7 @@ import { Component, inject, signal } from '@angular/core'; import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { AuthService } from '../../../core/services/auth.service'; +import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator'; @Component({ selector: 'app-change-password', @@ -17,10 +18,11 @@ export class ChangePassword { errorMessage = signal(null); isLoading = signal(false); + passwordHint = PASSWORD_HINT; form = this.fb.nonNullable.group({ current_password: ['', Validators.required], - new_password: ['', [Validators.required, Validators.minLength(12), Validators.maxLength(128)]], + new_password: ['', passwordValidators], }); onSubmit(): void { @@ -34,7 +36,7 @@ export class ChangePassword { }, error: () => { this.isLoading.set(false); - this.errorMessage.set('Mot de passe actuel incorrect, ou nouveau mot de passe invalide (12 à 128 caractères).'); + this.errorMessage.set(`Mot de passe actuel incorrect, ou nouveau mot de passe invalide (${this.passwordHint}).`); }, }); } diff --git a/apps/frontend/src/app/features/auth/forgot-password/forgot-password.html b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.html new file mode 100644 index 0000000..2bd7ef9 --- /dev/null +++ b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.html @@ -0,0 +1,37 @@ +
+
+

Mot de passe oublié

+

Recevez un lien de réinitialisation par email

+ + @if (submitted()) { +

+ Si un compte existe pour cet email, un lien de réinitialisation vient d'être envoyé. + Il expire dans 15 minutes. +

+ } @else { + + + + @if (errorMessage()) { +

+ {{ errorMessage() }} + @if (retryAfterSeconds(); as seconds) { + (réessayez dans {{ seconds }}s) + } +

+ } + + + } + + +
+
diff --git a/apps/frontend/src/app/features/auth/forgot-password/forgot-password.scss b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.scss new file mode 100644 index 0000000..31c9efc --- /dev/null +++ b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.scss @@ -0,0 +1,104 @@ +:host { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: #f3f4f6; + font-family: 'Segoe UI', system-ui, sans-serif; +} + +.auth-card { + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 2.5rem; + width: 100%; + max-width: 360px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); + display: flex; + flex-direction: column; + + h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + color: #1f2937; + } + + .auth-subtitle { + margin: 0.25rem 0 1.5rem; + color: #6b7280; + font-size: 0.9rem; + line-height: 1.4; + } + + label { + font-size: 0.85rem; + font-weight: 600; + color: #374151; + margin-bottom: 0.35rem; + margin-top: 1rem; + } + + input { + padding: 0.6rem 0.75rem; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 0.95rem; + + &:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); + } + } + + button { + margin-top: 1.5rem; + padding: 0.7rem; + background: #3b82f6; + color: #fff; + border: none; + border-radius: 8px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + + &:disabled { + background: #9ca3af; + cursor: not-allowed; + } + + &:not(:disabled):hover { + background: #2563eb; + } + } +} + +.auth-hint { + font-size: 0.75rem; + color: #9ca3af; + margin-top: 0.25rem; +} + +.auth-error { + margin: 0.75rem 0 0; + color: #dc2626; + font-size: 0.85rem; +} + +.auth-success { + margin: 0.75rem 0 0; + color: #16a34a; + font-size: 0.85rem; +} + +.auth-link { + margin-top: 1rem; + font-size: 0.85rem; + text-align: center; + + a { + color: #3b82f6; + } +} diff --git a/apps/frontend/src/app/features/auth/forgot-password/forgot-password.spec.ts b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.spec.ts new file mode 100644 index 0000000..56f7764 --- /dev/null +++ b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.spec.ts @@ -0,0 +1,75 @@ +import { TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; +import { of, throwError } from 'rxjs'; +import { vi } from 'vitest'; +import { ForgotPassword } from './forgot-password'; +import { AuthService } from '../../../core/services/auth.service'; + +describe('ForgotPassword', () => { + let authMock: { forgotPassword: ReturnType }; + let routerMock: { navigate: ReturnType }; + + beforeEach(async () => { + authMock = { forgotPassword: vi.fn() }; + routerMock = { navigate: vi.fn() }; + + await TestBed.configureTestingModule({ + imports: [ForgotPassword, ReactiveFormsModule], + providers: [ + { provide: AuthService, useValue: authMock }, + { provide: Router, useValue: routerMock }, + { provide: ActivatedRoute, useValue: {} }, + ], + }).compileComponents(); + }); + + it('ne soumet pas si le formulaire est invalide', () => { + const fixture = TestBed.createComponent(ForgotPassword); + fixture.componentInstance.onSubmit(); + expect(authMock.forgotPassword).not.toHaveBeenCalled(); + }); + + it('affiche le message générique après une soumission réussie', () => { + const fixture = TestBed.createComponent(ForgotPassword); + const component = fixture.componentInstance; + component.form.setValue({ email: 'operateur@enervision.fr' }); + authMock.forgotPassword.mockReturnValue(of(undefined)); + + component.onSubmit(); + + expect(component.submitted()).toBe(true); + }); + + it('affiche le même message générique même quand le serveur répond une erreur autre que 429', () => { + const fixture = TestBed.createComponent(ForgotPassword); + const component = fixture.componentInstance; + component.form.setValue({ email: 'inconnu@enervision.fr' }); + authMock.forgotPassword.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 500 }))); + + component.onSubmit(); + + expect(component.submitted()).toBe(true); + }); + + it('affiche le délai à respecter quand le taux limite est atteint', () => { + const fixture = TestBed.createComponent(ForgotPassword); + const component = fixture.componentInstance; + component.form.setValue({ email: 'operateur@enervision.fr' }); + authMock.forgotPassword.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 429, + headers: new HttpHeaders({ 'Retry-After': '900' }), + }) + ) + ); + + component.onSubmit(); + + expect(component.submitted()).toBe(false); + expect(component.retryAfterSeconds()).toBe(900); + }); +}); diff --git a/apps/frontend/src/app/features/auth/forgot-password/forgot-password.ts b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.ts new file mode 100644 index 0000000..6ceef5c --- /dev/null +++ b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.ts @@ -0,0 +1,53 @@ +import { Component, inject, signal } from '@angular/core'; +import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; +import { RouterLink } from '@angular/router'; +import { HttpErrorResponse } from '@angular/common/http'; +import { AuthService } from '../../../core/services/auth.service'; + +@Component({ + selector: 'app-forgot-password', + standalone: true, + imports: [ReactiveFormsModule, RouterLink], + templateUrl: './forgot-password.html', + styleUrl: './forgot-password.scss', +}) +export class ForgotPassword { + private fb = inject(FormBuilder); + private auth = inject(AuthService); + + errorMessage = signal(null); + retryAfterSeconds = signal(null); + submitted = signal(false); + isLoading = signal(false); + + form = this.fb.nonNullable.group({ + email: ['', [Validators.required, Validators.email]], + }); + + onSubmit(): void { + if (this.form.invalid) return; + + this.isLoading.set(true); + this.errorMessage.set(null); + this.retryAfterSeconds.set(null); + + this.auth.forgotPassword(this.form.getRawValue()).subscribe({ + // Le message affiché ne dépend jamais du fait que le compte existe ou non : la réponse + // du serveur est déjà générique, l'écran doit l'être aussi. + next: () => { + this.isLoading.set(false); + this.submitted.set(true); + }, + error: (error: HttpErrorResponse) => { + this.isLoading.set(false); + if (error.status === 429) { + const retryAfter = error.headers.get('Retry-After'); + this.retryAfterSeconds.set(retryAfter ? Number(retryAfter) : null); + this.errorMessage.set('Trop de demandes, réessayez plus tard.'); + return; + } + this.submitted.set(true); + }, + }); + } +} diff --git a/apps/frontend/src/app/features/auth/login/login.html b/apps/frontend/src/app/features/auth/login/login.html index 0083bd2..3ee100b 100644 --- a/apps/frontend/src/app/features/auth/login/login.html +++ b/apps/frontend/src/app/features/auth/login/login.html @@ -32,5 +32,7 @@ + +
diff --git a/apps/frontend/src/app/features/auth/login/login.scss b/apps/frontend/src/app/features/auth/login/login.scss index cc415b8..45b28c0 100644 --- a/apps/frontend/src/app/features/auth/login/login.scss +++ b/apps/frontend/src/app/features/auth/login/login.scss @@ -79,3 +79,13 @@ color: #dc2626; font-size: 0.85rem; } + +.auth-link { + margin-top: 1rem; + font-size: 0.85rem; + text-align: center; + + a { + color: #3b82f6; + } +} diff --git a/apps/frontend/src/app/features/auth/login/login.spec.ts b/apps/frontend/src/app/features/auth/login/login.spec.ts index 3c9bac1..d39298d 100644 --- a/apps/frontend/src/app/features/auth/login/login.spec.ts +++ b/apps/frontend/src/app/features/auth/login/login.spec.ts @@ -1,6 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { ReactiveFormsModule } from '@angular/forms'; -import { Router } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; import { of, throwError } from 'rxjs'; import { vi } from 'vitest'; @@ -20,6 +20,7 @@ describe('Login', () => { providers: [ { provide: AuthService, useValue: authMock }, { provide: Router, useValue: routerMock }, + { provide: ActivatedRoute, useValue: {} }, ], }).compileComponents(); }); diff --git a/apps/frontend/src/app/features/auth/login/login.ts b/apps/frontend/src/app/features/auth/login/login.ts index 34b9ff2..871e7cc 100644 --- a/apps/frontend/src/app/features/auth/login/login.ts +++ b/apps/frontend/src/app/features/auth/login/login.ts @@ -1,13 +1,13 @@ import { Component, inject, signal } from '@angular/core'; import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; -import { Router } from '@angular/router'; +import { Router, RouterLink } from '@angular/router'; import { HttpErrorResponse } from '@angular/common/http'; import { AuthService } from '../../../core/services/auth.service'; @Component({ selector: 'app-login', standalone: true, - imports: [ReactiveFormsModule], + imports: [ReactiveFormsModule, RouterLink], templateUrl: './login.html', styleUrl: './login.scss', }) diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.html b/apps/frontend/src/app/features/auth/reset-password/reset-password.html new file mode 100644 index 0000000..eed77a8 --- /dev/null +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.html @@ -0,0 +1,30 @@ +
+
+

Nouveau mot de passe

+ + @if (!hasToken) { +

Ce lien est incomplet. Redemandez un lien de réinitialisation.

+ } @else { +

Choisissez votre nouveau mot de passe

+ + + + {{ passwordHint }} + + @if (errorMessage()) { +

{{ errorMessage() }}

+ } + + + } + + +
+
diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.scss b/apps/frontend/src/app/features/auth/reset-password/reset-password.scss new file mode 100644 index 0000000..31c9efc --- /dev/null +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.scss @@ -0,0 +1,104 @@ +:host { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: #f3f4f6; + font-family: 'Segoe UI', system-ui, sans-serif; +} + +.auth-card { + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 2.5rem; + width: 100%; + max-width: 360px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); + display: flex; + flex-direction: column; + + h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + color: #1f2937; + } + + .auth-subtitle { + margin: 0.25rem 0 1.5rem; + color: #6b7280; + font-size: 0.9rem; + line-height: 1.4; + } + + label { + font-size: 0.85rem; + font-weight: 600; + color: #374151; + margin-bottom: 0.35rem; + margin-top: 1rem; + } + + input { + padding: 0.6rem 0.75rem; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 0.95rem; + + &:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); + } + } + + button { + margin-top: 1.5rem; + padding: 0.7rem; + background: #3b82f6; + color: #fff; + border: none; + border-radius: 8px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + + &:disabled { + background: #9ca3af; + cursor: not-allowed; + } + + &:not(:disabled):hover { + background: #2563eb; + } + } +} + +.auth-hint { + font-size: 0.75rem; + color: #9ca3af; + margin-top: 0.25rem; +} + +.auth-error { + margin: 0.75rem 0 0; + color: #dc2626; + font-size: 0.85rem; +} + +.auth-success { + margin: 0.75rem 0 0; + color: #16a34a; + font-size: 0.85rem; +} + +.auth-link { + margin-top: 1rem; + font-size: 0.85rem; + text-align: center; + + a { + color: #3b82f6; + } +} diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts b/apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts new file mode 100644 index 0000000..7e212cd --- /dev/null +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts @@ -0,0 +1,74 @@ +import { TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; +import { HttpErrorResponse } from '@angular/common/http'; +import { of, throwError } from 'rxjs'; +import { vi } from 'vitest'; +import { ResetPassword } from './reset-password'; +import { AuthService } from '../../../core/services/auth.service'; + +function configure(token: string | null) { + return TestBed.configureTestingModule({ + imports: [ResetPassword, ReactiveFormsModule], + providers: [ + { provide: AuthService, useValue: { resetPassword: vi.fn() } }, + { provide: Router, useValue: { navigate: vi.fn() } }, + { + provide: ActivatedRoute, + useValue: { snapshot: { queryParamMap: convertToParamMap(token ? { token } : {}) } }, + }, + ], + }).compileComponents(); +} + +describe('ResetPassword', () => { + it("signale un lien incomplet quand le jeton est absent de l'URL", async () => { + await configure(null); + const fixture = TestBed.createComponent(ResetPassword); + + expect(fixture.componentInstance.hasToken).toBe(false); + }); + + it('ne soumet pas si le mot de passe ne respecte pas la politique de complexité', async () => { + await configure('un-secret-opaque'); + const fixture = TestBed.createComponent(ResetPassword); + const component = fixture.componentInstance; + const auth = TestBed.inject(AuthService) as unknown as { resetPassword: ReturnType }; + component.form.setValue({ new_password: 'trop-simple' }); + + component.onSubmit(); + + expect(auth.resetPassword).not.toHaveBeenCalled(); + }); + + it('redirige vers /dashboard après une réinitialisation réussie', async () => { + await configure('un-secret-opaque'); + const fixture = TestBed.createComponent(ResetPassword); + const component = fixture.componentInstance; + const auth = TestBed.inject(AuthService) as unknown as { resetPassword: ReturnType }; + const router = TestBed.inject(Router) as unknown as { navigate: ReturnType }; + component.form.setValue({ new_password: 'Un-nouveau-mot-de-passe1!' }); + auth.resetPassword.mockReturnValue(of({ principal: { role: 'operateur' } })); + + component.onSubmit(); + + expect(auth.resetPassword).toHaveBeenCalledWith({ + token: 'un-secret-opaque', + new_password: 'Un-nouveau-mot-de-passe1!', + }); + expect(router.navigate).toHaveBeenCalledWith(['/dashboard']); + }); + + it('affiche un message dédié quand le lien est invalide ou expiré', async () => { + await configure('un-secret-perime'); + const fixture = TestBed.createComponent(ResetPassword); + const component = fixture.componentInstance; + const auth = TestBed.inject(AuthService) as unknown as { resetPassword: ReturnType }; + component.form.setValue({ new_password: 'Un-nouveau-mot-de-passe1!' }); + auth.resetPassword.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 400 }))); + + component.onSubmit(); + + expect(component.errorMessage()).toContain('invalide'); + }); +}); diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.ts b/apps/frontend/src/app/features/auth/reset-password/reset-password.ts new file mode 100644 index 0000000..6754fa7 --- /dev/null +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.ts @@ -0,0 +1,52 @@ +import { Component, inject, signal } from '@angular/core'; +import { ReactiveFormsModule, FormBuilder } from '@angular/forms'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { HttpErrorResponse } from '@angular/common/http'; +import { AuthService } from '../../../core/services/auth.service'; +import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator'; + +@Component({ + selector: 'app-reset-password', + standalone: true, + imports: [ReactiveFormsModule, RouterLink], + templateUrl: './reset-password.html', + styleUrl: './reset-password.scss', +}) +export class ResetPassword { + private fb = inject(FormBuilder); + private auth = inject(AuthService); + private router = inject(Router); + private route = inject(ActivatedRoute); + + private token = this.route.snapshot.queryParamMap.get('token') ?? ''; + + errorMessage = signal(null); + isLoading = signal(false); + passwordHint = PASSWORD_HINT; + hasToken = this.token.length > 0; + + form = this.fb.nonNullable.group({ + new_password: ['', passwordValidators], + }); + + onSubmit(): void { + if (this.form.invalid || !this.hasToken) return; + + this.isLoading.set(true); + this.errorMessage.set(null); + + this.auth.resetPassword({ token: this.token, new_password: this.form.getRawValue().new_password }).subscribe({ + next: () => { + this.router.navigate(['/dashboard']); + }, + error: (error: HttpErrorResponse) => { + this.isLoading.set(false); + if (error.status === 400) { + this.errorMessage.set('Ce lien est invalide, déjà utilisé, ou a expiré. Redemandez-en un.'); + return; + } + this.errorMessage.set(`Nouveau mot de passe invalide (${this.passwordHint}).`); + }, + }); + } +} diff --git a/apps/frontend/src/app/shared/models/auth.model.ts b/apps/frontend/src/app/shared/models/auth.model.ts index 932572f..ebed0d5 100644 --- a/apps/frontend/src/app/shared/models/auth.model.ts +++ b/apps/frontend/src/app/shared/models/auth.model.ts @@ -10,6 +10,15 @@ export interface PasswordChangeRequest { new_password: string; } +export interface ForgotPasswordRequest { + email: string; +} + +export interface ResetPasswordRequest { + token: string; + new_password: string; +} + export interface Principal { id: string; email: string; diff --git a/apps/frontend/src/app/shared/validators/password.validator.ts b/apps/frontend/src/app/shared/validators/password.validator.ts new file mode 100644 index 0000000..fac1359 --- /dev/null +++ b/apps/frontend/src/app/shared/validators/password.validator.ts @@ -0,0 +1,15 @@ +import { Validators } from '@angular/forms'; + +export const PASSWORD_MIN_LENGTH = 8; +export const PASSWORD_MAX_LENGTH = 128; +export const PASSWORD_HINT = + '8 à 128 caractères, avec au moins 1 majuscule, 1 minuscule, 1 chiffre et 1 caractère spécial'; + +const PASSWORD_PATTERN = /^(?=.*[A-ZÀ-Ý])(?=.*[a-zà-ÿ])(?=.*\d)(?=.*[^\w\s]).*$/; + +export const passwordValidators = [ + Validators.required, + Validators.minLength(PASSWORD_MIN_LENGTH), + Validators.maxLength(PASSWORD_MAX_LENGTH), + Validators.pattern(PASSWORD_PATTERN), +]; diff --git a/docker-compose.yml b/docker-compose.yml index 3d0ea63..3f7f9ea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,11 +27,22 @@ services: start_period: 40s restart: unless-stopped + # Piege : Mailpit ne relaie rien vers l'exterieur, il capture tout email envoye par le + # backend. Aucun acces reseau sortant n'est requis ; l'UI web (8025) sert a lire les emails. + mailpit: + image: axllent/mailpit + ports: + - "${MAILPIT_SMTP_PORT:-1025}:1025" + - "${MAILPIT_UI_PORT:-8025}:8025" + restart: unless-stopped + backend: build: ./apps/backend depends_on: db: condition: service_healthy + mailpit: + condition: service_started environment: APP_ENV: ${APP_ENV:-local} APP_DEBUG: ${APP_DEBUG:-false} @@ -39,6 +50,11 @@ services: APP_SECRET_KEY: ${APP_SECRET_KEY:?} APP_CORS_ORIGINS: ${APP_CORS_ORIGINS:-http://localhost:4200} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + APP_FRONTEND_RESET_PASSWORD_URL: ${APP_FRONTEND_RESET_PASSWORD_URL:-http://localhost:4200/reset-password} + APP_SMTP_HOST: mailpit + APP_SMTP_PORT: "1025" + APP_SMTP_USE_TLS: "false" + APP_SMTP_FROM_ADDRESS: ${APP_SMTP_FROM_ADDRESS:-no-reply@enervision.fr} ports: - "${BACKEND_PORT:-8000}:8000" restart: unless-stopped diff --git a/docs/architecture/31-contrat-authentification.md b/docs/architecture/31-contrat-authentification.md index 9c9fe66..981cd85 100644 --- a/docs/architecture/31-contrat-authentification.md +++ b/docs/architecture/31-contrat-authentification.md @@ -20,6 +20,8 @@ gérer : il suffit d'envoyer les requêtes avec `withCredentials`. | POST | `/api/v1/auth/logout` | cookie | `204` | | POST | `/api/v1/auth/logout-all` | jeton d'accès | `204` | | POST | `/api/v1/auth/password` | jeton d'accès | `200` `TokenResponse` | +| POST | `/api/v1/auth/forgot-password` | aucune | `202` (toujours, que le compte existe ou non) | +| POST | `/api/v1/auth/reset-password` | aucune (jeton dans le corps) | `200` `TokenResponse` | | GET | `/api/v1/auth/me` | jeton d'accès | `200` `PrincipalResponse` | | GET | `/api/v1/users` | jeton d'accès, `admin` | `200` `UserResponse[]` | | POST | `/api/v1/users` | jeton d'accès, `admin` | `201` `TemporaryPasswordResponse` | @@ -51,7 +53,17 @@ codes d'erreur ci-dessous reste la référence de comportement, le schéma celle } // POST /auth/password -{ "current_password": "...", "new_password": "..." } // 12 à 128 caractères +{ "current_password": "...", "new_password": "..." } // 8 à 128 caractères, au moins 1 majuscule, 1 minuscule, 1 chiffre, 1 caractère spécial + +// POST /auth/forgot-password +{ "email": "operateur@enervision.fr" } +// Répond toujours 202, sans corps, que le compte existe, soit inactif, ou soit inconnu. + +// POST /auth/reset-password +{ "token": "...", "new_password": "..." } // même règle de complexité que /auth/password +// Le jeton vient du lien reçu par email, valable 15 minutes, à usage unique. Répond +// TokenResponse au succès (l'appareil qui pose le nouveau mot de passe reste connecté), ou 400 +// si le jeton est invalide, déjà utilisé, ou expiré. ``` Le secret de rafraîchissement **n'apparaît jamais** dans le corps de la réponse. @@ -70,6 +82,9 @@ Le secret de rafraîchissement **n'apparaît jamais** dans le corps de la répon | `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 | +| `429` sur `/auth/forgot-password` | trop de demandes | afficher l'attente, l'en-tête `Retry-After` donne les secondes | +| `400` sur `/auth/reset-password` | lien invalide, déjà utilisé, ou expiré | inviter à redemander un lien depuis `/forgot-password` | +| `403` sur `/auth/reset-password` | origine hors liste autorisée | erreur de configuration réseau, pas un cas à gérer par l'utilisateur | ## Les quatre règles qui comptent From 9c78c6dc3882d12c3ce8958a80a8aad1b30ca7b2 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 17 Sep 2026 11:14:02 +0200 Subject: [PATCH 059/103] =?UTF-8?q?feat(frontend):=20design=20syst=C3=A8me?= =?UTF-8?q?=20-=20tokens,=20composants=20ui=20et=20restylage=20des=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Centralise les couleurs/rayons/espacements dispersés en dur dans chaque page (login, change-password, dashboard) en tokens CSS partagés, ajoute un petit set de composants standalone réutilisables (ev-button, ev-card, ev-alert, ev-badge) et intègre le logo EnerVision en en-tête des pages ainsi que dans Swagger/ReDoc côté backend. Refs #91 --- apps/backend/app/main.py | 50 ++++++++- apps/backend/app/static/logo-icon.png | Bin 0 -> 36468 bytes apps/frontend/README.md | 7 ++ apps/frontend/public/logo.png | Bin 0 -> 7152 bytes .../auth/change-password/change-password.html | 53 +++++---- .../auth/change-password/change-password.scss | 104 +++++------------- .../change-password/change-password.spec.ts | 4 +- .../auth/change-password/change-password.ts | 5 +- .../src/app/features/auth/login/login.html | 63 ++++++----- .../src/app/features/auth/login/login.scss | 100 ++++++----------- .../src/app/features/auth/login/login.spec.ts | 6 +- .../src/app/features/auth/login/login.ts | 5 +- .../src/app/features/dashboard/dashboard.html | 27 +++-- .../src/app/features/dashboard/dashboard.scss | 96 +++++----------- .../src/app/features/dashboard/dashboard.ts | 18 ++- .../app/shared/components/ui/alert/alert.html | 1 + .../app/shared/components/ui/alert/alert.scss | 27 +++++ .../shared/components/ui/alert/alert.spec.ts | 30 +++++ .../app/shared/components/ui/alert/alert.ts | 21 ++++ .../app/shared/components/ui/badge/badge.html | 3 + .../app/shared/components/ui/badge/badge.scss | 27 +++++ .../shared/components/ui/badge/badge.spec.ts | 30 +++++ .../app/shared/components/ui/badge/badge.ts | 13 +++ .../shared/components/ui/button/button.html | 3 + .../shared/components/ui/button/button.scss | 51 +++++++++ .../components/ui/button/button.spec.ts | 50 +++++++++ .../app/shared/components/ui/button/button.ts | 15 +++ .../app/shared/components/ui/card/card.html | 1 + .../app/shared/components/ui/card/card.scss | 10 ++ .../shared/components/ui/card/card.spec.ts | 22 ++++ .../src/app/shared/components/ui/card/card.ts | 9 ++ apps/frontend/src/styles.scss | 9 +- apps/frontend/src/styles/_forms.scss | 37 +++++++ apps/frontend/src/styles/_tokens.scss | 39 +++++++ .../32-design-systeme-frontend.md | 76 +++++++++++++ 35 files changed, 724 insertions(+), 288 deletions(-) create mode 100644 apps/backend/app/static/logo-icon.png create mode 100644 apps/frontend/public/logo.png create mode 100644 apps/frontend/src/app/shared/components/ui/alert/alert.html create mode 100644 apps/frontend/src/app/shared/components/ui/alert/alert.scss create mode 100644 apps/frontend/src/app/shared/components/ui/alert/alert.spec.ts create mode 100644 apps/frontend/src/app/shared/components/ui/alert/alert.ts create mode 100644 apps/frontend/src/app/shared/components/ui/badge/badge.html create mode 100644 apps/frontend/src/app/shared/components/ui/badge/badge.scss create mode 100644 apps/frontend/src/app/shared/components/ui/badge/badge.spec.ts create mode 100644 apps/frontend/src/app/shared/components/ui/badge/badge.ts create mode 100644 apps/frontend/src/app/shared/components/ui/button/button.html create mode 100644 apps/frontend/src/app/shared/components/ui/button/button.scss create mode 100644 apps/frontend/src/app/shared/components/ui/button/button.spec.ts create mode 100644 apps/frontend/src/app/shared/components/ui/button/button.ts create mode 100644 apps/frontend/src/app/shared/components/ui/card/card.html create mode 100644 apps/frontend/src/app/shared/components/ui/card/card.scss create mode 100644 apps/frontend/src/app/shared/components/ui/card/card.spec.ts create mode 100644 apps/frontend/src/app/shared/components/ui/card/card.ts create mode 100644 apps/frontend/src/styles/_forms.scss create mode 100644 apps/frontend/src/styles/_tokens.scss create mode 100644 docs/architecture/32-design-systeme-frontend.md diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index de1235e..5a8ec3e 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -1,9 +1,15 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from pathlib import Path from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html +from fastapi.openapi.utils import get_openapi +from fastapi.staticfiles import StaticFiles from prometheus_fastapi_instrumentator import Instrumentator +from starlette.requests import Request +from starlette.responses import HTMLResponse from app.api.errors import register_error_handlers from app.api.middleware import SecurityHeadersMiddleware @@ -18,6 +24,8 @@ logger = get_logger(__name__) METHODES_AUTORISEES = ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"] EN_TETES_AUTORISES = ["Authorization", "Content-Type"] +STATIC_DIR = Path(__file__).parent / "static" +LOGO_URL = "/static/logo-icon.png" @asynccontextmanager @@ -43,11 +51,49 @@ def create_app(settings: Settings | None = None) -> FastAPI: openapi_tags=TAGS, debug=resolved.debug, lifespan=lifespan, - docs_url="/docs" if documentee else None, - redoc_url="/redoc" if documentee else None, + docs_url=None, + redoc_url=None, openapi_url="/openapi.json" if documentee else None, ) + if documentee: + application.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + + # ReDoc supporte nativement `info.x-logo` (extension Redocly) pour afficher un logo + # en en-tête ; Swagger UI n'a pas d'equivalent, il ne reprend que le favicon. + def openapi_avec_logo() -> dict[str, object]: + if application.openapi_schema: + return application.openapi_schema + schema = get_openapi( + title=application.title, + version=application.version, + summary=application.summary, + description=application.description, + routes=application.routes, + tags=application.openapi_tags, + ) + schema["info"]["x-logo"] = {"url": LOGO_URL, "altText": "EnerVision"} + application.openapi_schema = schema + return application.openapi_schema + + application.openapi = openapi_avec_logo # type: ignore[method-assign] + + @application.get("/docs", include_in_schema=False) + async def docs_swagger(_: Request) -> HTMLResponse: + return get_swagger_ui_html( + openapi_url="/openapi.json", + title=f"{application.title} · Swagger UI", + swagger_favicon_url=LOGO_URL, + ) + + @application.get("/redoc", include_in_schema=False) + async def docs_redoc(_: Request) -> HTMLResponse: + return get_redoc_html( + openapi_url="/openapi.json", + title=f"{application.title} · ReDoc", + redoc_favicon_url=LOGO_URL, + ) + application.add_middleware(SecurityHeadersMiddleware) if resolved.allowed_origins: diff --git a/apps/backend/app/static/logo-icon.png b/apps/backend/app/static/logo-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d3bdc53f943206607cea054067c3888aa9056d83 GIT binary patch literal 36468 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 kzNePZU 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 literal 0 HcmV?d00001 diff --git a/apps/frontend/README.md b/apps/frontend/README.md index aeaf788..c5b7484 100644 --- a/apps/frontend/README.md +++ b/apps/frontend/README.md @@ -76,6 +76,13 @@ Points à vérifier après toute regénération : côté backend. Le `docker-compose.yml` n'a aucun service frontend. 4. Ajouter le `Dockerfile` multi-stage (build Angular puis service statique nginx). +## Design système + +Tokens (couleurs, typo, espacements) et composants partagés (`ev-button`, `ev-card`, +`ev-alert`, `ev-badge`) sont documentés dans +[`docs/architecture/32-design-systeme-frontend.md`](../../docs/architecture/32-design-systeme-frontend.md). +Toute nouvelle page doit les réutiliser plutôt que définir ses propres valeurs. + ## Additional Resources For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. diff --git a/apps/frontend/public/logo.png b/apps/frontend/public/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..e5e41b24ad30754b294062a27d7f16819fc51210 GIT binary patch literal 7152 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 -

-

Nouveau mot de passe

-

Votre mot de passe est provisoire, vous devez le modifier avant de continuer

+ + + +

Nouveau mot de passe

+

Votre mot de passe est provisoire, vous devez le modifier avant de continuer

- - + + - - - 12 à 128 caractères + + + 12 à 128 caractères - @if (errorMessage()) { -

{{ errorMessage() }}

- } + @if (errorMessage()) { + {{ errorMessage() }} + } - + + {{ isLoading() ? 'Modification...' : 'Valider' }} + +
diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.scss b/apps/frontend/src/app/features/auth/change-password/change-password.scss index f44fcb8..48051a5 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.scss +++ b/apps/frontend/src/app/features/auth/change-password/change-password.scss @@ -3,86 +3,42 @@ align-items: center; justify-content: center; min-height: 100vh; - background: #f3f4f6; - font-family: 'Segoe UI', system-ui, sans-serif; + background: var(--color-bg); } -.auth-card { - background: #ffffff; - border: 1px solid #e5e7eb; - border-radius: 12px; - padding: 2.5rem; +.auth-card-wrapper { width: 100%; max-width: 360px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); - display: flex; - flex-direction: column; - - h1 { - margin: 0; - font-size: 1.5rem; - font-weight: 700; - color: #1f2937; - } - - .auth-subtitle { - margin: 0.25rem 0 1.5rem; - color: #6b7280; - font-size: 0.9rem; - line-height: 1.4; - } - - label { - font-size: 0.85rem; - font-weight: 600; - color: #374151; - margin-bottom: 0.35rem; - margin-top: 1rem; - } - - input { - padding: 0.6rem 0.75rem; - border: 1px solid #d1d5db; - border-radius: 8px; - font-size: 0.95rem; - - &:focus { - outline: none; - border-color: #3b82f6; - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); - } - } - - button { - margin-top: 1.5rem; - padding: 0.7rem; - background: #3b82f6; - color: #fff; - border: none; - border-radius: 8px; - font-size: 0.95rem; - font-weight: 600; - cursor: pointer; - - &:disabled { - background: #9ca3af; - cursor: not-allowed; - } - - &:not(:disabled):hover { - background: #2563eb; - } - } } -.auth-hint { - font-size: 0.75rem; - color: #9ca3af; - margin-top: 0.25rem; +.auth-logo { + display: block; + height: 48px; + margin: 0 auto 1rem; } -.auth-error { - margin: 0.75rem 0 0; - color: #dc2626; - font-size: 0.85rem; +h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + color: var(--color-text); + text-align: center; +} + +.auth-subtitle { + margin: 0.25rem 0 1.5rem; + color: var(--color-text-muted); + font-size: 0.9rem; + line-height: 1.4; + text-align: center; +} + +ev-alert { + display: block; + margin-top: 0.75rem; +} + +ev-button { + display: block; + margin-top: 1.5rem; } diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts b/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts index 63e1872..6fe09d0 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts +++ b/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts @@ -54,7 +54,7 @@ describe('ChangePassword', () => { fixture.detectChanges(); // rend le bloc @if (errorMessage()) expect(component.errorMessage()).toContain('incorrect'); - const errorEl = fixture.nativeElement.querySelector('.auth-error'); + const errorEl = fixture.nativeElement.querySelector('.ev-alert'); expect(errorEl?.textContent).toContain('incorrect'); }); @@ -64,7 +64,7 @@ describe('ChangePassword', () => { const button = fixture.nativeElement.querySelector('button[type="submit"]'); expect(button.disabled).toBe(true); - expect(fixture.nativeElement.querySelector('.auth-error')).toBeNull(); + expect(fixture.nativeElement.querySelector('.ev-alert')).toBeNull(); }); it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => { diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.ts b/apps/frontend/src/app/features/auth/change-password/change-password.ts index 507af14..1acf568 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.ts +++ b/apps/frontend/src/app/features/auth/change-password/change-password.ts @@ -2,11 +2,14 @@ import { Component, inject, signal } from '@angular/core'; import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { AuthService } from '../../../core/services/auth.service'; +import { Button } from '../../../shared/components/ui/button/button'; +import { Card } from '../../../shared/components/ui/card/card'; +import { Alert } from '../../../shared/components/ui/alert/alert'; @Component({ selector: 'app-change-password', standalone: true, - imports: [ReactiveFormsModule], + imports: [ReactiveFormsModule, Button, Card, Alert], templateUrl: './change-password.html', styleUrl: './change-password.scss', }) diff --git a/apps/frontend/src/app/features/auth/login/login.html b/apps/frontend/src/app/features/auth/login/login.html index 0083bd2..89e67fe 100644 --- a/apps/frontend/src/app/features/auth/login/login.html +++ b/apps/frontend/src/app/features/auth/login/login.html @@ -1,36 +1,41 @@
-
-

Connexion

-

Accédez à votre espace EnerVision

+ + + +

Connexion

+

Accédez à votre espace EnerVision

- - + + - - + + - @if (errorMessage()) { -

- {{ errorMessage() }} - @if (retryAfterSeconds(); as seconds) { - (réessayez dans {{ seconds }}s) - } -

- } + @if (errorMessage()) { + + {{ errorMessage() }} + @if (retryAfterSeconds(); as seconds) { + (réessayez dans {{ seconds }}s) + } + + } - + + {{ isLoading() ? 'Connexion...' : 'Se connecter' }} + +
diff --git a/apps/frontend/src/app/features/auth/login/login.scss b/apps/frontend/src/app/features/auth/login/login.scss index cc415b8..46cc393 100644 --- a/apps/frontend/src/app/features/auth/login/login.scss +++ b/apps/frontend/src/app/features/auth/login/login.scss @@ -3,79 +3,41 @@ align-items: center; justify-content: center; min-height: 100vh; - background: #f3f4f6; - font-family: 'Segoe UI', system-ui, sans-serif; + background: var(--color-bg); } -.auth-card { - background: #ffffff; - border: 1px solid #e5e7eb; - border-radius: 12px; - padding: 2.5rem; +.auth-card-wrapper { width: 100%; max-width: 360px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); - display: flex; - flex-direction: column; - - h1 { - margin: 0; - font-size: 1.5rem; - font-weight: 700; - color: #1f2937; - } - - .auth-subtitle { - margin: 0.25rem 0 1.5rem; - color: #6b7280; - font-size: 0.9rem; - } - - label { - font-size: 0.85rem; - font-weight: 600; - color: #374151; - margin-bottom: 0.35rem; - margin-top: 1rem; - } - - input { - padding: 0.6rem 0.75rem; - border: 1px solid #d1d5db; - border-radius: 8px; - font-size: 0.95rem; - - &:focus { - outline: none; - border-color: #3b82f6; - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); - } - } - - button { - margin-top: 1.5rem; - padding: 0.7rem; - background: #3b82f6; - color: #fff; - border: none; - border-radius: 8px; - font-size: 0.95rem; - font-weight: 600; - cursor: pointer; - - &:disabled { - background: #9ca3af; - cursor: not-allowed; - } - - &:not(:disabled):hover { - background: #2563eb; - } - } } -.auth-error { - margin: 0.75rem 0 0; - color: #dc2626; - font-size: 0.85rem; +.auth-logo { + display: block; + height: 48px; + margin: 0 auto 1rem; +} + +h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + color: var(--color-text); + text-align: center; +} + +.auth-subtitle { + margin: 0.25rem 0 1.5rem; + color: var(--color-text-muted); + font-size: 0.9rem; + text-align: center; +} + +ev-alert { + display: block; + margin-top: 0.75rem; +} + +ev-button { + display: block; + margin-top: 1.5rem; } diff --git a/apps/frontend/src/app/features/auth/login/login.spec.ts b/apps/frontend/src/app/features/auth/login/login.spec.ts index 3c9bac1..46afce2 100644 --- a/apps/frontend/src/app/features/auth/login/login.spec.ts +++ b/apps/frontend/src/app/features/auth/login/login.spec.ts @@ -63,7 +63,7 @@ describe('Login', () => { fixture.detectChanges(); // rend le bloc @if (errorMessage()) du template expect(component.errorMessage()).toBe('Email ou mot de passe incorrect.'); - const errorEl = fixture.nativeElement.querySelector('.auth-error'); + const errorEl = fixture.nativeElement.querySelector('.ev-alert'); expect(errorEl?.textContent).toContain('Email ou mot de passe incorrect.'); }); @@ -80,7 +80,7 @@ describe('Login', () => { fixture.detectChanges(); // rend aussi le sous-bloc @if (retryAfterSeconds(); as seconds) expect(component.retryAfterSeconds()).toBe(30); - const errorEl = fixture.nativeElement.querySelector('.auth-error'); + const errorEl = fixture.nativeElement.querySelector('.ev-alert'); expect(errorEl?.textContent).toContain('30s'); }); @@ -90,7 +90,7 @@ describe('Login', () => { const button = fixture.nativeElement.querySelector('button[type="submit"]'); expect(button.disabled).toBe(true); - expect(fixture.nativeElement.querySelector('.auth-error')).toBeNull(); + expect(fixture.nativeElement.querySelector('.ev-alert')).toBeNull(); }); it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => { diff --git a/apps/frontend/src/app/features/auth/login/login.ts b/apps/frontend/src/app/features/auth/login/login.ts index 34b9ff2..ea8f62f 100644 --- a/apps/frontend/src/app/features/auth/login/login.ts +++ b/apps/frontend/src/app/features/auth/login/login.ts @@ -3,11 +3,14 @@ import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { HttpErrorResponse } from '@angular/common/http'; import { AuthService } from '../../../core/services/auth.service'; +import { Button } from '../../../shared/components/ui/button/button'; +import { Card } from '../../../shared/components/ui/card/card'; +import { Alert } from '../../../shared/components/ui/alert/alert'; @Component({ selector: 'app-login', standalone: true, - imports: [ReactiveFormsModule], + imports: [ReactiveFormsModule, Button, Card, Alert], templateUrl: './login.html', styleUrl: './login.scss', }) diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index 684b444..533f0e3 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -1,19 +1,22 @@
-
-

Vue d'ensemble

-

Consommation instantanée du parc

+
+ +
+

Vue d'ensemble

+

Consommation instantanée du parc

+
@if (error(); as message) { - + } @if (stats(); as s) {
-
+ Consommation vs capacité {{ s.total_consumption_kw | number: '1.0-1' }} / {{ s.total_capacity_kw | number }} kW -
+ -
+ Charge moyenne du parc {{ s.average_load_percent }} %
-
+ -
+ Sites suivis {{ s.total_sites }} -
+
@@ -50,8 +53,8 @@

Alertes actives

    @for (alert of alerts(); track alert.alert_id) { -
  • - {{ alert.severity }} +
  • + {{ alert.severity }} {{ alert.message }}
  • } diff --git a/apps/frontend/src/app/features/dashboard/dashboard.scss b/apps/frontend/src/app/features/dashboard/dashboard.scss index 75976e1..53c5364 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.scss +++ b/apps/frontend/src/app/features/dashboard/dashboard.scss @@ -1,23 +1,22 @@ :host { - --color-good: #2e7d32; - --color-partial: #f9a825; - --color-degraded: #ef6c00; - --color-critical: #c62828; - --color-bg-card: #ffffff; - --color-border: #e5e7eb; - --color-text-muted: #6b7280; - --radius: 10px; - display: block; - font-family: 'Segoe UI', system-ui, sans-serif; - color: #1f2937; + color: var(--color-text); padding: 2rem; max-width: 1100px; margin: 0 auto; } .dashboard__header { + display: flex; + align-items: flex-start; + justify-content: space-between; margin-bottom: 2rem; +} + +.dashboard__brand { + display: flex; + align-items: center; + gap: 0.85rem; h1 { margin: 0; @@ -26,6 +25,11 @@ } } +.dashboard__logo { + height: 40px; + width: auto; +} + .dashboard__subtitle { margin: 0.25rem 0 0; color: var(--color-text-muted); @@ -38,13 +42,8 @@ h2 { } .banner-error { + display: block; margin: 0 0 1.5rem; - padding: 0.75rem 1rem; - border: 1px solid var(--color-critical); - border-left-width: 4px; - border-radius: var(--radius); - background: #fdecea; - color: var(--color-critical); } .overview { @@ -55,14 +54,8 @@ h2 { } .card { - background: var(--color-bg-card); - border: 1px solid var(--color-border); - border-radius: var(--radius); padding: 1.25rem; - display: flex; - flex-direction: column; gap: 0.35rem; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); } .card--gauge { @@ -84,16 +77,16 @@ h2 { .progress-bar { height: 6px; - background: #e5e7eb; - border-radius: 999px; + background: var(--color-border-light); + border-radius: var(--radius-pill); overflow: hidden; margin-top: 0.25rem; } .progress-bar__fill { height: 100%; - background: #3b82f6; - border-radius: 999px; + background: var(--color-primary); + border-radius: var(--radius-pill); transition: width 0.3s ease; } @@ -115,59 +108,26 @@ h2 { align-items: center; gap: 0.75rem; padding: 0.7rem 1rem; - border-radius: var(--radius); - background: #fef2f2; - border: 1px solid #fecaca; -} - -.alert-item__badge { - font-size: 0.7rem; - font-weight: 700; - text-transform: uppercase; - padding: 0.2rem 0.55rem; - border-radius: 999px; - color: #fff; - background: var(--color-critical); - flex-shrink: 0; -} - -.alert-item--high .alert-item__badge { - background: var(--color-degraded); -} -.alert-item--medium .alert-item__badge { - background: var(--color-partial); -} -.alert-item--low .alert-item__badge { - background: var(--color-good); + border-radius: var(--radius-md); + background: var(--color-danger-bg); + border: 1px solid var(--color-danger-border); } .alert-item__message { font-size: 0.9rem; } -.dashboard__header { - display: flex; - align-items: flex-start; - justify-content: space-between; - margin-bottom: 2rem; - - h1 { - margin: 0; - font-size: 1.75rem; - font-weight: 700; - } -} .logout-button { padding: 0.5rem 1rem; - background: #ffffff; - border: 1px solid #d1d5db; - border-radius: 8px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); font-size: 0.85rem; font-weight: 600; - color: #374151; + color: var(--color-label); cursor: pointer; &:hover { - background: #f3f4f6; + background: var(--color-bg); } } diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index c6a6a56..28bc37e 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -9,16 +9,26 @@ import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load import { AlertsService } from '../../core/services/alerts.service'; import { AuthService } from '../../core/services/auth.service'; import { StatsSummary } from '../../shared/models/stats.model'; -import { Alert } from '../../shared/models/alert.model'; +import { Alert, AlertSeverity } from '../../shared/models/alert.model'; +import { Card } from '../../shared/components/ui/card/card'; +import { Alert as EvAlert } from '../../shared/components/ui/alert/alert'; +import { Badge, BadgeTone } from '../../shared/components/ui/badge/badge'; const REFRESH_INTERVAL_MS = 10000; const UNAVAILABLE_MESSAGE = 'Données indisponibles, les valeurs affichées datent du dernier relevé.'; +const TON_PAR_SEVERITE: Record = { + low: 'success', + medium: 'warning', + high: 'danger', + critical: 'danger', +}; + @Component({ selector: 'app-dashboard', standalone: true, - imports: [DecimalPipe, ConsumptionGauge, SiteLoadChart], + imports: [DecimalPipe, ConsumptionGauge, SiteLoadChart, Card, EvAlert, Badge], templateUrl: './dashboard.html', styleUrl: './dashboard.scss', }) @@ -54,6 +64,10 @@ export class Dashboard implements OnInit { }); } + badgeToneForSeverity(severity: AlertSeverity): BadgeTone { + return TON_PAR_SEVERITE[severity]; + } + onLogout(): void { this.auth.logout().subscribe({ next: () => this.router.navigate(['/login']), diff --git a/apps/frontend/src/app/shared/components/ui/alert/alert.html b/apps/frontend/src/app/shared/components/ui/alert/alert.html new file mode 100644 index 0000000..6dbc743 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/alert/alert.html @@ -0,0 +1 @@ + diff --git a/apps/frontend/src/app/shared/components/ui/alert/alert.scss b/apps/frontend/src/app/shared/components/ui/alert/alert.scss new file mode 100644 index 0000000..667f779 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/alert/alert.scss @@ -0,0 +1,27 @@ +:host { + display: block; + margin: 0; + padding: 0.75rem 1rem; + border-radius: var(--radius-sm); + border: 1px solid transparent; + font-size: 0.85rem; + line-height: 1.4; +} + +:host.ev-alert--success { + background: var(--color-success-bg); + border-color: var(--color-success); + color: var(--color-success); +} + +:host.ev-alert--warning { + background: var(--color-warning-bg); + border-color: var(--color-warning); + color: #92400e; +} + +:host.ev-alert--danger { + background: var(--color-danger-bg); + border-color: var(--color-danger-border); + color: var(--color-danger); +} diff --git a/apps/frontend/src/app/shared/components/ui/alert/alert.spec.ts b/apps/frontend/src/app/shared/components/ui/alert/alert.spec.ts new file mode 100644 index 0000000..cea6bd7 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/alert/alert.spec.ts @@ -0,0 +1,30 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Alert } from './alert'; + +@Component({ + standalone: true, + imports: [Alert], + template: `C'est fait`, +}) +class AlertHost {} + +describe('Alert', () => { + it('applique la classe danger par défaut', async () => { + await TestBed.configureTestingModule({ imports: [Alert] }).compileComponents(); + const fixture = TestBed.createComponent(Alert); + fixture.detectChanges(); + + expect(fixture.nativeElement.classList).toContain('ev-alert--danger'); + }); + + it('applique la sévérité demandée et projette le contenu', async () => { + await TestBed.configureTestingModule({ imports: [AlertHost] }).compileComponents(); + const fixture = TestBed.createComponent(AlertHost); + fixture.detectChanges(); + + const el = fixture.nativeElement.querySelector('.ev-alert'); + expect(el.classList).toContain('ev-alert--success'); + expect(el.textContent).toContain("C'est fait"); + }); +}); diff --git a/apps/frontend/src/app/shared/components/ui/alert/alert.ts b/apps/frontend/src/app/shared/components/ui/alert/alert.ts new file mode 100644 index 0000000..45e030f --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/alert/alert.ts @@ -0,0 +1,21 @@ +import { Component, HostBinding, input } from '@angular/core'; + +export type AlertSeverity = 'success' | 'warning' | 'danger'; + +@Component({ + selector: 'ev-alert', + standalone: true, + templateUrl: './alert.html', + styleUrl: './alert.scss', +}) +export class Alert { + severity = input('danger'); + + @HostBinding('class') + get hostClass(): string { + return `ev-alert ev-alert--${this.severity()}`; + } + + @HostBinding('attr.role') + readonly role = 'alert'; +} diff --git a/apps/frontend/src/app/shared/components/ui/badge/badge.html b/apps/frontend/src/app/shared/components/ui/badge/badge.html new file mode 100644 index 0000000..9dd0cd5 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/badge/badge.html @@ -0,0 +1,3 @@ + + + diff --git a/apps/frontend/src/app/shared/components/ui/badge/badge.scss b/apps/frontend/src/app/shared/components/ui/badge/badge.scss new file mode 100644 index 0000000..212281f --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/badge/badge.scss @@ -0,0 +1,27 @@ +.ev-badge { + display: inline-block; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.02em; + padding: 0.2rem 0.55rem; + border-radius: var(--radius-pill); + color: #fff; + flex-shrink: 0; +} + +.ev-badge--success { + background: var(--color-success); +} + +.ev-badge--warning { + background: var(--color-warning); +} + +.ev-badge--danger { + background: var(--color-danger); +} + +.ev-badge--neutral { + background: var(--color-text-muted); +} diff --git a/apps/frontend/src/app/shared/components/ui/badge/badge.spec.ts b/apps/frontend/src/app/shared/components/ui/badge/badge.spec.ts new file mode 100644 index 0000000..09e1a25 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/badge/badge.spec.ts @@ -0,0 +1,30 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Badge } from './badge'; + +@Component({ + standalone: true, + imports: [Badge], + template: `critique`, +}) +class BadgeHost {} + +describe('Badge', () => { + it('applique le ton neutral par défaut', async () => { + await TestBed.configureTestingModule({ imports: [Badge] }).compileComponents(); + const fixture = TestBed.createComponent(Badge); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.ev-badge').classList).toContain('ev-badge--neutral'); + }); + + it('applique le ton demandé et projette le contenu', async () => { + await TestBed.configureTestingModule({ imports: [BadgeHost] }).compileComponents(); + const fixture = TestBed.createComponent(BadgeHost); + fixture.detectChanges(); + + const el = fixture.nativeElement.querySelector('.ev-badge'); + expect(el.classList).toContain('ev-badge--danger'); + expect(el.textContent).toContain('critique'); + }); +}); diff --git a/apps/frontend/src/app/shared/components/ui/badge/badge.ts b/apps/frontend/src/app/shared/components/ui/badge/badge.ts new file mode 100644 index 0000000..e9cc0ca --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/badge/badge.ts @@ -0,0 +1,13 @@ +import { Component, input } from '@angular/core'; + +export type BadgeTone = 'success' | 'warning' | 'danger' | 'neutral'; + +@Component({ + selector: 'ev-badge', + standalone: true, + templateUrl: './badge.html', + styleUrl: './badge.scss', +}) +export class Badge { + tone = input('neutral'); +} diff --git a/apps/frontend/src/app/shared/components/ui/button/button.html b/apps/frontend/src/app/shared/components/ui/button/button.html new file mode 100644 index 0000000..08ab099 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/button/button.html @@ -0,0 +1,3 @@ + diff --git a/apps/frontend/src/app/shared/components/ui/button/button.scss b/apps/frontend/src/app/shared/components/ui/button/button.scss new file mode 100644 index 0000000..0f33524 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/button/button.scss @@ -0,0 +1,51 @@ +.ev-button { + width: 100%; + padding: 0.7rem; + border: none; + border-radius: var(--radius-sm); + font-size: 0.95rem; + font-weight: 600; + font-family: var(--font-family); + cursor: pointer; + + &:disabled { + cursor: not-allowed; + opacity: 0.7; + } +} + +.ev-button--primary { + background: var(--color-primary); + color: #fff; + + &:disabled { + background: var(--color-disabled); + } + + &:not(:disabled):hover { + background: var(--color-primary-hover); + } +} + +.ev-button--secondary { + background: var(--color-surface); + border: 1px solid var(--color-border); + color: var(--color-label); + + &:not(:disabled):hover { + background: var(--color-bg); + } +} + +.ev-button--danger { + background: var(--color-danger); + color: #fff; + + &:disabled { + background: var(--color-disabled); + } + + &:not(:disabled):hover { + background: #b91c1c; + } +} diff --git a/apps/frontend/src/app/shared/components/ui/button/button.spec.ts b/apps/frontend/src/app/shared/components/ui/button/button.spec.ts new file mode 100644 index 0000000..8362cdc --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/button/button.spec.ts @@ -0,0 +1,50 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Button } from './button'; + +@Component({ + standalone: true, + imports: [Button], + template: `Valider`, +}) +class ButtonHost {} + +describe('Button', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [Button] }).compileComponents(); + }); + + it('applique la classe de la variante primary par défaut', () => { + const fixture = TestBed.createComponent(Button); + fixture.detectChanges(); + + const button = fixture.nativeElement.querySelector('button'); + expect(button.classList).toContain('ev-button--primary'); + }); + + it('applique la classe de la variante demandée', () => { + const fixture = TestBed.createComponent(Button); + fixture.componentRef.setInput('variant', 'danger'); + fixture.detectChanges(); + + const button = fixture.nativeElement.querySelector('button'); + expect(button.classList).toContain('ev-button--danger'); + }); + + it('désactive le bouton natif quand disabled est vrai', () => { + const fixture = TestBed.createComponent(Button); + fixture.componentRef.setInput('disabled', true); + fixture.detectChanges(); + + const button = fixture.nativeElement.querySelector('button'); + expect(button.disabled).toBe(true); + }); + + it('projette le contenu', async () => { + await TestBed.configureTestingModule({ imports: [ButtonHost] }).compileComponents(); + const fixture = TestBed.createComponent(ButtonHost); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('button').textContent).toContain('Valider'); + }); +}); diff --git a/apps/frontend/src/app/shared/components/ui/button/button.ts b/apps/frontend/src/app/shared/components/ui/button/button.ts new file mode 100644 index 0000000..c9d6b11 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/button/button.ts @@ -0,0 +1,15 @@ +import { Component, input } from '@angular/core'; + +export type ButtonVariant = 'primary' | 'secondary' | 'danger'; + +@Component({ + selector: 'ev-button', + standalone: true, + templateUrl: './button.html', + styleUrl: './button.scss', +}) +export class Button { + variant = input('primary'); + type = input<'button' | 'submit'>('button'); + disabled = input(false); +} diff --git a/apps/frontend/src/app/shared/components/ui/card/card.html b/apps/frontend/src/app/shared/components/ui/card/card.html new file mode 100644 index 0000000..6dbc743 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/card/card.html @@ -0,0 +1 @@ + diff --git a/apps/frontend/src/app/shared/components/ui/card/card.scss b/apps/frontend/src/app/shared/components/ui/card/card.scss new file mode 100644 index 0000000..09307fb --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/card/card.scss @@ -0,0 +1,10 @@ +:host { + background: var(--color-surface); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); + padding: var(--space-5); + box-shadow: var(--shadow-card); + display: flex; + flex-direction: column; + box-sizing: border-box; +} diff --git a/apps/frontend/src/app/shared/components/ui/card/card.spec.ts b/apps/frontend/src/app/shared/components/ui/card/card.spec.ts new file mode 100644 index 0000000..cea71d9 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/card/card.spec.ts @@ -0,0 +1,22 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Card } from './card'; + +@Component({ + standalone: true, + imports: [Card], + template: `

    Contenu

    `, +}) +class CardHost {} + +describe('Card', () => { + it('projette son contenu', async () => { + await TestBed.configureTestingModule({ imports: [CardHost] }).compileComponents(); + const fixture = TestBed.createComponent(CardHost); + fixture.detectChanges(); + + const card = fixture.nativeElement.querySelector('ev-card'); + expect(card).toBeTruthy(); + expect(card.textContent).toContain('Contenu'); + }); +}); diff --git a/apps/frontend/src/app/shared/components/ui/card/card.ts b/apps/frontend/src/app/shared/components/ui/card/card.ts new file mode 100644 index 0000000..50c496c --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/card/card.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ev-card', + standalone: true, + templateUrl: './card.html', + styleUrl: './card.scss', +}) +export class Card {} diff --git a/apps/frontend/src/styles.scss b/apps/frontend/src/styles.scss index 90d4ee0..0d45801 100644 --- a/apps/frontend/src/styles.scss +++ b/apps/frontend/src/styles.scss @@ -1 +1,8 @@ -/* You can add global styles to this file, and also import other style files */ +@use 'styles/tokens'; +@use 'styles/forms'; + +body { + margin: 0; + font-family: var(--font-family); + color: var(--color-text); +} diff --git a/apps/frontend/src/styles/_forms.scss b/apps/frontend/src/styles/_forms.scss new file mode 100644 index 0000000..fc35ad4 --- /dev/null +++ b/apps/frontend/src/styles/_forms.scss @@ -0,0 +1,37 @@ +.form-label { + display: block; + font-size: 0.85rem; + font-weight: 600; + color: var(--color-label); + margin-bottom: var(--space-1); + margin-top: var(--space-3); +} + +.form-input { + width: 100%; + padding: var(--space-2) 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: 0.95rem; + font-family: var(--font-family); + box-sizing: border-box; + + &:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.15); + } +} + +.form-hint { + display: block; + font-size: 0.75rem; + color: var(--color-disabled); + margin-top: 0.25rem; +} + +.form-error { + margin: var(--space-2) 0 0; + color: var(--color-danger); + font-size: 0.85rem; +} diff --git a/apps/frontend/src/styles/_tokens.scss b/apps/frontend/src/styles/_tokens.scss new file mode 100644 index 0000000..4b85e61 --- /dev/null +++ b/apps/frontend/src/styles/_tokens.scss @@ -0,0 +1,39 @@ +:root { + // Marque (dérivé du logo : vert feuille/éclair, halo) + --color-primary: #16a34a; + --color-primary-hover: #15803d; + --color-primary-light: #dcfce7; + + // Neutres (texte, bordures, fonds) + --color-text: #1f2937; + --color-text-muted: #6b7280; + --color-label: #374151; + --color-border: #d1d5db; + --color-border-light: #e5e7eb; + --color-bg: #f3f4f6; + --color-surface: #ffffff; + --color-disabled: #9ca3af; + + // Sémantique (statuts, alertes) + --color-success: #16a34a; + --color-success-bg: #dcfce7; + --color-warning: #f9a825; + --color-warning-bg: #fef9e7; + --color-danger: #dc2626; + --color-danger-bg: #fef2f2; + --color-danger-border: #fecaca; + + // Typo, rayons, ombre + --font-family: 'Segoe UI', system-ui, sans-serif; + --radius-sm: 8px; + --radius-md: 12px; + --radius-pill: 999px; + --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06); + + // Espacements + --space-1: 0.35rem; + --space-2: 0.6rem; + --space-3: 1rem; + --space-4: 1.5rem; + --space-5: 2.5rem; +} diff --git a/docs/architecture/32-design-systeme-frontend.md b/docs/architecture/32-design-systeme-frontend.md new file mode 100644 index 0000000..d2324e9 --- /dev/null +++ b/docs/architecture/32-design-systeme-frontend.md @@ -0,0 +1,76 @@ +# Design système frontend + +Ce que toute nouvelle page ou tout nouveau composant Angular doit réutiliser, plutôt que +redéfinir ses propres couleurs, rayons ou espacements en dur. Contexte : issue +[#91](https://github.com/ineszang/ProjetPiscine_EnerVision/issues/91), née d'une incohérence +visuelle accumulée page après page (aucun jeton partagé n'existait avant ce chantier). + +## Tokens + +Déclarés en CSS custom properties dans `apps/frontend/src/styles/_tokens.scss`, importés une +seule fois dans `src/styles.scss`. Disponibles partout sans import supplémentaire. + +| Variable | Rôle | +|---|---| +| `--color-primary`, `--color-primary-hover`, `--color-primary-light` | Couleur de marque (vert, dérivé du logo), actions principales | +| `--color-text`, `--color-text-muted`, `--color-label` | Hiérarchie de texte (titres, texte secondaire, labels de formulaire) | +| `--color-border`, `--color-border-light` | Bordures d'inputs et de cartes | +| `--color-bg`, `--color-surface` | Fond de page vs fond des cartes/panneaux | +| `--color-disabled` | Éléments désactivés | +| `--color-success` / `-bg`, `--color-warning` / `-bg`, `--color-danger` / `-bg` / `-border` | États sémantiques (alertes, badges) | +| `--font-family` | Police unique de l'application | +| `--radius-sm`, `--radius-md`, `--radius-pill` | Rayons de bordure (input/bouton, carte, pastille) | +| `--shadow-card` | Ombre portée des cartes | +| `--space-1` à `--space-5` | Échelle d'espacement (0.35rem à 2.5rem) | + +Les classes de formulaire partagées (`.form-label`, `.form-input`, `.form-hint`, `.form-error`) +sont dans `apps/frontend/src/styles/_forms.scss`, importées globalement de la même façon. Elles +s'appliquent directement à des `