"""Unit tests for render functions in backend.app.services.export.""" from __future__ import annotations import csv as _csv import io as _io 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"), "description": "Execute a script", "commands": "whoami", "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) # --------------------------------------------------------------------------- # Shared constants # --------------------------------------------------------------------------- _FR_HEADERS = [ "Scénario", "Test", "Source de log", "Commentaires SOC", "Exécution", "Logs remontés au SIEM", "Cyber incident", ] # --------------------------------------------------------------------------- # 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_has_seven_column_table_headers(app) -> None: with app.app_context(): eng = _make_engagement() sim = _make_sim() result = render_engagement_markdown(eng, [sim]) for header in _FR_HEADERS: assert header in result, f"Expected French header '{header}' in markdown table" 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_no_simulations_has_no_table(app) -> None: with app.app_context(): eng = _make_engagement() result = render_engagement_markdown(eng, []) assert "Scénario" not in result assert "## Simulations" not in result def test_render_engagement_markdown_execution_cell_uses_br_separator(app) -> None: with app.app_context(): eng = _make_engagement() sim = _make_sim( executed_at=datetime(2026, 6, 1, 10, 0, tzinfo=UTC), commands="whoami", execution_result="admin@host", ) result = render_engagement_markdown(eng, [sim]) assert "
" in result assert "whoami" in result assert "admin@host" in result def test_render_engagement_markdown_pipe_in_cell_is_escaped(app) -> None: with app.app_context(): eng = _make_engagement() sim = _make_sim(name="Name | with pipe") result = render_engagement_markdown(eng, [sim]) assert "Name \\| with pipe" in result # --------------------------------------------------------------------------- # CSV tests # --------------------------------------------------------------------------- def _parse_csv(csv_text: str) -> list[list[str]]: return list(_csv.reader(_io.StringIO(csv_text))) def test_render_engagement_csv_has_header_row(app) -> None: with app.app_context(): eng = _make_engagement() result = render_engagement_csv(eng, []) rows = _parse_csv(result) assert rows[0] == _FR_HEADERS def test_render_engagement_csv_has_one_row_per_simulation(app) -> None: with app.app_context(): eng = _make_engagement() sims = [_make_sim(1, "S1"), _make_sim(2, "S2")] result = render_engagement_csv(eng, sims) rows = _parse_csv(result) assert len(rows) == 3 # header + 2 sims def test_render_engagement_csv_columns_are_seven(app) -> None: with app.app_context(): eng = _make_engagement() sim = _make_sim() result = render_engagement_csv(eng, [sim]) rows = _parse_csv(result) assert len(rows[0]) == 7 assert len(rows[1]) == 7 def test_render_engagement_csv_execution_column_contains_commands(app) -> None: with app.app_context(): eng = _make_engagement() sim = _make_sim( executed_at=datetime(2026, 6, 1, 10, 0, tzinfo=UTC), commands="net user /domain", execution_result="success", ) result = render_engagement_csv(eng, [sim]) rows = _parse_csv(result) exec_cell = rows[1][4] # col index 4 = Exécution assert "2026-06-01" in exec_cell assert "net user /domain" in exec_cell assert "success" in exec_cell # --------------------------------------------------------------------------- # CSV formula injection defense # --------------------------------------------------------------------------- def _parse_csv_data_row(csv_text: str, row_index: int = 1) -> list[str]: return _parse_csv(csv_text)[row_index] def test_render_engagement_csv_escapes_formula_injection_in_scenario(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) # col 0 = Scénario assert cells[0] == "'=cmd|'/c calc'!A1" def test_render_engagement_csv_escapes_formula_injection_in_execution(app) -> None: with app.app_context(): eng = _make_engagement() # executed_at=None so concat is "\ncommand\n" — leading \n is not a trigger. # Use a formula-triggering execution_result to test the final concat. sim = _make_sim(execution_result="=HYPERLINK(\"http://evil\")") result = render_engagement_csv(eng, [sim]) cells = _parse_csv_data_row(result) # col 4 = Exécution; concat starts with "\n" (empty executed_at) so not triggered # but the execution_result value is embedded — verify it's present assert "HYPERLINK" in cells[4] 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[0] == "Mimikatz LSASS Dump" assert "whoami /all" in cells[4] # --------------------------------------------------------------------------- # PDF tests # --------------------------------------------------------------------------- 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" def test_render_engagement_pdf_contains_simulation_table(app) -> None: from backend.app.services.export import _render_engagement_html with app.app_context(): eng = _make_engagement() sim = _make_sim() html = _render_engagement_html(eng, [sim]) assert "" in html for header in _FR_HEADERS: assert header in html, f"Expected French header '{header}' in HTML" # --------------------------------------------------------------------------- # Defense-in-depth: filename header injection # --------------------------------------------------------------------------- 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 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