|
2 | 2 |
|
3 | 3 | import json |
4 | 4 | from pathlib import Path |
| 5 | +from unittest.mock import patch |
5 | 6 |
|
6 | 7 | import pytest |
7 | 8 |
|
| 9 | +from political_event_tracking_research import rss_source_fetch |
8 | 10 | from political_event_tracking_research.rss_source_fetch import FeedConfig, fetch_rss_sources, parse_feed_items |
9 | 11 |
|
10 | 12 |
|
@@ -68,6 +70,70 @@ def test_parse_atom_feed_items_to_source_items() -> None: |
68 | 70 | assert "EVT2" in rows[0]["text"] |
69 | 71 |
|
70 | 72 |
|
| 73 | +@pytest.mark.parametrize( |
| 74 | + "payload", |
| 75 | + [ |
| 76 | + b"<!DOCTYPE rss [<!ENTITY x SYSTEM 'file:///etc/passwd'>]><rss version='2.0'><channel/></rss>", |
| 77 | + b"<?xml version='1.0'?><!DOCTYPE rss [<!ENTITY laugh 'x'>]><rss version='2.0'><channel/></rss>", |
| 78 | + b"<?xml version='1.0' encoding='UTF-16'?><!DOCTYPE rss><rss version='2.0'><channel/></rss>", |
| 79 | + b"<?xml version='1.0' encoding='x-unknown'?><rss version='2.0'><channel/></rss>", |
| 80 | + ], |
| 81 | +) |
| 82 | +def test_defused_parser_rejects_entities_and_dtd(payload: bytes) -> None: |
| 83 | + with pytest.raises(ValueError, match="feed_xml_"): |
| 84 | + parse_feed_items(payload, FeedConfig("x", "https://example.test", "official", "")) |
| 85 | + |
| 86 | + |
| 87 | +def test_utf16_entity_is_rejected_before_row_conversion() -> None: |
| 88 | + payload = """<?xml version='1.0' encoding='UTF-16'?> |
| 89 | + <!DOCTYPE rss [<!ENTITY x SYSTEM 'file:///etc/passwd'>]> |
| 90 | + <rss version='2.0'><channel><item><title>&x;</title></item></channel></rss>""".encode("utf-16") |
| 91 | + with pytest.raises(ValueError, match="feed_xml_"): |
| 92 | + parse_feed_items(payload, FeedConfig("x", "https://example.test", "official", "")) |
| 93 | + |
| 94 | + |
| 95 | +def test_utf16_valid_feed_is_supported_without_fallback() -> None: |
| 96 | + payload = """<?xml version='1.0' encoding='UTF-16'?> |
| 97 | + <rss version='2.0'><channel><item><title>UTF16</title> |
| 98 | + <link>https://example.test/utf16</link><pubDate>Fri, 01 May 2026 12:30:00 GMT</pubDate> |
| 99 | + </item></channel></rss>""".encode("utf-16") |
| 100 | + rows = parse_feed_items(payload, FeedConfig("x", "https://example.test", "official", "")) |
| 101 | + assert rows[0]["source_url"] == "https://example.test/utf16" |
| 102 | + |
| 103 | + |
| 104 | +def test_xml_payload_size_is_bounded_before_parse() -> None: |
| 105 | + feed = FeedConfig("x", "https://example.test", "official", "") |
| 106 | + with pytest.raises(ValueError, match="feed_xml_oversize"): |
| 107 | + parse_feed_items(b"x" * (rss_source_fetch.MAX_XML_BYTES + 1), feed) |
| 108 | + |
| 109 | + |
| 110 | +def test_network_read_is_bounded_and_oversize_is_sanitized() -> None: |
| 111 | + class Response: |
| 112 | + def __enter__(self): |
| 113 | + return self |
| 114 | + |
| 115 | + def __exit__(self, *_args): |
| 116 | + return False |
| 117 | + |
| 118 | + def read(self, size: int) -> bytes: |
| 119 | + assert size == rss_source_fetch.MAX_XML_BYTES + 1 |
| 120 | + return b"x" * size |
| 121 | + |
| 122 | + with patch.object(rss_source_fetch.urllib.request, "urlopen", return_value=Response()): |
| 123 | + with pytest.raises(ValueError, match="feed_xml_oversize"): |
| 124 | + rss_source_fetch.fetch_url("https://example.test/feed") |
| 125 | + |
| 126 | + |
| 127 | +def test_network_timeout_is_not_retried_or_parsed() -> None: |
| 128 | + with patch.object(rss_source_fetch.urllib.request, "urlopen", side_effect=TimeoutError("timeout")): |
| 129 | + with pytest.raises(TimeoutError, match="timeout"): |
| 130 | + rss_source_fetch.fetch_url("https://example.test/feed") |
| 131 | + |
| 132 | + |
| 133 | +def test_parser_has_no_stdlib_xml_fallback() -> None: |
| 134 | + assert rss_source_fetch.ET.__name__ == "defusedxml.ElementTree" |
| 135 | + |
| 136 | + |
71 | 137 |
|
72 | 138 | def test_fetch_rss_sources_can_continue_and_write_status(tmp_path: Path) -> None: |
73 | 139 | feeds_path = tmp_path / "feeds.csv" |
|
0 commit comments