Files
Metamorph/tasks/testing-m1.md
Knacky e995853f0d feat(m1): DB schema, migrations, diag visibility
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>
2026-05-11 06:16:24 +02:00

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

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_unreadWHERE 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)

-- 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) :
    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

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 migrate applique le schéma sur DB vide
  • \dt montre 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

  • 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

make down                     # garde les volumes
make clean                    # supprime aussi les volumes (DESTRUCTEUR)