Merge remote-tracking branch 'origin/dev' into feat/get-readings

This commit is contained in:
Dorian
2026-09-17 11:58:27 +02:00
40 changed files with 2069 additions and 13 deletions
+27
View File
@@ -0,0 +1,27 @@
# Dépendances (réinstallées dans l'image)
node_modules/
vendor/
__pycache__/
*.pyc
# Git et IDE
.git/
.gitignore
.vscode/
.idea/
*.swp
# Fichiers de build locaux
dist/
build/
*.log
# Secrets et config locale (CRITIQUE : risque d'exfiltration)
.env
.env.local
*.pem
*.key
secrets/
.npmrc
.pypirc
kubeconfig
+77
View File
@@ -0,0 +1,77 @@
name: Frontend
# Pipeline à choix multiple
on:
# workflow_dispatch -> lancement manuel des jobs
workflow_dispatch:
inputs:
job_choice:
required: true
description: "Choix du job"
type: choice
default: all
options:
- build
- sonarqube
- test
- all # lancer tous les jobs
push:
paths:
- "apps/frontend/**"
- ".github/workflows/frontend.yml"
pull_request:
paths:
- "apps/frontend/**"
- ".github/workflows/frontend.yml"
# Ordre de lancement des jobs
# build -> test -> sonarqube -> deploy
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
- run: npm ci
working-directory: apps/frontend
- run: npm run build
working-directory: apps/frontend
test:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
- run: npm ci
working-directory: apps/frontend
- run: npm test -- --watch=false
working-directory: apps/frontend
sonarqube:
needs: [build, test]
name: SonarQube
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@7006c4492b2e0ee0f816d36501671557c97f5995 # v8.1.0
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
# deploy:
# runs-on: ubuntu-latest
# steps:
# - run: echo "DEPLOY job is running"
+8
View File
@@ -33,6 +33,7 @@ from app.services.alert import AlertService
from app.services.auth import AuthService, LoginPolicy
from app.services.reading import ReadingService
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
@@ -175,6 +176,13 @@ def get_reading_service(session: SessionDep) -> ReadingService:
ReadingServiceDep = Annotated[ReadingService, Depends(get_reading_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,
+4
View File
@@ -79,6 +79,10 @@ TAGS: Final[list[dict[str, Any]]] = [
"rôle `lecteur`."
),
},
{
"name": "sensors",
"description": "État de santé des capteurs par site. Réservé au rôle `admin`.",
},
]
cookie_de_rafraichissement = APIKeyCookie(
@@ -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)
+4
View File
@@ -7,6 +7,7 @@ from app.api.v1.endpoints import (
health,
readings,
recommendations,
sensors,
sites,
stats,
users,
@@ -30,3 +31,6 @@ api_router.include_router(stats.router, prefix="/stats", tags=["stats"], respons
api_router.include_router(
readings.router, prefix="/readings", tags=["readings"], responses=REPONSES_LECTEUR
)
api_router.include_router(
sensors.router, prefix="/sensors", tags=["sensors"], responses=REPONSES_ADMIN
)
+42
View File
@@ -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]
+137
View File
@@ -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
)
+173
View File
@@ -1382,6 +1382,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": {
@@ -1946,6 +2002,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": {
@@ -2005,6 +2114,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": {
@@ -2337,6 +2506,10 @@
{
"name": "readings",
"description": "Historique des lectures de consommation. Fenêtre temporelle plafonnée à 90 jours, 24 dernières heures par défaut si `start`/`end` sont omis. Accessible à partir du rôle `lecteur`."
},
{
"name": "sensors",
"description": "État de santé des capteurs par site. Réservé au rôle `admin`."
}
]
}
+1
View File
@@ -36,6 +36,7 @@ ROUTES_A_ROLE = {
("GET", "/api/v1/recommendations/{recommendation_id}"),
("GET", "/api/v1/stats/summary"),
("GET", "/api/v1/readings"),
("GET", "/api/v1/sensors/status"),
}
+91
View File
@@ -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
+224
View File
@@ -0,0 +1,224 @@
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"
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"
+47
View File
@@ -0,0 +1,47 @@
# ==================
# Étape 1 : Build
# ==================
# Image pour frontend
FROM node:24-alpine3.22 AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
# Installation des dépendances du projet avec npm
RUN npm ci
# Copie du code source vers le conteneur
COPY . .
# Build
RUN npm run build
# ==================
# Étape 2 : Runner
# ==================
FROM dhi.io/nginx:1.28.0-alpine3.21-dev AS runner
# Copie de la configuration de nginx
COPY --chown=root:root --chmod=755 nginx.conf /etc/nginx/nginx.conf
# Copy the static build output from the build stage to Nginx's default HTML serving directory
COPY --chown=root:root --chmod=755 --from=builder /app/dist/*/browser /usr/share/nginx/html
# Create necessary directories with proper permissions for nginx
RUN mkdir -p /var/log/nginx /var/cache/nginx && \
chown -R nginx:nginx /var/log/nginx /var/cache/nginx /usr/share/nginx/html
# Use a non-root user for security best practices
USER nginx
# Frontend : port 3000
# Backend : port 8000
EXPOSE 3000
# Start Nginx directly with custom config
ENTRYPOINT ["nginx", "-c", "/etc/nginx/nginx.conf"]
CMD ["-g", "daemon off;"]
+1
View File
@@ -81,6 +81,7 @@
"builder": "@angular/build:unit-test",
"options": {
"coverage": true,
"isolate": true,
"coverageReporters": [
"text-summary",
"lcov",
+32
View File
@@ -0,0 +1,32 @@
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /tmp/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server {
listen 3000;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location ~ /\. {
deny all;
}
}
}
+10 -2
View File
@@ -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))));
}),
],
};
+5 -1
View File
@@ -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),
},
];
@@ -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<typeof vi.fn>; principal: ReturnType<typeof vi.fn> };
let routerMock: { navigate: ReturnType<typeof vi.fn> };
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);
});
});
@@ -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;
};
@@ -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<typeof vi.fn>; clearSession: ReturnType<typeof vi.fn>; refreshShared: ReturnType<typeof vi.fn> };
let routerMock: { navigate: ReturnType<typeof vi.fn> };
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();
});
});
@@ -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<TokenResponse>).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);
})
);
};
@@ -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);
});
});
@@ -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<string | null>(null);
private principalSignal = signal<Principal | null>(null);
readonly principal = this.principalSignal.asReadonly();
readonly isAuthenticated = computed(() => this.principalSignal() !== null);
private rotation$?: Observable<TokenResponse>;
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<TokenResponse> {
return this.http
.post<TokenResponse>(`${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<TokenResponse> {
this.rotation$ ??= this.http
.post<TokenResponse>(`${environment.apiUrl}/auth/refresh`, {}, { withCredentials: true })
.pipe(
tap((response) => this.setSession(response)),
finalize(() => (this.rotation$ = undefined)),
shareReplay(1)
);
return this.rotation$;
}
logout(): Observable<void> {
return this.http
.post<void>(`${environment.apiUrl}/auth/logout`, {}, { withCredentials: true })
.pipe(tap(() => this.clearSession()));
}
changePassword(payload: PasswordChangeRequest): Observable<TokenResponse> {
return this.http
.post<TokenResponse>(`${environment.apiUrl}/auth/password`, payload, { withCredentials: true })
.pipe(tap((response) => this.setSession(response)));
}
me(): Observable<Principal> {
return this.http.get<Principal>(`${environment.apiUrl}/auth/me`);
}
}
@@ -0,0 +1,31 @@
<div class="auth-page">
<form class="auth-card" [formGroup]="form" (ngSubmit)="onSubmit()">
<h1>Nouveau mot de passe</h1>
<p class="auth-subtitle">Votre mot de passe est provisoire, vous devez le modifier avant de continuer</p>
<label for="current_password">Mot de passe actuel</label>
<input
id="current_password"
type="password"
formControlName="current_password"
autocomplete="current-password"
/>
<label for="new_password">Nouveau mot de passe</label>
<input
id="new_password"
type="password"
formControlName="new_password"
autocomplete="new-password"
/>
<span class="auth-hint">12 à 128 caractères</span>
@if (errorMessage()) {
<p class="auth-error">{{ errorMessage() }}</p>
}
<button type="submit" [disabled]="form.invalid || isLoading()">
{{ isLoading() ? 'Modification...' : 'Valider' }}
</button>
</form>
</div>
@@ -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;
}
@@ -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<typeof vi.fn> };
let routerMock: { navigate: ReturnType<typeof vi.fn> };
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',
});
});
});
@@ -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<string | null>(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).');
},
});
}
}
@@ -0,0 +1,36 @@
<div class="auth-page">
<form class="auth-card" [formGroup]="form" (ngSubmit)="onSubmit()">
<h1>Connexion</h1>
<p class="auth-subtitle">Accédez à votre espace EnerVision</p>
<label for="email">Email</label>
<input
id="email"
type="email"
formControlName="email"
autocomplete="username"
placeholder="vous@enervision.fr"
/>
<label for="password">Mot de passe</label>
<input
id="password"
type="password"
formControlName="password"
autocomplete="current-password"
/>
@if (errorMessage()) {
<p class="auth-error">
{{ errorMessage() }}
@if (retryAfterSeconds(); as seconds) {
(réessayez dans {{ seconds }}s)
}
</p>
}
<button type="submit" [disabled]="form.invalid || isLoading()">
{{ isLoading() ? 'Connexion...' : 'Se connecter' }}
</button>
</form>
</div>
@@ -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;
}
@@ -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<typeof vi.fn> };
let routerMock: { navigate: ReturnType<typeof vi.fn> };
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' });
});
});
@@ -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<string | null>(null);
retryAfterSeconds = signal<number | null>(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.');
},
});
}
}
@@ -1,7 +1,10 @@
<div class="dashboard">
<header class="dashboard__header">
<h1>Vue d'ensemble</h1>
<p class="dashboard__subtitle">Consommation instantanée du parc</p>
<div>
<h1>Vue d'ensemble</h1>
<p class="dashboard__subtitle">Consommation instantanée du parc</p>
</div>
<button type="button" class="logout-button" (click)="onLogout()">Déconnexion</button>
</header>
@if (error(); as message) {
@@ -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;
}
}
@@ -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']);
});
});
@@ -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<StatsSummary | null>(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<never> {
this.error.set(UNAVAILABLE_MESSAGE);
return EMPTY;
@@ -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;
}
@@ -1,5 +1,5 @@
export const environment = {
production: true,
apiUrl: 'http://localhost:8000/api/v1',
apiUrl: '/api/v1',
useMockFixtures: false,
};
+7
View File
@@ -43,5 +43,12 @@ services:
- "${BACKEND_PORT:-8000}:8000"
restart: unless-stopped
frontend:
build: ./apps/frontend
ports:
- "${FRONTEND_PORT:-3000}:80"
restart: unless-stopped
volumes:
pgdata:
+8 -7
View File
@@ -12,10 +12,10 @@ Les quatre couches existent désormais, portées par l'authentification.
```mermaid
flowchart TB
ep["endpoints<br/>health, auth, users, sites,<br/>recommendations, stats"]
ep["endpoints<br/>health, auth, users, sites, alerts,<br/>recommendations, stats, sensors"]
sc["schemas<br/>Pydantic"]
sv["services<br/>AuthService, UserService,<br/>SiteService, RecommendationService,<br/>StatsService"]
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site, recommendation, reading"]
sv["services<br/>AuthService, UserService,<br/>SiteService, AlertService, RecommendationService,<br/>StatsService, SensorService"]
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site, alert, recommendation, reading"]
md["models<br/>10 tables"]
db[("PostgreSQL")]
@@ -147,6 +147,7 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke
| 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/readings` | Historique des lectures, filtrable par `site_id`, fenêtre `start`/`end` (24h par défaut, 90 jours maximum) et paginé par `limit`/`offset`. `lecteur` | 400, 401, 403, 422, 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` | |
@@ -168,9 +169,9 @@ testable par la fixture `fake_session` au niveau endpoint sans base réelle. `GE
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
/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).
`GET /readings` reprend le même gabarit mais s'en écarte sur un point : `reading` est l'hypertable,
@@ -261,7 +262,7 @@ 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`/
`readings` (`dataset`, `prediction`) :
`readings`/`sensors` (`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
+14
View File
@@ -0,0 +1,14 @@
sonar.projectKey=ProjetPiscine_EnerVision
sonar.organization=groupe3-ener-vision
# This is the name and version displayed in the SonarCloud UI.
#sonar.projectName=ProjetPiscine_EnerVision
#sonar.projectVersion=1.0
# Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows.
#sonar.sources=.
# Encoding of the source code. Default is default system encoding
#sonar.sourceEncoding=UTF-8