diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 980d2ae6..0df0511b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -47,8 +47,28 @@ jobs:
- name: Install dependencies
run: pip install -r backend/requirements.txt -r backend/requirements-dev.txt
- - name: Run tests
- run: cd backend && python -m pytest --override-ini="addopts="
+ - name: Run tests with coverage
+ run: cd backend && python -m pytest --override-ini="addopts=" --cov=app --cov-report=term-missing --cov-report=json:coverage.json
+
+ - name: Display coverage summary
+ if: always()
+ run: |
+ cd backend
+ python -c "
+ import json
+ with open('coverage.json') as f:
+ data = json.load(f)
+ total = data['totals']['percent_covered_display']
+ print(f'Total coverage: {total}%')
+ print()
+ print('Module coverage:')
+ for fname, fdata in sorted(data['files'].items()):
+ pct = fdata['summary']['percent_covered_display']
+ if float(pct) < 80:
+ print(f' ⚠ {fname}: {pct}%')
+ else:
+ print(f' ✓ {fname}: {pct}%')
+ "
frontend:
runs-on: ubuntu-latest
diff --git a/backend/tests/test_export_security.py b/backend/tests/test_export_security.py
new file mode 100644
index 00000000..4e2bc627
--- /dev/null
+++ b/backend/tests/test_export_security.py
@@ -0,0 +1,287 @@
+"""Security tests for session export generators.
+
+Validates that all user-supplied content is properly escaped in HTML exports
+to prevent XSS attacks, and that markdown/text exports handle edge cases.
+"""
+
+from datetime import datetime, timezone
+from unittest.mock import MagicMock
+
+import pytest
+
+from app.schemas.session import SessionExport
+from app.services.export_service import (
+ generate_html_export,
+ generate_markdown_export,
+ generate_text_export,
+)
+
+
+def _make_session(
+ tree_name="Test Tree",
+ ticket_number=None,
+ client_name=None,
+ decisions=None,
+ scratchpad="",
+):
+ """Create a mock session object for export testing."""
+ session = MagicMock()
+ session.tree_snapshot = {"name": tree_name}
+ session.ticket_number = ticket_number
+ session.client_name = client_name
+ session.decisions = decisions or []
+ session.scratchpad = scratchpad
+ session.started_at = datetime(2026, 1, 15, 10, 30, tzinfo=timezone.utc)
+ session.completed_at = datetime(2026, 1, 15, 11, 0, tzinfo=timezone.utc)
+ return session
+
+
+def _default_options(**kwargs):
+ """Create SessionExport options with defaults."""
+ return SessionExport(
+ format=kwargs.get("format", "html"),
+ include_timestamps=kwargs.get("include_timestamps", True),
+ include_tree_info=kwargs.get("include_tree_info", True),
+ )
+
+
+# --- XSS Prevention Tests (HTML Export) ---
+
+
+class TestHtmlExportXssSecurity:
+ """Verify all user-supplied fields are HTML-escaped in HTML export."""
+
+ def test_tree_name_script_tag_escaped(self):
+ session = _make_session(tree_name='')
+ result = generate_html_export(session, _default_options())
+ assert "'
+ )
+ result = generate_html_export(session, _default_options())
+ assert "', "answer": "Yes"}
+ ])
+ result = generate_html_export(session, _default_options())
+ assert "'}
+ ])
+ result = generate_html_export(session, _default_options())
+ assert "'}
+ ])
+ result = generate_html_export(session, _default_options())
+ assert "'
+ )
+ result = generate_html_export(session, _default_options())
+ assert "', "answer": "Done"}
+ ])
+ result = generate_html_export(session, _default_options())
+ assert "',
+ ticket_number='',
+ client_name='',
+ scratchpad='',
+ decisions=[
+ {
+ "question": '',
+ "answer": '',
+ "notes": '',
+ "timestamp": '',
+ }
+ ],
+ )
+ result = generate_html_export(session, _default_options())
+ # Count: there should be zero raw