Milestone 3

This commit is contained in:
Knacky
2026-05-11 06:05:27 +02:00
commit 4c25e198fc
125 changed files with 13489 additions and 0 deletions

212
tasks/testing-m2.md Normal file
View File

@@ -0,0 +1,212 @@
---
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="<paste-token-here>"
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` (`<span data-testid="me-email">…</span>`).
## 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=<INV_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 `<label>` enveloppe l'input ET le hint, le hint pollue le nom → matchs ratés. Le composant `TextField` met le hint en sibling, pas à l'intérieur du `<label>`.
- **Rate-limit `/auth/login` (10/min)** : peut bloquer une suite de tests. `flask-limiter` est désactivé par construction quand `APP_ENV=test`.
- **`/diag/reset`** est exposé seulement quand `APP_ENV in ("dev","test")`. Truncate les tables auth + force-mint un nouveau token install. **Ne jamais activer en prod.**
- **Compose recreate** : `make rebuild` ne recrée pas les containers — il faut `make down && make up` après un changement front pour que nginx serve le nouveau bundle.
## 12. Teardown
```bash
make down
# ou full reset (DESTRUCTEUR) :
make clean
```