219 lines
8.3 KiB
Markdown
219 lines
8.3 KiB
Markdown
|
|
---
|
||
|
|
type: testing
|
||
|
|
project: Metamorph
|
||
|
|
milestone: M1
|
||
|
|
date: "2026-05-10"
|
||
|
|
---
|
||
|
|
|
||
|
|
# Comment tester M1 (schéma DB & migrations)
|
||
|
|
|
||
|
|
> Procédure de validation manuelle + automatisée pour M1 : SQLAlchemy 2 + Alembic + 26 tables. Toutes les commandes se lancent depuis la racine du repo.
|
||
|
|
|
||
|
|
## 0. Prérequis
|
||
|
|
|
||
|
|
Voir `tasks/testing-m0.md §0` (Docker ou Podman, Make, Node 20+, etc.). Aucune dépendance Python locale n'est requise — pytest tourne dans un container éphémère bâti depuis le stage `test` du Dockerfile backend.
|
||
|
|
|
||
|
|
## 1. Bootstrap (si la stack n'est pas déjà up)
|
||
|
|
|
||
|
|
```bash
|
||
|
|
make env # crée .env si absent
|
||
|
|
make up # build + start de la stack (api / db / front)
|
||
|
|
make inspect-health # attends que les 3 soient healthy
|
||
|
|
```
|
||
|
|
|
||
|
|
## 2. Appliquer la migration
|
||
|
|
|
||
|
|
```bash
|
||
|
|
make migrate # alembic upgrade head dans le container api
|
||
|
|
make migrate-status # confirme la revision courante = head
|
||
|
|
```
|
||
|
|
|
||
|
|
**Attendu** :
|
||
|
|
```
|
||
|
|
INFO [alembic.runtime.migration] Will assume transactional DDL.
|
||
|
|
INFO [alembic.runtime.migration] Running upgrade -> 24765a5014b6, initial schema
|
||
|
|
```
|
||
|
|
|
||
|
|
et `make migrate-status` :
|
||
|
|
```
|
||
|
|
24765a5014b6 (head)
|
||
|
|
---
|
||
|
|
24765a5014b6 (head)
|
||
|
|
```
|
||
|
|
|
||
|
|
## 3. Tests fonctionnels manuels
|
||
|
|
|
||
|
|
### 3.1 — Liste des tables
|
||
|
|
|
||
|
|
```bash
|
||
|
|
make psql # ouvre psql dans le container db
|
||
|
|
\dt # une fois dans psql
|
||
|
|
```
|
||
|
|
|
||
|
|
**Attendu** : **27 lignes** (26 tables métier + `alembic_version`) :
|
||
|
|
|
||
|
|
```
|
||
|
|
detection_levels, evidence_files, group_permissions, groups,
|
||
|
|
invitation_groups, invitations, mission_categories, mission_members,
|
||
|
|
mission_scenarios, mission_test_mitre_tags, mission_tests, missions,
|
||
|
|
mitre_subtechniques, mitre_tactics, mitre_technique_tactics,
|
||
|
|
mitre_techniques, notifications, permissions, refresh_tokens,
|
||
|
|
scenario_template_tests, scenario_templates, settings,
|
||
|
|
test_template_mitre_tags, test_templates, user_groups, users,
|
||
|
|
alembic_version
|
||
|
|
```
|
||
|
|
|
||
|
|
Compter via SQL :
|
||
|
|
```bash
|
||
|
|
podman exec metamorph-db psql -U metamorph -d metamorph -tAc \
|
||
|
|
"SELECT count(*) FROM information_schema.tables WHERE table_schema='public' AND table_type='BASE TABLE'"
|
||
|
|
# 27
|
||
|
|
```
|
||
|
|
|
||
|
|
### 3.2 — Contraintes au niveau Postgres
|
||
|
|
|
||
|
|
```bash
|
||
|
|
podman exec metamorph-db psql -U metamorph -d metamorph -tAc \
|
||
|
|
"SELECT contype, count(*) FROM pg_constraint WHERE connamespace = 'public'::regnamespace GROUP BY contype ORDER BY contype"
|
||
|
|
```
|
||
|
|
|
||
|
|
**Attendu** :
|
||
|
|
- `c|9` — CHECK constraints (status valid, opsec_level valid, mitre_kind valid, exactly_one_mitre_fk uniquement sur `test_template_mitre_tags`, …)
|
||
|
|
- `f|32` — Foreign keys *(snapshot `mission_test_mitre_tags` n'a volontairement pas de FK MITRE)*
|
||
|
|
- `p|27` — Primary keys (1 par table)
|
||
|
|
- `u|14` — UNIQUE constraints
|
||
|
|
|
||
|
|
### 3.3 — Index partiels (soft delete + unread notifications)
|
||
|
|
|
||
|
|
```bash
|
||
|
|
podman exec metamorph-db psql -U metamorph -d metamorph -c \
|
||
|
|
"SELECT indexname FROM pg_indexes WHERE schemaname='public' AND indexdef ILIKE '%WHERE%' ORDER BY 1"
|
||
|
|
```
|
||
|
|
|
||
|
|
**Attendu** (12 indexes) :
|
||
|
|
- `ix_evidence_files_active`, `ix_groups_active`, `ix_missions_active`, `ix_mission_categories_active`, `ix_mission_scenarios_active`, `ix_mission_tests_active`, `ix_scenario_templates_active`, `ix_test_templates_active`, `ix_users_active` — soft-delete partiels (9)
|
||
|
|
- `ix_notifications_user_unread` — `WHERE read_at IS NULL`
|
||
|
|
- `uq_users_email_active`, `uq_groups_name_active` — uniques scopés aux lignes actives
|
||
|
|
|
||
|
|
### 3.4 — Test négatif d'un CHECK constraint (`exactly_one_mitre_fk`)
|
||
|
|
|
||
|
|
```sql
|
||
|
|
-- Dans psql, doit échouer :
|
||
|
|
INSERT INTO test_templates (id, name, opsec_level)
|
||
|
|
VALUES (gen_random_uuid(), 'tmp', 'low');
|
||
|
|
INSERT INTO mitre_tactics (id, external_id, short_name, name)
|
||
|
|
VALUES (gen_random_uuid(), 'TA0099', 'tmp', 'tmp');
|
||
|
|
-- (puis tente une insertion avec deux FK MITRE non null — bloqué par CHECK)
|
||
|
|
```
|
||
|
|
|
||
|
|
Couvert automatiquement par `tests/test_schema.py::test_exactly_one_mitre_fk_check_enforced`.
|
||
|
|
|
||
|
|
### 3.5 — Migration depuis DB totalement vide
|
||
|
|
|
||
|
|
```bash
|
||
|
|
make clean # DESTRUCTEUR — supprime aussi les volumes
|
||
|
|
make up # re-spawn db vierge
|
||
|
|
# attends que db soit healthy
|
||
|
|
make migrate # applique 0001_initial sur DB vide
|
||
|
|
podman exec metamorph-db psql -U metamorph -d metamorph -tAc "SELECT count(*) FROM information_schema.tables WHERE table_schema='public'"
|
||
|
|
# attendu : 27
|
||
|
|
```
|
||
|
|
|
||
|
|
## 4. Tests automatisés
|
||
|
|
|
||
|
|
### 4.1 — Backend pytest (M1 schema integration)
|
||
|
|
|
||
|
|
```bash
|
||
|
|
make test-api
|
||
|
|
```
|
||
|
|
|
||
|
|
**Attendu** : `9 passed in <1s` (1 health M0 + 8 schema M1).
|
||
|
|
|
||
|
|
Détail des 8 tests M1 :
|
||
|
|
|
||
|
|
| # | Test | Couvre |
|
||
|
|
|---|---|---|
|
||
|
|
| 1 | `test_all_expected_tables_exist` | Liste exhaustive des 26 tables métier |
|
||
|
|
| 2 | `test_soft_delete_columns_present` | `deleted_at` sur 6 tables |
|
||
|
|
| 3 | `test_standard_timestamp_columns_present` | `created_at`+`updated_at` sur 5 tables |
|
||
|
|
| 4 | `test_partial_index_for_soft_delete` | Index `ix_<table>_active` partiel |
|
||
|
|
| 5 | `test_expected_foreign_keys` | 14 paires FK clés (red→users, blue→users, evidence→test, etc.) |
|
||
|
|
| 6 | `test_expected_check_constraints` | Les 10 CHECK constraints fonctionnelles |
|
||
|
|
| 7 | `test_alembic_at_head` | `SELECT version_num FROM alembic_version` non-vide |
|
||
|
|
| 8 | `test_exactly_one_mitre_fk_check_enforced` | Test négatif INSERT — viole le CHECK |
|
||
|
|
|
||
|
|
Le test runner s'appuie sur le stage `test` du Dockerfile backend (`--target test` avec uv et les dev extras), spawné en container éphémère sur le réseau du compose. Le runtime stays minimal.
|
||
|
|
|
||
|
|
### 4.2 — Suite e2e Playwright (M0 + M1)
|
||
|
|
|
||
|
|
```bash
|
||
|
|
make e2e
|
||
|
|
```
|
||
|
|
|
||
|
|
**Attendu** : `12 passed`. Détail :
|
||
|
|
- 8 tests M0 (smoke bootstrap)
|
||
|
|
- 4 tests M1 (`e2e/tests/m1-db.spec.ts`) :
|
||
|
|
1. `GET /api/v1/diag/db` renvoie une revision Alembic en hex et `table_count >= 26`
|
||
|
|
2. La home page rend la card « Database » avec le short-hash de la revision et le compteur
|
||
|
|
3. La card « Roadmap » indique « M0 + M1 done » et cite M2
|
||
|
|
4. Le footer mentionne `M0 bootstrap` + `M1 db schema`
|
||
|
|
|
||
|
|
Le rapport HTML est dans `e2e/playwright-report/`.
|
||
|
|
|
||
|
|
### 4.3 — Endpoint diagnostique direct
|
||
|
|
|
||
|
|
```bash
|
||
|
|
curl -s http://localhost:8080/api/v1/diag/db | jq
|
||
|
|
```
|
||
|
|
|
||
|
|
**Attendu** :
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"reachable": true,
|
||
|
|
"alembic_revision": "24765a5014b6",
|
||
|
|
"table_count": 27
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Quand la DB est down (ex : `make down` sur le service `db` seul), l'endpoint renvoie `503` avec `{"reachable": false, "error": "database_unreachable"}`.
|
||
|
|
|
||
|
|
## 5. Génération d'une nouvelle migration (workflow dev)
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# 1. Modifier un modèle dans backend/app/models/
|
||
|
|
# 2. Générer la migration via Alembic dans le container :
|
||
|
|
make migrate-revision MSG="add foo column to mission_tests"
|
||
|
|
|
||
|
|
# Le fichier est créé dans le container — copie-le sur l'host pour le commit :
|
||
|
|
podman cp metamorph-api:/app/alembic/versions/. backend/alembic/versions/
|
||
|
|
|
||
|
|
# 3. Relire le fichier généré, le formatter (`make fmt`)
|
||
|
|
# 4. Rebuild + apply :
|
||
|
|
make rebuild && make up && make migrate
|
||
|
|
```
|
||
|
|
|
||
|
|
## 6. DoD M1 — checklist (extraits de `tasks/todo.md`)
|
||
|
|
|
||
|
|
- [x] `make migrate` applique le schéma sur DB vide
|
||
|
|
- [x] `\dt` montre les 27 tables (26 métier + alembic_version)
|
||
|
|
- [x] FK + CHECK + indexes en place (32 FK / 9 CHECK / 14 UQ / 12 partial)
|
||
|
|
- [x] Naming convention Alembic stable (préfixes `pk_/fk_/ck_/uq_/ix_`)
|
||
|
|
- [x] Soft delete partout sauf jointures simples (`deleted_at` + index partiel)
|
||
|
|
- [x] Audit minimal (`created_at`/`updated_at`) sur les tables principales
|
||
|
|
- [x] Tests d'intégration pytest verts (9 passed)
|
||
|
|
- [x] M0 e2e ne régresse pas
|
||
|
|
|
||
|
|
## 7. Pièges connus
|
||
|
|
|
||
|
|
- **`COMPOSE` cible le dernier stage du Dockerfile par défaut** : si on ajoute un stage après `runtime` (ici `test`), il faut explicitement `target: runtime` dans `docker-compose.yml`. Sinon `make up` lance pytest au lieu de gunicorn — le container exit en boucle.
|
||
|
|
- **Alembic autogenerate dans le container** : le fichier est créé dans `/app/alembic/versions/` du container. Le récupérer sur l'host via `podman cp` avant rebuild, sinon perdu.
|
||
|
|
- **Post-write hook `ruff`** : retiré d'`alembic.ini` parce que ruff est dev-only et n'est pas dans l'image runtime. Formatter les migrations à la main avec `make fmt` après génération.
|
||
|
|
- **`change-me-strong` (placeholder de `.env.example`)** est rejeté par `model_validator` en `APP_ENV=prod`. Pour les tests on a élargi le bypass à `APP_ENV in ("dev", "test")`.
|
||
|
|
|
||
|
|
## 8. Teardown
|
||
|
|
|
||
|
|
```bash
|
||
|
|
make down # garde les volumes
|
||
|
|
make clean # supprime aussi les volumes (DESTRUCTEUR)
|
||
|
|
```
|