Files
mimic/tasks/todo.md
Knacky cf006a2ba8 docs(plan): sprint 6 — apply spec-reviewer Pass 1 fixes (1 BLOCKER + 6 WARN)
Fixes applied:
- BLOCKER §2 : EngagementDetailPage.test.tsx → "nouveau" (n'existe pas
  encore), pas "existant — adapter".
- WARN §1 : "Première ligne du summary" obligatoire pour backend-builder
  avec le path final EXACT (anti-URL-drift, lesson sprint 5).
- WARN §0/§1 : slug avec NFKD-strip pour accents + fallback "unnamed"
  pour edge case nom 100% non-alphanum.
- WARN §2 : ExportEngagementButton les DEUX moitiés ouvrent le dropdown
  (pas d'action par défaut — différence vs NewSimulationDropdown).
- WARN §2 : exports.ts throw Error sur non-2xx pour pipeline toast.
- WARN §1 : created_by rendu username-only en MD/CSV (pas la dict).
- WARN §1 : PDF généré depuis les DONNÉES (pas depuis le string Markdown).

NITs incorporés :
- gdk-pixbuf-2.0-0 retiré du set minimal (text-only PDF), avec note
  pour confirmer via weasyprint --info.
- data-testid="export-dropdown" sur le wrapper pour AC-30.1.
- AC-29.3 : compter rows via csv.reader, pas file.split.
- §0 point 14 : style explicite btn-outline (cohérence header).
- Test MITRE-bundle-not-loaded ajouté à test_export_render.py.

Plan prêt pour spec-reviewer Pass 2.
2026-06-07 18:38:41 +02:00

22 KiB

Sprint 6 — Engagement export (Markdown + CSV + PDF)

Branch : sprint/6-export · Worktree : .claude/worktrees/sprint-6-export · Base : main @ 678ee8f

§0 — Binding decisions (locked with the user 2026-06-07)

  1. Scope du sprint : export d'un engagement (header + toutes ses simulations RT + SOC) vers Markdown, CSV et PDF — clôt la boucle « remplace l'utilisation d'un fichier excel plat partagé entre la redteam et les analystes SOC en fin de mission ».
  2. Formats livrés : Markdown, CSV, PDF (3 formats). JSON exclu (redondant avec l'API existante).
  3. RBAC : admin + redteam peuvent exporter. SOC = pas d'accès (pas de bouton dans l'UI, endpoint /api/engagements/<eid>/export → 403). Cohérent avec le pattern templates sprint 5 (livrable RedTeam).
  4. Contenu de l'export : Engagement header (name, description, dates, status, created_by, created_at) + toutes les simulations de l'engagement, avec leurs champs RT (name, techniques, tactics, description, commands, prerequisites, executed_at, execution_result, status) ET SOC (log_source, logs, soc_comment, incident_number). Ordre des simulations : id ASC (ordre de création).
  5. Déclenchement UI : un bouton split-button dropdown sur EngagementDetailPage libellé [Export ▼], qui ouvre un menu Markdown / CSV / PDF. Click → download direct (Blob + URL.createObjectURL). Pas de modal de configuration. Pattern réutilisé du dropdown sprint 5 (SimulationList).

Décisions techniques arrêtées par le team-lead (à challenger par spec-reviewer)

  1. Endpoint backend : un seul endpoint GET /api/engagements/<eid>/export?format=md|csv|pdf plutôt que 3 endpoints distincts. Une seule route à protéger (RBAC), un seul test d'intégration RBAC, switch sur format en interne. Format inconnu → 400 {error: "format must be one of: md, csv, pdf"}. Format manquant → 400 (pas de défaut implicite — évite l'ambiguïté).
  2. Markdown : généré via string templating Python (pas de lib externe). Simple, déterministe, testable par assertion de sous-chaînes.
  3. CSV : généré via csv.writer (stdlib). Une ligne d'en-tête + N lignes simulations. Colonnes : id, name, status, techniques (joined "|"), tactics (joined "|"), description, commands, prerequisites, executed_at, execution_result, log_source, logs, soc_comment, incident_number, created_at, updated_at. Pas de header engagement dans le CSV (format machine-readable strict) ; l'engagement context sort dans le filename.
  4. PDF : généré via WeasyPrint (Python HTML→PDF, lib mature, qualité de rendu pro, dépendances système cairo/pango/gdk-pixbuf à ajouter au python:3.12-slim du Dockerfile). Pipeline : on génère le même HTML que pour le Markdown (mais wrappé en <html>...<style>...</html>), puis WeasyPrint le rend en PDF. Le styling CSS est inline (≤ 30 lignes : hierarchy h1/h2/h3, code-block monospace, alternance fond pour les simulations). Pas de logo / page de garde — keep it simple.
  5. Filename convention : engagement-<id>-<slugified-name>-YYYYMMDD.{ext}. Slugification :
    import unicodedata, re
    normalized = unicodedata.normalize('NFKD', name).encode('ascii', 'ignore').decode()
    slug = re.sub(r'[^a-z0-9]+', '-', normalized.lower()).strip('-')[:60] or "unnamed"
    
    Le NFKD-strip enlève les accents proprement (OpérationOperation), le fallback "unnamed" couvre le edge case d'un nom 100 % non-alphanum ("---!!!""""unnamed"). YYYYMMDD = date.today().strftime('%Y%m%d') côté serveur. Le frontend lit Content-Disposition pour le nom du fichier.
  6. Content-Type : text/markdown; charset=utf-8, text/csv; charset=utf-8, application/pdf.
  7. Génération synchrone : Flask renvoie le fichier dans la même requête (un engagement = quelques dizaines de simulations max, génération < 500 ms même PDF). Pas de job async.
  8. Pas de cache : chaque export régénère depuis la DB (état toujours frais).
  9. Frontend client : pour télécharger, on utilise un fetch avec responseType: 'blob', on lit Content-Disposition pour le filename, puis URL.createObjectURL + <a> invisible + click(). Pas de navigation. Le bouton [Export ▼] utilise la classe btn-outline (la même que le bouton Edit du header existant — cohérence visuelle directe). Le dropdown wrapper réutilise le même token set que le sprint 5 NewSimulationDropdown (shadow-floating + dark:shadow-floating-dark, bg-canvas + dark:bg-fog).

Points OUVERTS pour le spec-reviewer (à valider Pass 1)

  • WeasyPrint vs alternatives : retenu pour rendu pro + pipeline HTML mutualisable. Alternatives écartées : reportlab (layout programmatique = beaucoup plus de code), xhtml2pdf (rendu inférieur), pdfkit + wkhtmltopdf (binaire externe en archive partielle). Le coût Dockerfile (≈ 50 MB de libs cairo/pango) est accepté.
  • CSV sans header engagement : choix de pureté tabulaire (Excel-friendly direct). Le team-lead a tranché. Spec-reviewer doit confirmer ou proposer la variante "1 ligne commentaire # Engagement: <name>".
  • Pas de JSON export : redondant avec l'API. À confirmer.
  • Statut done inclus comme tous les autres : pas de filtre par défaut. L'utilisateur exporte toujours TOUT.

§1 — Backend (Sonnet · backend-builder)

Modèle de données

Aucun changement de modèle. Pas de migration. L'export est en lecture seule sur les modèles existants Engagement + Simulation.

Services / serializers

  • Nouveau module backend/app/services/export.py avec 3 fonctions pures testables unitairement :
    • render_engagement_markdown(engagement: Engagement, simulations: list[Simulation]) -> str
    • render_engagement_csv(engagement: Engagement, simulations: list[Simulation]) -> str
    • render_engagement_pdf(engagement: Engagement, simulations: list[Simulation]) -> bytes
  • Le rendu Markdown réutilise _enrich_techniques + _enrich_tactics de serializers.py pour avoir [{id, name}] au lieu de juste les IDs.
  • Le rendu PDF construit l'HTML à partir d'un template Python _render_engagement_html(engagement, simulations) -> str (string templating, pas Jinja — KISS) et le passe à weasyprint.HTML(string=html).write_pdf(). Important : le PDF est généré à partir des MÊMES DONNÉES (engagement + simulations) que le Markdown, PAS à partir du string Markdown — _render_engagement_html est un rendu distinct.
  • Rendu de created_by : pour Markdown et CSV, on rend la username seule (engagement.created_by.username), pas la dict {id, username}. Pour la cohérence du livrable handoff. Idem pour simulation.created_by.
  • MITRE non chargé : si le bundle n'est pas chargé, _enrich_techniques retourne tactics: [] silencieusement (cohérent avec serialize_simulation existant — pas de 503 dans l'export). Le render doit continuer sans crash. Test dédié exigé (cf. § Tests).

Endpoint

  • Extension du blueprint engagements_bp existant. Path : GET /api/engagements/<int:eid>/export?format=md|csv|pdf.
  • Décorateur : @role_required("admin", "redteam").
  • Logique :
    1. Charger l'engagement (404 si absent).
    2. Parse format query param. Format manquant ou inconnu → 400 {error: "format must be one of: md, csv, pdf"}.
    3. Charger les simulations triées par id ASC.
    4. Appeler la fonction render_engagement_<fmt>(engagement, simulations).
    5. Construire la Response avec Content-Type, Content-Disposition: attachment; filename="<slug>.<ext>", et le body.
  • Filename helper : _export_filename(engagement, ext) -> str (slugifier + date).

Tests

Cible : 226 → 245+ pytest passing.

Fichiers nouveaux :

  • backend/tests/test_export_engagement.py — couvre l'endpoint + RBAC + format inconnu.
    • test_export_markdown_admin_returns_md_with_engagement_header_and_all_simulations
    • test_export_markdown_redteam_ok
    • test_export_markdown_soc_403
    • test_export_csv_returns_csv_with_one_row_per_simulation
    • test_export_csv_columns_match_contract (assert exact header row)
    • test_export_csv_escapes_special_characters (commands avec virgule, guillemet, newline)
    • test_export_pdf_returns_pdf_magic_bytes_and_non_empty
    • test_export_unknown_format_400
    • test_export_missing_format_400
    • test_export_unknown_engagement_404
    • test_export_engagement_with_zero_simulations_renders_header_only
    • test_export_unauthenticated_401
    • test_export_filename_slugifies_name_and_carries_date
  • backend/tests/test_export_render.py — tests unitaires sur les 3 fonctions pures.
    • test_render_engagement_markdown_includes_header_fields
    • test_render_engagement_markdown_lists_all_simulations_in_order
    • test_render_engagement_markdown_includes_techniques_with_id_and_name
    • test_render_engagement_markdown_includes_tactics
    • test_render_engagement_markdown_includes_soc_fields_even_when_blank (cohérence handoff)
    • test_render_engagement_markdown_escapes_backticks_in_commands (fenced code block safety)
    • test_render_engagement_csv_has_header_row
    • test_render_engagement_csv_joins_multi_techniques_with_pipe
    • test_render_engagement_pdf_starts_with_pdf_magic (assert output[:4] == b'%PDF')
    • test_render_engagement_markdown_with_mitre_bundle_not_loaded_does_not_crash (assert render OK et contient les technique IDs même quand le bundle MITRE est absent — sécurise les Docker cold-starts)

Dépendances

  • weasyprint>=60.0 ajouté à backend/requirements.txt.
  • docker/Dockerfile stage Python : ajouter les libs minimales WeasyPrint pour Debian slim. Set minimal pour text-only PDF :
    apt-get install -y libcairo2 libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libfontconfig1 shared-mime-info
    
    Note : libgdk-pixbuf-2.0-0 n'est requis QUE si on intègre des images dans le PDF. Notre rendu est text-only → on peut s'en passer. Le builder confirme via weasyprint --info dans le container après build. Documenter dans le PR.

Livrable backend-builder (summary attendu)

  • PREMIÈRE LIGNE OBLIGATOIRE du summary (lesson sprint 5 — URL drift silencieuse interdite) :
    endpoint final = GET /api/engagements/<int:eid>/export?format=md|csv|pdf
    
    Texte EXACT, pas paraphrasé. Si le builder a choisi un autre path, il le déclare ici en deviation.
  • Tous les fichiers créés/modifiés
  • Contrat API précis (statuts, query params, headers de réponse) en table
  • Liste des helpers réutilisés (_enrich_techniques, _enrich_tactics, serialize_user_brief)
  • Section "Déviations vs plan" explicite (cf. lesson sprint 5)
  • Résultats pytest + ruff + mypy

§2 — Frontend (Sonnet · frontend-builder)

Composants

  • ExportEngagementButton.tsx (nouveau) : split-button dropdown style sprint 5.
    • Bouton principal Export (icône Download lucide-react) + chevron à droite (icône ChevronDown).
    • IMPORTANT — différence sémantique vs NewSimulationDropdown sprint 5 : les DEUX moitiés (label Export + chevron) ouvrent le dropdown. Il n'y a PAS d'action par défaut sur le click gauche (parce qu'il n'y a pas de format "défaut" évident parmi Markdown/CSV/PDF). Ce n'est PAS le même pattern que [+ New] (où la gauche navigue vers /.../new blank).
    • Dropdown : 3 items "Markdown" / "CSV" / "PDF". Click → mutation download.
    • Fermeture : click outside + Escape (réutiliser le hook/effet du dropdown sprint 5 dans SimulationList).
    • Loading state : pendant la mutation, le composant affiche un spinner inline sur l'item cliqué, le dropdown reste ouvert. Désactive les 3 items pendant l'in-flight.
    • Toast erreur sur 4xx/5xx.
    • data-testid="export-dropdown" sur le wrapper du composant pour permettre au test-verifier d'asserter la présence/absence DOM (AC-30.1).
    • Style : utiliser la classe utilitaire btn-outline (la même que le bouton Edit du header existant) — cohérence visuelle directe avec le header.
  • EngagementDetailPage.tsx : intégrer <ExportEngagementButton engagementId={engagement.id} /> dans le header de la page, à côté du bouton Edit existant. Visible uniquement si currentUser.role in ['admin', 'redteam'] (gate côté UI + RBAC backend de toute façon en force) — réutiliser le helper canEditEngagements de useAuth (le même rôle set).

API client

  • frontend/src/api/exports.ts (nouveau) :
    • downloadEngagementExport(engagementId: number, format: 'md' | 'csv' | 'pdf'): Promise<void> — fait un GET /api/engagements/<id>/export?format=<fmt> avec responseType: 'blob', lit Content-Disposition pour le filename, crée un Blob + URL.createObjectURL + <a>.click(), puis URL.revokeObjectURL. Contrat d'erreur : sur réponse non-2xx, parse le JSON {error: "..."} du body (ou défaut "Export failed") et throw un Error avec le message — laisse le caller catcher pour le toast.
    • Helper parseContentDispositionFilename(header: string | undefined): string | null (regex filename="...", fallback null).

Types

  • Aucun nouveau type API (l'export retourne un Blob).

Tests

Cible : 121 → 130+ vitest passing.

Fichiers nouveaux :

  • frontend/tests/ExportEngagementButton.test.tsx
    • renders Export button with chevron
    • clicking primary opens dropdown with three formats
    • clicking outside closes dropdown
    • Escape closes dropdown
    • clicking Markdown triggers download with format=md
    • clicking CSV triggers download with format=csv
    • clicking PDF triggers download with format=pdf
    • loading state disables items during in-flight
    • error response shows toast
  • frontend/tests/EngagementDetailPage.test.tsx (nouveau — il n'existe pas encore, le builder le crée from scratch) :
    • admin sees Export button
    • redteam sees Export button
    • soc does NOT see Export button

Screenshots OBLIGATOIRES (lesson sprint 4)

  • EngagementDetailPage light + dark, dropdown fermé.
  • EngagementDetailPage light + dark, dropdown ouvert (3 items visibles).
  • EngagementDetailPage SOC view — bouton Export ABSENT.
  • Le builder doit fournir un script Playwright authenti (réutiliser le pattern sprint 5 — page.goto('/login') → fill → wait nav).

Livrable frontend-builder (summary attendu)

  • Tous les fichiers créés/modifiés
  • API contracts consommés exactement comme livrés par backend (cf. lesson sprint 5 — path drift à éviter, grep Content-Disposition dans la PR)
  • Helpers réutilisés (useToast, etc.)
  • Résultats vitest + typecheck + lint
  • Liste des écrans capturés (light + dark, role-by-role)

§3 — Acceptance tests (Sonnet · test-verifier)

Cible : 201 → 215+ Playwright passing.

3 user stories à couvrir :

US-29 — Admin/redteam exporte l'engagement en Markdown/CSV/PDF

  • AC-29.1 : login admin → engagement avec ≥ 2 simulations → click "Export" → dropdown s'ouvre.
  • AC-29.2 : click "Markdown" → download d'un .md avec Content-Type: text/markdown. Le fichier contient le nom de l'engagement, la date de début, et le nom de chaque simulation.
  • AC-29.3 : click "CSV" → download d'un .csv avec exactement N+1 rows CSV (1 header + N simulations). La colonne name contient les noms des simulations. Note implémentation test : compter les rows via csv.reader (ou équivalent JS), PAS via file.split('\n') — les commands multilines produisent des cells avec newlines embedded entre quotes, le line-count du fichier > row-count CSV.
  • AC-29.4 : click "PDF" → download avec Content-Type: application/pdf, taille > 1 KB, magic bytes %PDF.
  • AC-29.5 : login redteam → mêmes 3 formats fonctionnent.
  • AC-29.6 : filename respecte engagement-<id>-<slug>-YYYYMMDD.{ext} (assert via Content-Disposition).

US-30 — SOC pas d'accès à l'export

  • AC-30.1 : login SOC → engagement page → bouton "Export" ABSENT du DOM (pas seulement display: none). Assert via expect(page.locator('[data-testid="export-dropdown"]')).not.toBeAttached().
  • AC-30.2 : appel direct API GET /api/engagements/<id>/export?format=md (Bearer SOC) → 403.
  • AC-30.3 : (sanity) appel API sans token → 401.

US-31 — Robustesse format / engagement

  • AC-31.1 : GET /api/engagements/<id>/export sans format → 400 message friendly.
  • AC-31.2 : GET /api/engagements/<id>/export?format=xml → 400 friendly.
  • AC-31.3 : GET /api/engagements/99999/export?format=md → 404.
  • AC-31.4 : engagement avec 0 simulations → export OK (header seul, le CSV n'a qu'une ligne d'en-tête, le MD n'a pas de section simulation).

Bouncing

  • Si un AC échoue → bounce au builder responsable (backend ou frontend), pas de patch test-side.

§4 — Reviews

Spec-reviewer Pass 1 (avant dispatch)

  • Lit ce tasks/todo.md § 0 + § 1 + § 2 + § 3.
  • Verdict attendu : APPROVED / NEEDS-CHANGES par section.
  • Points particuliers à challenger : WeasyPrint vs reportlab, CSV sans header engagement, URL drift (un seul endpoint avec query param vs 3 endpoints distincts).

Spec-reviewer Pass 2 (après mes éventuels édits du plan)

  • Re-validation des changements apportés.
  • TEAM-LEAD : ne PAS dispatcher backend tant que Pass 2 n'a pas répondu APPROVED. Lesson sprint 5 — la patience sur le 2-pass a éliminé les addenda mid-implementation.

Code-reviewer (après backend + frontend)

  • LSP first (goToDefinition, findReferences).
  • Focalise sur : pureté des render functions (testables), gestion des deps WeasyPrint dans Dockerfile, échappement CSV, filename slug, dropdown close-on-outside réutilisation.

Design-reviewer (après screenshots frontend)

  • Light + dark cohérence du dropdown Export.
  • Vérifie que le bouton respecte la convention "icône + label court ≤ 8 chars" (Export).
  • Audit alignement vs le header existant de la page.

Test-verifier (après code-reviewer APPROVED)

  • Écrit 1 spec file par US (us29-export-formats, us30-export-rbac, us31-export-robustness).
  • Rapport pass/fail par AC.

§5 — SPEC.md update (au tout début du sprint — lesson sprint 3/4/5)

Ajouter une section § Export d'engagement entre § Templates de simulations et § Authentification & rôles :

Export d'engagement

Un engagement peut être exporté à tout moment dans 3 formats au choix : Markdown (handoff narratif), CSV (machine-readable, intégration tableurs), PDF (livrable client). L'export contient l'en-tête de l'engagement et toutes ses simulations avec les champs Red Team et SOC. Endpoint unique : GET /api/engagements/<id>/export?format=md|csv|pdf. Réservé aux rôles admin et redteam (livrable RedTeam, cohérent avec le RBAC Templates). Filename normalisé : engagement-<id>-<slug>-YYYYMMDD.<ext>.

Le commit qui crée cette section doit être le PREMIER commit du sprint (pas le dernier — sinon on rate le bug récurrent identifié dans les lessons). Le commit suivant peut être le plan lui-même (tasks/todo.md + tasks/lessons.md).


§6 — Workflow git du sprint

  • Branch : sprint/6-export (créée @ 678ee8f).
  • Commit séquence :
    1. docs(spec): add § Export d'engagement section (le M SPEC.md ne doit JAMAIS rester unstaged)
    2. docs(plan): sprint 6 plan + sprint-5 lessons folded (tasks/)
    3. Commits backend (un ou deux, signés par backend-builder)
    4. Commits frontend (un ou deux, signés par frontend-builder)
    5. Commit post-code-review fixes (si nécessaire)
    6. Commit screenshots design + e2e tests
    7. Wrap-up commit team-lead : CHANGELOG + README + lessons.md sprint-6 + plan final
  • PR via make open-pr SPRINT=6 TITLE="feat: sprint 6 — engagement export (md/csv/pdf)" BODY=tasks/pr-body-sprint-6.md (3e dogfood du wrapper sprint 4).

§7 — Risk / hazard list

# Risk Mitigation
1 WeasyPrint deps gonflent l'image Docker Liste minimale documentée + WeasyPrint déjà packagé sur Debian slim ; mesurer Δ MB image build après vs avant
2 CSV mal-échappé avec commands multilines / quotes Utiliser csv.writer stdlib (handles tout automatiquement), pas de string concat manuel
3 Markdown casse sur backticks dans commands Fenced code blocks ~~~bash (tildes au lieu de backticks pour les blocks contenant des backticks), OU escape via markdown.escape
4 Test PDF fragile sur le contenu Asserter UNIQUEMENT : Content-Type, magic bytes %PDF, taille > 1 KB. Pas de regex sur le texte rendu (binary).
5 URL drift backend (/export vs /engagements/<id>/export) Lesson sprint 5 — la 1re ligne du backend summary doit confirmer le path exact
6 Frontend oublie URL.revokeObjectURL → fuite mémoire Test unitaire explicite : assert revokeObjectURL appelé après le click téléchargement
7 SPEC.md uncommitted à la fin du sprint (3 sprints en série !) Commit SPEC.md en commit #1 du sprint, pas en wrap-up. Étape « cendrillon » du plan ci-dessus.

§8 — Definition of Done (sprint-level)

  • §5 SPEC.md committed AS THE FIRST COMMIT of the sprint.
  • Backend : 245+ pytest, ruff clean, mypy clean.
  • Frontend : 130+ vitest, typecheck clean, lint clean.
  • E2e : 215+ Playwright, 0 régression vs main.
  • Screenshots fournies : EngagementDetailPage light + dark, dropdown fermé + ouvert, vue SOC sans bouton.
  • Dockerfile mis à jour avec deps WeasyPrint + make build réussit.
  • CHANGELOG.md [Unreleased] → Sprint 6 rédigée.
  • README.md « Status » bumped + section dans le tableau des features si pertinent.
  • PR ouverte via make open-pr (pas via UI manuelle).
  • git status au sprint-close affiche uniquement des fichiers ignorés (lesson récurrente).