7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
601 lines
21 KiB
Python
601 lines
21 KiB
Python
"""
|
|
Tests for web/services/pdf_service.py
|
|
|
|
Tests cover:
|
|
- PDFService initialization
|
|
- Markdown to HTML conversion
|
|
- Markdown to PDF conversion
|
|
- Error handling
|
|
- Singleton pattern
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import Mock, patch
|
|
|
|
|
|
class TestPDFServiceInit:
|
|
"""Tests for PDFService initialization."""
|
|
|
|
def test_pdf_service_init(self):
|
|
"""Test PDFService initializes with minimal CSS."""
|
|
from local_deep_research.web.services.pdf_service import PDFService
|
|
|
|
service = PDFService()
|
|
|
|
assert service.minimal_css is not None
|
|
|
|
def test_pdf_service_css_contains_a4(self):
|
|
"""Test that CSS sets A4 page size."""
|
|
from local_deep_research.web.services.pdf_service import PDFService
|
|
|
|
service = PDFService()
|
|
|
|
# The CSS string should be accessible
|
|
assert service.minimal_css is not None
|
|
|
|
def test_minimal_css_excludes_emoji_font_families(self):
|
|
"""No font-family stack may list an emoji font explicitly.
|
|
|
|
Digits 0-9, '#' and '*' carry the Unicode Emoji property and have
|
|
glyphs in Noto Color Emoji, so Pango routes them to an explicitly
|
|
listed emoji font even when an earlier family in the stack covers
|
|
them — every number in an exported report then renders as wide,
|
|
square emoji glyphs ("2 0 2 6" instead of "2026"). Emoji still
|
|
render via Pango/fontconfig per-character fallback to an installed
|
|
emoji font, so the stacks must not name one. Regression test for
|
|
the digit-capture bug introduced by #4730.
|
|
"""
|
|
import re
|
|
|
|
from local_deep_research.web.services.pdf_service import MINIMAL_CSS
|
|
|
|
font_family_blocks = re.findall(r"font-family:\s*([^;]+);", MINIMAL_CSS)
|
|
assert len(font_family_blocks) >= 2, (
|
|
"expected at least body + code font-family declarations"
|
|
)
|
|
for block in font_family_blocks:
|
|
assert "emoji" not in block.lower(), (
|
|
f"font-family block must not list an emoji font (it would "
|
|
f"capture digits): {block!r}"
|
|
)
|
|
|
|
def test_digits_not_rendered_with_emoji_font(self):
|
|
"""Digits in a rendered PDF must come from a text font, not an emoji font.
|
|
|
|
Functional regression test for the digit-capture bug: with an emoji
|
|
family in the font-family stack, Pango renders 0-9 with Noto Color
|
|
Emoji (wide square glyphs). Renders a digit-heavy document and
|
|
asserts no digit glyph is drawn with an emoji font. On hosts with
|
|
no emoji font installed the assertion passes trivially, which is
|
|
correct — there is no emoji font to capture the digits.
|
|
"""
|
|
import io
|
|
|
|
pytest.importorskip("pdfminer.high_level")
|
|
from pdfminer.high_level import extract_pages
|
|
from pdfminer.layout import LTChar, LTTextContainer, LTTextLine
|
|
|
|
from local_deep_research.web.services.pdf_service import PDFService
|
|
|
|
service = PDFService()
|
|
pdf_bytes = service.markdown_to_pdf(
|
|
"As of July 11, 2026, plans cost $20/month [21][30]."
|
|
)
|
|
|
|
digit_fonts = set()
|
|
for page in extract_pages(io.BytesIO(pdf_bytes)):
|
|
for element in page:
|
|
if not isinstance(element, LTTextContainer):
|
|
continue
|
|
for line in element:
|
|
if not isinstance(line, LTTextLine):
|
|
continue
|
|
for char in line:
|
|
if (
|
|
isinstance(char, LTChar)
|
|
and char.get_text().isdigit()
|
|
):
|
|
digit_fonts.add(char.fontname)
|
|
|
|
assert digit_fonts, "expected the rendered PDF to contain digits"
|
|
emoji_fonts = {f for f in digit_fonts if "emoji" in f.lower()}
|
|
assert not emoji_fonts, (
|
|
f"digits were rendered with an emoji font: {emoji_fonts}"
|
|
)
|
|
|
|
|
|
class TestMarkdownToHTML:
|
|
"""Tests for _markdown_to_html method."""
|
|
|
|
def test_markdown_to_html_basic(self):
|
|
"""Test basic markdown conversion."""
|
|
from local_deep_research.web.services.pdf_service import PDFService
|
|
|
|
service = PDFService()
|
|
markdown = "# Hello World\n\nThis is a **test**."
|
|
|
|
html = service._markdown_to_html(markdown)
|
|
|
|
# h1 may have an id attribute added by markdown TOC extension
|
|
assert "Hello World</h1>" in html
|
|
assert "<strong>test</strong>" in html
|
|
assert "<!DOCTYPE html>" in html
|
|
assert "</html>" in html
|
|
|
|
def test_markdown_to_html_with_title(self):
|
|
"""Test conversion with title."""
|
|
from local_deep_research.web.services.pdf_service import PDFService
|
|
|
|
service = PDFService()
|
|
markdown = "Content here"
|
|
|
|
html = service._markdown_to_html(markdown, title="Test Title")
|
|
|
|
assert "<title>Test Title</title>" in html
|
|
|
|
def test_markdown_to_html_escapes_html_in_title(self):
|
|
"""Test that HTML special characters in title are escaped."""
|
|
from local_deep_research.web.services.pdf_service import PDFService
|
|
|
|
service = PDFService()
|
|
|
|
html = service._markdown_to_html(
|
|
"content", title='<script>alert("xss")</script>'
|
|
)
|
|
|
|
assert "<script>" not in html
|
|
assert "<script>" in html
|
|
|
|
def test_markdown_to_html_escapes_html_in_metadata(self):
|
|
"""Test that HTML special characters in metadata are escaped."""
|
|
from local_deep_research.web.services.pdf_service import PDFService
|
|
|
|
service = PDFService()
|
|
metadata = {"<b>key</b>": '<img src="x" onerror="alert(1)">'}
|
|
|
|
html = service._markdown_to_html("content", metadata=metadata)
|
|
|
|
assert "<b>key</b>" not in html
|
|
assert "<b>key</b>" in html
|
|
assert 'onerror="alert(1)"' not in html
|
|
|
|
def test_markdown_to_html_with_metadata(self):
|
|
"""Test conversion with metadata."""
|
|
from local_deep_research.web.services.pdf_service import PDFService
|
|
|
|
service = PDFService()
|
|
markdown = "Content"
|
|
metadata = {"author": "Test Author", "date": "2024-01-01"}
|
|
|
|
html = service._markdown_to_html(markdown, metadata=metadata)
|
|
|
|
assert 'name="author"' in html
|
|
assert 'content="Test Author"' in html
|
|
assert 'name="date"' in html
|
|
|
|
def test_markdown_to_html_tables(self):
|
|
"""Test table markdown is converted."""
|
|
from local_deep_research.web.services.pdf_service import PDFService
|
|
|
|
service = PDFService()
|
|
markdown = """
|
|
| Header 1 | Header 2 |
|
|
|----------|----------|
|
|
| Cell 1 | Cell 2 |
|
|
"""
|
|
html = service._markdown_to_html(markdown)
|
|
|
|
assert "<table>" in html
|
|
assert "<th>" in html or "<td>" in html
|
|
|
|
def test_markdown_to_html_code_blocks(self):
|
|
"""Test fenced code blocks are converted."""
|
|
from local_deep_research.web.services.pdf_service import PDFService
|
|
|
|
service = PDFService()
|
|
markdown = """
|
|
```python
|
|
def hello():
|
|
print("Hello")
|
|
```
|
|
"""
|
|
html = service._markdown_to_html(markdown)
|
|
|
|
assert "<pre>" in html or "<code>" in html
|
|
|
|
def test_markdown_to_html_includes_footer(self):
|
|
"""Test that footer with LDR attribution is included."""
|
|
from local_deep_research.web.services.pdf_service import PDFService
|
|
|
|
service = PDFService()
|
|
markdown = "Test content"
|
|
|
|
html = service._markdown_to_html(markdown)
|
|
|
|
assert "Local Deep Research" in html or "LDR" in html
|
|
|
|
|
|
class TestMarkdownToPDF:
|
|
"""Tests for markdown_to_pdf method."""
|
|
|
|
def test_markdown_to_pdf_returns_bytes(self):
|
|
"""Test that PDF conversion returns bytes."""
|
|
from local_deep_research.web.services.pdf_service import PDFService
|
|
|
|
service = PDFService()
|
|
markdown = "# Test Document\n\nThis is a test."
|
|
|
|
pdf_bytes = service.markdown_to_pdf(markdown)
|
|
|
|
assert isinstance(pdf_bytes, bytes)
|
|
assert len(pdf_bytes) > 0
|
|
# PDF files start with %PDF
|
|
assert pdf_bytes[:4] == b"%PDF"
|
|
|
|
def test_markdown_to_pdf_with_title(self):
|
|
"""Test PDF conversion with title."""
|
|
from local_deep_research.web.services.pdf_service import PDFService
|
|
|
|
service = PDFService()
|
|
markdown = "Content here"
|
|
|
|
pdf_bytes = service.markdown_to_pdf(markdown, title="My Document")
|
|
|
|
assert isinstance(pdf_bytes, bytes)
|
|
assert len(pdf_bytes) > 0
|
|
|
|
def test_markdown_to_pdf_with_metadata(self):
|
|
"""Test PDF conversion with metadata."""
|
|
from local_deep_research.web.services.pdf_service import PDFService
|
|
|
|
service = PDFService()
|
|
markdown = "Content"
|
|
metadata = {"author": "Test Author"}
|
|
|
|
pdf_bytes = service.markdown_to_pdf(markdown, metadata=metadata)
|
|
|
|
assert isinstance(pdf_bytes, bytes)
|
|
|
|
def test_markdown_to_pdf_with_custom_css(self):
|
|
"""Test PDF conversion with custom CSS."""
|
|
from local_deep_research.web.services.pdf_service import PDFService
|
|
|
|
service = PDFService()
|
|
markdown = "# Styled Content"
|
|
custom_css = "body { color: red; }"
|
|
|
|
pdf_bytes = service.markdown_to_pdf(markdown, custom_css=custom_css)
|
|
|
|
assert isinstance(pdf_bytes, bytes)
|
|
assert len(pdf_bytes) > 0
|
|
|
|
def test_custom_css_layers_on_top_of_default(self):
|
|
"""custom_css must extend, not replace, the default stylesheet.
|
|
|
|
Regression test: previously markdown_to_pdf used an if/else that
|
|
dropped self.minimal_css entirely when custom_css was provided,
|
|
silently losing the CJK font fallbacks. Verify the default
|
|
stylesheet is always passed to WeasyPrint.
|
|
"""
|
|
from unittest.mock import patch, MagicMock
|
|
from local_deep_research.web.services.pdf_service import PDFService
|
|
|
|
service = PDFService()
|
|
custom_css = "body { color: red; }"
|
|
|
|
with (
|
|
patch(
|
|
"local_deep_research.web.services.pdf_service.CSS",
|
|
side_effect=lambda **kwargs: MagicMock(**kwargs),
|
|
),
|
|
patch(
|
|
"local_deep_research.web.services.pdf_service.HTML"
|
|
) as mock_html,
|
|
):
|
|
mock_html.return_value.write_pdf.return_value = b"%PDF-1.7"
|
|
|
|
service.markdown_to_pdf("# x", custom_css=custom_css)
|
|
|
|
# write_pdf must receive both the default and the custom CSS.
|
|
call_kwargs = mock_html.return_value.write_pdf.call_args.kwargs
|
|
stylesheets = call_kwargs.get("stylesheets", [])
|
|
assert len(stylesheets) == 2, (
|
|
f"expected [default, custom]; got {len(stylesheets)} sheets"
|
|
)
|
|
assert stylesheets[0] is service.minimal_css, (
|
|
"default MINIMAL_CSS must always be applied first"
|
|
)
|
|
|
|
def test_markdown_to_pdf_complex_document(self):
|
|
"""Test PDF conversion with complex document."""
|
|
from local_deep_research.web.services.pdf_service import PDFService
|
|
|
|
service = PDFService()
|
|
markdown = """
|
|
# Research Report
|
|
|
|
## Introduction
|
|
|
|
This is a comprehensive research document.
|
|
|
|
### Key Findings
|
|
|
|
1. First finding
|
|
2. Second finding
|
|
3. Third finding
|
|
|
|
### Data Table
|
|
|
|
| Metric | Value |
|
|
|--------|-------|
|
|
| A | 100 |
|
|
| B | 200 |
|
|
|
|
## Conclusion
|
|
|
|
The research concludes that **testing is important**.
|
|
|
|
```python
|
|
def analyze():
|
|
return True
|
|
```
|
|
"""
|
|
pdf_bytes = service.markdown_to_pdf(
|
|
markdown,
|
|
title="Research Report",
|
|
metadata={"author": "Research Team", "date": "2024"},
|
|
)
|
|
|
|
assert isinstance(pdf_bytes, bytes)
|
|
assert len(pdf_bytes) > 100 # Should be a reasonable size
|
|
|
|
def test_markdown_to_pdf_empty_content(self):
|
|
"""Test PDF conversion with empty content."""
|
|
from local_deep_research.web.services.pdf_service import PDFService
|
|
|
|
service = PDFService()
|
|
markdown = ""
|
|
|
|
pdf_bytes = service.markdown_to_pdf(markdown)
|
|
|
|
# Should still generate valid PDF
|
|
assert isinstance(pdf_bytes, bytes)
|
|
assert pdf_bytes[:4] == b"%PDF"
|
|
|
|
|
|
class TestGetPDFService:
|
|
"""Tests for get_pdf_service singleton function."""
|
|
|
|
def test_get_pdf_service_returns_instance(self):
|
|
"""Test that get_pdf_service returns a PDFService instance."""
|
|
# Reset singleton for test
|
|
import local_deep_research.web.services.pdf_service as pdf_module
|
|
|
|
pdf_module._pdf_service = None
|
|
|
|
from local_deep_research.web.services.pdf_service import get_pdf_service
|
|
|
|
service = get_pdf_service()
|
|
|
|
assert service is not None
|
|
from local_deep_research.web.services.pdf_service import PDFService
|
|
|
|
assert isinstance(service, PDFService)
|
|
|
|
def test_get_pdf_service_singleton(self):
|
|
"""Test that get_pdf_service returns same instance."""
|
|
# Reset singleton for test
|
|
import local_deep_research.web.services.pdf_service as pdf_module
|
|
|
|
pdf_module._pdf_service = None
|
|
|
|
from local_deep_research.web.services.pdf_service import get_pdf_service
|
|
|
|
service1 = get_pdf_service()
|
|
service2 = get_pdf_service()
|
|
|
|
assert service1 is service2
|
|
|
|
|
|
class TestPDFServiceErrorHandling:
|
|
"""Tests for error handling in PDF service."""
|
|
|
|
def test_markdown_to_pdf_raises_on_error(self):
|
|
"""Test that exceptions are propagated."""
|
|
from local_deep_research.web.services.pdf_service import PDFService
|
|
|
|
service = PDFService()
|
|
|
|
# Mock HTML to raise an exception
|
|
with patch(
|
|
"local_deep_research.web.services.pdf_service.HTML"
|
|
) as mock_html:
|
|
mock_html.side_effect = Exception("Conversion error")
|
|
|
|
with pytest.raises(Exception) as exc_info:
|
|
service.markdown_to_pdf("test")
|
|
|
|
assert "Conversion error" in str(exc_info.value)
|
|
|
|
|
|
class TestMissingPDFDependencyError:
|
|
"""Tests for MissingPDFDependencyError behavior."""
|
|
|
|
def test_is_runtime_error_subclass(self):
|
|
"""MissingPDFDependencyError must be a RuntimeError subclass.
|
|
|
|
Documents the security boundary: the web route catches this specific
|
|
class (not generic RuntimeError) so that other exporters' RuntimeErrors
|
|
(e.g., pandoc stderr from ODT) don't leak into HTTP responses.
|
|
"""
|
|
from local_deep_research.web.services.pdf_service import (
|
|
MissingPDFDependencyError,
|
|
)
|
|
|
|
assert issubclass(MissingPDFDependencyError, RuntimeError)
|
|
|
|
def test_get_pdf_service_raises_specific_class_when_unavailable(self):
|
|
"""get_pdf_service() raises MissingPDFDependencyError, not plain RuntimeError."""
|
|
from local_deep_research.web.services import pdf_service as pdf_module
|
|
from local_deep_research.web.services.pdf_service import (
|
|
MissingPDFDependencyError,
|
|
get_pdf_service,
|
|
)
|
|
|
|
with patch.object(pdf_module, "WEASYPRINT_AVAILABLE", False):
|
|
with pytest.raises(MissingPDFDependencyError):
|
|
get_pdf_service()
|
|
|
|
|
|
class TestSafeUrlFetcher:
|
|
"""Tests for the SSRF-guarded WeasyPrint url_fetcher (GHSA-fj2m-qvh9-jq4q).
|
|
|
|
The custom fetcher blocks outbound requests to internal/private IPs and
|
|
non-http(s) schemes, so URLs that reach the rendered HTML via the
|
|
markdown body, citations, or any other channel cannot be weaponised
|
|
into SSRF against cloud metadata or internal services.
|
|
"""
|
|
|
|
def test_blocks_aws_metadata_endpoint(self):
|
|
"""AWS metadata IP is always blocked — critical SSRF target."""
|
|
from local_deep_research.web.services.pdf_service import (
|
|
UnsafePDFResourceURLError,
|
|
_safe_url_fetcher,
|
|
)
|
|
|
|
with pytest.raises(UnsafePDFResourceURLError):
|
|
_safe_url_fetcher(
|
|
"http://169.254.169.254/latest/meta-data/iam/security-credentials/"
|
|
)
|
|
|
|
def test_blocks_loopback_address(self):
|
|
"""Loopback addresses are blocked by default."""
|
|
from local_deep_research.web.services.pdf_service import (
|
|
UnsafePDFResourceURLError,
|
|
_safe_url_fetcher,
|
|
)
|
|
|
|
with pytest.raises(UnsafePDFResourceURLError):
|
|
_safe_url_fetcher("http://127.0.0.1:8080/admin")
|
|
|
|
def test_blocks_rfc1918_private_ip(self):
|
|
"""RFC1918 private range is blocked."""
|
|
from local_deep_research.web.services.pdf_service import (
|
|
UnsafePDFResourceURLError,
|
|
_safe_url_fetcher,
|
|
)
|
|
|
|
with pytest.raises(UnsafePDFResourceURLError):
|
|
_safe_url_fetcher("http://10.0.0.1/internal-api")
|
|
|
|
def test_blocks_non_http_scheme(self):
|
|
"""Non-http(s) schemes like file:// are blocked."""
|
|
from local_deep_research.web.services.pdf_service import (
|
|
UnsafePDFResourceURLError,
|
|
_safe_url_fetcher,
|
|
)
|
|
|
|
with pytest.raises(UnsafePDFResourceURLError):
|
|
_safe_url_fetcher("file:///etc/passwd")
|
|
|
|
def test_unsafe_url_error_subclasses_valueerror(self):
|
|
"""UnsafePDFResourceURLError must inherit from ValueError.
|
|
|
|
WeasyPrint's url_fetcher contract treats ValueError as a
|
|
retrievable fetch failure — the offending resource is skipped
|
|
and rendering continues. Breaking this subclass relationship
|
|
would turn blocked URLs into hard render failures.
|
|
"""
|
|
from local_deep_research.web.services.pdf_service import (
|
|
UnsafePDFResourceURLError,
|
|
)
|
|
|
|
assert issubclass(UnsafePDFResourceURLError, ValueError)
|
|
|
|
def test_validated_url_delegates_to_url_fetcher(self):
|
|
"""Validated URLs are forwarded to the module's URLFetcher instance."""
|
|
from local_deep_research.web.services import pdf_service as pdf_module
|
|
from local_deep_research.web.services.pdf_service import (
|
|
_safe_url_fetcher,
|
|
)
|
|
|
|
pdf_module._ensure_weasyprint() # populate the lazy _URL_FETCHER
|
|
with (
|
|
patch.object(pdf_module, "validate_url", return_value=True),
|
|
patch.object(pdf_module._URL_FETCHER, "fetch") as mock_fetch,
|
|
):
|
|
mock_fetch.return_value = Mock(
|
|
status=200, url="https://example.com/image.png"
|
|
)
|
|
|
|
_safe_url_fetcher("https://example.com/image.png")
|
|
|
|
mock_fetch.assert_called_once_with("https://example.com/image.png")
|
|
|
|
def test_url_fetcher_disables_redirects(self):
|
|
"""The module-level URLFetcher must keep allow_redirects=False.
|
|
|
|
validate_url only inspects the initial URL string, so a 30x
|
|
response redirecting to the AWS metadata endpoint would bypass
|
|
the SSRF guard if URLFetcher were to follow redirects.
|
|
default_url_fetcher
|
|
hard-coded this; after migrating away from it, we assert the
|
|
posture explicitly.
|
|
"""
|
|
from local_deep_research.web.services import pdf_service as pdf_module
|
|
from urllib.request import HTTPRedirectHandler
|
|
|
|
pdf_module._ensure_weasyprint() # populate the lazy _URL_FETCHER
|
|
redirect_handlers = [
|
|
h
|
|
for h in pdf_module._URL_FETCHER.handlers
|
|
if isinstance(h, HTTPRedirectHandler)
|
|
]
|
|
assert redirect_handlers == []
|
|
|
|
def test_pdf_html_passes_fetcher_to_weasyprint(self):
|
|
"""markdown_to_pdf must wire _safe_url_fetcher into HTML(...).
|
|
|
|
Regression guard: if a refactor drops the url_fetcher= kwarg,
|
|
WeasyPrint silently reverts to its default fetcher and the
|
|
SSRF guard disappears.
|
|
"""
|
|
from local_deep_research.web.services import pdf_service as pdf_module
|
|
from local_deep_research.web.services.pdf_service import PDFService
|
|
|
|
service = PDFService()
|
|
|
|
with patch.object(pdf_module, "HTML") as mock_html:
|
|
mock_html.return_value.write_pdf.return_value = None
|
|
service.markdown_to_pdf("content")
|
|
|
|
mock_html.assert_called_once()
|
|
assert (
|
|
mock_html.call_args.kwargs.get("url_fetcher")
|
|
is pdf_module._safe_url_fetcher
|
|
)
|
|
|
|
def test_render_succeeds_when_body_url_is_blocked(self):
|
|
"""End-to-end: a malicious URL in the markdown body doesn't break rendering.
|
|
|
|
Covers the full chain (markdown passthrough → WeasyPrint → fetcher →
|
|
ValueError → WeasyPrint skips resource → PDF still renders), which
|
|
is what the SSRF guard relies on. Would fail if a future WeasyPrint
|
|
upgrade changed the ValueError-as-skip contract, if Python-Markdown
|
|
started stripping raw HTML, or if the url_fetcher got unwired.
|
|
"""
|
|
from local_deep_research.web.services.pdf_service import PDFService
|
|
|
|
service = PDFService()
|
|
markdown = (
|
|
"# Report\n\n"
|
|
"Body text.\n\n"
|
|
'<img src="http://169.254.169.254/latest/meta-data/" alt="x" />\n'
|
|
)
|
|
|
|
pdf_bytes = service.markdown_to_pdf(markdown)
|
|
|
|
assert isinstance(pdf_bytes, bytes)
|
|
assert pdf_bytes[:4] == b"%PDF"
|
|
assert len(pdf_bytes) > 100
|