461 lines
18 KiB
Python
461 lines
18 KiB
Python
"""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")
|