epub2pdf: title-based export names, .pdf download headers, security hardening (68 tests green)
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
"""Edge cases: bad uploads, size limits, unknown jobs, path traversal."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
import app
|
||||
from tests.helpers import make_epub_zip, make_zip_without_container, wait_for_job
|
||||
|
||||
|
||||
def test_wrong_extension_returns_400(client, fixture_epub_bytes):
|
||||
files = {"file": (io.BytesIO(fixture_epub_bytes), "book.pdf")}
|
||||
resp = client.post("/api/convert", data=files, content_type="multipart/form-data")
|
||||
assert resp.status_code == 400
|
||||
assert ".epub" in resp.get_json()["error"]
|
||||
|
||||
|
||||
def test_corrupt_zip_returns_202_then_job_error(client):
|
||||
"""Non-zip upload: accepted (202) but the job must end in error state."""
|
||||
files = {"file": (io.BytesIO(b"this is not a zip at all"), "book.epub")}
|
||||
resp = client.post("/api/convert", data=files, content_type="multipart/form-data")
|
||||
assert resp.status_code == 202
|
||||
job_id = resp.get_json()["job_id"]
|
||||
job = wait_for_job(client, job_id)
|
||||
assert job["status"] == "error"
|
||||
assert "Not a valid EPUB" in job["error"]
|
||||
# The failed upload must not linger on disk.
|
||||
assert not (app.UPLOAD_DIR / f"{job_id}.epub").exists()
|
||||
|
||||
|
||||
def test_zip_without_container_xml_ends_in_job_error(client):
|
||||
"""Valid zip, not an EPUB: worker hits the missing-container.xml error."""
|
||||
files = {
|
||||
"file": (io.BytesIO(make_zip_without_container()), "book.epub"),
|
||||
}
|
||||
resp = client.post("/api/convert", data=files, content_type="multipart/form-data")
|
||||
assert resp.status_code == 202
|
||||
job = wait_for_job(client, resp.get_json()["job_id"])
|
||||
assert job["status"] == "error", job
|
||||
assert "container.xml" in job["error"]
|
||||
|
||||
|
||||
def test_epub_without_readable_content_ends_in_job_error(client):
|
||||
"""EPUB whose spine has no html items -> 'No readable content' error."""
|
||||
content = make_epub_zip(
|
||||
items=[("img1", "img.png", "image/png", "")],
|
||||
spine_ids=["img1"],
|
||||
)
|
||||
files = {"file": (io.BytesIO(content), "empty.epub")}
|
||||
resp = client.post("/api/convert", data=files, content_type="multipart/form-data")
|
||||
assert resp.status_code == 202
|
||||
job = wait_for_job(client, resp.get_json()["job_id"])
|
||||
assert job["status"] == "error", job
|
||||
assert "No readable content" in job["error"]
|
||||
|
||||
|
||||
def test_oversized_upload_returns_413_json(client, submit_epub, monkeypatch):
|
||||
"""Flask MAX_CONTENT_LENGTH path: 413 with a JSON error body."""
|
||||
monkeypatch.setitem(app.app.config, "MAX_CONTENT_LENGTH", 1024) # 1 KiB
|
||||
resp = submit_epub(filename="book.epub") # fixture is ~3.3 KB
|
||||
assert resp.status_code == 413
|
||||
body = resp.get_json()
|
||||
assert body is not None, "413 body must be JSON, not the HTML error page"
|
||||
assert "too large" in body["error"].lower()
|
||||
|
||||
|
||||
def test_app_side_size_check_returns_400(client, submit_epub, monkeypatch):
|
||||
"""app.py's own MAX_UPLOAD_MB check (distinct from Flask's 413)."""
|
||||
monkeypatch.setattr(app, "MAX_UPLOAD_MB", 0)
|
||||
monkeypatch.setitem(app.app.config, "MAX_CONTENT_LENGTH", 10**10)
|
||||
resp = submit_epub(filename="book.epub")
|
||||
assert resp.status_code == 400
|
||||
assert "too large" in resp.get_json()["error"].lower()
|
||||
|
||||
|
||||
def test_unknown_job_returns_404(client):
|
||||
resp = client.get("/api/jobs/doesnotexist12")
|
||||
assert resp.status_code == 404
|
||||
assert resp.get_json()["error"] == "unknown job"
|
||||
|
||||
|
||||
def test_download_unknown_file_returns_404(client):
|
||||
assert client.get("/download/never-converted.pdf").status_code == 404
|
||||
|
||||
|
||||
def test_download_traversal_dotdot_returns_404(client):
|
||||
""".. segments must be neutralized by Path(name).name, never resolve
|
||||
outside OUTPUT_DIR."""
|
||||
for name in ("../app.py", "../../etc/passwd", "..%2F..%2Fetc%2Fpasswd"):
|
||||
resp = client.get(f"/download/{name}")
|
||||
assert resp.status_code == 404, f"traversal not blocked for {name!r}"
|
||||
|
||||
|
||||
def test_download_traversal_cannot_read_app_source(client, monkeypatch, tmp_path):
|
||||
"""Prove the guard: even a file sitting next to OUTPUT_DIR is unreachable."""
|
||||
secret = tmp_path / "secret.txt"
|
||||
secret.write_text("top secret", encoding="utf-8")
|
||||
# OUTPUT_DIR is DATA_DIR/output; the secret is one level up.
|
||||
# Path('..').name == '' -> OUTPUT_DIR / '' -> OUTPUT_DIR itself (a dir)
|
||||
# -> is_file() False -> 404 regardless.
|
||||
resp = client.get("/download/..%2Fsecret.txt")
|
||||
assert resp.status_code == 404
|
||||
assert b"top secret" not in resp.data
|
||||
Reference in New Issue
Block a user