351 lines
14 KiB
Python
351 lines
14 KiB
Python
"""
|
|||
|
|
Unit tests for markdown conversion utility module.
|
||
|
|
|
||
|
|
Tests cover:
|
||
|
|
- Markdown element conversion (headings, lists, bold, italic, code, tables)
|
||
|
|
- Link security attributes
|
||
|
|
- XSS prevention (script/iframe injection)
|
||
|
|
- Image/embedded content exclusion
|
||
|
|
- Error handling and fallback behavior
|
||
|
|
- Logging for conversion issues
|
||
|
|
"""
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from unittest.mock import patch, MagicMock
|
||
|
|
from markupsafe import Markup
|
||
|
|
|
||
|
|
|
||
|
|
# Import will fail until implementation exists - expected for TDD
|
||
|
|
try:
|
||
|
|
from app.utils.markdown_utils import markdown_filter
|
||
|
|
except ImportError:
|
||
|
|
markdown_filter = None
|
||
|
|
|
||
|
|
|
||
|
|
pytestmark = pytest.mark.skipif(
|
||
|
|
markdown_filter is None,
|
||
|
|
reason="markdown_utils module not yet implemented"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class TestMarkdownConversionBasics:
|
||
|
|
"""Test basic markdown element conversion."""
|
||
|
|
|
||
|
|
def test_none_input_returns_empty_string(self):
|
||
|
|
"""T003: None input should return empty string."""
|
||
|
|
result = markdown_filter(None)
|
||
|
|
assert result == ""
|
||
|
|
assert isinstance(result, (str, Markup))
|
||
|
|
|
||
|
|
def test_empty_string_returns_empty_string(self):
|
||
|
|
"""T003: Empty string input should return empty string."""
|
||
|
|
result = markdown_filter("")
|
||
|
|
assert result == ""
|
||
|
|
assert isinstance(result, (str, Markup))
|
||
|
|
|
||
|
|
def test_whitespace_only_returns_minimal_html(self):
|
||
|
|
"""T003: Whitespace-only input should return minimal/empty HTML."""
|
||
|
|
result = markdown_filter(" \n\n ")
|
||
|
|
# Should be empty or minimal whitespace, not crash
|
||
|
|
assert len(result.strip()) < 20 # Allow for minimal wrapper tags
|
||
|
|
|
||
|
|
|
||
|
|
class TestMarkdownHeadings:
|
||
|
|
"""Test markdown heading conversion."""
|
||
|
|
|
||
|
|
def test_h2_heading_conversion(self):
|
||
|
|
"""T004: H2 markdown (##) converts to <h2> tag."""
|
||
|
|
result = markdown_filter("## Summary")
|
||
|
|
assert "<h2>" in result
|
||
|
|
assert "Summary" in result
|
||
|
|
assert "</h2>" in result
|
||
|
|
|
||
|
|
def test_h3_heading_conversion(self):
|
||
|
|
"""T004: H3 markdown (###) converts to <h3> tag."""
|
||
|
|
result = markdown_filter("### Key Points")
|
||
|
|
assert "<h3>" in result
|
||
|
|
assert "Key Points" in result
|
||
|
|
assert "</h3>" in result
|
||
|
|
|
||
|
|
def test_multiple_heading_levels(self):
|
||
|
|
"""T004: Multiple heading levels are preserved."""
|
||
|
|
markdown = "# Title\n## Section\n### Subsection"
|
||
|
|
result = markdown_filter(markdown)
|
||
|
|
assert "<h1>" in result
|
||
|
|
assert "<h2>" in result
|
||
|
|
assert "<h3>" in result
|
||
|
|
|
||
|
|
|
||
|
|
class TestMarkdownLists:
|
||
|
|
"""Test markdown list conversion."""
|
||
|
|
|
||
|
|
def test_unordered_list_conversion(self):
|
||
|
|
"""T005: Unordered list converts to <ul> with <li> items."""
|
||
|
|
markdown = "- Item 1\n- Item 2\n- Item 3"
|
||
|
|
result = markdown_filter(markdown)
|
||
|
|
assert "<ul>" in result
|
||
|
|
assert "<li>Item 1</li>" in result
|
||
|
|
assert "<li>Item 2</li>" in result
|
||
|
|
assert "</ul>" in result
|
||
|
|
|
||
|
|
def test_ordered_list_conversion(self):
|
||
|
|
"""T005: Ordered list converts to <ol> with <li> items."""
|
||
|
|
markdown = "1. First\n2. Second\n3. Third"
|
||
|
|
result = markdown_filter(markdown)
|
||
|
|
assert "<ol>" in result
|
||
|
|
assert "<li>First</li>" in result
|
||
|
|
assert "<li>Second</li>" in result
|
||
|
|
assert "</ol>" in result
|
||
|
|
|
||
|
|
def test_nested_lists(self):
|
||
|
|
"""T005: Nested lists are properly structured."""
|
||
|
|
markdown = "- Parent\n - Child 1\n - Child 2"
|
||
|
|
result = markdown_filter(markdown)
|
||
|
|
# Should have nested list structure
|
||
|
|
assert result.count("<ul>") >= 2 # At least two <ul> tags for nesting
|
||
|
|
|
||
|
|
|
||
|
|
class TestMarkdownEmphasis:
|
||
|
|
"""Test markdown bold and italic conversion."""
|
||
|
|
|
||
|
|
def test_bold_text_conversion(self):
|
||
|
|
"""T006: Bold markdown (**text**) converts to <strong> tag."""
|
||
|
|
result = markdown_filter("This is **important**")
|
||
|
|
assert "<strong>important</strong>" in result
|
||
|
|
|
||
|
|
def test_italic_text_conversion(self):
|
||
|
|
"""T006: Italic markdown (*text*) converts to <em> tag."""
|
||
|
|
result = markdown_filter("This is *emphasized*")
|
||
|
|
assert "<em>emphasized</em>" in result
|
||
|
|
|
||
|
|
def test_combined_bold_italic(self):
|
||
|
|
"""T006: Combined bold and italic formatting works."""
|
||
|
|
result = markdown_filter("***bold and italic***")
|
||
|
|
# Should have both strong and em tags (order may vary)
|
||
|
|
assert "<strong>" in result or "<em>" in result
|
||
|
|
assert "bold and italic" in result
|
||
|
|
|
||
|
|
|
||
|
|
class TestMarkdownCode:
|
||
|
|
"""Test markdown code block and inline code conversion."""
|
||
|
|
|
||
|
|
def test_inline_code_conversion(self):
|
||
|
|
"""T007: Inline code (`code`) converts to <code> tag."""
|
||
|
|
result = markdown_filter("Use `print()` function")
|
||
|
|
assert "<code>print()</code>" in result
|
||
|
|
|
||
|
|
def test_code_block_conversion(self):
|
||
|
|
"""T007: Code blocks convert to <pre><code> structure."""
|
||
|
|
markdown = "```python\ndef hello():\n pass\n```"
|
||
|
|
result = markdown_filter(markdown)
|
||
|
|
assert "<pre>" in result or "<code>" in result
|
||
|
|
assert "def hello():" in result
|
||
|
|
|
||
|
|
def test_indented_code_block(self):
|
||
|
|
"""T007: Indented code blocks are recognized."""
|
||
|
|
markdown = " code line 1\n code line 2"
|
||
|
|
result = markdown_filter(markdown)
|
||
|
|
assert "<pre>" in result or "<code>" in result
|
||
|
|
|
||
|
|
|
||
|
|
class TestMarkdownTables:
|
||
|
|
"""Test markdown table conversion."""
|
||
|
|
|
||
|
|
def test_simple_table_conversion(self):
|
||
|
|
"""T008: Markdown table converts to HTML table structure."""
|
||
|
|
markdown = "| Column A | Column B |\n|----------|----------|\n| Value 1 | Value 2 |"
|
||
|
|
result = markdown_filter(markdown)
|
||
|
|
assert "<table>" in result
|
||
|
|
assert "<thead>" in result
|
||
|
|
assert "<tbody>" in result
|
||
|
|
assert "<tr>" in result
|
||
|
|
assert "<th>" in result
|
||
|
|
assert "<td>" in result
|
||
|
|
assert "Column A" in result
|
||
|
|
assert "Value 1" in result
|
||
|
|
|
||
|
|
def test_table_with_multiple_rows(self):
|
||
|
|
"""T008: Tables with multiple data rows work correctly."""
|
||
|
|
markdown = "| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |"
|
||
|
|
result = markdown_filter(markdown)
|
||
|
|
assert result.count("<tr>") >= 3 # Header + 2 data rows
|
||
|
|
|
||
|
|
|
||
|
|
class TestMarkdownLinks:
|
||
|
|
"""Test markdown link conversion with security attributes."""
|
||
|
|
|
||
|
|
def test_link_basic_conversion(self):
|
||
|
|
"""T009: Markdown links convert to <a> tags."""
|
||
|
|
result = markdown_filter("[Link Text](http://example.com)")
|
||
|
|
assert "<a" in result
|
||
|
|
assert 'href="http://example.com"' in result
|
||
|
|
assert "Link Text" in result
|
||
|
|
assert "</a>" in result
|
||
|
|
|
||
|
|
def test_link_has_target_blank(self):
|
||
|
|
"""T009: Links have target='_blank' attribute."""
|
||
|
|
result = markdown_filter("[External](https://example.com)")
|
||
|
|
assert 'target="_blank"' in result
|
||
|
|
|
||
|
|
def test_link_has_security_rel_attributes(self):
|
||
|
|
"""T009: Links have rel='noopener noreferrer nofollow' attributes."""
|
||
|
|
result = markdown_filter("[Link](http://example.com)")
|
||
|
|
# Check for all three rel attributes
|
||
|
|
assert 'rel=' in result
|
||
|
|
rel_content = result.lower()
|
||
|
|
assert 'noopener' in rel_content
|
||
|
|
assert 'noreferrer' in rel_content
|
||
|
|
assert 'nofollow' in rel_content
|
||
|
|
|
||
|
|
def test_multiple_links_all_secured(self):
|
||
|
|
"""T009: Multiple links all get security attributes."""
|
||
|
|
markdown = "[Link1](http://ex1.com) and [Link2](http://ex2.com)"
|
||
|
|
result = markdown_filter(markdown)
|
||
|
|
# Should have two links with security attributes
|
||
|
|
assert result.count('target="_blank"') == 2
|
||
|
|
assert result.count('noopener') == 2
|
||
|
|
|
||
|
|
|
||
|
|
class TestXSSPrevention:
|
||
|
|
"""Test XSS prevention through HTML sanitization."""
|
||
|
|
|
||
|
|
def test_script_tag_removed(self):
|
||
|
|
"""T010: Script tags are completely removed."""
|
||
|
|
result = markdown_filter("<script>alert('xss')</script>")
|
||
|
|
assert "<script>" not in result.lower()
|
||
|
|
assert "alert" not in result # Script content should be gone
|
||
|
|
|
||
|
|
def test_iframe_removed(self):
|
||
|
|
"""T010: Iframe tags are removed."""
|
||
|
|
result = markdown_filter("<iframe src='evil.com'></iframe>")
|
||
|
|
assert "<iframe" not in result.lower()
|
||
|
|
|
||
|
|
def test_onclick_event_handler_removed(self):
|
||
|
|
"""T010: Event handlers are removed from tags."""
|
||
|
|
result = markdown_filter("<a href='#' onclick='alert(1)'>Click</a>")
|
||
|
|
assert "onclick" not in result.lower()
|
||
|
|
# Link text might remain, but event handler must be gone
|
||
|
|
|
||
|
|
def test_javascript_protocol_removed(self):
|
||
|
|
"""T010: javascript: protocol in links is removed."""
|
||
|
|
result = markdown_filter("[Click](javascript:alert('xss'))")
|
||
|
|
# Either link is removed entirely or javascript: protocol is stripped
|
||
|
|
result_lower = result.lower()
|
||
|
|
if "href" in result_lower:
|
||
|
|
assert "javascript:" not in result_lower
|
||
|
|
|
||
|
|
def test_mixed_content_xss_attempt(self):
|
||
|
|
"""T010: Mixed markdown and HTML XSS attempts are sanitized."""
|
||
|
|
markdown = "## Heading\n<script>bad()</script>\n**Bold**"
|
||
|
|
result = markdown_filter(markdown)
|
||
|
|
assert "<h2>Heading</h2>" in result
|
||
|
|
assert "<strong>Bold</strong>" in result
|
||
|
|
assert "<script>" not in result.lower()
|
||
|
|
|
||
|
|
|
||
|
|
class TestImageAndEmbedExclusion:
|
||
|
|
"""Test that images and embedded content are excluded."""
|
||
|
|
|
||
|
|
def test_markdown_image_removed(self):
|
||
|
|
"""T011: Markdown images  are removed."""
|
||
|
|
result = markdown_filter("")
|
||
|
|
# Image tag should not appear in output
|
||
|
|
assert "<img" not in result.lower()
|
||
|
|
|
||
|
|
def test_html_image_tag_removed(self):
|
||
|
|
"""T011: HTML <img> tags are removed."""
|
||
|
|
result = markdown_filter("<img src='bad.jpg' />")
|
||
|
|
assert "<img" not in result.lower()
|
||
|
|
|
||
|
|
def test_embedded_video_removed(self):
|
||
|
|
"""T011: Embedded video/audio tags are removed."""
|
||
|
|
result = markdown_filter("<video src='vid.mp4'></video>")
|
||
|
|
assert "<video" not in result.lower()
|
||
|
|
|
||
|
|
def test_object_embed_tags_removed(self):
|
||
|
|
"""T011: Object and embed tags are removed."""
|
||
|
|
result = markdown_filter("<object data='file.swf'></object><embed src='file.swf' />")
|
||
|
|
assert "<object" not in result.lower()
|
||
|
|
assert "<embed" not in result.lower()
|
||
|
|
|
||
|
|
|
||
|
|
class TestErrorHandling:
|
||
|
|
"""Test error handling and fallback behavior."""
|
||
|
|
|
||
|
|
def test_fallback_on_markdown_exception(self):
|
||
|
|
"""T012: Conversion exceptions trigger fallback to <pre> wrapped original."""
|
||
|
|
# Mock markdown2.markdown to raise exception
|
||
|
|
with patch('app.utils.markdown_utils.markdown2') as mock_md:
|
||
|
|
mock_md.markdown.side_effect = Exception("Conversion error")
|
||
|
|
|
||
|
|
result = markdown_filter("Some **markdown**", "test-id-123")
|
||
|
|
|
||
|
|
# Should fall back to preformatted block with original content
|
||
|
|
assert "<pre>" in result
|
||
|
|
assert "Some **markdown**" in result
|
||
|
|
assert "</pre>" in result
|
||
|
|
|
||
|
|
def test_fallback_escapes_html_in_original(self):
|
||
|
|
"""T012: Fallback mode escapes HTML in original markdown."""
|
||
|
|
with patch('app.utils.markdown_utils.markdown2') as mock_md:
|
||
|
|
mock_md.markdown.side_effect = Exception("Error")
|
||
|
|
|
||
|
|
result = markdown_filter("<script>alert('xss')</script>", "test-id")
|
||
|
|
|
||
|
|
# Original should be escaped in fallback
|
||
|
|
assert "<script>" in result or "<script>" not in result.lower()
|
||
|
|
|
||
|
|
def test_malformed_markdown_graceful_handling(self):
|
||
|
|
"""T012: Malformed markdown doesn't crash, renders best-effort."""
|
||
|
|
malformed = "## Heading\n[Unclosed link(http://example.com"
|
||
|
|
result = markdown_filter(malformed)
|
||
|
|
# Should return something without crashing
|
||
|
|
assert result is not None
|
||
|
|
assert isinstance(result, (str, Markup))
|
||
|
|
|
||
|
|
|
||
|
|
class TestLogging:
|
||
|
|
"""Test warning logs for conversion issues."""
|
||
|
|
|
||
|
|
@patch('app.utils.markdown_utils.logger')
|
||
|
|
def test_logs_warning_on_conversion_exception(self, mock_logger):
|
||
|
|
"""T013: Conversion exceptions trigger warning log with feedback_id."""
|
||
|
|
with patch('app.utils.markdown_utils.markdown2') as mock_md:
|
||
|
|
mock_md.markdown.side_effect = Exception("Test error")
|
||
|
|
|
||
|
|
markdown_filter("test content", feedback_id="feedback-456")
|
||
|
|
|
||
|
|
# Should log warning with feedback_id
|
||
|
|
mock_logger.warning.assert_called_once()
|
||
|
|
call_args = str(mock_logger.warning.call_args)
|
||
|
|
assert "feedback-456" in call_args
|
||
|
|
|
||
|
|
@patch('app.utils.markdown_utils.logger')
|
||
|
|
def test_logs_warning_on_sanitization_issues(self, mock_logger):
|
||
|
|
"""T013: Sanitization removing content triggers warning log."""
|
||
|
|
# This test depends on implementation details
|
||
|
|
# If bleach removes dangerous content, we should log it
|
||
|
|
result = markdown_filter(
|
||
|
|
"<script>alert('xss')</script>Safe content",
|
||
|
|
feedback_id="feedback-789"
|
||
|
|
)
|
||
|
|
|
||
|
|
# If script was removed, warning should be logged
|
||
|
|
if "<script>" not in result.lower():
|
||
|
|
# May or may not log depending on implementation choice
|
||
|
|
# This is a placeholder for implementation-specific behavior
|
||
|
|
pass
|
||
|
|
|
||
|
|
@patch('app.utils.markdown_utils.logger')
|
||
|
|
def test_log_includes_feedback_id_parameter(self, mock_logger):
|
||
|
|
"""T013: Feedback ID parameter is included in log context."""
|
||
|
|
with patch('app.utils.markdown_utils.markdown2') as mock_md:
|
||
|
|
mock_md.markdown.side_effect = ValueError("Parse error")
|
||
|
|
|
||
|
|
markdown_filter("content", feedback_id="specific-id-999")
|
||
|
|
|
||
|
|
# Verify feedback_id appears in log call
|
||
|
|
assert mock_logger.warning.called
|
||
|
|
log_message = str(mock_logger.warning.call_args)
|
||
|
|
assert "specific-id-999" in log_message
|