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