--- type: testing project: Metamorph milestone: M2 date: "2026-05-10" --- # Comment tester M2 (auth, bootstrap, invitations) > Procédure de validation manuelle + automatisée pour M2 (auth JWT, /setup, invitations, RBAC). Toutes les commandes se lancent depuis la racine. ## 0. Prérequis Voir `tasks/testing-m0.md §0`. M2 n'ajoute aucune dépendance host (le pytest tourne dans un container éphémère via `make test-api`). ## 1. Bootstrap stack vide → premier admin ```bash make env make up make migrate # 27 tables (M1) ``` Récupère le token install dans les logs : ```bash make logs-api | grep -E "INSTALL TOKEN" | tail -1 # ou : podman logs metamorph-api 2>&1 | grep "INSTALL TOKEN" ``` Si la stack a été utilisée et le token consommé, force-mint un nouveau : ```bash make print-install-token-force ``` ## 2. /setup — création du 1er admin via curl ```bash TOKEN="" curl -s -X POST http://localhost:8080/api/v1/setup \ -H "Content-Type: application/json" \ -d "{\"install_token\":\"$TOKEN\",\"email\":\"admin@metamorph.local\",\"password\":\"AdminPass1234!\"}" | jq # Attendu : {"ok":true,"user_id":"..."} ``` Vérifie que `/setup` ne peut plus être consommé : ```bash curl -s http://localhost:8080/api/v1/setup | jq # Attendu : {"completed":true} ``` ## 3. /setup via la SPA Ouvrir http://127.0.0.1:8080/setup dans le navigateur. Le formulaire affiche : - **Install token** (paste depuis les logs) - **Admin email** - **Display name (optional)** - **Password** + **Confirm password** (≥ 8 chars) Le bouton « Create admin » appelle `POST /api/v1/setup` puis redirige vers `/login`. Si la stack a déjà un admin, la page affiche « Already done · Go to login → ». ## 4. Login + /auth/me ```bash LOGIN=$(curl -s -c /tmp/cookies.txt -X POST http://localhost:8080/api/v1/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"admin@metamorph.local","password":"AdminPass1234!"}') echo "$LOGIN" | jq ACCESS=$(echo "$LOGIN" | jq -r .access_token) curl -s http://localhost:8080/api/v1/auth/me -H "Authorization: Bearer $ACCESS" | jq # Attendu : {is_admin: true, groups: ["admin"], email: "...", ...} ``` Le cookie `metamorph_refresh` est dans `/tmp/cookies.txt` (HTTPOnly, scope `/api/v1/auth/`). Côté SPA : `/login` → email + password → redirige vers `/`. Le header affiche l'email courant via le testid `me-email` (``). ## 5. Refresh rotation ```bash curl -s -b /tmp/cookies.txt -c /tmp/cookies.txt -X POST http://localhost:8080/api/v1/auth/refresh | jq # Attendu : nouveau access_token, le cookie refresh est rotaté ``` L'ancien refresh token devient `revoked_at IS NOT NULL` en base ; un 2e usage du même refresh révoque la chaîne entière (détection de réutilisation). ## 6. Invitation → register → 2e login ```bash # Admin crée invitation INV=$(curl -s -X POST http://localhost:8080/api/v1/invitations \ -H "Authorization: Bearer $ACCESS" -H "Content-Type: application/json" \ -d '{"email_hint":"alice@metamorph.local"}') echo "$INV" | jq INV_TOKEN=$(echo "$INV" | jq -r .token) # Preview (anonyme) curl -s "http://localhost:8080/api/v1/invitations/preview/$INV_TOKEN" | jq # Acceptance curl -s -X POST "http://localhost:8080/api/v1/invitations/accept/$INV_TOKEN" \ -H "Content-Type: application/json" \ -d '{"email":"alice@metamorph.local","password":"AlicePass1234!"}' | jq # Login Alice curl -s -X POST http://localhost:8080/api/v1/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"alice@metamorph.local","password":"AlicePass1234!"}' | jq -r .access_token | head -c 40 ``` Côté SPA : ouvrir `http://127.0.0.1:8080/register?token=`. La page affiche le hint email, demande password + confirm, redirige vers `/login` après acceptance. ## 7. RBAC — non-admin reçoit 403 ```bash ALICE_ACCESS=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"alice@metamorph.local","password":"AlicePass1234!"}' | jq -r .access_token) curl -s -o /dev/null -w "HTTP %{http_code}\n" -X POST http://localhost:8080/api/v1/invitations \ -H "Authorization: Bearer $ALICE_ACCESS" -H "Content-Type: application/json" -d '{}' # Attendu : HTTP 403 ``` ## 8. Change password (force logout-all) ```bash curl -s -X POST http://localhost:8080/api/v1/auth/change-password \ -H "Authorization: Bearer $ACCESS" -H "Content-Type: application/json" \ -d '{"current_password":"AdminPass1234!","new_password":"AdminPass5678!"}' | jq # Attendu : {"ok":true} ``` Tous les refresh tokens du user sont révoqués → toute autre session existante repasse par /login. ## 9. Profile (SPA) Une fois loggué, ouvrir `/profile` : - Carte **Identity** (email, display name, locale) - Carte **Groups** (tags) - Carte **Permissions** (admin → tag « ADMIN — bypasses checks ») - Carte **Change password** (current + new + confirm → submit → redirige vers /login après 1.5 s) Logout via le bouton du header → redirige vers `/login`. Tenter `/profile` sans session → redirige vers `/login`. ## 10. Tests automatisés ### 10.1 — Backend pytest ```bash make test-api ``` **Attendu** : **24 passed** (1 health + 8 schema + 15 auth flow). Détail M2 (`tests/test_auth_flow.py`) : | Test | Couvre | |---|---| | setup_status_starts_uncompleted | /setup état initial | | setup_creates_first_admin | bootstrap consomme le token, crée admin | | setup_status_now_completed | idempotence | | setup_replay_is_blocked | 409 si déjà fait | | login_and_me | flux login → /me complet | | login_with_wrong_password_returns_401 | aucune énumération | | me_without_token_returns_401 | bearer requis | | refresh_rotates_and_old_token_is_revoked | rotation chaîne | | refresh_with_no_cookie_returns_401 | cookie obligatoire | | logout_clears_cookie_and_is_idempotent | idempotence | | admin_creates_invitation_and_invitee_accepts | flux complet | | unauthenticated_cannot_create_invitation | auth requis | | non_admin_cannot_create_invitation | RBAC 403 | | used_invitation_cannot_be_accepted_twice | usage unique | | change_password_revokes_all_refresh_tokens | logout-all | ### 10.2 — Playwright e2e ```bash make e2e ``` **Attendu** : **20 passed** (8 M0 + 4 M1 + 8 M2). Détail M2 (`e2e/tests/m2-auth.spec.ts`) : | Test | Couvre | |---|---| | setup status is uncompleted before bootstrap | API contract | | SPA setup form creates the first admin | UI /setup | | SPA login works and reveals the profile page | UI /login + /profile | | admin issues an invitation via the API and the front renders the registration form | UI /register?token=… | | invitee submits the registration form and can log in | UI register accept | | non-admin gets 403 on the admin invitations endpoint | RBAC | | refresh token rotation works through the SPA | rotation | | logout clears the session and redirects to login | logout | Le rapport HTML : `e2e/playwright-report/index.html`. JUnit : `e2e/playwright-report/junit.xml`. ## 11. Pièges connus (M2 spécifiques) - **Cookie `Secure` en HTTP** : `secure=True` fait rejeter le cookie par le browser sur HTTP. Métamorph utilise `secure = APP_ENV in ("prod","staging")` — en `dev`/`test` le cookie est non-secure. En prod, le reverse proxy externe doit terminer la TLS. - **`pydantic.EmailStr` rejette les TLD réservés** (`.local`, `.corp`, `.test`) avec `globally_deliverable=True`. Métamorph utilise un type `Email` permissif via regex (cf. `app/api/_validation.py`). - **`getByLabel` Playwright** récupère le **nom accessible** de l'input. Si la `