epub2pdf: title-based export names, .pdf download headers, security hardening (68 tests green)

This commit is contained in:
BMad Master
2026-08-30 09:03:39 -04:00
commit 5580004aeb
22 changed files with 2351 additions and 0 deletions
+460
View File
@@ -0,0 +1,460 @@
"""Focused regression tests for the 2026-08-29 multi-lens review fixes.
Source of truth: .a0proj/_bmad-output/test-artifacts/review-findings-20260829.json
Each test maps to a finding (lens + location); guard behavior is asserted
through the public HTTP API wherever possible.
"""
import io
import os
import re
import subprocess
import sys
import threading
import time
import zipfile
from pathlib import Path
import pytest
import app
from tests.helpers import (
_CONTAINER_XML,
download_bytes,
make_epub_zip,
pdf_text,
wait_for_job,
)
ROOT = Path(__file__).resolve().parents[1]
def _zip_with(items: dict) -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for name, data in items.items():
if isinstance(data, str):
data = data.encode("utf-8")
zf.writestr(name, data)
return buf.getvalue()
def _append_member(epub_bytes, name, data=b"x", external_attr=None):
src = io.BytesIO(epub_bytes)
out = io.BytesIO()
with zipfile.ZipFile(src, "r") as zin, zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zout:
for item in zin.namelist():
zout.writestr(item, zin.read(item))
zi = zipfile.ZipInfo(name)
if external_attr is not None:
zi.external_attr = external_attr
zout.writestr(zi, data)
return out.getvalue()
def _upload(client, content, filename="book.epub"):
"""POST an upload, wait for the terminal job state, return the snapshot."""
files = {"file": (io.BytesIO(content), filename)}
resp = client.post("/api/convert", data=files, content_type="multipart/form-data")
assert resp.status_code == 202, resp.get_json()
return wait_for_job(client, resp.get_json()["job_id"])
# ---------------------------------------------------------------------------
# Fix 1: ZIP-SLIP + ZIP BOMB (adversarial, app.py extractall)
# ---------------------------------------------------------------------------
def test_zip_slip_dotdot_member_rejected(client, fixture_epub_bytes):
job = _upload(client, _append_member(fixture_epub_bytes, "../pwned.txt"))
assert job["status"] == "error", job
assert "unsafe zip member" in job["error"]
def test_zip_symlink_member_rejected(client, fixture_epub_bytes):
content = _append_member(
fixture_epub_bytes, "OEBPS/link", external_attr=(0xA << 28) | 0o777
)
job = _upload(client, content)
assert job["status"] == "error", job
assert "unsafe zip member" in job["error"]
def test_zip_bomb_guard_rejects_huge_expansion(client, fixture_epub_bytes, monkeypatch):
# Production floor is 2 GiB (impractical in a unit test); shrink the floor
# so the guard logic is exercised with a 4 MiB member in a ~8 KiB zip.
monkeypatch.setattr(app, "ZIP_BOMB_MIN_CAP", 1024 * 1024)
content = _append_member(fixture_epub_bytes, "OEBPS/bomb.bin", b"\x00" * (4 * 1024 * 1024))
job = _upload(client, content)
assert job["status"] == "error", job
assert "zip-bomb" in job["error"].lower()
# ---------------------------------------------------------------------------
# Fix 2: UNBOUNDED THREADS -> bounded pool + 503 backpressure
# (adversarial, app.py:293-307 / 355)
# ---------------------------------------------------------------------------
def test_saturated_pool_returns_503_json(client, fixture_epub_bytes, monkeypatch):
monkeypatch.setattr(app, "WORKER_SLOTS", threading.BoundedSemaphore(0))
files = {"file": (io.BytesIO(fixture_epub_bytes), "book.epub")}
resp = client.post("/api/convert", data=files, content_type="multipart/form-data")
assert resp.status_code == 503
body = resp.get_json()
assert body is not None, "503 body must be JSON so client pollers never hang"
assert "busy" in body["error"].lower()
def test_worker_slot_released_after_job_finishes(client, submit_epub):
before = app.WORKER_SLOTS._value
job = wait_for_job(client, submit_epub().get_json()["job_id"])
assert job["status"] == "done"
assert app.WORKER_SLOTS._value == before, "slot leaked: semaphore not released"
# ---------------------------------------------------------------------------
# Fix 3: OUTPUT NAME COLLISION + TORN FILE (adversarial, app.py:274-287)
# ---------------------------------------------------------------------------
def _clean_output(pattern: str) -> None:
"""Remove output-dir files matching pattern so naming tests are deterministic."""
for f in app.OUTPUT_DIR.glob(pattern):
f.unlink()
def _synthetic_epub_bytes(title=None) -> bytes:
"""Minimal renderable EPUB; title=None omits <dc:title>, '' makes it empty."""
title_tag = "" if title is None else f"<dc:title>{title}</dc:title>"
opf = (
'<?xml version="1.0" encoding="utf-8"?>'
'<package xmlns="http://www.idpf.org/2007/opf" version="3.0" '
'unique-identifier="uid">'
'<metadata xmlns:dc="http://purl.org/dc/elements/1.1">'
f"{title_tag}<dc:creator>Test</dc:creator>"
"</metadata>"
'<manifest><item id="ch1" href="chapter1.xhtml" '
'media-type="application/xhtml+xml"/></manifest>'
'<spine><itemref idref="ch1"/></spine>'
"</package>"
)
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("META-INF/container.xml", _CONTAINER_XML)
zf.writestr("OEBPS/book.opf", opf)
zf.writestr(
"OEBPS/chapter1.xhtml",
'<?xml version="1.0" encoding="utf-8"?>'
"<html><body><p>naming body</p></body></html>",
)
return buf.getvalue()
def test_output_filename_unique_per_job(client, submit_epub):
# New contract (export naming): clean title name first, -2 suffix on collision
_clean_output("Structured Test Book*.pdf")
job1 = wait_for_job(client, submit_epub(filename="book.epub").get_json()["job_id"])
job2 = wait_for_job(client, submit_epub(filename="book.epub").get_json()["job_id"])
assert job1["status"] == "done" and job2["status"] == "done"
assert job1["filename"] != job2["filename"], "same book must not overwrite its PDF"
assert job2["filename"].endswith("-2.pdf"), "second conversion must append -2"
assert (app.OUTPUT_DIR / job1["filename"]).is_file()
assert (app.OUTPUT_DIR / job2["filename"]).is_file()
def test_atomic_write_leaves_no_partial_files(client, submit_epub):
job = wait_for_job(client, submit_epub().get_json()["job_id"])
assert job["status"] == "done"
assert not list(app.OUTPUT_DIR.glob("*.part")), "torn-write .part temp file left behind"
def test_export_name_is_book_title(client, submit_epub):
# req 1: export name is the clean book title, not the upload name
_clean_output("Structured Test Book*.pdf")
job = wait_for_job(
client, submit_epub(filename="weird-upload-name.epub").get_json()["job_id"]
)
assert job["status"] == "done", job
assert job["filename"] == "Structured Test Book.pdf"
def test_export_name_falls_back_to_epub_stem(client, submit_epub):
# req 1 fallback: missing <dc:title> -> uploaded .epub stem
_clean_output("pg2680-images-3*.pdf")
job = wait_for_job(
client,
submit_epub(
filename="pg2680-images-3.epub",
content=_synthetic_epub_bytes(title=None),
).get_json()["job_id"],
)
assert job["status"] == "done", job
assert job["filename"] == "pg2680-images-3.pdf", job["filename"]
# req 1 fallback: empty <dc:title></dc:title> -> same stem fallback
_clean_output("pg2680-images-4*.pdf")
job2 = wait_for_job(
client,
submit_epub(
filename="pg2680-images-4.epub",
content=_synthetic_epub_bytes(title=""),
).get_json()["job_id"],
)
assert job2["status"] == "done", job2
assert job2["filename"] == "pg2680-images-4.pdf", job2["filename"]
def test_export_name_pdf_title_not_doubled(client, submit_epub):
# req 2: title ending in .pdf (case-insensitive) must not double the extension
_clean_output("My Book*.pdf")
job = wait_for_job(
client,
submit_epub(
filename="seed.epub",
content=_synthetic_epub_bytes(title="My Book.PDF"),
).get_json()["job_id"],
)
assert job["status"] == "done", job
assert job["filename"] == "My Book.pdf", job["filename"]
assert not job["filename"].endswith(".pdf.pdf")
def test_download_headers_pdf_type_and_disposition(client, submit_epub):
# req 4: Content-Type application/pdf + attachment filename ending in .pdf
_clean_output("Structured Test Book*.pdf")
job = wait_for_job(client, submit_epub(filename="book.epub").get_json()["job_id"])
assert job["status"] == "done", job
resp = client.get(f"/download/{job['filename']}")
assert resp.status_code == 200
assert resp.headers["Content-Type"].startswith("application/pdf")
cd = resp.headers.get("Content-Disposition", "")
assert "attachment" in cd
assert cd.split("filename")[-1].strip().strip('"').endswith(".pdf")
def test_download_defensively_appends_pdf_suffix(client):
# req 4: non-.pdf file served by /download gets a .pdf download_name
target = app.OUTPUT_DIR / "note.txt"
target.write_bytes(b"%PDF-1.4 fake")
try:
resp = client.get("/download/note.txt")
assert resp.status_code == 200
assert resp.headers["Content-Type"].startswith("application/pdf")
assert resp.headers["Content-Disposition"].split("filename")[-1].strip().strip('"').endswith(".pdf")
finally:
target.unlink(missing_ok=True)
# ---------------------------------------------------------------------------
# Fix 4: EVICTION KILLS IN-FLIGHT JOBS (adversarial app.py:69-73 +
# verification-gap index.html:253-279)
# ---------------------------------------------------------------------------
def test_eviction_never_touches_queued_or_working_jobs():
now = time.time()
ids = []
for i in range(52):
jid = app._register_job(f"j{i}.epub")
ids.append(jid)
app.JOBS[jid]["created"] = now - (2000 - i)
app.JOBS[jid]["status"] = "done"
# At the 52nd registration 51 jobs were terminal -> oldest (j0) evicted.
assert ids[0] not in app.JOBS
# Make the oldest *remaining* job in-flight, then force another eviction
# pass with two more terminal jobs.
app.JOBS[ids[1]]["status"] = "working"
for i in range(2):
jid = app._register_job(f"extra{i}.epub")
app.JOBS[jid]["created"] = now + 100 + i
app.JOBS[jid]["status"] = "done"
assert app.JOBS[ids[1]]["status"] == "working", "in-flight job must survive eviction"
assert ids[1] in app.JOBS
def test_evicted_job_yields_404_unknown_job_body(client):
"""The exact body the rewritten UI poll loop receives on a 404."""
now = time.time()
first = None
for i in range(52):
jid = app._register_job(f"t{i}.epub")
if i == 0:
first = jid
app.JOBS[jid]["created"] = now - (1000 - i)
app.JOBS[jid]["status"] = "done"
resp = client.get(f"/api/jobs/{first}")
assert resp.status_code == 404
assert resp.get_json() == {"error": "unknown job"}
# ---------------------------------------------------------------------------
# Fix 5: 0-PAGE + LEAKS + FAKE HEALTH (adversarial app.py:278-288 / 347-352)
# ---------------------------------------------------------------------------
def test_zero_page_book_errors_instead_of_blank_pdf(client):
# Spine item is html but the file is absent -> every chunk is skipped ->
# no pages. Must be a job error, not a blank-PDF success.
content = make_epub_zip(
items=[("ch1", "chapter1.xhtml", "application/xhtml+xml", "")],
spine_ids=["ch1"],
)
job = _upload(client, content, "ghost.epub")
assert job["status"] == "error", job
assert "no renderable content" in job["error"]
def test_upload_unlinked_after_successful_job(client, submit_epub):
jid = submit_epub(filename="book.epub").get_json()["job_id"]
job = wait_for_job(client, jid)
assert job["status"] == "done"
assert not (app.UPLOAD_DIR / f"{jid}.epub").exists(), "upload leaked after success"
def test_health_503_when_cjk_fonts_missing(client, monkeypatch):
monkeypatch.setattr(app, "_cjk_fonts_present", lambda: False)
resp = client.get("/health")
assert resp.status_code == 503
body = resp.get_json()
assert body["status"] == "degraded"
assert any("CJK" in p for p in body["problems"])
def test_health_503_when_data_dir_not_writable(client, monkeypatch, tmp_path):
monkeypatch.setattr(app, "DATA_DIR", tmp_path / "gone") # nonexistent dir
resp = client.get("/health")
assert resp.status_code == 503
assert any("writable" in p for p in resp.get_json()["problems"])
def test_health_exposes_max_upload_mb_single_source(client, monkeypatch):
monkeypatch.setattr(app, "MAX_UPLOAD_MB", 77)
body = client.get("/health").get_json()
assert body["max_upload_mb"] == 77
# ---------------------------------------------------------------------------
# Fix 6: CHARSET (adversarial app.py:262) — legacy windows-1252/latin-1 books
# ---------------------------------------------------------------------------
def test_charset_windows_1252_xml_declaration_honored(client):
chap = (
'<?xml version="1.0" encoding="windows-1252"?>\n'
"<html><body><p>caf\xe9 \u2014 em dash test</p></body></html>"
)
base = make_epub_zip(
items=[("ch1", "ch1.xhtml", "application/xhtml+xml", "")],
spine_ids=["ch1"],
)
content = _append_member(base, "OEBPS/ch1.xhtml", chap.encode("windows-1252"))
job = _upload(client, content, "legacy.epub")
assert job["status"] == "done", job
full = pdf_text(download_bytes(client, job["filename"]))
assert "\u2014" in full, "em dash lost: declared windows-1252 encoding not honored"
assert "café" in full, "latin-1 mojibake: windows-1252 not honored"
def test_decode_html_meta_charset_latin1():
raw = b'<html><head><meta charset="latin-1"></head><body>caf\xe9</body></html>'
assert app._decode_html(raw) == "café"
def test_decode_html_falls_back_to_utf8_replace():
raw = b"<html><body>" + b"\xff\xfe\xfa" + b"</body></html>"
assert app._decode_html(raw), "undecodable chapter must not raise"
# ---------------------------------------------------------------------------
# Fix 7: LAUNCHER DIVERGENCE + LIMIT DRY (verification-gap start.sh:25-38 /
# Dockerfile:37 + adversarial index.html:139)
# ---------------------------------------------------------------------------
def test_launcher_flags_aligned_single_source():
start = (ROOT / "start.sh").read_text(encoding="utf-8")
docker = (ROOT / "Dockerfile").read_text(encoding="utf-8")
for text, label in ((start, "start.sh"), (docker, "Dockerfile")):
assert "--workers 1" in text, f"{label}: single worker required (in-memory JOBS)"
assert "--timeout 1200" in text, f"{label}: 1200s timeout for long conversions"
assert "--threads 8" in text or "THREADS:-8" in text, f"{label}: 8 threads"
def test_ui_consumes_max_upload_mb_from_health(client):
html = client.get("/").get_data(as_text=True)
assert "max_upload_mb" in html, "UI must read the upload limit from /health"
assert 'id="maxmb"' in html
def test_ui_poll_loop_treats_404_as_terminal(client):
html = client.get("/").get_data(as_text=True)
assert "status === 404" in html, "poll loop must treat a 404 /api/jobs as terminal"
# ---------------------------------------------------------------------------
# Cheap edge-case findings folded into the same code paths
# ---------------------------------------------------------------------------
def test_non_numeric_port_env_exits_2():
env = dict(os.environ)
env["PORT"] = "not-a-port"
proc = subprocess.run(
[sys.executable, str(ROOT / "app.py")],
capture_output=True, text=True, timeout=60, env=env, cwd=str(ROOT),
)
assert proc.returncode == 2
assert "PORT must be an integer" in proc.stdout
def test_contained_rejects_escaping_path(tmp_path):
with pytest.raises(ValueError, match="escapes"):
app._contained(tmp_path, "../escape.txt")
def test_contained_accepts_inner_path(tmp_path):
assert app._contained(tmp_path, "a/b.txt") == (tmp_path / "a" / "b.txt").resolve()
_OPF_TMPL = (
'<?xml version="1.0" encoding="utf-8"?>'
'<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid">'
'<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">'
"<dc:title>T</dc:title></metadata>"
'<manifest><item id="ch1" href="{href}" media-type="application/xhtml+xml"/></manifest>'
'<spine><itemref idref="ch1"/></spine></package>'
)
def test_opf_path_escaping_tree_rejected(client):
container = _CONTAINER_XML.replace('full-path="OEBPS/book.opf"', 'full-path="../book.opf"')
content = _zip_with({
"META-INF/container.xml": container,
"OEBPS/book.opf": _OPF_TMPL.format(href="ch1.xhtml"),
"OEBPS/ch1.xhtml": "<html><body>hi</body></html>",
})
job = _upload(client, content, "escape.epub")
assert job["status"] == "error", job
assert "escapes" in job["error"]
def test_spine_href_escaping_tree_skipped(client):
content = _zip_with({
"META-INF/container.xml": _CONTAINER_XML,
"OEBPS/book.opf": _OPF_TMPL.format(href="../../etc/passwd"),
"OEBPS/ch1.xhtml": "<html><body>hi</body></html>",
})
job = _upload(client, content, "href.epub")
assert job["status"] == "error", job
assert "no renderable content" in job["error"]
def test_startup_sweep_removes_only_stale_work_dirs(tmp_path, monkeypatch):
monkeypatch.setattr(app, "WORK_DIR", tmp_path)
stale = tmp_path / "job-old"
stale.mkdir()
fresh = tmp_path / "job-new"
fresh.mkdir()
old = time.time() - 7 * 3600
os.utime(stale, (old, old))
app._sweep_stale_work_dirs()
assert not stale.exists(), "stale work dir must be swept"
assert fresh.exists(), "fresh work dir must be kept"
def test_progress_message_contract_matches_ui_regex():
"""Verification-gap finding: the message contract the UI poll loop parses."""
m = re.search(r"\((\d+)/(\d+)\)", app._format_progress(3, 9, "ch1.xhtml"))
assert m is not None
assert (m.group(1), m.group(2)) == ("3", "9")