23 tables + alembic_version covering the v1 data model:
- Auth/RBAC (8): users, groups, permissions, user_groups, group_permissions,
invitations, invitation_groups, refresh_tokens.
- MITRE (4): mitre_tactics, mitre_techniques, mitre_subtechniques + the
technique↔tactic many-to-many.
- Templates (4): test_templates, test_template_mitre_tags (3 nullable FKs +
CHECK exactly_one_mitre_fk), scenario_templates, scenario_template_tests
(UUID PK + UNIQUE(scenario_id, position) so a test can appear at multiple
positions).
- Missions (6): missions, mission_members, mission_scenarios, mission_tests,
mission_test_mitre_tags (deliberately denormalised — copies external_id +
name + url, no FK to mitre_* — so a re-sync of the catalogue can't purge
historical tags), mission_categories.
- Evidence/settings/notifications (5): evidence_files, settings (JSONB
value), detection_levels, notifications.
SQLAlchemy 2.x with Mapped[]/mapped_column(), pk_/fk_/ck_/uq_/ix_ naming
convention. Reusable mixins (UuidPkMixin, TimestampMixin, SoftDeleteMixin —
no auto __table_args__ since classes silently clobber the mixin's).
Soft delete: deleted_at + partial indexes ix_<table>_active WHERE deleted_at
IS NULL on 9 tables (users, groups, test_templates, scenario_templates,
missions, mission_scenarios, mission_tests, mission_categories,
evidence_files). Notifications gets ix_..._unread WHERE read_at IS NULL.
CHECK constraints for status / state / opsec_level / mitre_kind enums.
New API endpoint GET /api/v1/diag/db: returns alembic_revision (short hash)
and the public-schema table_count. 503 with {"reachable": false} on a DB
outage. Database card on the SPA home consumes it.
Test stage in backend/Dockerfile (--target test): runtime + dev extras +
tests/. New make test-api spins an ephemeral pytest container against the
live DB on the compose network. backend/tests/test_schema.py: 8 integration
tests (tables, FK pairs, CHECK constraints, partial indexes, alembic-at-head,
negative INSERT proving the exactly_one_mitre_fk CHECK fires).
e2e/tests/m1-db.spec.ts: 4 Playwright tests covering the diag endpoint
contract + the Database card + footer/roadmap labels.
DoD: make clean && make up && make migrate → 23 tables, 32 FKs, 9 CHECKs,
make test-api → 9 passed, make e2e → 12 passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8.3 KiB
type, project, milestone, date
| type | project | milestone | date |
|---|---|---|---|
| testing | Metamorph | M1 | 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)
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
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
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 :
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
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 surtest_template_mitre_tags, …)f|32— Foreign keys (snapshotmission_test_mitre_tagsn'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)
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 NULLuq_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)
-- 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
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)
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)
make e2e
Attendu : 12 passed. Détail :
- 8 tests M0 (smoke bootstrap)
- 4 tests M1 (
e2e/tests/m1-db.spec.ts) :GET /api/v1/diag/dbrenvoie une revision Alembic en hex ettable_count >= 26- La home page rend la card « Database » avec le short-hash de la revision et le compteur
- La card « Roadmap » indique « M0 + M1 done » et cite M2
- Le footer mentionne
M0 bootstrap+M1 db schema
Le rapport HTML est dans e2e/playwright-report/.
4.3 — Endpoint diagnostique direct
curl -s http://localhost:8080/api/v1/diag/db | jq
Attendu :
{
"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)
# 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)
make migrateapplique le schéma sur DB vide\dtmontre les 27 tables (26 métier + alembic_version)- FK + CHECK + indexes en place (32 FK / 9 CHECK / 14 UQ / 12 partial)
- Naming convention Alembic stable (préfixes
pk_/fk_/ck_/uq_/ix_) - Soft delete partout sauf jointures simples (
deleted_at+ index partiel) - Audit minimal (
created_at/updated_at) sur les tables principales - Tests d'intégration pytest verts (9 passed)
- M0 e2e ne régresse pas
7. Pièges connus
COMPOSEcible le dernier stage du Dockerfile par défaut : si on ajoute un stage aprèsruntime(icitest), il faut explicitementtarget: runtimedansdocker-compose.yml. Sinonmake uplance 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 viapodman cpavant rebuild, sinon perdu. - Post-write hook
ruff: retiré d'alembic.iniparce que ruff est dev-only et n'est pas dans l'image runtime. Formatter les migrations à la main avecmake fmtaprès génération. change-me-strong(placeholder de.env.example) est rejeté parmodel_validatorenAPP_ENV=prod. Pour les tests on a élargi le bypass àAPP_ENV in ("dev", "test").
8. Teardown
make down # garde les volumes
make clean # supprime aussi les volumes (DESTRUCTEUR)