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.
262 lines
18 KiB
Markdown
262 lines
18 KiB
Markdown
# 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)
|
|
|
|
6. **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é).
|
|
7. **Markdown** : généré via string templating Python (pas de lib externe). Simple, déterministe, testable par assertion de sous-chaînes.
|
|
8. **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.
|
|
9. **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.
|
|
10. **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 lit `Content-Disposition` pour le nom du fichier.
|
|
11. **Content-Type** : `text/markdown; charset=utf-8`, `text/csv; charset=utf-8`, `application/pdf`.
|
|
12. **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.
|
|
13. **Pas de cache** : chaque export régénère depuis la DB (état toujours frais).
|
|
14. **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 ▼]` 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 `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()`.
|
|
|
|
### 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'`)
|
|
|
|
### Dépendances
|
|
- `weasyprint>=60.0` ajouté à `backend/requirements.txt`.
|
|
- `docker/Dockerfile` stage Python : ajouter `apt-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ône `Download` lucide-react) + chevron à droite (icône `ChevronDown`).
|
|
- 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.
|
|
- **`EngagementDetailPage.tsx`** : intégrer `<ExportEngagementButton engagementId={engagement.id} />` dans le header de la page, à côté des autres CTAs existants (edit/close). **Visible uniquement si `currentUser.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>` avec `responseType: 'blob'`, lit `Content-Disposition` pour le filename, crée un `Blob` + `URL.createObjectURL` + `<a>.click()`, puis `URL.revokeObjectURL`.
|
|
- 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` (existant — adapter) :
|
|
- `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 lignes (1 header + N simulations). La colonne `name` contient 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>/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).
|