3 user stories scoped (US-29 export formats, US-30 SOC zero access, US-31 format/engagement robustness). Backend extends engagements_bp with GET /api/engagements/<id>/export?format=md|csv|pdf returning the rendered file, no DB schema change. Frontend adds an ExportEngagementButton split-button dropdown on EngagementDetailPage, gated to admin+redteam. Binding decisions locked with the user: 3 formats Markdown/CSV/PDF, RBAC admin+redteam, engagement + all simulations RT+SOC, single endpoint with format query param. WeasyPrint chosen for PDF (Python HTML→PDF, ~50MB cairo/pango deps to add to Dockerfile, accepted). Plan ready for spec-reviewer Pass 1.
18 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)
- 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 ».
- Formats livrés : Markdown, CSV, PDF (3 formats). JSON exclu (redondant avec l'API existante).
- RBAC :
admin+redteampeuvent 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). - 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). - Déclenchement UI : un bouton split-button dropdown sur
EngagementDetailPagelibellé[Export ▼], qui ouvre un menuMarkdown / 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)
- Endpoint backend : un seul endpoint
GET /api/engagements/<eid>/export?format=md|csv|pdfplutôt que 3 endpoints distincts. Une seule route à protéger (RBAC), un seul test d'intégration RBAC, switch surformaten 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é). - Markdown : généré via string templating Python (pas de lib externe). Simple, déterministe, testable par assertion de sous-chaînes.
- 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. - 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-slimdu 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. - Filename convention :
engagement-<id>-<slugified-name>-YYYYMMDD.{ext}. Slugification =re.sub(r'[^a-z0-9]+', '-', name.lower()).strip('-')[:60].YYYYMMDD=date.today().strftime('%Y%m%d')côté serveur. Le frontend litContent-Dispositionpour le nom du fichier. - Content-Type :
text/markdown; charset=utf-8,text/csv; charset=utf-8,application/pdf. - 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.
- Pas de cache : chaque export régénère depuis la DB (état toujours frais).
- Frontend client : pour télécharger, on utilise un fetch avec
responseType: 'blob', on litContent-Dispositionpour le filename, puisURL.createObjectURL+<a>invisible +click(). Pas de navigation. Le bouton[Export ▼]partage l'esthétique du[+ New ▼]du sprint 5 (bg-canvas,text-ink,border,rounded,shadow-soft-lift+dark:shadow-soft-lift-dark).
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
doneinclus 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.pyavec 3 fonctions pures testables unitairement :render_engagement_markdown(engagement: Engagement, simulations: list[Simulation]) -> strrender_engagement_csv(engagement: Engagement, simulations: list[Simulation]) -> strrender_engagement_pdf(engagement: Engagement, simulations: list[Simulation]) -> bytes
- Le rendu Markdown réutilise
_enrich_techniques+_enrich_tacticsdeserializers.pypour 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().
Endpoint
- Extension du blueprint
engagements_bpexistant. Path :GET /api/engagements/<int:eid>/export?format=md|csv|pdf. - Décorateur :
@role_required("admin", "redteam"). - Logique :
- Charger l'engagement (404 si absent).
- Parse
formatquery param. Format manquant ou inconnu → 400{error: "format must be one of: md, csv, pdf"}. - Charger les simulations triées par
id ASC. - Appeler la fonction
render_engagement_<fmt>(engagement, simulations). - Construire la
ResponseavecContent-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_simulationstest_export_markdown_redteam_oktest_export_markdown_soc_403test_export_csv_returns_csv_with_one_row_per_simulationtest_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_emptytest_export_unknown_format_400test_export_missing_format_400test_export_unknown_engagement_404test_export_engagement_with_zero_simulations_renders_header_onlytest_export_unauthenticated_401test_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_fieldstest_render_engagement_markdown_lists_all_simulations_in_ordertest_render_engagement_markdown_includes_techniques_with_id_and_nametest_render_engagement_markdown_includes_tacticstest_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_rowtest_render_engagement_csv_joins_multi_techniques_with_pipetest_render_engagement_pdf_starts_with_pdf_magic(assertoutput[:4] == b'%PDF')
Dépendances
weasyprint>=60.0ajouté àbackend/requirements.txt.docker/Dockerfilestage Python : ajouterapt-get install -y libcairo2 libpango-1.0-0 libpangoft2-1.0-0 libgdk-pixbuf-2.0-0 libffi-dev shared-mime-info(liste minimale WeasyPrint pour Debian slim). Documenter dans le PR.
Livrable backend-builder (summary attendu)
- 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 — URL drift silencieuse à interdire)
- Résultats pytest + ruff + mypy
§2 — Frontend (Sonnet · frontend-builder)
Composants
ExportEngagementButton.tsx(nouveau) : split-button dropdown style sprint 5.- Bouton principal
Export(icôneDownloadlucide-react) + chevron à droite (icôneChevronDown). - Click sur principal : ouvre le dropdown (pas d'action par défaut).
- 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.
- Bouton principal
EngagementDetailPage.tsx: intégrer<ExportEngagementButton engagementId={engagement.id} />dans le header de la page, à côté des autres CTAs existants (edit/close). Visible uniquement sicurrentUser.role in ['admin', 'redteam'](gate côté UI + RBAC backend de toute façon en force).
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>avecresponseType: 'blob', litContent-Dispositionpour le filename, crée unBlob+URL.createObjectURL+<a>.click(), puisURL.revokeObjectURL.- Helper
parseContentDispositionFilename(header: string | undefined): string | null(regexfilename="...", fallback null).
Types
- Aucun nouveau type API (l'export retourne un Blob).
Tests
Cible : 121 → 130+ vitest passing.
Fichiers nouveaux :
frontend/tests/ExportEngagementButton.test.tsxrenders Export button with chevronclicking primary opens dropdown with three formatsclicking outside closes dropdownEscape closes dropdownclicking Markdown triggers download with format=mdclicking CSV triggers download with format=csvclicking PDF triggers download with format=pdfloading state disables items during in-flighterror response shows toast
frontend/tests/EngagementDetailPage.test.tsx(existant — adapter) :admin sees Export buttonredteam sees Export buttonsoc does NOT see Export button
Screenshots OBLIGATOIRES (lesson sprint 4)
EngagementDetailPagelight + dark, dropdown fermé.EngagementDetailPagelight + dark, dropdown ouvert (3 items visibles).EngagementDetailPageSOC 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-Dispositiondans 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
.mdavecContent-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
.csvavec exactement N+1 lignes (1 header + N simulations). La colonnenamecontient les noms des simulations. - 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). - 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>/exportsansformat→ 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 :
docs(spec): add § Export d'engagement section(leM SPEC.mdne doit JAMAIS rester unstaged)docs(plan): sprint 6 plan + sprint-5 lessons folded(tasks/)- Commits backend (un ou deux, signés par backend-builder)
- Commits frontend (un ou deux, signés par frontend-builder)
- Commit post-code-review fixes (si nécessaire)
- Commit screenshots design + e2e tests
- 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 buildréussit. - CHANGELOG.md
[Unreleased] → Sprint 6ré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 statusau sprint-close affiche uniquement des fichiers ignorés (lesson récurrente).