213 lines
8.2 KiB
Markdown
213 lines
8.2 KiB
Markdown
|
|
---
|
||
|
|
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
|
||
|
|
```
|