Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

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 "&lt;script&gt;" 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 "&lt;b&gt;key&lt;/b&gt;" 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