Files
OCRmyPDF/tests/test_webui.py

402 lines
14 KiB
Python

# SPDX-FileCopyrightText: 2026 James R. Barlow
# SPDX-License-Identifier: AGPL-3.0-or-later
"""End-to-end tests for the batch web interface in ``webui/``.
These drive a real uvicorn server over real HTTP and run real OCR, so they are
slow. They are skipped unless the ``webui`` extra is installed::
uv sync --extra webui --group test
"""
from __future__ import annotations
import json
import mimetypes
import socket
import sys
import threading
import time
import urllib.error
import urllib.request
import uuid
import zipfile
from io import BytesIO
from pathlib import Path
import pytest
pytest.importorskip("fastapi", reason="webui extra not installed")
pytest.importorskip("uvicorn", reason="webui extra not installed")
pytest.importorskip("multipart", reason="python-multipart not installed")
# webui/ lives at the repo root and is deliberately not part of the installed
# ocrmypdf distribution, so put the repo root on the path explicitly.
REPO_ROOT = Path(__file__).resolve().parent.parent
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
RESOURCES = Path(__file__).parent / "resources"
pytestmark = pytest.mark.slow
# --------------------------------------------------------------- HTTP helpers
def encode_multipart(
files: list[tuple[str, str, bytes]], fields: dict[str, str]
) -> tuple[bytes, str]:
"""Build a multipart/form-data body without pulling in a HTTP library."""
boundary = f"----ocrmypdf{uuid.uuid4().hex}"
buffer = BytesIO()
for name, value in fields.items():
buffer.write(f"--{boundary}\r\n".encode())
buffer.write(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
buffer.write(value.encode() + b"\r\n")
for field_name, filename, content in files:
content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
buffer.write(f"--{boundary}\r\n".encode())
buffer.write(
f'Content-Disposition: form-data; name="{field_name}"; '
f'filename="{filename}"\r\n'.encode()
)
buffer.write(f"Content-Type: {content_type}\r\n\r\n".encode())
buffer.write(content + b"\r\n")
buffer.write(f"--{boundary}--\r\n".encode())
return buffer.getvalue(), f"multipart/form-data; boundary={boundary}"
class Client:
"""The smallest HTTP client that can exercise the API."""
def __init__(self, base_url: str):
"""Bind this client to a running server."""
self.base_url = base_url
def request(self, method: str, path: str, data=None, content_type=None):
request = urllib.request.Request(self.base_url + path, data=data, method=method)
if content_type:
request.add_header("Content-Type", content_type)
try:
with urllib.request.urlopen(request, timeout=120) as response:
return response.status, response.read(), dict(response.headers)
except urllib.error.HTTPError as error:
return error.code, error.read(), dict(error.headers)
def get_json(self, path: str):
status, body, _ = self.request("GET", path)
assert status == 200, body
return json.loads(body)
def post_batch(self, files: list[tuple[str, bytes]], options: dict | None = None):
body, content_type = encode_multipart(
[("files", name, content) for name, content in files],
{"options": json.dumps(options or {})},
)
status, raw, _ = self.request("POST", "/api/batches", body, content_type)
return status, (json.loads(raw) if raw else None)
# ------------------------------------------------------------------- fixtures
@pytest.fixture(scope="module")
def server(tmp_path_factory):
"""Run the real ASGI app on a real port for the duration of the module."""
import uvicorn
work_dir = tmp_path_factory.mktemp("webui-work")
# A module-scoped fixture cannot use the function-scoped monkeypatch.
monkeypatch = pytest.MonkeyPatch()
monkeypatch.setenv("OCRMYPDF_WEBUI_WORK_DIR", str(work_dir))
monkeypatch.setenv("OCRMYPDF_WEBUI_MAX_UPLOAD_MB", "2")
monkeypatch.setenv("OCRMYPDF_WEBUI_MAX_FILES", "4")
monkeypatch.setenv("OCRMYPDF_WEBUI_WORKERS", "2")
monkeypatch.setenv("OCRMYPDF_WEBUI_OCR_JOBS", "1")
monkeypatch.setenv("OCRMYPDF_WEBUI_BATCH_TTL_SECONDS", "600")
from webui.config import get_settings, installed_languages
get_settings.cache_clear()
installed_languages.cache_clear()
from webui.app import app
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
port = probe.getsockname()[1]
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning")
uv_server = uvicorn.Server(config)
thread = threading.Thread(target=uv_server.run, daemon=True)
thread.start()
deadline = time.monotonic() + 30
while not uv_server.started:
if time.monotonic() > deadline:
raise RuntimeError("uvicorn did not start")
time.sleep(0.05)
yield Client(f"http://127.0.0.1:{port}")
uv_server.should_exit = True
thread.join(timeout=30)
get_settings.cache_clear()
monkeypatch.undo()
def wait_for_batch(client: Client, batch_id: str, timeout: float = 600.0) -> dict:
"""Poll until every file in the batch reaches a terminal state."""
deadline = time.monotonic() + timeout
while True:
batch = client.get_json(f"/api/batches/{batch_id}")
if batch["finished"]:
return batch
if time.monotonic() > deadline:
pytest.fail(f"batch did not finish in {timeout}s: {batch}")
time.sleep(0.5)
def pdf_text(data: bytes) -> str:
"""Extract text from an in-memory PDF."""
from pdfminer.high_level import extract_text
return extract_text(BytesIO(data))
# ---------------------------------------------------------------------- tests
def test_config_advertises_capabilities(server):
config = server.get_json("/api/config")
assert "eng" in config["languages"]
assert ".pdf" in config["accepted_extensions"]
assert config["max_files"] == 4
assert config["max_upload_bytes"] == 2 * 1024 * 1024
assert config["defaults"]["mode"] == "skip-text"
def test_health(server):
status, body, _ = server.request("GET", "/healthz")
assert status == 200
assert body == b"ok"
def test_index_page_is_served(server):
status, body, headers = server.request("GET", "/")
assert status == 200
assert b"OCRmyPDF" in body
assert "text/html" in headers["content-type"]
def test_batch_of_several_files_ocrs_and_downloads(server):
"""The core flow: many files in, searchable PDFs out, plus a zip."""
inputs = [
("linn.png", (RESOURCES / "linn.png").read_bytes()),
("ccitt.pdf", (RESOURCES / "ccitt.pdf").read_bytes()),
("trivial.pdf", (RESOURCES / "trivial.pdf").read_bytes()),
]
status, batch = server.post_batch(
inputs, {"languages": ["eng"], "mode": "skip-text", "output_type": "pdf"}
)
assert status == 201, batch
assert batch["total"] == 3
assert {file["name"] for file in batch["files"]} == {
"linn.png",
"ccitt.pdf",
"trivial.pdf",
}
batch = wait_for_batch(server, batch["id"])
by_name = {file["name"]: file for file in batch["files"]}
# An image of text must come back as a PDF that actually contains that text.
linn = by_name["linn.png"]
assert linn["status"] == "succeeded", linn
assert linn["output_name"] == "linn.pdf"
status, pdf, headers = server.request(
"GET", f"/api/batches/{batch['id']}/files/{linn['id']}"
)
assert status == 200
assert pdf.startswith(b"%PDF")
assert "linn.pdf" in headers["content-disposition"]
assert "linnsequencer" in pdf_text(pdf).lower()
# A blank page is not an error; it just yields no text.
assert by_name["trivial.pdf"]["status"] == "succeeded"
assert by_name["ccitt.pdf"]["status"] == "succeeded"
assert batch["downloadable"] == 3
assert batch["completed"] == 3
# The zip must contain one entry per successful file.
status, archive, headers = server.request(
"GET", f"/api/batches/{batch['id']}/download"
)
assert status == 200
assert headers["content-type"] == "application/zip"
with zipfile.ZipFile(BytesIO(archive)) as zf:
assert sorted(zf.namelist()) == ["ccitt.pdf", "linn.pdf", "trivial.pdf"]
assert zf.read("linn.pdf").startswith(b"%PDF")
def test_duplicate_names_are_disambiguated_in_zip(server):
content = (RESOURCES / "trivial.pdf").read_bytes()
status, batch = server.post_batch(
[("same.pdf", content), ("same.pdf", content)], {"output_type": "pdf"}
)
assert status == 201
batch = wait_for_batch(server, batch["id"])
assert batch["downloadable"] == 2
_, archive, _ = server.request("GET", f"/api/batches/{batch['id']}/download")
with zipfile.ZipFile(BytesIO(archive)) as zf:
assert sorted(zf.namelist()) == ["same (1).pdf", "same.pdf"]
def test_file_with_existing_text_fails_helpfully_in_normal_mode(server):
"""A per-file failure must be reported per file, not as a batch error."""
status, batch = server.post_batch(
[
("graph_ocred.pdf", (RESOURCES / "graph_ocred.pdf").read_bytes()),
("trivial.pdf", (RESOURCES / "trivial.pdf").read_bytes()),
],
{"mode": "normal", "output_type": "pdf"},
)
assert status == 201
batch = wait_for_batch(server, batch["id"])
by_name = {file["name"]: file for file in batch["files"]}
failed = by_name["graph_ocred.pdf"]
assert failed["status"] == "failed"
assert "already contains text" in failed["error"]
# The healthy file in the same batch still succeeds and is downloadable.
assert by_name["trivial.pdf"]["status"] == "succeeded"
assert batch["downloadable"] == 1
status, _, _ = server.request(
"GET", f"/api/batches/{batch['id']}/files/{failed['id']}"
)
assert status == 409
status, log, _ = server.request(
"GET", f"/api/batches/{batch['id']}/files/{failed['id']}/log"
)
assert status == 200
assert len(log) > 0
def test_skip_text_mode_handles_the_same_file(server):
status, batch = server.post_batch(
[("graph_ocred.pdf", (RESOURCES / "graph_ocred.pdf").read_bytes())],
{"mode": "skip-text", "output_type": "pdf"},
)
assert status == 201
batch = wait_for_batch(server, batch["id"])
assert batch["files"][0]["status"] == "succeeded"
@pytest.mark.parametrize(
"options,expected_status",
[
({"languages": ["eng; rm -rf /"]}, 422), # command injection attempt
({"languages": ["klingon"]}, 422), # uninstalled language
({"optimize": 9}, 422), # out of range
({"mode": "--evil"}, 422), # not a member of the enum
({"image_dpi": -1}, 422), # out of range
({"unknown_option": True}, 422), # extra fields forbidden
],
)
def test_invalid_options_are_rejected(server, options, expected_status):
status, body = server.post_batch(
[("trivial.pdf", (RESOURCES / "trivial.pdf").read_bytes())], options
)
assert status == expected_status, body
def test_unsupported_file_type_is_rejected(server):
status, body = server.post_batch([("evil.exe", b"MZ" + b"\0" * 100)])
assert status == 415
assert "unsupported type" in body["detail"]
def test_oversized_file_is_rejected(server):
status, body = server.post_batch([("big.pdf", b"%PDF-1.7\n" + b"x" * 3_000_000)])
assert status == 413
assert "limit" in body["detail"]
def test_empty_file_is_rejected(server):
status, body = server.post_batch([("empty.pdf", b"")])
assert status == 400
def test_too_many_files_is_rejected(server):
content = (RESOURCES / "trivial.pdf").read_bytes()
status, body = server.post_batch(
[(f"file{n}.pdf", content) for n in range(5)] # limit is 4
)
assert status == 413
assert "Too many files" in body["detail"]
def test_path_traversal_in_filename_is_neutralized(server):
status, batch = server.post_batch(
[("../../etc/passwd.pdf", (RESOURCES / "trivial.pdf").read_bytes())],
{"output_type": "pdf"},
)
assert status == 201
assert batch["files"][0]["name"] == "passwd.pdf"
batch = wait_for_batch(server, batch["id"])
_, archive, _ = server.request("GET", f"/api/batches/{batch['id']}/download")
with zipfile.ZipFile(BytesIO(archive)) as zf:
assert zf.namelist() == ["passwd.pdf"]
def test_delete_removes_the_batch_and_its_files(server):
status, batch = server.post_batch(
[("trivial.pdf", (RESOURCES / "trivial.pdf").read_bytes())],
{"output_type": "pdf"},
)
assert status == 201
batch_id = batch["id"]
wait_for_batch(server, batch_id)
from webui.config import get_settings
directory = get_settings().work_dir / batch_id
assert directory.exists()
status, _, _ = server.request("DELETE", f"/api/batches/{batch_id}")
assert status == 204
assert not directory.exists()
status, _, _ = server.request("GET", f"/api/batches/{batch_id}")
assert status == 404
def test_unknown_batch_returns_404(server):
status, _, _ = server.request("GET", "/api/batches/" + "0" * 32)
assert status == 404
def test_sweep_removes_expired_batches(server):
"""The TTL reaper must delete batches whether or not they were downloaded."""
status, batch = server.post_batch(
[("trivial.pdf", (RESOURCES / "trivial.pdf").read_bytes())],
{"output_type": "pdf"},
)
assert status == 201
batch_id = batch["id"]
wait_for_batch(server, batch_id)
from webui.app import app
manager = app.state.manager
directory = manager.settings.work_dir / batch_id
assert directory.exists()
# Pretend enough time has passed for the TTL to lapse.
manager.sweep(now=time.time() + manager.settings.batch_ttl_seconds + 1)
assert not directory.exists()
assert manager.get_batch(batch_id) is None