From a197af91ff8b781bbefb273cc0e86242547fc573 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 23 Sep 2026 09:25:37 +0200 Subject: [PATCH] =?UTF-8?q?test(e2e):=20joue=20les=20parcours=20utilisateu?= =?UTF-8?q?r=20avec=20Playwright=20contre=20la=20stack=20d=C3=A9ploy=C3=A9?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aucun parcours n'était vérifié de bout en bout : les tests unitaires du frontend simulent l'API, ceux du backend n'ouvrent jamais de navigateur. tests/e2e, paquet npm autonome, 18 parcours dans Chromium : - authentification, premier login, réinitialisation du mot de passe par Mailpit ; - rôles : lecteur, opérateur, administrateur ; - sites, recommandations, fil d'alertes. e2e.yml, appelé par ci.yml, démarre db, mailpit, backend, frontend et proxy avec docker-compose.prod.yml sur https://localhost, sème demo.sql, crée les comptes et joue la suite. Il construit au passage les images backend et frontend, que la CI ne construisait jamais. Un seul worker et une session par fichier : la zone auth de nginx admet 30 connexions par minute, et rejouer un cookie de refresh dans un second contexte révoque toute la session. make e2e-install, e2e-prepare et e2e pour le poste ; make help affiche désormais les cibles dont le nom contient un chiffre. Closes #46 --- .github/dependabot.yml | 11 ++ .github/workflows/ci.yml | 23 +++- .github/workflows/e2e.yml | 102 ++++++++++++++++++ .gitignore | 6 ++ Makefile | 22 +++- .../sensor-status/sensor-status.html | 2 +- scripts/comptes-test.sh | 24 ++--- tests/e2e/.prettierrc | 4 + tests/e2e/README.md | 61 +++++++++++ tests/e2e/package-lock.json | 93 ++++++++++++++++ tests/e2e/package.json | 15 +++ tests/e2e/playwright.config.ts | 29 +++++ tests/e2e/specs/alertes.spec.ts | 42 ++++++++ tests/e2e/specs/authentification.spec.ts | 38 +++++++ tests/e2e/specs/mot-de-passe-oublie.spec.ts | 30 ++++++ tests/e2e/specs/premiere-connexion.spec.ts | 21 ++++ tests/e2e/specs/recommandations.spec.ts | 32 ++++++ tests/e2e/specs/roles.spec.ts | 57 ++++++++++ tests/e2e/specs/sites.spec.ts | 38 +++++++ tests/e2e/support/api.ts | 36 +++++++ tests/e2e/support/comptes.ts | 22 ++++ tests/e2e/support/environnement.ts | 3 + tests/e2e/support/mailpit.ts | 37 +++++++ tests/e2e/support/session.ts | 30 ++++++ tests/e2e/tsconfig.json | 13 +++ 25 files changed, 774 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 tests/e2e/.prettierrc create mode 100644 tests/e2e/README.md create mode 100644 tests/e2e/package-lock.json create mode 100644 tests/e2e/package.json create mode 100644 tests/e2e/playwright.config.ts create mode 100644 tests/e2e/specs/alertes.spec.ts create mode 100644 tests/e2e/specs/authentification.spec.ts create mode 100644 tests/e2e/specs/mot-de-passe-oublie.spec.ts create mode 100644 tests/e2e/specs/premiere-connexion.spec.ts create mode 100644 tests/e2e/specs/recommandations.spec.ts create mode 100644 tests/e2e/specs/roles.spec.ts create mode 100644 tests/e2e/specs/sites.spec.ts create mode 100644 tests/e2e/support/api.ts create mode 100644 tests/e2e/support/comptes.ts create mode 100644 tests/e2e/support/environnement.ts create mode 100644 tests/e2e/support/mailpit.ts create mode 100644 tests/e2e/support/session.ts create mode 100644 tests/e2e/tsconfig.json diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a02f919..1cf5cb9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -20,6 +20,17 @@ updates: - dependency-name: "@vitest/coverage-v8" update-types: ["version-update:semver-major"] + # Tests de bout en bout, paquet npm distinct du frontend + - package-ecosystem: "npm" + directory: "/tests/e2e" + schedule: + interval: "weekly" + open-pull-requests-limit: 2 + groups: + e2e-dependencies: + patterns: + - "*" + # Backend — uv (lit pyproject.toml / uv.lock) - package-ecosystem: "uv" directory: "/apps/backend" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f81574..abac168 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,7 @@ jobs: terraform: ${{ github.event_name != 'pull_request' || steps.filtre.outputs.ci == 'true' || steps.filtre.outputs.terraform == 'true' }} compose: ${{ github.event_name != 'pull_request' || steps.filtre.outputs.ci == 'true' || steps.filtre.outputs.compose == 'true' }} workflows: ${{ github.event_name != 'pull_request' || steps.filtre.outputs.workflows == 'true' }} + e2e: ${{ github.event_name != 'pull_request' || steps.filtre.outputs.ci == 'true' || steps.filtre.outputs.e2e == 'true' }} sonar: ${{ github.event_name != 'pull_request' || steps.filtre.outputs.ci == 'true' || steps.filtre.outputs.sonar == 'true' }} steps: @@ -83,6 +84,20 @@ jobs: - ".github/workflows/infra.yml" workflows: - ".github/**" + e2e: + - "apps/frontend/**" + - "apps/backend/app/**" + - "apps/backend/alembic/**" + - "apps/backend/Dockerfile" + - "apps/backend/pyproject.toml" + - "apps/backend/uv.lock" + - "infra/proxy/**" + - "docker-compose*.yml" + - "db/**" + - "tests/**" + - "scripts/comptes-test.sh" + - "scripts/tls-selfsigned.sh" + - ".github/workflows/e2e.yml" sonar: - "apps/backend/**" - "apps/frontend/**" @@ -127,6 +142,12 @@ jobs: compose: ${{ needs.changes.outputs.compose == 'true' }} workflows: ${{ needs.changes.outputs.workflows == 'true' }} + e2e: + name: E2E + needs: changes + if: needs.changes.outputs.e2e == 'true' + uses: ./.github/workflows/e2e.yml + # Ni dependabot[bot] ni une PR de fork ne reçoivent SONAR_TOKEN : le scan échouerait sans rien # analyser. Tests et couverture restent joués par leurs jobs. sonar: @@ -177,7 +198,7 @@ jobs: ci-ok: name: CI ok - needs: [changes, backend, frontend, ml, airflow, infra, sonar] + needs: [changes, backend, frontend, ml, airflow, infra, e2e, sonar] if: always() runs-on: ubuntu-latest timeout-minutes: 5 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..805703d --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,102 @@ +name: E2E + +# Pourquoi : les parcours tournent contre la stack telle qu'elle est déployée, derrière le proxy +# TLS (cookie `__Secure-`, CSP, limitation de débit), pas contre `ng serve` - job parcours. Il +# construit aussi les images backend et frontend, que rien d'autre ne construit avant le +# déploiement (ADR 0015). +# Piège : pas d'Airflow ici. `up` nomme ses services : sans eux, la construction de l'image +# Airflow doublerait la durée du job sans rien tester de plus. + +on: + workflow_call: + +permissions: + contents: read + +jobs: + parcours: + name: Parcours Playwright + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + COMPOSE_FILE: docker-compose.yml:docker-compose.prod.yml + PUBLIC_HOST: localhost + E2E_BASE_URL: https://localhost + + steps: + - name: Récupère le dépôt + uses: actions/checkout@v7 + + - name: Prépare le .env de la stack + run: | + secret() { openssl rand -hex 32; } + sed -e "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(secret)|" \ + -e "s|^APP_SECRET_KEY=.*|APP_SECRET_KEY=$(secret)|" \ + -e "s|^PUBLIC_HOST=.*|PUBLIC_HOST=localhost|" \ + .env.example > .env + + - name: Génère le certificat de démonstration + run: ./scripts/tls-selfsigned.sh + + - name: Construit et démarre la stack derrière le proxy + run: docker compose up --detach --build --wait --wait-timeout 300 db mailpit backend frontend proxy + + - name: Applique les migrations + run: docker compose exec -T backend alembic upgrade head + + - name: Sème le jeu de démonstration + run: docker compose exec -T db psql -U enervision -d enervision -v ON_ERROR_STOP=1 < db/seeds/demo.sql + + - name: Crée les comptes de test + env: + BASE_URL: https://localhost + APP_CLI: docker compose exec -T backend python -m app.cli + COMPTES_FICHIER: ${{ runner.temp }}/comptes.json + run: ./scripts/comptes-test.sh + + - name: Installe Node + uses: actions/setup-node@v7 + with: + node-version: 26 + cache: npm + cache-dependency-path: tests/e2e/package-lock.json + + - name: Installe Playwright + working-directory: tests/e2e + run: npm ci + + - name: Restaure les navigateurs de Playwright + uses: actions/cache@v6 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('tests/e2e/package-lock.json') }} + + # `--with-deps` tourne même quand le cache a servi : il pose aussi les bibliothèques système. + - name: Installe Chromium + working-directory: tests/e2e + run: npx playwright install --with-deps chromium + + - name: Joue les parcours + working-directory: tests/e2e + env: + E2E_COMPTES: ${{ runner.temp }}/comptes.json + run: npx playwright test + + - name: Publie le rapport Playwright + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: playwright-report + path: | + tests/e2e/playwright-report/ + tests/e2e/test-results/ + if-no-files-found: ignore + retention-days: 14 + + - name: Journaux de la stack en cas d'échec + if: failure() + run: docker compose logs --tail=200 backend proxy frontend + + - name: Arrête la stack + if: always() + run: docker compose down --volumes diff --git a/.gitignore b/.gitignore index c15d9ae..d9dbdc3 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,12 @@ apps/frontend/.angular/ npm-debug.log* yarn-error.log* +# Tests de bout en bout et de charge : rapports générés et identifiants des comptes de test +playwright-report/ +blob-report/ +tests/e2e/.comptes.json +tests/load/results/ + # Terraform .terraform/ # .terraform.lock.hcl est versionne (pas ignore) pour figer les versions de provider entre contributeurs/CI diff --git a/Makefile b/Makefile index 9a270f8..64615cc 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,7 @@ BACKEND := apps/backend FRONTEND := apps/frontend ML := ml AIRFLOW := etl/airflow +E2E := tests/e2e COMPOSE_PROD := docker compose -f docker-compose.yml -f docker-compose.prod.yml # Piège : sans `export`, une valeur passée en ligne de commande n'atteindrait pas docker compose. @@ -34,6 +35,11 @@ PG_TEST_DB ?= enervision_test TEST_DATABASE_URL ?= postgresql+asyncpg://$(PG_USER):$(PG_PASSWORD)@localhost:$(PG_PORT)/$(PG_TEST_DB) ML_TEST_DATABASE_URL ?= postgresql+psycopg://$(PG_USER):$(PG_PASSWORD)@localhost:$(PG_PORT)/$(PG_TEST_DB) +# Piege : `e2e-prepare` ajoute trois sites `demo-*` et des comptes `test-*` a la base visee. Elle +# vise la base de `make dev` ; ne jamais la lancer contre la recette ou la prod. +E2E_COMPTES ?= $(CURDIR)/$(E2E)/.comptes.json +E2E_API ?= http://localhost:$(or $(strip $(call env-val,BACKEND_PORT)),8000) + # Le jeu historique s'arrete au 31/12/2024 : score et detection ancres a l'horloge reelle ne # verraient qu'un parc muet depuis des mois. Cf. `--now` de enervision_ml.score. DEMO_NOW ?= 2024-12-31T00:00:00Z @@ -47,10 +53,11 @@ DEMO_NOW ?= 2024-12-31T00:00:00Z migrate migrate-test bootstrap-admin services-up demo-data demo-data-force \ ml-lint ml-typecheck ml-test ml-check ml-train ml-score mlflow-up detect-alerts recommendations \ airflow-lint airflow-test airflow-check airflow-up airflow-down airflow-logs \ - tls-selfsigned tls-acme tls-renew stack-up stack-down stack-logs + tls-selfsigned tls-acme tls-renew stack-up stack-down stack-logs \ + e2e-install e2e-prepare e2e 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}' + @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' install: install-backend install-frontend install-ml install-airflow ## Installe les dépendances backend, frontend, ML et Airflow @@ -205,6 +212,17 @@ tls-renew: ## Renouvelle les certificats Let's Encrypt et recharge le proxy $(COMPOSE_PROD) --profile acme run --rm certbot renew --deploy-hook /deploy-hook.sh $(COMPOSE_PROD) exec proxy nginx -s reload +e2e-install: ## Installe Playwright et Chromium pour les tests de bout en bout + cd $(E2E) && npm ci && npx playwright install chromium + +e2e-prepare: ## Sème le jeu de démonstration et crée les comptes de test sur la base de `make dev` + docker compose exec -T db psql -U $(PG_USER) -d $(PG_DB) -v ON_ERROR_STOP=1 < db/seeds/demo.sql + cd $(BACKEND) && BASE_URL=$(E2E_API) COMPTES_FICHIER=$(E2E_COMPTES) ADMIN_SUPPLEMENTAIRE=1 \ + ../../scripts/comptes-test.sh + +e2e: ## Joue les parcours Playwright. E2E_BASE_URL= optionnel (défaut http://localhost:4200) + cd $(E2E) && E2E_COMPTES=$(E2E_COMPTES) npx playwright test + db-up: ## Démarre la base PostgreSQL TimescaleDB docker compose up -d db diff --git a/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.html b/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.html index 3497dd5..7548289 100644 --- a/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.html +++ b/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.html @@ -20,7 +20,7 @@ @if (data(); as d) {
@for (site of d.sites; track site.site_id) { - +
{{ site.site_name }} {{ site.overall }} diff --git a/scripts/comptes-test.sh b/scripts/comptes-test.sh index 793b8e0..742b6b2 100755 --- a/scripts/comptes-test.sh +++ b/scripts/comptes-test.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash # Contrainte : réservé à une base JETABLE (CI, e2e, charge sur le poste) - comptes-test.sh. Crée -# un administrateur par la CLI, puis un lecteur, un opérateur et un compte laissé sur son mot de -# passe temporaire, et écrit leurs identifiants en JSON dans $COMPTES_FICHIER. -# Piège : un compte neuf est en `must_change_password`, que toute route gardée refuse. Seul -# `premiere_connexion` garde ce statut : c'est le parcours du premier login de l'e2e. -# Piège : `create-admin` refuse un second administrateur actif. Sur une base qui en a déjà un -# (recette, poste peuplé), créer les comptes depuis l'interface d'administration. +# un administrateur par la CLI, puis un lecteur et un opérateur, et écrit leurs identifiants en +# JSON dans $COMPTES_FICHIER. +# Piège : un compte neuf est en `must_change_password`, que toute route gardée refuse. Chaque +# compte passe donc le changement de mot de passe avant d'être écrit dans le fichier. +# Piège : `create-admin` refuse un second administrateur actif. ADMIN_SUPPLEMENTAIRE=1 passe +# `--force`, pour la base du poste ; jamais en recette, où les comptes se créent par l'interface. # # BASE_URL vise l'API (http://localhost:8000 par défaut, ou le proxy en https). APP_CLI lance la # CLI du backend : depuis apps/backend par défaut, ou `docker compose ... exec -T backend python @@ -67,7 +67,9 @@ activer_compte() { EMAIL_ADMIN="test-admin-$SUFFIXE@enervision.fr" journal "création de l'administrateur $EMAIL_ADMIN" -if ! SORTIE="$("${CLI[@]}" create-admin --email "$EMAIL_ADMIN" --generate)"; then +OPTIONS_ADMIN=(--email "$EMAIL_ADMIN" --generate) +[[ "${ADMIN_SUPPLEMENTAIRE:-0}" == "1" ]] && OPTIONS_ADMIN+=(--force) +if ! SORTIE="$("${CLI[@]}" create-admin "${OPTIONS_ADMIN[@]}")"; then journal "la création de l'administrateur a échoué : $SORTIE" exit 1 fi @@ -80,24 +82,20 @@ JETON_ADMIN="$(changer_mot_de_passe "$JETON_ADMIN" "$MDP_TEMPORAIRE_ADMIN" "$MDP EMAIL_LECTEUR="test-lecteur-$SUFFIXE@enervision.fr" EMAIL_OPERATEUR="test-operateur-$SUFFIXE@enervision.fr" -EMAIL_PREMIERE="test-premiere-connexion-$SUFFIXE@enervision.fr" -journal "création du lecteur, de l'opérateur et du compte de première connexion" +journal "création du lecteur et de l'opérateur" TEMPORAIRE_LECTEUR="$(creer_compte "$JETON_ADMIN" "$EMAIL_LECTEUR" lecteur)" MDP_LECTEUR="$(activer_compte "$EMAIL_LECTEUR" "$TEMPORAIRE_LECTEUR")" TEMPORAIRE_OPERATEUR="$(creer_compte "$JETON_ADMIN" "$EMAIL_OPERATEUR" operateur)" MDP_OPERATEUR="$(activer_compte "$EMAIL_OPERATEUR" "$TEMPORAIRE_OPERATEUR")" -MDP_PREMIERE="$(creer_compte "$JETON_ADMIN" "$EMAIL_PREMIERE" lecteur)" umask 077 jq -n \ --arg ae "$EMAIL_ADMIN" --arg ap "$MDP_ADMIN" \ --arg le "$EMAIL_LECTEUR" --arg lp "$MDP_LECTEUR" \ --arg oe "$EMAIL_OPERATEUR" --arg op "$MDP_OPERATEUR" \ - --arg pe "$EMAIL_PREMIERE" --arg pp "$MDP_PREMIERE" \ '{ admin: {email: $ae, password: $ap}, lecteur: {email: $le, password: $lp}, - operateur: {email: $oe, password: $op}, - premiere_connexion: {email: $pe, password: $pp} + operateur: {email: $oe, password: $op} }' >"$COMPTES_FICHIER" journal "identifiants écrits dans $COMPTES_FICHIER" diff --git a/tests/e2e/.prettierrc b/tests/e2e/.prettierrc new file mode 100644 index 0000000..5ac85e2 --- /dev/null +++ b/tests/e2e/.prettierrc @@ -0,0 +1,4 @@ +{ + "printWidth": 100, + "singleQuote": true +} diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 0000000..e46b279 --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,61 @@ +# Tests de bout en bout (Playwright) + +Parcours utilisateur joués dans Chromium contre une stack qui tourne : frontend, API, base et, +en CI, le reverse proxy TLS. Issue #46, décisions dans l'ADR 0015. + +## Ce qui est couvert + +| Fichier | Parcours | +|---|---| +| `authentification.spec.ts` | Redirection sans session, identifiants refusés, connexion, session conservée au rechargement, déconnexion | +| `premiere-connexion.spec.ts` | Compte neuf : changement du mot de passe temporaire imposé avant le tableau de bord | +| `roles.spec.ts` | Lecteur et opérateur sans supervision ni génération, page admin refusée ; admin sur la santé des capteurs | +| `sites.spec.ts` | Liste des sites, détail (mesure instantanée, historique), recommandations filtrées sur le site | +| `recommandations.spec.ts` | Génération par l'admin, bilan, isolement d'une alerte (`?alert=`) | +| `alertes.spec.ts` | Pagination du fil (« Afficher plus »), filtres par sévérité et par site, fil vide | +| `mot-de-passe-oublie.spec.ts` | Lien invalide refusé ; réinitialisation par le lien reçu dans Mailpit | + +Les parcours s'appuient sur le jeu `db/seeds/demo.sql` (sites `demo-*`) et sur les comptes créés +par `scripts/comptes-test.sh`. + +## Lancer en local, contre `make dev` + +```bash +make e2e-install # une fois : dépendances et Chromium +make dev # dans un autre terminal +make e2e-prepare # sème demo.sql et crée les comptes test-* dans la base de dev +make e2e # joue les parcours contre http://localhost:4200 +``` + +`make e2e-prepare` écrit dans la base de `make dev` : trois sites `demo-*`, quatorze alertes et +des comptes `test-*`, dont un administrateur supplémentaire. Ne jamais le lancer contre la +recette ou la prod. + +Rapport HTML : `cd tests/e2e && npx playwright show-report`. + +## En CI + +Le workflow `e2e.yml`, appelé par `ci.yml` dès que le frontend, l'API, le proxy, les fichiers +Compose ou ces tests changent : + +1. construit et démarre `db`, `mailpit`, `backend`, `frontend` et `proxy` avec + `docker-compose.prod.yml`, sur `https://localhost` et un certificat auto-signé ; +2. migre, sème `demo.sql`, crée les comptes ; +3. joue les parcours, puis publie `playwright-report` en artefact (traces au premier réessai). + +## Règles d'écriture + +- Sélecteurs par rôle, libellé ou `data-testid`, jamais par classe CSS de mise en page. +- Une session par fichier (`ouvrirSession` dans `beforeAll`, mode `serial`). Rejouer un cookie + de refresh dans un autre contexte révoque la famille de session, donc pas de `storageState` + partagé. +- Un seul worker : la zone `auth` de nginx admet 30 connexions par minute. +- Un parcours qui consomme un compte (premier login, réinitialisation) le crée lui-même par + l'API (`creerCompteTemporaire`), pour qu'un nouvel essai ne retombe pas sur un compte déjà + activé. + +| Variable | Défaut | Rôle | +|---|---|---| +| `E2E_BASE_URL` | `http://localhost:4200` | Origine du frontend. Doit figurer dans `APP_CORS_ORIGINS` | +| `E2E_COMPTES` | aucun, requis | JSON écrit par `scripts/comptes-test.sh` | +| `E2E_MAILPIT_URL` | `http://localhost:8025` | API de Mailpit, pour le lien de réinitialisation | diff --git a/tests/e2e/package-lock.json b/tests/e2e/package-lock.json new file mode 100644 index 0000000..01ae1f4 --- /dev/null +++ b/tests/e2e/package-lock.json @@ -0,0 +1,93 @@ +{ + "name": "enervision-e2e", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "enervision-e2e", + "version": "0.0.0", + "devDependencies": { + "@playwright/test": "^1.63.0", + "@types/node": "^26.6.2", + "typescript": "~6.0.2" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@types/node": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz", + "integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/tests/e2e/package.json b/tests/e2e/package.json new file mode 100644 index 0000000..4ebb970 --- /dev/null +++ b/tests/e2e/package.json @@ -0,0 +1,15 @@ +{ + "name": "enervision-e2e", + "version": "0.0.0", + "private": true, + "scripts": { + "test": "playwright test", + "typecheck": "tsc --noEmit", + "report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.63.0", + "@types/node": "^26.6.2", + "typescript": "~6.0.2" + } +} diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts new file mode 100644 index 0000000..0518c6a --- /dev/null +++ b/tests/e2e/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, devices } from '@playwright/test'; + +import { BASE_URL } from './support/environnement'; + +const enCi = Boolean(process.env.CI); + +// Piège : un seul worker. La zone `auth` de nginx admet 30 connexions par minute, et deux +// fichiers en parallèle dépasseraient sa rafale de 20 dès le démarrage de la suite. +export default defineConfig({ + testDir: './specs', + fullyParallel: false, + workers: 1, + forbidOnly: enCi, + retries: enCi ? 1 : 0, + timeout: 30_000, + expect: { timeout: 10_000 }, + reporter: enCi + ? [['list'], ['github'], ['html', { open: 'never' }]] + : [['list'], ['html', { open: 'never' }]], + use: { + baseURL: BASE_URL, + ignoreHTTPSErrors: true, + locale: 'fr-FR', + timezoneId: 'Europe/Paris', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/tests/e2e/specs/alertes.spec.ts b/tests/e2e/specs/alertes.spec.ts new file mode 100644 index 0000000..f56d309 --- /dev/null +++ b/tests/e2e/specs/alertes.spec.ts @@ -0,0 +1,42 @@ +import { expect, test, type Page } from '@playwright/test'; + +import { comptes } from '../support/comptes'; +import { fermerSession, ouvrirSession } from '../support/session'; + +test.describe.configure({ mode: 'serial' }); +let page: Page; + +const fil = () => page.getByRole('complementary', { name: 'Alertes actives' }); +const alertes = () => fil().locator('li.alert-feed__item'); + +test.beforeAll(async ({ browser }) => { + page = await ouvrirSession(browser, comptes.lecteur); +}); +test.afterAll(async () => fermerSession(page)); + +test('affiche dix alertes, puis les suivantes à la demande', async () => { + await expect(alertes()).toHaveCount(10); + const plus = fil().getByTestId('show-more'); + await expect(plus).toBeVisible(); + + await plus.click(); + + await expect.poll(async () => alertes().count()).toBeGreaterThan(10); +}); + +test('filtre par sévérité', async () => { + await fil().getByTestId('severity-filter').selectOption('critical'); + + await expect(alertes().first()).toBeVisible(); + const severites = await alertes().evaluateAll((items) => + items.map((item) => item.className.includes('alert-feed__item--critical')), + ); + expect(severites.every(Boolean)).toBe(true); +}); + +test('annonce un fil vide quand aucun critère ne correspond', async () => { + await fil().getByTestId('site-filter').selectOption('demo-ecole'); + + await expect(fil().getByText('Aucune alerte pour ces critères.')).toBeVisible(); + await expect(alertes()).toHaveCount(0); +}); diff --git a/tests/e2e/specs/authentification.spec.ts b/tests/e2e/specs/authentification.spec.ts new file mode 100644 index 0000000..9075e43 --- /dev/null +++ b/tests/e2e/specs/authentification.spec.ts @@ -0,0 +1,38 @@ +import { expect, test } from '@playwright/test'; + +import { comptes } from '../support/comptes'; +import { seConnecter } from '../support/session'; + +test('renvoie vers la connexion sans session', async ({ page }) => { + await page.goto('/dashboard'); + + await expect(page).toHaveURL(/\/login$/); + await expect(page.getByRole('heading', { name: 'Connexion' })).toBeVisible(); +}); + +test('refuse des identifiants incorrects', async ({ page }) => { + await seConnecter(page, { email: comptes.lecteur.email, password: 'Mauvais-mot-de-passe-1!' }); + + await expect(page.getByRole('alert')).toContainText('Email ou mot de passe incorrect.'); + await expect(page).toHaveURL(/\/login$/); +}); + +test('ouvre le tableau de bord, garde la session au rechargement puis la ferme', async ({ + page, +}) => { + await seConnecter(page, comptes.lecteur); + + await expect(page).toHaveURL(/\/dashboard$/); + await expect(page.getByRole('heading', { name: "Vue d'ensemble" })).toBeVisible(); + await expect(page.getByRole('region', { name: 'Indicateurs du parc' })).toBeVisible(); + await expect(page.getByRole('complementary', { name: 'Alertes actives' })).toBeVisible(); + + await page.reload(); + await expect(page.getByRole('heading', { name: "Vue d'ensemble" })).toBeVisible(); + + await page.getByRole('button', { name: 'Déconnexion' }).click(); + await expect(page).toHaveURL(/\/login$/); + + await page.goto('/dashboard'); + await expect(page).toHaveURL(/\/login$/); +}); diff --git a/tests/e2e/specs/mot-de-passe-oublie.spec.ts b/tests/e2e/specs/mot-de-passe-oublie.spec.ts new file mode 100644 index 0000000..4434ffa --- /dev/null +++ b/tests/e2e/specs/mot-de-passe-oublie.spec.ts @@ -0,0 +1,30 @@ +import { expect, test } from '@playwright/test'; + +import { creerCompteTemporaire, nouveauMotDePasse } from '../support/api'; +import { jetonDeReinitialisation } from '../support/mailpit'; + +test('refuse un lien de réinitialisation invalide', async ({ page }) => { + await page.goto('/reset-password?token=lien-invalide'); + + await expect(page).toHaveURL(/\/login\?motif=lien-expire$/); + await expect( + page.getByText('Ce lien de réinitialisation est invalide ou a expiré.'), + ).toBeVisible(); +}); + +test('réinitialise le mot de passe par le lien reçu par courriel', async ({ page }) => { + const compte = await creerCompteTemporaire('lecteur'); + + await page.goto('/forgot-password'); + await page.getByLabel('Email').fill(compte.email); + await page.getByRole('button', { name: 'Envoyer le lien' }).click(); + await expect(page.getByText('Si un compte existe pour cet email')).toBeVisible(); + + const jeton = await jetonDeReinitialisation(compte.email); + await page.goto(`/reset-password?token=${encodeURIComponent(jeton)}`); + await page.getByLabel('Nouveau mot de passe').fill(nouveauMotDePasse()); + await page.getByRole('button', { name: 'Valider' }).click(); + + await expect(page).toHaveURL(/\/dashboard$/); + await expect(page.getByRole('heading', { name: "Vue d'ensemble" })).toBeVisible(); +}); diff --git a/tests/e2e/specs/premiere-connexion.spec.ts b/tests/e2e/specs/premiere-connexion.spec.ts new file mode 100644 index 0000000..3f34783 --- /dev/null +++ b/tests/e2e/specs/premiere-connexion.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from '@playwright/test'; + +import { creerCompteTemporaire, nouveauMotDePasse } from '../support/api'; +import { seConnecter } from '../support/session'; + +test('impose le changement du mot de passe temporaire avant le tableau de bord', async ({ + page, +}) => { + const compte = await creerCompteTemporaire('lecteur'); + + await seConnecter(page, compte); + await expect(page).toHaveURL(/\/change-password$/); + await expect(page.getByRole('heading', { name: 'Nouveau mot de passe' })).toBeVisible(); + + await page.getByLabel('Mot de passe actuel').fill(compte.password); + await page.getByLabel('Nouveau mot de passe').fill(nouveauMotDePasse()); + await page.getByRole('button', { name: 'Valider' }).click(); + + await expect(page).toHaveURL(/\/dashboard$/); + await expect(page.getByRole('heading', { name: "Vue d'ensemble" })).toBeVisible(); +}); diff --git a/tests/e2e/specs/recommandations.spec.ts b/tests/e2e/specs/recommandations.spec.ts new file mode 100644 index 0000000..74ff8e0 --- /dev/null +++ b/tests/e2e/specs/recommandations.spec.ts @@ -0,0 +1,32 @@ +import { expect, test, type Page } from '@playwright/test'; + +import { comptes } from '../support/comptes'; +import { fermerSession, ouvrirSession } from '../support/session'; + +test.describe.configure({ mode: 'serial' }); +let page: Page; + +test.beforeAll(async ({ browser }) => { + page = await ouvrirSession(browser, comptes.admin); +}); +test.afterAll(async () => fermerSession(page)); + +test('génère les recommandations à partir des alertes', async () => { + await page.goto('/recommendations'); + await page.getByTestId('generate').click(); + + await expect(page.getByRole('alert').filter({ hasText: /alertes? examinées?/ })).toBeVisible(); + await expect(page.locator('[id^="alerte-"]').first()).toBeVisible(); + await expect(page.locator('.reco__action').first()).not.toBeEmpty(); +}); + +test("isole les recommandations d'une alerte", async () => { + const carte = page.locator('[id^="alerte-"]').first(); + const identifiant = (await carte.getAttribute('id'))?.replace('alerte-', ''); + expect(identifiant).toBeTruthy(); + + await page.goto(`/recommendations?alert=${identifiant}`); + + await expect(page.getByText(`Alerte n° ${identifiant}`)).toBeVisible(); + await expect(page.locator(`#alerte-${identifiant}`)).toBeVisible(); +}); diff --git a/tests/e2e/specs/roles.spec.ts b/tests/e2e/specs/roles.spec.ts new file mode 100644 index 0000000..9b648db --- /dev/null +++ b/tests/e2e/specs/roles.spec.ts @@ -0,0 +1,57 @@ +import { expect, test, type Page } from '@playwright/test'; + +import { comptes } from '../support/comptes'; +import { fermerSession, ouvrirSession } from '../support/session'; + +test.describe('lecteur', () => { + test.describe.configure({ mode: 'serial' }); + let page: Page; + + test.beforeAll(async ({ browser }) => { + page = await ouvrirSession(browser, comptes.lecteur); + }); + test.afterAll(async () => fermerSession(page)); + + test('ne voit ni la supervision des capteurs ni la génération', async () => { + await expect(page.getByRole('link', { name: 'Voir les sites' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Supervision des capteurs' })).toHaveCount(0); + + await page.getByRole('link', { name: 'Recommandations', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Recommandations' })).toBeVisible(); + await expect(page.getByTestId('generate')).toHaveCount(0); + }); + + test('est renvoyé vers la connexion sur la page réservée aux administrateurs', async () => { + await page.goto('/monitoring/sensors'); + + await expect(page).toHaveURL(/\/login$/); + }); +}); + +test.describe('opérateur', () => { + test('voit le tableau de bord sans la supervision des capteurs', async ({ browser }) => { + const page = await ouvrirSession(browser, comptes.operateur); + + await expect(page.getByRole('heading', { name: "Vue d'ensemble" })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Supervision des capteurs' })).toHaveCount(0); + + await fermerSession(page); + }); +}); + +test.describe('administrateur', () => { + test('suit la santé des capteurs de chaque site', async ({ browser }) => { + const page = await ouvrirSession(browser, comptes.admin); + + await page.getByRole('link', { name: 'Supervision des capteurs' }).click(); + await expect(page.getByRole('heading', { name: 'Supervision des capteurs' })).toBeVisible(); + + const cartes = page.getByTestId('site-card'); + await expect(cartes.filter({ hasText: 'Siège Part-Dieu' })).toContainText('ok'); + await expect(cartes.filter({ hasText: 'Groupe scolaire Gratte-Ciel' })).toContainText( + 'degraded', + ); + + await fermerSession(page); + }); +}); diff --git a/tests/e2e/specs/sites.spec.ts b/tests/e2e/specs/sites.spec.ts new file mode 100644 index 0000000..0609d1a --- /dev/null +++ b/tests/e2e/specs/sites.spec.ts @@ -0,0 +1,38 @@ +import { expect, test, type Page } from '@playwright/test'; + +import { comptes } from '../support/comptes'; +import { fermerSession, ouvrirSession } from '../support/session'; + +test.describe.configure({ mode: 'serial' }); +let page: Page; + +test.beforeAll(async ({ browser }) => { + page = await ouvrirSession(browser, comptes.lecteur); +}); +test.afterAll(async () => fermerSession(page)); + +test('liste les sites du parc', async () => { + await page.getByRole('link', { name: 'Voir les sites' }).click(); + + await expect(page.getByRole('heading', { name: 'Sites' })).toBeVisible(); + await expect(page.getByRole('row', { name: /Siège Part-Dieu/ })).toBeVisible(); +}); + +test('détaille un site : mesure instantanée et historique', async () => { + await page + .getByRole('row', { name: /Siège Part-Dieu/ }) + .getByRole('link', { name: 'Détail' }) + .click(); + + await expect(page).toHaveURL(/\/sites\/demo-siege$/); + await expect(page.getByRole('heading', { level: 1, name: 'Siège Part-Dieu' })).toBeVisible(); + await expect(page.getByText('Mesure instantanée')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Historique de consommation' })).toBeVisible(); +}); + +test('ouvre les recommandations filtrées sur ce site', async () => { + await page.getByRole('link', { name: 'Voir dans la vue recommandations' }).click(); + + await expect(page).toHaveURL(/\/recommendations\?site=demo-siege$/); + await expect(page.getByTestId('site-filter')).toHaveValue('demo-siege'); +}); diff --git a/tests/e2e/support/api.ts b/tests/e2e/support/api.ts new file mode 100644 index 0000000..1080eca --- /dev/null +++ b/tests/e2e/support/api.ts @@ -0,0 +1,36 @@ +import { request } from '@playwright/test'; + +import { comptes, type Compte } from './comptes'; +import { API_URL } from './environnement'; + +// Crée par l'API un compte neuf, encore sur son mot de passe temporaire. Un compte par appel : +// un nouvel essai après échec ne retombe jamais sur un compte déjà activé. +export async function creerCompteTemporaire(role: 'lecteur' | 'operateur'): Promise { + const api = await request.newContext({ baseURL: API_URL, ignoreHTTPSErrors: true }); + try { + const connexion = await api.post('auth/login', { data: comptes.admin }); + if (!connexion.ok()) { + throw new Error(`Connexion administrateur refusée : ${connexion.status()}`); + } + const { access_token: jeton } = (await connexion.json()) as { access_token: string }; + + const email = `e2e-${role}-${Date.now()}@enervision.fr`; + const creation = await api.post('users', { + data: { email, role }, + headers: { Authorization: `Bearer ${jeton}` }, + }); + if (!creation.ok()) { + throw new Error(`Création du compte refusée : ${creation.status()}`); + } + const { temporary_password: password } = (await creation.json()) as { + temporary_password: string; + }; + return { email, password }; + } finally { + await api.dispose(); + } +} + +export function nouveauMotDePasse(): string { + return `E2e-${Math.random().toString(36).slice(2, 14)}-Aa1!`; +} diff --git a/tests/e2e/support/comptes.ts b/tests/e2e/support/comptes.ts new file mode 100644 index 0000000..8f34c3f --- /dev/null +++ b/tests/e2e/support/comptes.ts @@ -0,0 +1,22 @@ +import { readFileSync } from 'node:fs'; + +export interface Compte { + email: string; + password: string; +} + +interface ComptesDeTest { + admin: Compte; + lecteur: Compte; + operateur: Compte; +} + +function lireComptes(): ComptesDeTest { + const chemin = process.env.E2E_COMPTES; + if (!chemin) { + throw new Error('E2E_COMPTES doit désigner le JSON écrit par scripts/comptes-test.sh'); + } + return JSON.parse(readFileSync(chemin, 'utf-8')) as ComptesDeTest; +} + +export const comptes = lireComptes(); diff --git a/tests/e2e/support/environnement.ts b/tests/e2e/support/environnement.ts new file mode 100644 index 0000000..d247219 --- /dev/null +++ b/tests/e2e/support/environnement.ts @@ -0,0 +1,3 @@ +export const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:4200'; +export const API_URL = new URL('/api/v1/', BASE_URL).toString(); +export const MAILPIT_URL = process.env.E2E_MAILPIT_URL ?? 'http://localhost:8025'; diff --git a/tests/e2e/support/mailpit.ts b/tests/e2e/support/mailpit.ts new file mode 100644 index 0000000..d082d38 --- /dev/null +++ b/tests/e2e/support/mailpit.ts @@ -0,0 +1,37 @@ +import { expect, request } from '@playwright/test'; + +import { MAILPIT_URL } from './environnement'; + +interface Recherche { + messages: { ID: string }[]; +} + +// Le courriel part en tâche de fond après la réponse de l'API : on l'attend dans Mailpit. +export async function jetonDeReinitialisation(email: string): Promise { + const mailpit = await request.newContext({ baseURL: MAILPIT_URL }); + try { + let identifiant = ''; + await expect + .poll( + async () => { + const reponse = await mailpit.get('/api/v1/search', { params: { query: `to:${email}` } }); + const { messages } = (await reponse.json()) as Recherche; + identifiant = messages[0]?.ID ?? ''; + return identifiant; + }, + { message: `aucun courriel reçu pour ${email}`, timeout: 15_000 }, + ) + .not.toBe(''); + + const message = (await (await mailpit.get(`/api/v1/message/${identifiant}`)).json()) as { + Text: string; + }; + const jeton = /reset-password\?token=([^\s"<>&]+)/.exec(message.Text)?.[1]; + if (!jeton) { + throw new Error('lien de réinitialisation introuvable dans le courriel'); + } + return decodeURIComponent(jeton); + } finally { + await mailpit.dispose(); + } +} diff --git a/tests/e2e/support/session.ts b/tests/e2e/support/session.ts new file mode 100644 index 0000000..999f57f --- /dev/null +++ b/tests/e2e/support/session.ts @@ -0,0 +1,30 @@ +import { expect, type Browser, type Page } from '@playwright/test'; + +import type { Compte } from './comptes'; +import { BASE_URL } from './environnement'; + +export async function seConnecter(page: Page, compte: Compte): Promise { + await page.goto('/login'); + await page.getByLabel('Email').fill(compte.email); + await page.getByLabel('Mot de passe').fill(compte.password); + await page.getByRole('button', { name: 'Se connecter' }).click(); +} + +// Pourquoi : une session par fichier, pas par test. Rejouer un même cookie de refresh dans deux +// contextes révoque toute sa famille (ADR 0002), d'où ni `storageState` partagé ni reconnexions. +export async function ouvrirSession(browser: Browser, compte: Compte): Promise { + const contexte = await browser.newContext({ + baseURL: BASE_URL, + ignoreHTTPSErrors: true, + locale: 'fr-FR', + timezoneId: 'Europe/Paris', + }); + const page = await contexte.newPage(); + await seConnecter(page, compte); + await expect(page).toHaveURL(/\/dashboard$/); + return page; +} + +export async function fermerSession(page: Page): Promise { + await page.context().close(); +} diff --git a/tests/e2e/tsconfig.json b/tests/e2e/tsconfig.json new file mode 100644 index 0000000..3679a19 --- /dev/null +++ b/tests/e2e/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["**/*.ts"], + "exclude": ["node_modules"] +}