"""Endpoint tests for GET /api/engagements//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(): from backend.app.models import User 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")