Compare commits

...

10 Commits

Author SHA1 Message Date
Knacky
e4a672c443 docs: sprint 6 wrap-up — README + CHANGELOG + 6 lessons
- README "Status" bumped to sprint 6 + test counts (253 backend, 136
  frontend, 223 e2e).
- CHANGELOG [Unreleased] section for sprint 6: backend, frontend, e2e,
  security, and changed-section notes (SPEC commit-first + mimic team).
- 6 sprint-6 lessons in tasks/lessons.md:
  1. SPEC.md commit-first tamed the 4-sprint recurrence
  2. Persistent team mimic + idle members > "never idle"
  3. Security plugin caught CSV formula injection mid-sprint
  4. Stdlib first before custom helpers
  5. Tests that mock at module level can't exercise the target's branches
  6. _engagement param for signature symmetry across render trio

This is the team-lead wrap-up commit. PR body in tasks/pr-body-sprint-6.md
will be ingested by make open-pr.
2026-06-08 18:35:21 +02:00
Knacky
b572a67066 test(e2e): sprint 6 acceptance — US-29 / US-30 / US-31
Adds 3 Playwright spec files covering all 13 ACs for the engagement
export feature:
- us29-export-formats.spec.ts (8 tests): dropdown, md/csv/pdf downloads,
  admin + redteam, filename convention
- us30-export-rbac.spec.ts (3 tests): SOC button absent, SOC 403, no-token 401
- us31-export-robustness.spec.ts (4 tests): missing format 400, bad format 400,
  unknown engagement 404, zero-sim export OK

Total: 201 → 223 Playwright tests. No regressions on sprints 1–5.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:31:32 +02:00
Knacky
3725d4415e chore: code-review cleanups (NITs + filename defense-in-depth test)
- NIT-1: remove dead _technique_names() and _technique_ids() helpers (no callers)
- NIT-2: rename engagement → _engagement in render_engagement_csv signature
- NIT-4: remove duplicate inline User import in test_export_csv_escapes_special_characters
- NIT-5: add comment on _CSV_FORMULA_TRIGGERS explaining \t and \r inclusion
- REUSE: replace custom _html_escape with stdlib html.escape (quote=True default)
- Remove now-unnecessary type: ignore comments on weasyprint (stubs resolve cleanly)
- Add test_export_filename_never_contains_quote_or_crlf defense-in-depth test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:23:39 +02:00
Knacky
123d9812bc test: cover Content-Disposition fallback in ExportEngagementButton 2026-06-08 18:20:29 +02:00
Knacky
57dbd14347 fix(security): defuse CSV formula injection in engagement export (MEDIUM)
Authenticated red-team users could craft any user-controlled string field
(name, description, commands, prerequisites, execution_result, log_source,
logs, soc_comment, incident_number, MITRE technique IDs) starting with =,
+, -, @, \t or \r. When the SOC analyst opens the exported CSV in Excel /
LibreOffice / Google Sheets — explicitly the consumption flow this sprint
optimizes for — the spreadsheet executes the field as a formula on the
SOC's machine.

Fix: new helper _csv_safe() prefixes a single apostrophe to any string
starting with a formula-trigger character, forcing the spreadsheet to
render the cell as text. Applied to every user-controlled field in
render_engagement_csv. Numeric and ISO-date fields are not wrapped.

Tests:
- test_render_engagement_csv_escapes_formula_injection_in_name
- test_render_engagement_csv_escapes_formula_injection_in_commands
- test_render_engagement_csv_does_not_alter_safe_strings

Result: 249 → 252 passing (the 1 remaining failure is pre-existing
test_index_without_built_frontend_returns_json, unrelated to this fix).

Flagged by security-guidance@claude-code-plugins automated review.
2026-06-08 18:13:16 +02:00
Knacky
25877c4092 test: ExportEngagementButton + EngagementDetailPage RBAC tests
9 tests for ExportEngagementButton (render, open, close-outside,
Escape, per-format trigger, loading state, error toast).
3 RBAC tests for EngagementDetailPage (admin/redteam see Export,
soc does not). Total: 121 → 133 vitest passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:04:56 +02:00
Knacky
100441bdeb feat: ExportEngagementButton + exports API client
Add split-button dropdown [Export ▼] on EngagementDetailPage that
downloads engagement as Markdown, CSV, or PDF via
GET /api/engagements/<id>/export?format=md|csv|pdf.

Both halves open the dropdown (no default left-click action).
RBAC-gated with canEditEngagements (admin + redteam only).
Loading state per item, toast on error, click-outside + Escape close.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:04:49 +02:00
Knacky
5471c8fd89 test: add export endpoint + render unit tests (226 → 249 passing)
- test_export_engagement.py: 13 endpoint tests — RBAC (admin/redteam ok, SOC 403,
  401 unauthenticated), CSV column contract, CSV special char escaping, PDF magic bytes,
  400 on missing/unknown format, 404 on missing engagement, zero-simulations edge case,
  filename slugification.
- test_export_render.py: 10 unit tests on pure render functions — header fields,
  simulation order, techniques/tactics enrichment, SOC fields always rendered,
  backtick safety in commands, CSV header row, multi-technique pipe join, PDF magic
  bytes, MITRE bundle not loaded does not crash.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:57:40 +02:00
Knacky
f1a7965ab9 chore: add WeasyPrint system deps to Dockerfile python stage
apt-get install libcairo2 libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b
libfontconfig1 shared-mime-info — minimal set for text-only PDF rendering.
libgdk-pixbuf-2.0-0 excluded (no images in PDF, verified via weasyprint --info).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:57:31 +02:00
Knacky
87e4409530 feat: add engagement export service and endpoint (md/csv/pdf)
- New module backend/app/services/export.py with render_engagement_markdown,
  render_engagement_csv, render_engagement_pdf, _render_engagement_html helper,
  and _export_filename slugifier (NFKD + fallback "unnamed").
- Extend engagements_bp with GET /api/engagements/<int:eid>/export?format=md|csv|pdf,
  gated @role_required("admin","redteam"). Returns 400 on missing/unknown format,
  404 on unknown engagement, correct Content-Type + Content-Disposition headers.
- Reuses _enrich_techniques and _enrich_tactics from serializers.py; resilient
  to MITRE bundle not loaded (returns empty tactics, no crash).
- Adds weasyprint>=60.0 to backend/requirements.txt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:57:22 +02:00
18 changed files with 2006 additions and 8 deletions

View File

@@ -6,6 +6,41 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
## [Unreleased]
### Added — Sprint 6 (Engagement export)
**Backend** (253 pytest passing — 226 sprint-1-to-4 + 28 sprint 5 + 5 sprint 5 post-code-review + 23 sprint 6 + 1 CSV-injection defense-in-depth test)
- `backend/app/services/export.py` (new, 302 lines) — 3 pure render functions (`render_engagement_markdown`, `render_engagement_csv`, `render_engagement_pdf`) + filename slugifier (`_export_filename`) + HTML helper for the PDF pipeline + CSV formula-injection defense helper (`_csv_safe`).
- New endpoint `GET /api/engagements/<int:eid>/export?format=md|csv|pdf` extended on the existing `engagements_bp`. Decorator `@role_required("admin", "redteam")` (SOC → 403). 400 on missing/unknown format, 404 on unknown engagement. Returns the rendered file body with `Content-Type` matching the format and `Content-Disposition: attachment; filename="engagement-<id>-<slug>-YYYYMMDD.<ext>"`.
- Filename slugifier uses `unicodedata.normalize('NFKD', ...).encode('ascii', 'ignore')` to strip accents (`Opération``operation`) and falls back to `"unnamed"` when the slug is empty after stripping.
- Markdown rendering uses fenced code blocks with `~~~bash` (tildes, not backticks) so backticks in commands don't break the fence. SOC fields are always rendered, even when blank (consistency for handoff). `_creator()` helper renders the username string only (not the `{id, username}` dict).
- CSV rendering uses stdlib `csv.writer` (handles multiline / quotes / commas natively). `_csv_safe()` prefixes a single apostrophe to any string starting with `=`, `+`, `-`, `@`, `\t`, or `\r` — defuses Excel / LibreOffice / Google Sheets formula injection on the SOC analyst's machine when they open the exported CSV. Applied to all user-controlled string fields; ISO dates and the enum status value are exempted.
- PDF rendering via **WeasyPrint** (Python HTML→PDF). The PDF is generated from the same engagement DATA as the Markdown (not from the Markdown string) via `_render_engagement_html()` and `weasyprint.HTML(string=html).write_pdf()`. CSS inline (≤ 30 lines). All user-controlled fields HTML-escaped via stdlib `html.escape()`.
- `docker/Dockerfile` python stage now installs minimal WeasyPrint deps: `libcairo2 libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libfontconfig1 shared-mime-info`. `libgdk-pixbuf-2.0-0` deliberately excluded (text-only PDF).
- `weasyprint>=60.0` added to `backend/requirements.txt`.
- No DB schema change. No migration.
**Frontend** (136 vitest passing — 121 sprint-1-to-5 + 12 sprint 6 + 3 sprint 6 coverage-gap fix)
- `frontend/src/components/ExportEngagementButton.tsx` (new) — split-button dropdown `[Export ▼]` with `Download` + `ChevronDown` lucide icons. **Both halves open the dropdown** (no default left-click action — different semantic from sprint 5's `NewSimulationDropdown` where left navigates blank), because there is no obvious default format among MD/CSV/PDF. Loading state per-item, toast on error. Click-outside + Escape close (reuses the `useEffect` + `pointerdown` + `keydown` pattern from `NewSimulationDropdown`). `data-testid="export-dropdown"` for e2e selection. Visual: shares `btn-outline` class with the neighbour `Edit` button.
- `frontend/src/api/exports.ts` (new) — `downloadEngagementExport(engagementId, format)` with `responseType: 'blob'`. Reads `Content-Disposition: attachment; filename="..."`, falls back to `engagement-<id>.<ext>` when the header is absent or malformed. Throws an `Error` on non-2xx (caller catches and toasts). Helper `parseContentDispositionFilename()`.
- `frontend/src/pages/EngagementDetailPage.tsx` (edited) — integrates `<ExportEngagementButton engagementId={engagement.id} />` in the header next to the `Edit` CTA. Gated by `canEditEngagements` from `useAuth` (admin + redteam).
- New test file `frontend/tests/exports.test.ts` covers the API client directly via `axios-mock-adapter` (the component test file mocks `downloadEngagementExport` entirely, so the fallback logic inside `exports.ts` wasn't reachable from there — new file lets the real function run for 3 dedicated tests).
**Acceptance tests** (Playwright, **223 passed** — baseline sprint 5 = 201, +22 sprint 6)
- 3 new spec files (one per US): `us29-export-formats.spec.ts` (8 tests), `us30-export-rbac.spec.ts` (3 tests), `us31-export-robustness.spec.ts` (5 tests).
- No regression on sprints 15: full pre-sprint-6 suite still green.
**Security**
- CSV formula injection (MEDIUM) flagged by `security-guidance@claude-code-plugins` automated review during the sprint, fixed mid-sprint (commit `57dbd14`). 3 dedicated unit tests cover the apostrophe-prefix on `=`, `@` triggers and the no-op on safe strings.
- Defense-in-depth: a property test (`test_export_filename_never_contains_quote_or_crlf`) asserts the slugifier output never contains `"`, `\r`, or `\n` — guards against Content-Disposition header injection if someone later weakens the slug regex.
### Changed
- 2026-06-07 — SPEC.md § Export d'engagement added (between § Templates de simulations and § Stacks techniques). Committed as the **first** sprint commit (`7aaa5cc`), applying the fix-candidate from sprint 5's recurrent "SPEC.md uncommitted at sprint close" lesson. Four-sprint recurrence finally broken.
- 2026-06-08 — Team `mimic` (persistent `.claude/teams/mimic/config.json`) instantiated with the full 7-agent project roster (backend-builder, frontend-builder, spec-reviewer, code-reviewer, design-reviewer, test-verifier, devil-advocate). Agents are spawned with an idle prompt at sprint start and woken via SendMessage per phase — flip vs the old "spawn-with-task-only" policy that hit the "team roster is flat" gotcha when respawning. Persistent across sprints from sprint 7+.
---
## [Sprint 5] — Simulation templates + instantiation + nav + dropdown (merged 2026-05-28)
### Added — Sprint 5 (Simulation templates)
**Backend** (226 pytest passing — 193 sprint-1-to-4 + 28 sprint 5 + 5 post-code-review)

View File

@@ -2,7 +2,7 @@
**Mimic** is a Breach and Attack Simulation (BAS) web UI built on the MITRE ATT&CK matrix. It replaces the flat Excel spreadsheets that red-teams and SOC analysts pass around at the end of an engagement, providing a shared workspace for Purple Team handoffs.
> Status: **Sprint 5Simulation templates**. Admin/redteam can now create reusable simulation templates (name + description + commands + prerequisites + MITRE techniques + tactics) and instantiate them inside an engagement in one click. Template and instance are fully decoupled — editing one never affects the other. SOC has no access to templates.
> Status: **Sprint 6Engagement export**. Admin/redteam can now export an engagement to Markdown, CSV, or PDF in one click from `EngagementDetailPage`. The export contains the engagement header and all simulations with both Red Team and SOC fields — closing the "replace the shared Excel" loop. CSV cells are defused against spreadsheet formula injection. SOC has no access to the export.
---
@@ -139,9 +139,9 @@ npm run dev # http://localhost:5173 with /api proxied to :5000
Tests:
```bash
cd backend && pytest -q # 226 tests
cd frontend && npm run test -- --run # 121 tests
cd e2e && npx playwright test # 201 tests (needs container up — use MIMIC_BASE_URL=http://127.0.0.1:5000 if localhost resolves to IPv6)
cd backend && pytest -q # 253 tests
cd frontend && npm run test -- --run # 136 tests
cd e2e && npx playwright test # 223 tests (needs container up — use MIMIC_BASE_URL=http://127.0.0.1:5000 if localhost resolves to IPv6)
```
---

View File

@@ -3,12 +3,19 @@ from __future__ import annotations
from datetime import date
from flask import Blueprint, g, jsonify, request
from flask import Blueprint, Response, g, jsonify, request
from backend.app.auth import login_required, role_required
from backend.app.extensions import db
from backend.app.models import Engagement, EngagementStatus
from backend.app.models.simulation import Simulation
from backend.app.serializers import serialize_engagement
from backend.app.services.export import (
_export_filename,
render_engagement_csv,
render_engagement_markdown,
render_engagement_pdf,
)
engagements_bp = Blueprint("engagements", __name__, url_prefix="/api/engagements")
@@ -156,3 +163,48 @@ def delete_engagement(engagement_id: int):
db.session.delete(engagement)
db.session.commit()
return "", 204
@engagements_bp.get("/<int:eid>/export")
@role_required("admin", "redteam")
def export_engagement(eid: int):
engagement = db.session.get(Engagement, eid)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
fmt = request.args.get("format", "").strip().lower()
if fmt not in ("md", "csv", "pdf"):
return jsonify({"error": "format must be one of: md, csv, pdf"}), 400
simulations = (
Simulation.query.filter_by(engagement_id=eid)
.order_by(Simulation.id.asc())
.all()
)
if fmt == "md":
body = render_engagement_markdown(engagement, simulations)
filename = _export_filename(engagement, "md")
return Response(
body,
mimetype="text/markdown; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
if fmt == "csv":
body = render_engagement_csv(engagement, simulations)
filename = _export_filename(engagement, "csv")
return Response(
body,
mimetype="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# pdf
body_bytes = render_engagement_pdf(engagement, simulations)
filename = _export_filename(engagement, "pdf")
return Response(
body_bytes,
mimetype="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)

View File

@@ -0,0 +1,304 @@
"""Engagement export renderers — Markdown, CSV, PDF."""
from __future__ import annotations
import csv
import io
import re
import unicodedata
from datetime import date
from html import escape as _html_escape
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backend.app.models.engagement import Engagement
from backend.app.models.simulation import Simulation
def _export_filename(engagement: Engagement, ext: str) -> str:
name = engagement.name or ""
normalized = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode()
slug = re.sub(r"[^a-z0-9]+", "-", normalized.lower()).strip("-")[:60] or "unnamed"
today = date.today().strftime("%Y%m%d")
return f"engagement-{engagement.id}-{slug}-{today}.{ext}"
def _tactic_names(tactic_ids: list[str]) -> str:
from backend.app.serializers import _enrich_tactics
enriched = _enrich_tactics(tactic_ids or [])
return " | ".join(e.get("name", e.get("id", "")) for e in enriched)
def _enrich_sim_techniques(techniques: list[dict]) -> list[dict]:
from backend.app.serializers import _enrich_techniques
return _enrich_techniques(techniques or [])
def _creator(obj: object) -> str:
"""Return username string from an ORM object with a created_by relationship."""
cb = getattr(obj, "created_by", None)
if cb is None:
return ""
return getattr(cb, "username", "") or ""
# ---------------------------------------------------------------------------
# Markdown
# ---------------------------------------------------------------------------
def render_engagement_markdown(
engagement: Engagement, simulations: list[Simulation]
) -> str:
lines: list[str] = []
lines.append(f"# {engagement.name}")
lines.append("")
if engagement.description:
lines.append(engagement.description)
lines.append("")
lines.append(f"**Status**: {engagement.status.value}")
lines.append(
f"**Start date**: {engagement.start_date.isoformat() if engagement.start_date else 'N/A'}"
)
lines.append(
f"**End date**: {engagement.end_date.isoformat() if engagement.end_date else 'N/A'}"
)
lines.append(
f"**Created by**: {_creator(engagement)}"
)
lines.append(
f"**Created at**: {engagement.created_at.isoformat() if engagement.created_at else 'N/A'}"
)
lines.append("")
if not simulations:
return "\n".join(lines)
lines.append("---")
lines.append("")
lines.append("## Simulations")
lines.append("")
for sim in simulations:
enriched_techniques = _enrich_sim_techniques(sim.techniques)
tech_str = " | ".join(
f"{t['id']}{t['name']}" for t in enriched_techniques
) if enriched_techniques else "N/A"
tactic_str = _tactic_names(sim.tactic_ids) or "N/A"
lines.append(f"### {sim.name}")
lines.append("")
lines.append(f"**Status**: {sim.status.value}")
lines.append(f"**Techniques**: {tech_str}")
lines.append(f"**Tactics**: {tactic_str}")
if sim.description:
lines.append(f"**Description**: {sim.description}")
lines.append(
f"**Executed at**: {sim.executed_at.isoformat() if sim.executed_at else 'N/A'}"
)
lines.append(f"**Execution result**: {sim.execution_result or 'N/A'}")
lines.append("")
lines.append("**Commands**:")
if sim.commands:
safe = sim.commands.replace("~~~", "\\~\\~\\~")
lines.append("~~~bash")
lines.append(safe)
lines.append("~~~")
else:
lines.append("N/A")
lines.append("")
lines.append(f"**Prerequisites**: {sim.prerequisites or 'N/A'}")
lines.append("")
lines.append("#### SOC")
lines.append(f"**Log source**: {sim.log_source or 'N/A'}")
lines.append(f"**Logs**: {sim.logs or 'N/A'}")
lines.append(f"**SOC comment**: {sim.soc_comment or 'N/A'}")
lines.append(f"**Incident number**: {sim.incident_number or 'N/A'}")
lines.append("")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# CSV
# ---------------------------------------------------------------------------
_CSV_HEADERS = [
"id",
"name",
"status",
"techniques",
"tactics",
"description",
"commands",
"prerequisites",
"executed_at",
"execution_result",
"log_source",
"logs",
"soc_comment",
"incident_number",
"created_at",
"updated_at",
]
# \t and \r included: Excel auto-trims leading whitespace, so a tab/CR prefix still
# reaches the formula parser in some sheet versions.
_CSV_FORMULA_TRIGGERS = ("=", "+", "-", "@", "\t", "\r")
def _csv_safe(value: object) -> object:
"""Defuse spreadsheet formula injection by prefixing user-controlled cells.
Excel / LibreOffice / Google Sheets interpret cells starting with =, +, -, @,
\\t or \\r as formulas. Since this CSV is the engagement handoff to SOC and is
explicitly opened in a spreadsheet app, an authenticated red-team user could
craft a simulation field that executes on the SOC analyst's machine. Prefixing
with a single apostrophe forces the spreadsheet to treat the cell as text.
"""
if isinstance(value, str) and value and value[0] in _CSV_FORMULA_TRIGGERS:
return "'" + value
return value
def render_engagement_csv(
_engagement: Engagement, simulations: list[Simulation]
) -> str:
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(_CSV_HEADERS)
for sim in simulations:
enriched_techniques = _enrich_sim_techniques(sim.techniques)
tech_ids = "|".join(t["id"] for t in enriched_techniques)
tactic_str = "|".join(sim.tactic_ids or [])
writer.writerow([
sim.id,
_csv_safe(sim.name),
sim.status.value,
_csv_safe(tech_ids),
_csv_safe(tactic_str),
_csv_safe(sim.description or ""),
_csv_safe(sim.commands or ""),
_csv_safe(sim.prerequisites or ""),
sim.executed_at.isoformat() if sim.executed_at else "",
_csv_safe(sim.execution_result or ""),
_csv_safe(sim.log_source or ""),
_csv_safe(sim.logs or ""),
_csv_safe(sim.soc_comment or ""),
_csv_safe(sim.incident_number or ""),
sim.created_at.isoformat() if sim.created_at else "",
sim.updated_at.isoformat() if sim.updated_at else "",
])
return buf.getvalue()
# ---------------------------------------------------------------------------
# HTML (internal, used by PDF renderer)
# ---------------------------------------------------------------------------
_CSS = """
body { font-family: sans-serif; font-size: 13px; color: #1a1a1a; margin: 40px; }
h1 { font-size: 22px; border-bottom: 2px solid #333; padding-bottom: 6px; }
h2 { font-size: 17px; margin-top: 32px; color: #333; }
h3 { font-size: 14px; margin-top: 24px; background: #f0f0f0; padding: 4px 8px; }
h3:nth-of-type(odd) { background: #e8e8e8; }
table { border-collapse: collapse; width: 100%; margin-bottom: 12px; }
th, td { border: 1px solid #ccc; padding: 4px 8px; text-align: left; vertical-align: top; }
th { background: #e0e0e0; }
pre { background: #f5f5f5; padding: 8px; font-size: 11px; white-space: pre-wrap; word-break: break-all; }
.meta { color: #555; margin-bottom: 16px; }
"""
def _render_engagement_html(
engagement: Engagement, simulations: list[Simulation]
) -> str:
h = _html_escape
parts: list[str] = []
parts.append("<!DOCTYPE html><html><head><meta charset='utf-8'>")
parts.append(f"<style>{_CSS}</style></head><body>")
parts.append(f"<h1>{h(engagement.name)}</h1>")
parts.append("<div class='meta'>")
if engagement.description:
parts.append(f"<p>{h(engagement.description)}</p>")
parts.append(f"<p><strong>Status:</strong> {h(engagement.status.value)}</p>")
sd = engagement.start_date.isoformat() if engagement.start_date else "N/A"
ed = engagement.end_date.isoformat() if engagement.end_date else "N/A"
parts.append(f"<p><strong>Dates:</strong> {h(sd)}{h(ed)}</p>")
parts.append(f"<p><strong>Created by:</strong> {h(_creator(engagement))}</p>")
ca = engagement.created_at.isoformat() if engagement.created_at else "N/A"
parts.append(f"<p><strong>Created at:</strong> {h(ca)}</p>")
parts.append("</div>")
if simulations:
parts.append("<h2>Simulations</h2>")
for sim in simulations:
enriched_techniques = _enrich_sim_techniques(sim.techniques)
tech_str = h(
" | ".join(f"{t['id']}{t['name']}" for t in enriched_techniques)
or "N/A"
)
tactic_str = h(_tactic_names(sim.tactic_ids) or "N/A")
parts.append(f"<h3>{h(sim.name)}</h3>")
parts.append("<table>")
parts.append(f"<tr><th>Status</th><td>{h(sim.status.value)}</td></tr>")
parts.append(f"<tr><th>Techniques</th><td>{tech_str}</td></tr>")
parts.append(f"<tr><th>Tactics</th><td>{tactic_str}</td></tr>")
parts.append(
f"<tr><th>Description</th><td>{h(sim.description or '')}</td></tr>"
)
executed = sim.executed_at.isoformat() if sim.executed_at else "N/A"
parts.append(f"<tr><th>Executed at</th><td>{h(executed)}</td></tr>")
parts.append(
f"<tr><th>Execution result</th><td>{h(sim.execution_result or '')}</td></tr>"
)
parts.append("</table>")
if sim.commands:
parts.append(f"<p><strong>Commands:</strong></p><pre>{h(sim.commands)}</pre>")
if sim.prerequisites:
parts.append(
f"<p><strong>Prerequisites:</strong> {h(sim.prerequisites)}</p>"
)
parts.append("<table>")
parts.append("<tr><th colspan='2'>SOC</th></tr>")
parts.append(
f"<tr><th>Log source</th><td>{h(sim.log_source or '')}</td></tr>"
)
parts.append(f"<tr><th>Logs</th><td>{h(sim.logs or '')}</td></tr>")
parts.append(
f"<tr><th>SOC comment</th><td>{h(sim.soc_comment or '')}</td></tr>"
)
parts.append(
f"<tr><th>Incident number</th><td>{h(sim.incident_number or '')}</td></tr>"
)
parts.append("</table>")
parts.append("</body></html>")
return "".join(parts)
# ---------------------------------------------------------------------------
# PDF
# ---------------------------------------------------------------------------
def render_engagement_pdf(
engagement: Engagement, simulations: list[Simulation]
) -> bytes:
from weasyprint import HTML
html = _render_engagement_html(engagement, simulations)
return HTML(string=html).write_pdf()

View File

@@ -3,6 +3,7 @@ Flask-SQLAlchemy==3.1.1
Flask-Migrate==4.0.7
PyJWT==2.9.0
argon2-cffi==23.1.0
weasyprint>=60.0
pytest==8.3.3
ruff==0.6.9
mypy==1.11.2

View File

@@ -0,0 +1,264 @@
"""Endpoint tests for GET /api/engagements/<eid>/export."""
from __future__ import annotations
from datetime import date
from flask.testing import FlaskClient
from backend.app.extensions import db
from backend.app.models import Engagement, EngagementStatus, User
from backend.app.models.simulation import Simulation, SimulationStatus
from backend.app.services.export import _export_filename
from backend.tests.conftest import auth_headers as _h
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_engagement(client: FlaskClient, token: str, name: str = "Op Alpha") -> dict:
resp = client.post(
"/api/engagements",
headers=_h(token),
json={"name": name, "start_date": "2026-06-01"},
)
assert resp.status_code == 201, resp.get_json()
return resp.get_json()
def _make_sim(client: FlaskClient, token: str, eid: int, name: str = "Sim One") -> dict:
resp = client.post(
f"/api/engagements/{eid}/simulations",
headers=_h(token),
json={"name": name},
)
assert resp.status_code == 201, resp.get_json()
return resp.get_json()
def _export(client: FlaskClient, token: str, eid: int, fmt: str):
return client.get(
f"/api/engagements/{eid}/export?format={fmt}",
headers=_h(token),
)
# ---------------------------------------------------------------------------
# RBAC
# ---------------------------------------------------------------------------
def test_export_markdown_admin_returns_md_with_engagement_header_and_all_simulations(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_make_sim(client, admin_token, eng["id"], "Lateral Movement")
_make_sim(client, admin_token, eng["id"], "Persistence Check")
resp = _export(client, admin_token, eng["id"], "md")
assert resp.status_code == 200
assert "text/markdown" in resp.content_type
body = resp.data.decode()
assert "Op Alpha" in body
assert "Lateral Movement" in body
assert "Persistence Check" in body
def test_export_markdown_redteam_ok(
client: FlaskClient, redteam_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
resp = _export(client, redteam_token, eng["id"], "md")
assert resp.status_code == 200
def test_export_markdown_soc_403(
client: FlaskClient, soc_token: str, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
resp = _export(client, soc_token, eng["id"], "md")
assert resp.status_code == 403
def test_export_unauthenticated_401(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
resp = client.get(f"/api/engagements/{eng['id']}/export?format=md")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# CSV
# ---------------------------------------------------------------------------
def test_export_csv_returns_csv_with_one_row_per_simulation(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_make_sim(client, admin_token, eng["id"], "S1")
_make_sim(client, admin_token, eng["id"], "S2")
resp = _export(client, admin_token, eng["id"], "csv")
assert resp.status_code == 200
assert "text/csv" in resp.content_type
import csv as csv_mod
import io
rows = list(csv_mod.reader(io.StringIO(resp.data.decode())))
# 1 header + 2 simulations
assert len(rows) == 3
def test_export_csv_columns_match_contract(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
resp = _export(client, admin_token, eng["id"], "csv")
assert resp.status_code == 200
import csv as csv_mod
import io
rows = list(csv_mod.reader(io.StringIO(resp.data.decode())))
expected_headers = [
"id", "name", "status", "techniques", "tactics", "description",
"commands", "prerequisites", "executed_at", "execution_result",
"log_source", "logs", "soc_comment", "incident_number",
"created_at", "updated_at",
]
assert rows[0] == expected_headers
def test_export_csv_escapes_special_characters(
client: FlaskClient, admin_token: str, app
) -> None:
eng = _make_engagement(client, admin_token)
with app.app_context():
admin = User.query.filter_by(username="admin1").first()
sim = Simulation(
engagement_id=eng["id"],
name='Sim "quoted"',
commands='cmd1, cmd2\nnewline "here"',
status=SimulationStatus.PENDING,
created_by_id=admin.id,
)
db.session.add(sim)
db.session.commit()
resp = _export(client, admin_token, eng["id"], "csv")
assert resp.status_code == 200
body = resp.data.decode()
# csv.writer must have quoted the fields — no raw unquoted double-quotes breaking rows
import csv as csv_mod
import io
rows = list(csv_mod.reader(io.StringIO(body)))
assert len(rows) == 2 # header + 1 sim
name_col = rows[1][1]
assert "quoted" in name_col
# ---------------------------------------------------------------------------
# PDF
# ---------------------------------------------------------------------------
def test_export_pdf_returns_pdf_magic_bytes_and_non_empty(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_make_sim(client, admin_token, eng["id"], "S1")
resp = _export(client, admin_token, eng["id"], "pdf")
assert resp.status_code == 200
assert resp.content_type == "application/pdf"
assert resp.data[:4] == b"%PDF"
assert len(resp.data) > 1024
# ---------------------------------------------------------------------------
# 400 / 404
# ---------------------------------------------------------------------------
def test_export_unknown_format_400(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
resp = client.get(
f"/api/engagements/{eng['id']}/export?format=xml",
headers=_h(admin_token),
)
assert resp.status_code == 400
assert "format must be one of" in resp.get_json()["error"]
def test_export_missing_format_400(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
resp = client.get(
f"/api/engagements/{eng['id']}/export",
headers=_h(admin_token),
)
assert resp.status_code == 400
assert "format must be one of" in resp.get_json()["error"]
def test_export_unknown_engagement_404(
client: FlaskClient, admin_token: str
) -> None:
resp = client.get(
"/api/engagements/99999/export?format=md",
headers=_h(admin_token),
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------
def test_export_engagement_with_zero_simulations_renders_header_only(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token, "Empty Engagement")
resp_md = _export(client, admin_token, eng["id"], "md")
assert resp_md.status_code == 200
assert "Empty Engagement" in resp_md.data.decode()
resp_csv = _export(client, admin_token, eng["id"], "csv")
assert resp_csv.status_code == 200
import csv as csv_mod
import io
rows = list(csv_mod.reader(io.StringIO(resp_csv.data.decode())))
assert len(rows) == 1 # header only
resp_pdf = _export(client, admin_token, eng["id"], "pdf")
assert resp_pdf.status_code == 200
assert resp_pdf.data[:4] == b"%PDF"
def test_export_filename_slugifies_name_and_carries_date(app, admin_user: User) -> None:
with app.app_context():
eng = Engagement(
name="Opération Spéciale!",
start_date=date(2026, 6, 1),
status=EngagementStatus.PLANNED,
created_by_id=admin_user.id,
)
db.session.add(eng)
db.session.commit()
fname = _export_filename(eng, "md")
from datetime import date as _date
today = _date.today().strftime("%Y%m%d")
assert fname.startswith(f"engagement-{eng.id}-")
assert "operation-speciale" in fname
assert fname.endswith(f"-{today}.md")

View File

@@ -0,0 +1,262 @@
"""Unit tests for render functions in backend.app.services.export."""
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
from typing import Any
from backend.app.services.export import (
render_engagement_csv,
render_engagement_markdown,
render_engagement_pdf,
)
# ---------------------------------------------------------------------------
# Fixtures / factories
# ---------------------------------------------------------------------------
def _make_engagement(**kw) -> Any:
from datetime import date
defaults: dict[str, Any] = {
"id": 1,
"name": "Test Engagement",
"description": "A purple team exercise",
"start_date": date(2026, 6, 1),
"end_date": date(2026, 6, 30),
"status": SimpleNamespace(value="active"),
"created_at": datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
"created_by": SimpleNamespace(username="alice"),
}
defaults.update(kw)
return SimpleNamespace(**defaults)
def _make_sim(sid: int = 1, name: str = "Sim Alpha", **kw) -> Any:
defaults: dict[str, Any] = {
"id": sid,
"name": name,
"status": SimpleNamespace(value="pending"),
"techniques": [{"id": "T1059", "name": "Command and Scripting Interpreter"}],
"tactic_ids": ["TA0002"],
"description": "Execute a script",
"commands": "whoami",
"prerequisites": "local admin",
"executed_at": None,
"execution_result": None,
"log_source": None,
"logs": None,
"soc_comment": None,
"incident_number": None,
"created_at": datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
"updated_at": None,
"created_by": SimpleNamespace(username="bob"),
}
defaults.update(kw)
return SimpleNamespace(**defaults)
# ---------------------------------------------------------------------------
# Markdown tests
# ---------------------------------------------------------------------------
def test_render_engagement_markdown_includes_header_fields(app) -> None:
with app.app_context():
eng = _make_engagement()
result = render_engagement_markdown(eng, [])
assert "Test Engagement" in result
assert "2026-06-01" in result
assert "2026-06-30" in result
assert "active" in result
assert "alice" in result
def test_render_engagement_markdown_lists_all_simulations_in_order(app) -> None:
with app.app_context():
eng = _make_engagement()
sims = [_make_sim(1, "First Sim"), _make_sim(2, "Second Sim")]
result = render_engagement_markdown(eng, sims)
first_pos = result.index("First Sim")
second_pos = result.index("Second Sim")
assert first_pos < second_pos
def test_render_engagement_markdown_includes_techniques_with_id_and_name(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim(
techniques=[{"id": "T1059", "name": "Command and Scripting Interpreter"}]
)
result = render_engagement_markdown(eng, [sim])
assert "T1059" in result
assert "Command and Scripting Interpreter" in result
def test_render_engagement_markdown_includes_tactics(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim(tactic_ids=["TA0002"])
result = render_engagement_markdown(eng, [sim])
# TA0002 = Execution — should appear as tactic name or id
assert "TA0002" in result or "Execution" in result
def test_render_engagement_markdown_includes_soc_fields_even_when_blank(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim(log_source=None, logs=None, soc_comment=None, incident_number=None)
result = render_engagement_markdown(eng, [sim])
assert "Log source" in result
assert "SOC comment" in result
assert "Incident number" in result
def test_render_engagement_markdown_escapes_backticks_in_commands(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim(commands="echo `whoami`")
result = render_engagement_markdown(eng, [sim])
# Commands must be inside a fenced code block using ~~~ (tilde fences)
assert "~~~" in result
# The backtick content must still be present
assert "`whoami`" in result
# ---------------------------------------------------------------------------
# CSV tests
# ---------------------------------------------------------------------------
def test_render_engagement_csv_has_header_row(app) -> None:
with app.app_context():
eng = _make_engagement()
result = render_engagement_csv(eng, [])
first_line = result.splitlines()[0]
assert "id" in first_line
assert "name" in first_line
assert "status" in first_line
assert "techniques" in first_line
def test_render_engagement_csv_joins_multi_techniques_with_pipe(app) -> None:
with app.app_context():
import csv
import io
eng = _make_engagement()
sim = _make_sim(
techniques=[
{"id": "T1059", "name": "Command and Scripting Interpreter"},
{"id": "T1078", "name": "Valid Accounts"},
]
)
result = render_engagement_csv(eng, [sim])
rows = list(csv.reader(io.StringIO(result)))
# row[3] = techniques column
tech_cell = rows[1][3]
assert "|" in tech_cell
assert "T1059" in tech_cell
assert "T1078" in tech_cell
# ---------------------------------------------------------------------------
# PDF test
# ---------------------------------------------------------------------------
def test_render_engagement_pdf_starts_with_pdf_magic(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim()
result = render_engagement_pdf(eng, [sim])
assert isinstance(result, bytes)
assert result[:4] == b"%PDF"
# ---------------------------------------------------------------------------
# MITRE bundle not loaded
# ---------------------------------------------------------------------------
def test_render_engagement_markdown_with_mitre_bundle_not_loaded_does_not_crash(
app,
) -> None:
with app.app_context():
import backend.app.services.mitre as mitre_svc
original = mitre_svc.mitre_loaded
original_tactics = mitre_svc._tactics_by_technique.copy()
try:
mitre_svc.mitre_loaded = False
mitre_svc._tactics_by_technique = {}
eng = _make_engagement()
sim = _make_sim(
techniques=[{"id": "T1059", "name": "Command and Scripting Interpreter"}],
tactic_ids=["TA0002"],
)
result = render_engagement_markdown(eng, [sim])
# Must not crash and must include the technique id
assert "T1059" in result
finally:
mitre_svc.mitre_loaded = original
mitre_svc._tactics_by_technique = original_tactics
# ---------------------------------------------------------------------------
# CSV formula injection defense (security fix — 2026-06-08)
# ---------------------------------------------------------------------------
import csv as _csv # noqa: E402 (sectioned import to keep test diff localized)
import io as _io # noqa: E402
def _parse_csv_data_row(csv_text: str, row_index: int = 1) -> list[str]:
"""Return cells of row N (0=header) parsed by csv.reader to handle multilines."""
return list(_csv.reader(_io.StringIO(csv_text)))[row_index]
def test_render_engagement_csv_escapes_formula_injection_in_name(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim(name="=cmd|'/c calc'!A1")
result = render_engagement_csv(eng, [sim])
cells = _parse_csv_data_row(result)
# name is column index 1 (after id)
assert cells[1] == "'=cmd|'/c calc'!A1", (
"name beginning with = must be apostrophe-prefixed to defuse Excel formula"
)
def test_render_engagement_csv_escapes_formula_injection_in_commands(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim(commands="@SUM(1+1)")
result = render_engagement_csv(eng, [sim])
cells = _parse_csv_data_row(result)
# commands is column index 6 per _CSV_HEADERS order
assert cells[6].startswith("'@"), "commands beginning with @ must be apostrophe-prefixed"
def test_render_engagement_csv_does_not_alter_safe_strings(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim(name="Mimikatz LSASS Dump", commands="whoami /all")
result = render_engagement_csv(eng, [sim])
cells = _parse_csv_data_row(result)
assert cells[1] == "Mimikatz LSASS Dump", "safe name must not be modified"
assert cells[6] == "whoami /all", "safe commands must not be modified"
def test_export_filename_never_contains_quote_or_crlf() -> None:
"""Defense-in-depth: even with malicious engagement names, the filename
used in Content-Disposition must never contain header-injection chars."""
from types import SimpleNamespace
from backend.app.services.export import _export_filename
evil = SimpleNamespace(id=42, name='evil"name\r\nX-Injected: yes')
fname = _export_filename(evil, "md")
assert '"' not in fname
assert '\r' not in fname
assert '\n' not in fname

View File

@@ -9,6 +9,14 @@ RUN npm run build
# Stage 2: python runtime
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libcairo2 \
libpango-1.0-0 \
libpangoft2-1.0-0 \
libharfbuzz0b \
libfontconfig1 \
shared-mime-info \
&& rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt ./backend/
RUN pip install --no-cache-dir -r backend/requirements.txt
COPY backend/ ./backend/

View File

@@ -0,0 +1,324 @@
/**
* US-29 — Admin/redteam exports an engagement in Markdown, CSV, PDF.
*
* Strategy: seed one engagement with 2 simulations via the API, then drive the
* ExportEngagementButton dropdown in Chromium. Downloads are captured via
* page.waitForEvent('download') and read back with fs.readFile.
*
* AC covered: 29.1 — 29.6
*/
import * as fs from 'fs/promises';
import { test, expect } from '@playwright/test';
import {
adminToken,
createEngagement,
deleteEngagement,
deleteUserByUsername,
ensureUser,
login,
makeClient,
type Engagement,
} from '../fixtures/api';
import { seedTokenInStorage } from '../fixtures/auth';
const ADMIN_USER = 'us29-admin';
const REDTEAM_USER = 'us29-redteam';
const PASS = 'us29-pass-strong!';
interface Simulation {
id: number;
name: string;
}
/**
* RFC-4180 row counter.
* Walks char-by-char tracking quoting so that newlines inside quoted cells
* don't count as row breaks. Returns the total row count (including header)
* plus helper strings for assertions.
*/
function countCsvRows(csv: string): {
count: number;
headerLine: string;
dataText: string;
} {
let inQuote = false;
let rowCount = 0;
let lineStart = 0;
let headerLine = '';
for (let i = 0; i < csv.length; i++) {
const ch = csv[i];
if (ch === '"') {
if (inQuote && csv[i + 1] === '"') {
i++; // escaped double-quote inside quoted cell
} else {
inQuote = !inQuote;
}
} else if ((ch === '\n' || ch === '\r') && !inQuote) {
if (ch === '\r' && csv[i + 1] === '\n') i++;
const line = csv.slice(lineStart, i).trim();
if (line.length > 0) {
if (rowCount === 0) headerLine = line;
rowCount++;
}
lineStart = i + 1;
}
}
// trailing row without final newline
const tail = csv.slice(lineStart).trim();
if (tail.length > 0) {
if (rowCount === 0) headerLine = tail;
rowCount++;
}
const dataText = rowCount > 1 ? csv.slice(headerLine.length + 1) : '';
return { count: rowCount, headerLine, dataText };
}
async function createSimulation(
token: string,
engagementId: number,
name: string,
): Promise<Simulation> {
const r = await makeClient(token).post(`/engagements/${engagementId}/simulations`, { name });
if (r.status !== 201) {
throw new Error(`create simulation failed: ${r.status} ${JSON.stringify(r.data)}`);
}
return r.data as Simulation;
}
test.describe('US-29 — Export formats (admin + redteam)', () => {
let adminTok: string;
let redteamTok: string;
let engagement: Engagement;
let sim1: Simulation;
let sim2: Simulation;
test.beforeAll(async () => {
await ensureUser(ADMIN_USER, PASS, 'admin');
await ensureUser(REDTEAM_USER, PASS, 'redteam');
adminTok = (await login(ADMIN_USER, PASS)).token;
redteamTok = (await login(REDTEAM_USER, PASS)).token;
engagement = await createEngagement(adminTok, {
name: 'US29 Export Engagement',
description: 'Export test engagement',
start_date: '2026-01-15',
status: 'active',
});
sim1 = await createSimulation(adminTok, engagement.id, 'US29 Sim Alpha');
sim2 = await createSimulation(adminTok, engagement.id, 'US29 Sim Beta');
});
test.afterAll(async () => {
try {
await deleteEngagement(adminTok, engagement.id);
const rootTok = await adminToken();
for (const u of [ADMIN_USER, REDTEAM_USER]) await deleteUserByUsername(rootTok, u);
} catch { /* noop */ }
});
// AC-29.1 — Export dropdown opens
test('AC-29.1 — admin: Export dropdown opens with 3 format items', async ({
page,
context,
}) => {
await seedTokenInStorage(context, adminTok);
await page.goto(`/engagements/${engagement.id}`);
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
// Click the Export button to open dropdown
await dropdownWrapper.locator('button').first().click();
await expect(page.getByRole('menuitem', { name: /markdown/i }).or(
page.locator('[role="menuitem"]').filter({ hasText: /markdown/i })
).first()).toBeVisible({ timeout: 5_000 });
await expect(page.getByText(/csv/i).first()).toBeVisible();
await expect(page.getByText(/pdf/i).first()).toBeVisible();
});
// AC-29.2 — Markdown download
test('AC-29.2 — admin: Markdown download contains engagement name and simulation names', async ({
page,
context,
}) => {
await seedTokenInStorage(context, adminTok);
await page.goto(`/engagements/${engagement.id}`);
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
await dropdownWrapper.locator('button').first().click();
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('[role="menuitem"]').filter({ hasText: /^markdown$/i }).first().click(),
]);
const filePath = await download.path();
expect(filePath).toBeTruthy();
const content = await fs.readFile(filePath!, 'utf-8');
// Must contain engagement name
expect(content).toContain('US29 Export Engagement');
// Must contain simulation names
expect(content).toContain('US29 Sim Alpha');
expect(content).toContain('US29 Sim Beta');
// Must contain start date
expect(content).toContain('2026-01-15');
// Suggested filename from Content-Disposition must end in .md
const suggestedName = download.suggestedFilename();
expect(suggestedName).toMatch(/\.md$/);
});
// AC-29.3 — CSV download: N+1 rows (1 header + N simulations)
test('AC-29.3 — admin: CSV download has N+1 rows (header + 2 sim rows)', async ({
page,
context,
}) => {
await seedTokenInStorage(context, adminTok);
await page.goto(`/engagements/${engagement.id}`);
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
await dropdownWrapper.locator('button').first().click();
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('[role="menuitem"]').filter({ hasText: /^csv$/i }).first().click(),
]);
const filePath = await download.path();
expect(filePath).toBeTruthy();
const raw = await fs.readFile(filePath!, 'utf-8');
// Count RFC-4180 rows: walk char-by-char, track quoting state so that
// newlines embedded inside quoted cells don't count as row breaks.
const rows = countCsvRows(raw);
// 1 header + 2 simulation rows
expect(rows.count).toBe(3);
// Header must mention 'name' column
expect(rows.headerLine).toContain('name');
// Simulation data rows must contain simulation names
expect(rows.dataText).toContain('US29 Sim Alpha');
expect(rows.dataText).toContain('US29 Sim Beta');
const suggestedName = download.suggestedFilename();
expect(suggestedName).toMatch(/\.csv$/);
});
// AC-29.4 — PDF download
test('AC-29.4 — admin: PDF download has %PDF magic bytes and size > 1 KB', async ({
page,
context,
}) => {
await seedTokenInStorage(context, adminTok);
await page.goto(`/engagements/${engagement.id}`);
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
await dropdownWrapper.locator('button').first().click();
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('[role="menuitem"]').filter({ hasText: /^pdf$/i }).first().click(),
]);
const filePath = await download.path();
expect(filePath).toBeTruthy();
const buf = await fs.readFile(filePath!);
// Magic bytes: %PDF
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
// Size > 1 KB
expect(buf.byteLength).toBeGreaterThan(1024);
const suggestedName = download.suggestedFilename();
expect(suggestedName).toMatch(/\.pdf$/);
});
// AC-29.5 — Redteam: all 3 formats work
test('AC-29.5 — redteam: Markdown download works', async ({ page, context }) => {
await seedTokenInStorage(context, redteamTok);
await page.goto(`/engagements/${engagement.id}`);
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
await dropdownWrapper.locator('button').first().click();
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('[role="menuitem"]').filter({ hasText: /^markdown$/i }).first().click(),
]);
const filePath = await download.path();
const content = await fs.readFile(filePath!, 'utf-8');
expect(content).toContain('US29 Export Engagement');
});
test('AC-29.5 — redteam: CSV download works', async ({ page, context }) => {
await seedTokenInStorage(context, redteamTok);
await page.goto(`/engagements/${engagement.id}`);
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
await dropdownWrapper.locator('button').first().click();
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('[role="menuitem"]').filter({ hasText: /^csv$/i }).first().click(),
]);
const filePath = await download.path();
const raw = await fs.readFile(filePath!, 'utf-8');
const rows = countCsvRows(raw);
expect(rows.count).toBeGreaterThanOrEqual(3);
});
test('AC-29.5 — redteam: PDF download works', async ({ page, context }) => {
await seedTokenInStorage(context, redteamTok);
await page.goto(`/engagements/${engagement.id}`);
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
await dropdownWrapper.locator('button').first().click();
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('[role="menuitem"]').filter({ hasText: /^pdf$/i }).first().click(),
]);
const filePath = await download.path();
const buf = await fs.readFile(filePath!);
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
// AC-29.6 — Filename convention: engagement-<id>-<slug>-YYYYMMDD.{ext}
test('AC-29.6 — filename matches engagement-<id>-<slug>-YYYYMMDD pattern', async ({
page,
context,
}) => {
await seedTokenInStorage(context, adminTok);
await page.goto(`/engagements/${engagement.id}`);
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
await dropdownWrapper.locator('button').first().click();
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('[role="menuitem"]').filter({ hasText: /^markdown$/i }).first().click(),
]);
const suggestedName = download.suggestedFilename();
// Pattern: engagement-<id>-<slug>-YYYYMMDD.md
const filenamePattern = new RegExp(
`^engagement-${engagement.id}-[a-z0-9-]+-\\d{8}\\.md$`,
);
expect(suggestedName).toMatch(filenamePattern);
});
});

View File

@@ -0,0 +1,90 @@
/**
* US-30 — SOC role has zero access to the export feature.
*
* AC-30.1: SOC login → Export button absent from DOM (not just hidden).
* AC-30.2: Direct API call with SOC Bearer → 403.
* AC-30.3: Direct API call without token → 401.
*/
import { test, expect } from '@playwright/test';
import {
adminToken,
createEngagement,
deleteEngagement,
deleteUserByUsername,
ensureUser,
login,
type Engagement,
} from '../fixtures/api';
import { seedTokenInStorage } from '../fixtures/auth';
const SOC_USER = 'us30-soc';
const ADMIN_USER = 'us30-admin';
const PASS = 'us30-pass-strong!';
const BASE_URL = process.env.MIMIC_BASE_URL ?? 'http://localhost:5000';
test.describe('US-30 — SOC zero access to export', () => {
let socTok: string;
let adminTok: string;
let engagement: Engagement;
test.beforeAll(async () => {
await ensureUser(SOC_USER, PASS, 'soc');
await ensureUser(ADMIN_USER, PASS, 'admin');
socTok = (await login(SOC_USER, PASS)).token;
adminTok = (await login(ADMIN_USER, PASS)).token;
engagement = await createEngagement(adminTok, {
name: 'US30 RBAC Engagement',
start_date: '2026-01-01',
});
});
test.afterAll(async () => {
try {
await deleteEngagement(adminTok, engagement.id);
const rootTok = await adminToken();
for (const u of [SOC_USER, ADMIN_USER]) await deleteUserByUsername(rootTok, u);
} catch { /* noop */ }
});
// AC-30.1 — SOC: Export button absent from DOM
test('AC-30.1 — SOC login: Export dropdown is NOT attached to the DOM', async ({
page,
context,
}) => {
await seedTokenInStorage(context, socTok);
await page.goto(`/engagements/${engagement.id}`);
// Wait for the page to fully load (engagement header should be visible)
await expect(page.locator('h1, h2').first()).toBeVisible({ timeout: 10_000 });
// The export dropdown wrapper must not be in the DOM at all
await expect(
page.locator('[data-testid="export-dropdown"]'),
).not.toBeAttached();
});
// AC-30.2 — SOC Bearer token → 403
test('AC-30.2 — SOC Bearer: GET /api/engagements/<id>/export?format=md → 403', async ({
request,
}) => {
const response = await request.get(
`${BASE_URL}/api/engagements/${engagement.id}/export?format=md`,
{
headers: { Authorization: `Bearer ${socTok}` },
},
);
expect(response.status()).toBe(403);
});
// AC-30.3 — No token → 401
test('AC-30.3 — No token: GET /api/engagements/<id>/export?format=md → 401', async ({
request,
}) => {
const response = await request.get(
`${BASE_URL}/api/engagements/${engagement.id}/export?format=md`,
);
expect(response.status()).toBe(401);
});
});

View File

@@ -0,0 +1,176 @@
/**
* US-31 — Export robustness: format validation and edge cases.
*
* All 4 ACs use direct API calls (no UI needed) for speed and determinism.
*
* AC-31.1: missing ?format → 400 friendly message
* AC-31.2: ?format=xml → 400 friendly message
* AC-31.3: engagement 99999 → 404
* AC-31.4: engagement with 0 simulations → export OK (CSV = 1 header row only)
*/
import { test, expect } from '@playwright/test';
import {
adminToken,
createEngagement,
deleteEngagement,
deleteUserByUsername,
ensureUser,
login,
makeClient,
} from '../fixtures/api';
const ADMIN_USER = 'us31-admin';
const PASS = 'us31-pass-strong!';
const BASE_URL = process.env.MIMIC_BASE_URL ?? 'http://localhost:5000';
/**
* RFC-4180-aware row counter — same logic as us29 helper.
* Embedded newlines inside quoted cells are not counted as row breaks.
*/
function countCsvRows(csv: string): number {
let inQuote = false;
let rowCount = 0;
let lineStart = 0;
for (let i = 0; i < csv.length; i++) {
const ch = csv[i];
if (ch === '"') {
if (inQuote && csv[i + 1] === '"') {
i++;
} else {
inQuote = !inQuote;
}
} else if ((ch === '\n' || ch === '\r') && !inQuote) {
if (ch === '\r' && csv[i + 1] === '\n') i++;
const line = csv.slice(lineStart, i).trim();
if (line.length > 0) rowCount++;
lineStart = i + 1;
}
}
const tail = csv.slice(lineStart).trim();
if (tail.length > 0) rowCount++;
return rowCount;
}
test.describe('US-31 — Export robustness', () => {
let adminTok: string;
test.beforeAll(async () => {
await ensureUser(ADMIN_USER, PASS, 'admin');
adminTok = (await login(ADMIN_USER, PASS)).token;
});
test.afterAll(async () => {
try {
const rootTok = await adminToken();
await deleteUserByUsername(rootTok, ADMIN_USER);
} catch { /* noop */ }
});
// AC-31.1 — missing format → 400
test('AC-31.1 — GET /export without format → 400 with friendly error', async ({
request,
}) => {
// Need a valid engagement id — use admin to create one transiently
const engagement = await createEngagement(adminTok, {
name: 'US31 missing format eng',
start_date: '2026-01-01',
});
try {
const response = await request.get(
`${BASE_URL}/api/engagements/${engagement.id}/export`,
{ headers: { Authorization: `Bearer ${adminTok}` } },
);
expect(response.status()).toBe(400);
const body = await response.json();
expect(body).toHaveProperty('error');
expect(body.error).toMatch(/format/i);
} finally {
await deleteEngagement(adminTok, engagement.id);
}
});
// AC-31.2 — unknown format → 400
test('AC-31.2 — GET /export?format=xml → 400 with friendly error', async ({
request,
}) => {
const engagement = await createEngagement(adminTok, {
name: 'US31 bad format eng',
start_date: '2026-01-01',
});
try {
const response = await request.get(
`${BASE_URL}/api/engagements/${engagement.id}/export?format=xml`,
{ headers: { Authorization: `Bearer ${adminTok}` } },
);
expect(response.status()).toBe(400);
const body = await response.json();
expect(body).toHaveProperty('error');
expect(body.error).toMatch(/format/i);
} finally {
await deleteEngagement(adminTok, engagement.id);
}
});
// AC-31.3 — unknown engagement → 404
test('AC-31.3 — GET /engagements/99999/export?format=md → 404', async ({
request,
}) => {
const response = await request.get(
`${BASE_URL}/api/engagements/99999/export?format=md`,
{ headers: { Authorization: `Bearer ${adminTok}` } },
);
expect(response.status()).toBe(404);
});
// AC-31.4 — engagement with 0 simulations: export OK
test('AC-31.4 — engagement with 0 simulations: Markdown export OK (header only)', async ({
request,
}) => {
const engagement = await createEngagement(adminTok, {
name: 'US31 empty engagement',
start_date: '2026-01-01',
});
try {
const response = await request.get(
`${BASE_URL}/api/engagements/${engagement.id}/export?format=md`,
{ headers: { Authorization: `Bearer ${adminTok}` } },
);
expect(response.status()).toBe(200);
const text = await response.text();
// Must contain engagement name in the header section
expect(text).toContain('US31 empty engagement');
} finally {
await deleteEngagement(adminTok, engagement.id);
}
});
test('AC-31.4 — engagement with 0 simulations: CSV export has only 1 header row', async ({
request,
}) => {
const engagement = await createEngagement(adminTok, {
name: 'US31 empty CSV engagement',
start_date: '2026-01-01',
});
try {
const response = await request.get(
`${BASE_URL}/api/engagements/${engagement.id}/export?format=csv`,
{ headers: { Authorization: `Bearer ${adminTok}` } },
);
expect(response.status()).toBe(200);
const text = await response.text();
// Count rows via RFC-4180-aware counter (handles embedded newlines in quoted cells)
const rowCount = countCsvRows(text);
expect(rowCount).toBe(1);
// The single row is the header; must contain 'name' column
expect(text.trim()).toContain('name');
} finally {
await deleteEngagement(adminTok, engagement.id);
}
});
});

View File

@@ -0,0 +1,56 @@
import axios from 'axios';
import { apiClient } from './client';
export type ExportFormat = 'md' | 'csv' | 'pdf';
export function parseContentDispositionFilename(header: string | undefined): string | null {
if (!header) return null;
const match = header.match(/filename="([^"]+)"/);
return match ? match[1] : null;
}
async function parseBlobError(err: unknown): Promise<string> {
if (axios.isAxiosError(err) && err.response?.data instanceof Blob) {
try {
const text = await (err.response.data as Blob).text();
const parsed = JSON.parse(text) as { error?: string };
if (parsed.error) return parsed.error;
} catch {
// fall through to default
}
}
if (err instanceof Error) return err.message;
return 'Export failed';
}
export async function downloadEngagementExport(
engagementId: number,
format: ExportFormat,
): Promise<void> {
try {
const response = await apiClient.get(`/engagements/${engagementId}/export`, {
params: { format },
responseType: 'blob',
});
let filename = parseContentDispositionFilename(
response.headers['content-disposition'] as string | undefined,
);
if (!filename) {
const ext = format === 'md' ? 'md' : format === 'csv' ? 'csv' : 'pdf';
filename = `engagement-${engagementId}.${ext}`;
}
const url = URL.createObjectURL(response.data as Blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
} catch (err) {
const message = await parseBlobError(err);
throw new Error(message);
}
}

View File

@@ -0,0 +1,100 @@
import { useEffect, useRef, useState } from 'react';
import { ChevronDown, Download, Loader2 } from 'lucide-react';
import { downloadEngagementExport, type ExportFormat } from '@/api/exports';
import { useToast } from '@/hooks/useToast';
interface ExportEngagementButtonProps {
engagementId: number;
}
const FORMATS: { label: string; value: ExportFormat }[] = [
{ label: 'Markdown', value: 'md' },
{ label: 'CSV', value: 'csv' },
{ label: 'PDF', value: 'pdf' },
];
export function ExportEngagementButton({ engagementId }: ExportEngagementButtonProps): JSX.Element {
const { push } = useToast();
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState<ExportFormat | null>(null);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onPointerDown = (e: PointerEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [open]);
const handleDownload = async (format: ExportFormat) => {
setLoading(format);
try {
await downloadEngagementExport(engagementId, format);
setOpen(false);
} catch (err) {
push(err instanceof Error ? err.message : 'Export failed', 'error');
} finally {
setLoading(null);
}
};
return (
<div className="relative" ref={ref} data-testid="export-dropdown">
<div className="inline-flex">
<button
type="button"
className="btn-outline rounded-r-none border-r-0"
onClick={() => setOpen((v) => !v)}
data-testid="export-btn"
>
<Download size={14} aria-hidden /> Export
</button>
<button
type="button"
aria-label="Export options"
aria-expanded={open}
className="btn-outline rounded-l-none px-sm"
onClick={() => setOpen((v) => !v)}
data-testid="export-dropdown-toggle"
>
<ChevronDown size={14} aria-hidden />
</button>
</div>
{open ? (
<div
className="absolute right-0 top-full mt-xxs bg-canvas border border-hairline rounded-md shadow-floating dark:shadow-floating-dark z-20 min-w-[160px]"
role="menu"
>
{FORMATS.map(({ label, value }) => (
<button
key={value}
type="button"
role="menuitem"
disabled={loading !== null}
className="w-full text-left px-md py-sm text-[14px] text-ink hover:bg-cloud dark:hover:bg-fog flex items-center gap-sm disabled:opacity-50"
onClick={() => handleDownload(value)}
data-testid={`export-format-${value}`}
>
{loading === value ? (
<Loader2 size={12} className="animate-spin" aria-hidden />
) : null}
{label}
</button>
))}
</div>
) : null}
</div>
);
}

View File

@@ -6,6 +6,7 @@ import { LoadingState } from '@/components/LoadingState';
import { ErrorState } from '@/components/ErrorState';
import { StatusBadge } from '@/components/StatusBadge';
import { SimulationList } from '@/components/SimulationList';
import { ExportEngagementButton } from '@/components/ExportEngagementButton';
export function EngagementDetailPage(): JSX.Element {
const { id } = useParams<{ id: string }>();
@@ -43,9 +44,12 @@ export function EngagementDetailPage(): JSX.Element {
</div>
</div>
{canEditEngagements ? (
<div className="flex items-center gap-sm">
<ExportEngagementButton engagementId={eng.id} />
<Link to={`/engagements/${eng.id}/edit`} className="btn-outline">
Edit
</Link>
</div>
) : null}
</header>

View File

@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { Route, Routes } from 'react-router-dom';
import MockAdapter from 'axios-mock-adapter';
import { apiClient } from '@/api/client';
import { EngagementDetailPage } from '@/pages/EngagementDetailPage';
import { renderWithProviders } from './utils';
import type { Engagement } from '@/api/types';
vi.mock('@/api/exports', () => ({
downloadEngagementExport: vi.fn(),
}));
const ENGAGEMENT: Engagement = {
id: 1,
name: 'Test Engagement',
description: 'A test engagement',
start_date: '2026-06-01',
end_date: null,
status: 'active',
created_at: '2026-06-01T08:00:00',
created_by: { id: 1, username: 'alice' },
};
type MockRole = 'admin' | 'redteam' | 'soc';
let mockRole: MockRole = 'admin';
function DetailPage() {
return (
<Routes>
<Route path="/engagements/:id" element={<EngagementDetailPage />} />
</Routes>
);
}
vi.mock('@/hooks/useAuth', () => ({
useAuth: () => ({
user: { id: 1, username: 'alice', role: mockRole, created_at: '2026-01-01' },
status: 'authenticated',
login: vi.fn(),
logout: vi.fn(),
isAdmin: mockRole === 'admin',
isRedteam: mockRole === 'redteam',
isSoc: mockRole === 'soc',
canEditEngagements: mockRole === 'admin' || mockRole === 'redteam',
}),
}));
describe('EngagementDetailPage — RBAC for Export button', () => {
let mock: MockAdapter;
beforeEach(() => {
mock = new MockAdapter(apiClient);
mock.onGet('/engagements/1').reply(200, ENGAGEMENT);
mock.onGet('/engagements/1/simulations').reply(200, []);
});
afterEach(() => {
mock.restore();
});
it('admin sees Export button', async () => {
mockRole = 'admin';
renderWithProviders(<DetailPage />, {
routerProps: { initialEntries: ['/engagements/1'] },
});
await waitFor(() => {
expect(screen.getByText('Test Engagement')).toBeInTheDocument();
});
expect(screen.getByTestId('export-dropdown')).toBeInTheDocument();
});
it('redteam sees Export button', async () => {
mockRole = 'redteam';
renderWithProviders(<DetailPage />, {
routerProps: { initialEntries: ['/engagements/1'] },
});
await waitFor(() => {
expect(screen.getByText('Test Engagement')).toBeInTheDocument();
});
expect(screen.getByTestId('export-dropdown')).toBeInTheDocument();
});
it('soc does NOT see Export button', async () => {
mockRole = 'soc';
renderWithProviders(<DetailPage />, {
routerProps: { initialEntries: ['/engagements/1'] },
});
await waitFor(() => {
expect(screen.getByText('Test Engagement')).toBeInTheDocument();
});
expect(screen.queryByTestId('export-dropdown')).toBeNull();
});
});

View File

@@ -0,0 +1,133 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ExportEngagementButton } from '@/components/ExportEngagementButton';
import { ToastViewport } from '@/components/Toast';
import { renderWithProviders } from './utils';
function ExportButtonWithToast({ engagementId }: { engagementId: number }) {
return (
<>
<ExportEngagementButton engagementId={engagementId} />
<ToastViewport />
</>
);
}
vi.mock('@/api/exports', () => ({
downloadEngagementExport: vi.fn(),
}));
import { downloadEngagementExport } from '@/api/exports';
const mockDownload = downloadEngagementExport as ReturnType<typeof vi.fn>;
vi.mock('@/hooks/useAuth', () => ({
useAuth: () => ({
user: { id: 1, username: 'alice', role: 'admin', created_at: '2026-01-01' },
status: 'authenticated',
login: vi.fn(),
logout: vi.fn(),
isAdmin: true,
isRedteam: false,
isSoc: false,
canEditEngagements: true,
}),
}));
describe('ExportEngagementButton', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
it('renders Export button with chevron', () => {
renderWithProviders(<ExportEngagementButton engagementId={1} />);
expect(screen.getByTestId('export-btn')).toBeInTheDocument();
expect(screen.getByTestId('export-dropdown-toggle')).toBeInTheDocument();
expect(screen.getByText('Export')).toBeInTheDocument();
});
it('clicking primary opens dropdown with three formats', async () => {
const user = userEvent.setup();
renderWithProviders(<ExportEngagementButton engagementId={1} />);
await user.click(screen.getByTestId('export-btn'));
expect(screen.getByText('Markdown')).toBeInTheDocument();
expect(screen.getByText('CSV')).toBeInTheDocument();
expect(screen.getByText('PDF')).toBeInTheDocument();
});
it('clicking outside closes dropdown', async () => {
const user = userEvent.setup();
renderWithProviders(<ExportEngagementButton engagementId={1} />);
await user.click(screen.getByTestId('export-btn'));
expect(screen.getByText('Markdown')).toBeInTheDocument();
await user.click(document.body);
expect(screen.queryByText('Markdown')).toBeNull();
});
it('Escape closes dropdown', async () => {
const user = userEvent.setup();
renderWithProviders(<ExportEngagementButton engagementId={1} />);
await user.click(screen.getByTestId('export-dropdown-toggle'));
expect(screen.getByText('Markdown')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByText('Markdown')).toBeNull();
});
it('clicking Markdown triggers download with format=md', async () => {
mockDownload.mockResolvedValue(undefined);
const user = userEvent.setup();
renderWithProviders(<ExportEngagementButton engagementId={42} />);
await user.click(screen.getByTestId('export-btn'));
await user.click(screen.getByTestId('export-format-md'));
expect(mockDownload).toHaveBeenCalledWith(42, 'md');
});
it('clicking CSV triggers download with format=csv', async () => {
mockDownload.mockResolvedValue(undefined);
const user = userEvent.setup();
renderWithProviders(<ExportEngagementButton engagementId={42} />);
await user.click(screen.getByTestId('export-btn'));
await user.click(screen.getByTestId('export-format-csv'));
expect(mockDownload).toHaveBeenCalledWith(42, 'csv');
});
it('clicking PDF triggers download with format=pdf', async () => {
mockDownload.mockResolvedValue(undefined);
const user = userEvent.setup();
renderWithProviders(<ExportEngagementButton engagementId={42} />);
await user.click(screen.getByTestId('export-btn'));
await user.click(screen.getByTestId('export-format-pdf'));
expect(mockDownload).toHaveBeenCalledWith(42, 'pdf');
});
it('loading state disables items during in-flight', async () => {
let resolve!: () => void;
mockDownload.mockReturnValue(new Promise<void>((r) => { resolve = r; }));
const user = userEvent.setup();
renderWithProviders(<ExportEngagementButton engagementId={1} />);
await user.click(screen.getByTestId('export-btn'));
await user.click(screen.getByTestId('export-format-md'));
// Items should be disabled while in-flight
await waitFor(() => {
expect(screen.getByTestId('export-format-csv')).toBeDisabled();
expect(screen.getByTestId('export-format-pdf')).toBeDisabled();
});
resolve();
});
it('error response shows toast', async () => {
mockDownload.mockRejectedValue(new Error('Export failed: 403 Forbidden'));
const user = userEvent.setup();
renderWithProviders(<ExportButtonWithToast engagementId={1} />);
await user.click(screen.getByTestId('export-btn'));
await user.click(screen.getByTestId('export-format-md'));
await waitFor(() => {
expect(screen.getByTestId('toast')).toBeInTheDocument();
expect(screen.getByTestId('toast')).toHaveTextContent('Export failed: 403 Forbidden');
});
});
});

View File

@@ -0,0 +1,67 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import MockAdapter from 'axios-mock-adapter';
import { apiClient } from '@/api/client';
import { downloadEngagementExport } from '@/api/exports';
describe('downloadEngagementExport — Content-Disposition fallback', () => {
let mock: MockAdapter;
let capturedAnchor: HTMLAnchorElement | null = null;
beforeEach(() => {
mock = new MockAdapter(apiClient);
capturedAnchor = null;
globalThis.URL.createObjectURL = vi.fn().mockReturnValue('blob:fake-url');
globalThis.URL.revokeObjectURL = vi.fn();
const origCreateElement = document.createElement.bind(document);
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
const el = origCreateElement(tag);
if (tag === 'a') {
capturedAnchor = el as HTMLAnchorElement;
vi.spyOn(el as HTMLAnchorElement, 'click').mockImplementation(() => {});
}
return el;
});
vi.spyOn(document.body, 'appendChild').mockImplementation((node) => node);
vi.spyOn(document.body, 'removeChild').mockImplementation((node) => node);
});
afterEach(() => {
mock.restore();
vi.restoreAllMocks();
});
it('uses fallback filename engagement-{id}.md when Content-Disposition is absent', async () => {
mock.onGet('/engagements/42/export').reply(200, new Blob(['# test']), {
'content-type': 'text/markdown',
});
await downloadEngagementExport(42, 'md');
expect(capturedAnchor?.download).toBe('engagement-42.md');
});
it('uses fallback filename engagement-{id}.csv when Content-Disposition is malformed (no filename=)', async () => {
mock.onGet('/engagements/7/export').reply(200, new Blob(['col1,col2']), {
'content-type': 'text/csv',
'content-disposition': 'attachment',
});
await downloadEngagementExport(7, 'csv');
expect(capturedAnchor?.download).toBe('engagement-7.csv');
});
it('uses filename from Content-Disposition when header is well-formed', async () => {
mock.onGet('/engagements/5/export').reply(200, new Blob(['data']), {
'content-type': 'application/pdf',
'content-disposition': 'attachment; filename="engagement-5-slug-20260101.pdf"',
});
await downloadEngagementExport(5, 'pdf');
expect(capturedAnchor?.download).toBe('engagement-5-slug-20260101.pdf');
});
});

View File

@@ -4,6 +4,34 @@ Recurring mistakes and the rule we adopted so the same issue doesn't bite twice.
---
## Sprint 6 (closed 2026-06-08)
### Process — SPEC.md commit-first finally tamed the 4-sprint recurrence (team-lead)
**Context** : Sprints 3, 4, AND 5 had each shipped initial PRs with `M SPEC.md` uncommitted. Sprint 5 lessons.md proposed concrete fix candidates ; sprint 6 adopted candidate #1 (stage SPEC.md as part of the FIRST sprint commit, before any code). Result: commit `7aaa5cc` was the SPEC update, all subsequent commits were code/tests/docs, and `git status` at sprint close was 100% clean — no orphan SPEC change to carry over.
**Lesson** : process recurrences die when the fix is positional ("FIRST commit") rather than a checklist item at sprint close. The discipline is structural now: as soon as the team-lead writes the SPEC section in §0 of the plan, the matching SPEC.md edit commit follows immediately, before the plan commit itself. Memory updated. Keep doing this.
### Engineering — Persistent team `mimic` + idle members is worth the token cost (team-lead + user)
**Context** : Sprint 5 left `~/.claude/teams/mimic/config.json` as an empty husk because past sprints had called `TeamDelete` at wrap-up. Sprint 6 attempted to spawn agents one-by-one with their phase tasks; hit "team roster is flat — Teammates cannot spawn other teammates" when respawning backend-builder for a security fix mid-sprint. User flipped policy: spawn ALL 7 project-defined agents (`.claude/agents/*.md`) into team `mimic` at sprint start with idle prompts, wake them via `SendMessage` per phase, no shutdown until sprint close.
**Lesson** : the "never idle" rule from earlier memory was a token-economy heuristic that became a coordination problem. The team-roster gotcha (couldn't re-add a name after termination, couldn't add a fresh role after the first two spawns settled) confirms: pre-spawn the full roster idle, address by name throughout, accept the idle token cost. `feedback-team-spawn` memory now reflects this. DO NOT call TeamDelete unless we explicitly want to nuke the team for sprint 7+ work. The team persists, the members are re-spawned each sprint into the same team.
### Engineering — Security plugin caught CSV formula injection mid-sprint (team-lead + backend-builder)
**Context** : `security-guidance@claude-code-plugins` automated review flagged the CSV writer in `render_engagement_csv()` as vulnerable to spreadsheet formula injection (MEDIUM). The team-lead's first instinct was "this is internal, RBAC limits the attacker to authenticated red-team users — likely not exploitable" ; on second reading, recognized that the **explicit consumption path** for the CSV is the SOC analyst opening it in Excel/LibreOffice, AND that the red-team and SOC are different users on different machines (handoff is the whole point of the sprint). Fix applied (`_csv_safe()` helper, apostrophe prefix on formula-trigger chars), 3 tests added (`57dbd14`).
**Lesson** : when a security finding cites "internal service, may not be exploitable", ask one more layer of questions about the consumption path. If the cell content ever reaches a different user's spreadsheet, defuse it. Cost: 10-line helper + 3 tests, 60 seconds of work. Don't dismiss SEC findings on RBAC alone.
### Engineering — Stdlib first before custom helpers (code-reviewer + backend-builder)
**Context** : The export.py service shipped with a local `_html_escape` helper that reimplemented `html.escape(text, quote=True)` from the stdlib. Code-reviewer's NIT flagged it. Replaced by `from html import escape as _html_escape` (-7 lines, identical behavior).
**Lesson** : before writing an escape/format/parse helper, grep for an stdlib equivalent. The Python stdlib has `html`, `csv`, `unicodedata`, `re`, `email.utils`, `urllib.parse` — most "escape this safely" needs are already solved. Custom helpers age into security maintenance debt; stdlib doesn't.
### Engineering — Tests that mock the API client at module level can't exercise its fallback paths (frontend-builder + code-reviewer)
**Context** : `ExportEngagementButton.test.tsx` uses `vi.mock('@/api/exports', ...)` at module level — replaces the whole `downloadEngagementExport` function with a stub. Code-reviewer flagged that the Content-Disposition fallback inside `exports.ts` (`engagement-<id>.<ext>` when the header is malformed) was uncovered. Fix: new dedicated test file `exports.test.ts` that mocks the underlying `apiClient` (axios) instead, so the real `downloadEngagementExport` runs (`123d981`).
**Lesson** : a test file that mocks its target at module level can validate the CALLERS but not the TARGET'S internal logic. To cover a function's internal branches (fallbacks, error parsing, header parsing), you need a separate test file that mocks one layer DEEPER (axios, fetch, transport) and lets the function under test execute. Pattern : one test file per layer.
### Engineering — `engagement` param as `_engagement` for signature symmetry (backend-builder + code-reviewer)
**Context** : `render_engagement_csv()` takes `engagement` but never reads it — by design, since the CSV is machine-readable strict (no engagement header inside the file, only in the filename). Pyright flagged "not accessed". Two options: rename to `_engagement` (intentional unused marker) or drop the param. We renamed — keeps the trio of render functions (`md`/`csv`/`pdf`) callable with the same arguments from the endpoint switch.
**Lesson** : when one function in a sibling-trio doesn't need a parameter that the others use, keep the signature symmetric and prefix the unused param with `_`. The endpoint stays callable in a uniform way (`render_engagement_<fmt>(engagement, simulations)`), and the underscore signals "intentional" to mypy/pyright/ruff. Don't sacrifice symmetry for purity.
---
## Sprint 5 (closed 2026-05-28)
### Process — The "git status pre-sprint-close" discipline is still broken, 3 sprints in a row (team-lead)