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.
This commit is contained in:
@@ -16,11 +16,17 @@
|
|||||||
7. **Markdown** : généré via string templating Python (pas de lib externe). Simple, déterministe, testable par assertion de sous-chaînes.
|
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.
|
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.
|
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.
|
10. **Filename convention** : `engagement-<id>-<slugified-name>-YYYYMMDD.{ext}`. Slugification :
|
||||||
|
```python
|
||||||
|
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ération` → `Operation`), 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.
|
||||||
11. **Content-Type** : `text/markdown; charset=utf-8`, `text/csv; charset=utf-8`, `application/pdf`.
|
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.
|
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).
|
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`).
|
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 ▼]` 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)
|
### Points OUVERTS pour le spec-reviewer (à valider Pass 1)
|
||||||
|
|
||||||
@@ -42,7 +48,9 @@
|
|||||||
- `render_engagement_csv(engagement: Engagement, simulations: list[Simulation]) -> str`
|
- `render_engagement_csv(engagement: Engagement, simulations: list[Simulation]) -> str`
|
||||||
- `render_engagement_pdf(engagement: Engagement, simulations: list[Simulation]) -> bytes`
|
- `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 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()`.
|
- 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
|
### Endpoint
|
||||||
- Extension du blueprint `engagements_bp` existant. Path : `GET /api/engagements/<int:eid>/export?format=md|csv|pdf`.
|
- Extension du blueprint `engagements_bp` existant. Path : `GET /api/engagements/<int:eid>/export?format=md|csv|pdf`.
|
||||||
@@ -83,16 +91,26 @@ Fichiers nouveaux :
|
|||||||
- `test_render_engagement_csv_has_header_row`
|
- `test_render_engagement_csv_has_header_row`
|
||||||
- `test_render_engagement_csv_joins_multi_techniques_with_pipe`
|
- `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_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
|
### Dépendances
|
||||||
- `weasyprint>=60.0` ajouté à `backend/requirements.txt`.
|
- `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.
|
- `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)
|
### 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
|
- Tous les fichiers créés/modifiés
|
||||||
- Contrat API précis (statuts, query params, headers de réponse) en table
|
- 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`)
|
- 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)
|
- **Section "Déviations vs plan"** explicite (cf. lesson sprint 5)
|
||||||
- Résultats pytest + ruff + mypy
|
- Résultats pytest + ruff + mypy
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -102,16 +120,18 @@ Fichiers nouveaux :
|
|||||||
### Composants
|
### Composants
|
||||||
- **`ExportEngagementButton.tsx`** (nouveau) : split-button dropdown style sprint 5.
|
- **`ExportEngagementButton.tsx`** (nouveau) : split-button dropdown style sprint 5.
|
||||||
- Bouton principal `Export` (icône `Download` lucide-react) + chevron à droite (icône `ChevronDown`).
|
- 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).
|
- **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.
|
- Dropdown : 3 items "Markdown" / "CSV" / "PDF". Click → mutation download.
|
||||||
- Fermeture : click outside + Escape (réutiliser le hook/effet du dropdown sprint 5 dans `SimulationList`).
|
- 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.
|
- 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.
|
- 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).
|
- **`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
|
### API client
|
||||||
- **`frontend/src/api/exports.ts`** (nouveau) :
|
- **`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`.
|
- `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).
|
- Helper `parseContentDispositionFilename(header: string | undefined): string | null` (regex `filename="..."`, fallback null).
|
||||||
|
|
||||||
### Types
|
### Types
|
||||||
@@ -131,7 +151,7 @@ Fichiers nouveaux :
|
|||||||
- `clicking PDF triggers download with format=pdf`
|
- `clicking PDF triggers download with format=pdf`
|
||||||
- `loading state disables items during in-flight`
|
- `loading state disables items during in-flight`
|
||||||
- `error response shows toast`
|
- `error response shows toast`
|
||||||
- `frontend/tests/EngagementDetailPage.test.tsx` (existant — adapter) :
|
- `frontend/tests/EngagementDetailPage.test.tsx` (**nouveau** — il n'existe pas encore, le builder le crée from scratch) :
|
||||||
- `admin sees Export button`
|
- `admin sees Export button`
|
||||||
- `redteam sees Export button`
|
- `redteam sees Export button`
|
||||||
- `soc does NOT see Export button`
|
- `soc does NOT see Export button`
|
||||||
@@ -160,13 +180,13 @@ Fichiers nouveaux :
|
|||||||
### US-29 — Admin/redteam exporte l'engagement en Markdown/CSV/PDF
|
### 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.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.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.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.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.5** : login redteam → mêmes 3 formats fonctionnent.
|
||||||
- **AC-29.6** : filename respecte `engagement-<id>-<slug>-YYYYMMDD.{ext}` (assert via Content-Disposition).
|
- **AC-29.6** : filename respecte `engagement-<id>-<slug>-YYYYMMDD.{ext}` (assert via Content-Disposition).
|
||||||
|
|
||||||
### US-30 — SOC pas d'accès à l'export
|
### 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.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.2** : appel direct API `GET /api/engagements/<id>/export?format=md` (Bearer SOC) → 403.
|
||||||
- **AC-30.3** : (sanity) appel API sans token → 401.
|
- **AC-30.3** : (sanity) appel API sans token → 401.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user