--- 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__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) ```