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
|
|
|
"""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
|
refactor(export): switch render output to fixed 7-column schema (Scénario, Test, ...)
All three renderers (MD, CSV, PDF) now emit a uniform 7-column table with
French headers matching the RT↔SOC handoff contract locked in SPEC.md fdab324.
Helpers added:
- _format_execution(sim): canonical 3-part concat (executed_at / commands / execution_result)
- _MD_HEADERS / _HTML_HEADERS / _CSV_HEADERS unified to the same 7 FR strings
Helpers removed (no longer called):
- _tactic_names() — MITRE tactics dropped from export
- _enrich_sim_techniques() — MITRE techniques dropped from export
Fields dropped from export: status, techniques, tactic_ids, prerequisites, id,
created_at, updated_at (intentional — focused RT↔SOC handoff, see SPEC §Export).
_csv_safe() still applied to all 7 user-controlled cells including Exécution concat.
Tests updated: 255 passed, ruff clean, mypy clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:15:49 +02:00
|
|
|
# Both simulation names appear as cells in the 7-column table
|
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
|
|
|
assert "Lateral Movement" in body
|
|
|
|
|
assert "Persistence Check" in body
|
refactor(export): switch render output to fixed 7-column schema (Scénario, Test, ...)
All three renderers (MD, CSV, PDF) now emit a uniform 7-column table with
French headers matching the RT↔SOC handoff contract locked in SPEC.md fdab324.
Helpers added:
- _format_execution(sim): canonical 3-part concat (executed_at / commands / execution_result)
- _MD_HEADERS / _HTML_HEADERS / _CSV_HEADERS unified to the same 7 FR strings
Helpers removed (no longer called):
- _tactic_names() — MITRE tactics dropped from export
- _enrich_sim_techniques() — MITRE techniques dropped from export
Fields dropped from export: status, techniques, tactic_ids, prerequisites, id,
created_at, updated_at (intentional — focused RT↔SOC handoff, see SPEC §Export).
_csv_safe() still applied to all 7 user-controlled cells including Exécution concat.
Tests updated: 255 passed, ruff clean, mypy clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:15:49 +02:00
|
|
|
# Table uses French column headers
|
|
|
|
|
assert "Scénario" in body
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
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 = [
|
refactor(export): switch render output to fixed 7-column schema (Scénario, Test, ...)
All three renderers (MD, CSV, PDF) now emit a uniform 7-column table with
French headers matching the RT↔SOC handoff contract locked in SPEC.md fdab324.
Helpers added:
- _format_execution(sim): canonical 3-part concat (executed_at / commands / execution_result)
- _MD_HEADERS / _HTML_HEADERS / _CSV_HEADERS unified to the same 7 FR strings
Helpers removed (no longer called):
- _tactic_names() — MITRE tactics dropped from export
- _enrich_sim_techniques() — MITRE techniques dropped from export
Fields dropped from export: status, techniques, tactic_ids, prerequisites, id,
created_at, updated_at (intentional — focused RT↔SOC handoff, see SPEC §Export).
_csv_safe() still applied to all 7 user-controlled cells including Exécution concat.
Tests updated: 255 passed, ruff clean, mypy clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:15:49 +02:00
|
|
|
"Scénario",
|
|
|
|
|
"Test",
|
|
|
|
|
"Source de log",
|
|
|
|
|
"Commentaires SOC",
|
|
|
|
|
"Exécution",
|
|
|
|
|
"Logs remontés au SIEM",
|
|
|
|
|
"Cyber incident",
|
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
|
|
|
]
|
|
|
|
|
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
|
refactor(export): switch render output to fixed 7-column schema (Scénario, Test, ...)
All three renderers (MD, CSV, PDF) now emit a uniform 7-column table with
French headers matching the RT↔SOC handoff contract locked in SPEC.md fdab324.
Helpers added:
- _format_execution(sim): canonical 3-part concat (executed_at / commands / execution_result)
- _MD_HEADERS / _HTML_HEADERS / _CSV_HEADERS unified to the same 7 FR strings
Helpers removed (no longer called):
- _tactic_names() — MITRE tactics dropped from export
- _enrich_sim_techniques() — MITRE techniques dropped from export
Fields dropped from export: status, techniques, tactic_ids, prerequisites, id,
created_at, updated_at (intentional — focused RT↔SOC handoff, see SPEC §Export).
_csv_safe() still applied to all 7 user-controlled cells including Exécution concat.
Tests updated: 255 passed, ruff clean, mypy clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:15:49 +02:00
|
|
|
name_col = rows[1][0] # col 0 = Scénario
|
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
|
|
|
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")
|