"""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