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"] +}