diff --git a/submit_ce/api/compile_service.py b/submit_ce/api/compile_service.py index 3116dc8f..278ced54 100644 --- a/submit_ce/api/compile_service.py +++ b/submit_ce/api/compile_service.py @@ -169,3 +169,36 @@ def is_available(self) -> bool: `True` if service is configured and available. """ ... + + @abstractmethod + def stamp(self, pdf_bytes: bytes, watermark_text: str, + watermark_link: Optional[str] = None) -> bytes: + """ + Apply the temporary submission watermark/stamp to a PDF. + + Used for PDF-only submissions, which never run ``/convert`` (TeX + submissions are stamped by the compile service during conversion). + The stamped PDF is returned so the caller can install it; nothing is + written to the file store here. + + Parameters + ---------- + pdf_bytes : bytes + The unstamped PDF. + watermark_text : str + Stamp text, e.g. ``arXiv:submit/1234567 [cs.LG] 15 Jan 2026``. + watermark_link : Optional[str] + Optional link embedded in the stamp. + + Returns + ------- + bytes + The stamped PDF. + + Raises + ------ + Exception + If stamping fails; the caller is expected to fall back to the + unstamped PDF so the preview slot is never empty. + """ + ... diff --git a/submit_ce/api/file_store.py b/submit_ce/api/file_store.py index d4c7957a..5671176a 100644 --- a/submit_ce/api/file_store.py +++ b/submit_ce/api/file_store.py @@ -167,6 +167,21 @@ def store_preview(self, submission_id: str, content: IO[bytes], chunk_size: int Returns checksum""" pass + @abstractmethod + def does_nostamp_preview_exist(self, submission_id: str) -> bool: + """Whether an unstamped PDF exists at ``-nostamp.pdf``. + + For TeX submissions TeX2PDF writes this alongside the stamped preview. + PDF-only does not store one (the original PDF lives in ``src/``); this + lets the PDF-only install detect and clean up a stale copy left by a + prior TeX compile.""" + pass + + @abstractmethod + def delete_nostamp_preview(self, submission_id: str) -> None: + """Delete ``-nostamp.pdf`` if it exists.""" + pass + @abstractmethod def store_directives(self, submission_id: str, content: dict) -> str: """Store directives.json for a submission. diff --git a/submit_ce/domain/event/process.py b/submit_ce/domain/event/process.py index 558ed064..5be4e1bf 100644 --- a/submit_ce/domain/event/process.py +++ b/submit_ce/domain/event/process.py @@ -1,5 +1,7 @@ """Events related to external or long-running processes.""" -from datetime import datetime +import io +import logging +from datetime import datetime, timezone import json from typing import Optional @@ -9,13 +11,17 @@ from pydantic import BaseModel from ..exceptions import InvalidEvent +from ..preview import Preview from ..submission import Submission from ..process import ProcessStatus +from ..uploads import SourceFormat from .base import Event, EventWithSideEffect from submit_ce.api import SubmitApi +logger = logging.getLogger(__name__) + class ProcessInfo(BaseModel): process_id: str @@ -155,6 +161,168 @@ def project(self, submission: Submission) -> Submission: return submission +def _stamp_text_and_link(submission: Submission) -> tuple[str, Optional[str]]: + """Build the temporary submission stamp text and link. + + Mirrors ``arXiv::Submit::Util::stamp_and_link`` (arxiv-lib): + ``arXiv:submit/ [] `` -- two spaces + between segments, category in brackets, day not zero-padded, three-letter + month. A working submission has no submit_time yet, so the date is "now". + """ + sid = submission.submission_id + text = f"arXiv:submit/{sid}" + try: + category = submission.primary_category + except Exception: + category = None + if category: + text += f" [{category}]" + now = datetime.now(timezone.utc) + text += f" {now.day} {now.strftime('%b %Y')}" + link = f"https://arxiv.org/submit/{sid}/pdf" + return text, link + + +class InstallPdfPreview(EventWithSideEffect): + """Install a PDF-only submission's PDF as its (stamped) preview, under lock. + + PDF-only submissions never run ``/convert``, so the stamped preview that + TeX2PDF produces for TeX submissions must be produced on the Submit 2.0 + side. Running as an :class:`.EventWithSideEffect`, ``SubmitApi.save`` + holds the submission row lock for the whole operation (see the "critical + section" note in ``CLAUDE.md``), so a concurrent upload/delete cannot + change the file set mid-install. Under the lock this: + + 1. calls the stamp service to watermark the uploaded PDF with the + temporary submission stamp; + 2. writes the stamped PDF to the preview slot ``.pdf`` -- or, if + stamping fails, writes the unstamped bytes there so the preview slot is + never empty; + 3. removes any stale ``-nostamp.pdf`` left by a prior TeX compile + (the submitter switched from TeX to PDF-only). PDF-only keeps its + original PDF in ``src/``, so no dedicated unstamped copy is stored. + [SUBMISSION-196] + + :meth:`project` marks the source processed and records the preview, the + same as the pre-stamping ``ConfirmSourceProcessed`` install did. + """ + + NAME = "install pdf preview" + NAMED = "installed pdf preview" + + source_checksum: str = field(default='') + preview_checksum: str = field(default='') + size_bytes: int = field(default=-1) + stamped: bool = field(default=False) + added: Optional[datetime] = field(default=None) + + def validate_pre_lock(self, submission: Submission) -> None: + """Only applies to PDF-only submissions with an id.""" + if not submission.submission_id: + raise InvalidEvent( + self, "Cannot install PDF preview: submission has no id.") + if submission.source_format != SourceFormat.PDF: + raise InvalidEvent( + self, "InstallPdfPreview only applies to PDF-only submissions.") + + def validate_under_lock(self, api: 'SubmitApi', submission: Submission) -> None: + """Require exactly one PDF in the source workspace before installing. + + Runs inside the submission row lock, so the file set cannot change + between this check and :meth:`execute`. For a PDF-only submission the + workspace is a single lone PDF (see ``_infer_source_format``); if that + invariant is violated we reject the event here rather than install a + partial or absent preview -- the caller degrades gracefully on the + resulting :class:`.InvalidEvent`. [SUBMISSION-196] + """ + file_store = api.get_file_store() + sid = submission.submission_id + workspace = file_store.get_workspace(submission_id=sid) + pdfs = [f for f in (workspace.files if workspace else []) + if f.name.lower().endswith('.pdf')] + if len(pdfs) != 1: + raise InvalidEvent( + self, + f"Expected exactly one PDF for PDF-only submission {sid}, " + f"found {len(pdfs)}.") + + def execute(self, api: 'SubmitApi', submission: Submission) -> None: + """Stamp the uploaded PDF, install it as the preview, and drop any + stale unstamped copy left by a prior TeX compile. + + :meth:`validate_under_lock` has already guaranteed exactly one PDF in + the workspace under the same lock, so we take it directly. + """ + file_store = api.get_file_store() + sid = submission.submission_id + workspace = file_store.get_workspace(submission_id=sid) + pdf = [f for f in workspace.files + if f.name.lower().endswith('.pdf')][0] + source = file_store.get_source_file(sid, pdf.path) + with source.open('rb') as stream: + data = stream.read() + + # PDF-only keeps its original PDF in src/, so we don't persist a + # redundant -nostamp.pdf. If a prior TeX compile left an unstamped + # PDF at the top-level slot (the submitter switched from TeX to + # PDF-only), remove it so a stale copy can't linger. The stamping + # fallback below uses the in-memory `data`, not this slot. [SUBMISSION-196] + if file_store.does_nostamp_preview_exist(sid): + file_store.delete_nostamp_preview(sid) + logger.info( + "InstallPdfPreview: removed stale unstamped PDF for %s", sid) + + text, link = _stamp_text_and_link(submission) + stamped_bytes: Optional[bytes] = None + try: + stamped_bytes = api.get_compiler().stamp(data, text, link) + except Exception as exc: + logger.error( + "InstallPdfPreview: stamping failed for %s: %s; installing " + "unstamped preview instead", sid, exc) + + if stamped_bytes: + self.preview_checksum = file_store.store_preview( + sid, io.BytesIO(stamped_bytes)) + self.stamped = True + else: + self.preview_checksum = file_store.store_preview( + sid, io.BytesIO(data)) + self.stamped = False + + self.source_checksum = pdf.crc32c + self.size_bytes = pdf.bytes + self.added = datetime.now(timezone.utc) + + def project(self, submission: Submission) -> Submission: + """Mark the source processed and record the preview *artifact*. + + Records ``submission.preview`` (the preview PDF's checksums and size) + and sets ``is_source_processed`` -- the same as + ``ConfirmSourceProcessed`` did. This does NOT mark the preview as + viewed: the "submitter reviewed the preview" bit + (``submitter_confirmed_preview``, which gates Submit on the Confirm + page) is set only by ``ConfirmPreview`` when the submitter actually + opens ``/preview.pdf``. Recording ``submission.preview`` here is in + fact the precondition for that step -- ``ConfirmPreview.validate`` + requires it to be present and checksum-matches it -- not a claim that + the submitter previewed anything. [SUBMISSION-196] + + The one-PDF precondition is enforced in :meth:`validate_under_lock`, so + an event that reaches ``project`` has installed a real preview via + ``execute`` (``added`` is set). + """ + submission.is_source_processed = True + submission.preview = Preview( + source_id=-1, + source_checksum=self.source_checksum, + preview_checksum=self.preview_checksum, + size_bytes=self.size_bytes, + added=self.added, + ) + return submission + + class StartDirectives(EventWithSideEffect): """Start directives generation for a submission.""" diff --git a/submit_ce/domain/event/tests/test_install_pdf_preview.py b/submit_ce/domain/event/tests/test_install_pdf_preview.py new file mode 100644 index 00000000..2dcff440 --- /dev/null +++ b/submit_ce/domain/event/tests/test_install_pdf_preview.py @@ -0,0 +1,75 @@ +"""Tests for InstallPdfPreview.validate_under_lock. [SUBMISSION-196] + +The event requires exactly one PDF in the source workspace. That precondition +lives in ``validate_under_lock`` (run under the submission row lock), so a +violated invariant rejects the event cleanly -- raising ``InvalidEvent`` -- +instead of installing a partial or absent preview. + +Unreachable in normal flow (``source_format == PDF`` implies a single lone PDF; +see ``_infer_source_format``); these tests guard the invariant. +""" + +from datetime import datetime +from types import SimpleNamespace + +import pytest +from pytz import UTC + +from submit_ce.domain import submission as submod, agent +from submit_ce.domain.uploads import SourceFormat +from submit_ce.domain.exceptions import InvalidEvent +from submit_ce.domain.event.process import InstallPdfPreview + + +class _FakeStore: + def __init__(self, files): + self._files = files + + def get_workspace(self, submission_id): + return SimpleNamespace(files=self._files) + + +class _FakeApi: + def __init__(self, files): + self._store = _FakeStore(files) + + def get_file_store(self): + return self._store + + +def _user(uid="u1"): + return agent.PublicUser(name="Test User", user_id=uid, + email=f"{uid}@example.org", endorsements=[]) + + +def _pdf_submission(sid="1234567"): + u = _user() + s = submod.Submission(creator=u, owner=u, created=datetime.now(UTC), + source_format=SourceFormat.PDF) + s.submission_id = sid + return s + + +def _pdf_file(name): + return SimpleNamespace(name=name, path=name, crc32c="c", bytes=10) + + +def _validate(files): + s = _pdf_submission() + api = _FakeApi(files) + InstallPdfPreview(creator=s.creator).validate_under_lock(api, s) + + +def test_zero_pdfs_rejected(): + with pytest.raises(InvalidEvent): + _validate([]) + + +def test_multiple_pdfs_rejected(): + with pytest.raises(InvalidEvent): + _validate([_pdf_file("a.pdf"), _pdf_file("b.pdf")]) + + +def test_exactly_one_pdf_passes(): + # Should not raise. + _validate([_pdf_file("paper.pdf")]) diff --git a/submit_ce/implementations/__init__.py b/submit_ce/implementations/__init__.py index ad83a82d..c9afb2d2 100644 --- a/submit_ce/implementations/__init__.py +++ b/submit_ce/implementations/__init__.py @@ -132,6 +132,12 @@ def get_full_outcome_path(self, submission_id: str) -> str: def store_preview(self, submission_id: str, content: IO[bytes], chunk_size: int) -> str: return "not really stored, NullFileStore" + def does_nostamp_preview_exist(self, submission_id: str) -> bool: + return False + + def delete_nostamp_preview(self, submission_id: str) -> None: + pass + def store_compile_log(self, submission_id: str, content: IO[bytes], chunk_size: int = 4096) -> str: return "not really stored, NullFileStore" diff --git a/submit_ce/implementations/compile/compile_api_service.py b/submit_ce/implementations/compile/compile_api_service.py index efb1a170..15907a0b 100644 --- a/submit_ce/implementations/compile/compile_api_service.py +++ b/submit_ce/implementations/compile/compile_api_service.py @@ -248,6 +248,32 @@ def is_available(self) -> bool: logger.error(f"Compile service at '{settings.COMPILE_API_URL}' is not available: {exc}") return False + @override + def stamp(self, pdf_bytes: bytes, watermark_text: str, + watermark_link: Optional[str] = None) -> bytes: + """Stamp a PDF via the tex2pdf ``/stamp/`` endpoint. + + The endpoint takes the PDF as a multipart upload plus the watermark + text/link as query params, and returns the stamped PDF bytes in the + response body (it does not write to the bucket). Non-2xx raises, so + the caller falls back to the unstamped PDF. + """ + query_params = {'watermark_text': watermark_text} + if watermark_link: + query_params['watermark_link'] = watermark_link + # No trailing slash: the deployed service routes ``/stamp`` and 307- + # redirects ``/stamp/`` -> ``/stamp`` (also downgrading to http, which + # we must not follow with the auth token). Matches how /convert, + # /preflight and /directives are called. + url = f'{settings.COMPILE_API_URL}/stamp?{urllib.parse.urlencode(query_params)}' + files = {'incoming': ('submission.pdf', pdf_bytes, 'application/pdf')} + headers = _auth_headers() + + with httpx.Client(timeout=settings.COMPILE_API_CONVERT_TIMEOUT) as client: + response = client.post(url, files=files, headers=headers) + response.raise_for_status() + return response.content + @override def start_directives(self, submission: Submission, diff --git a/submit_ce/implementations/compile/mock_compile_mimesis_pdf.py b/submit_ce/implementations/compile/mock_compile_mimesis_pdf.py index 3902eb8f..6d05f6e3 100644 --- a/submit_ce/implementations/compile/mock_compile_mimesis_pdf.py +++ b/submit_ce/implementations/compile/mock_compile_mimesis_pdf.py @@ -149,3 +149,14 @@ def check(self, process_id: str, user: User, client: Client) -> ProcessStatus: @override def is_available(self) -> bool: return True + + @override + def stamp(self, pdf_bytes: bytes, watermark_text: str, + watermark_link: Optional[str] = None) -> bytes: + """Pretend to stamp by prepending a marker. + + The marker lets tests distinguish the stamped preview from the + unstamped fallback without inspecting PDF pixels, and keeps stamping + offline/deterministic. + """ + return b"STAMPED:" + pdf_bytes diff --git a/submit_ce/implementations/file_store/gs_file_store.py b/submit_ce/implementations/file_store/gs_file_store.py index fe2eb389..7b98c1be 100644 --- a/submit_ce/implementations/file_store/gs_file_store.py +++ b/submit_ce/implementations/file_store/gs_file_store.py @@ -229,6 +229,19 @@ def store_preview(self, submission_id: str, blob.reload() return blob.crc32c + @override + def does_nostamp_preview_exist(self, submission_id: str) -> bool: + """Whether an unstamped ``-nostamp.pdf`` exists.""" + return self.bucket.blob( + self._nostamp_preview_path(submission_id)).exists() + + @override + def delete_nostamp_preview(self, submission_id: str) -> None: + """Delete ``-nostamp.pdf`` if it exists.""" + blob = self.bucket.blob(self._nostamp_preview_path(submission_id)) + if blob.exists(): + blob.delete() + @override def store_directives(self, submission_id: str, content: dict) -> str: """Store directives.json for a submission.""" @@ -645,6 +658,9 @@ def _source_package_path(self, submission_id: str) -> str: def _preview_path(self, submission_id: str) -> str: return posixpath.join(self._submission_path(submission_id), f'{submission_id}.pdf') + def _nostamp_preview_path(self, submission_id: str) -> str: + return posixpath.join(self._submission_path(submission_id), f'{submission_id}-nostamp.pdf') + def _qa_meta_json_path(self, submission_id: str) -> str: """QA-bucket object path, e.g. ``{qa_prefix}/4848983/4848983.meta.json``.""" return posixpath.join(self.qa_prefix, str(submission_id), f'{submission_id}.meta.json') diff --git a/submit_ce/implementations/file_store/mock_file_store.py b/submit_ce/implementations/file_store/mock_file_store.py index dcba62a3..6b40c6d9 100644 --- a/submit_ce/implementations/file_store/mock_file_store.py +++ b/submit_ce/implementations/file_store/mock_file_store.py @@ -92,6 +92,7 @@ def __init__(self) -> None: self._source: Dict[str, Dict[str, bytes]] = {} # single-blob slots self._preview: Dict[str, bytes] = {} + self._nostamp_preview: Dict[str, bytes] = {} self._preflight: Dict[str, bytes] = {} self._directives: Dict[str, bytes] = {} self._user_decisions: Dict[str, bytes] = {} @@ -199,6 +200,12 @@ def store_preview(self, submission_id: str, content: IO[bytes], self._preview[submission_id] = content.read() return "" + def does_nostamp_preview_exist(self, submission_id: str) -> bool: + return submission_id in self._nostamp_preview + + def delete_nostamp_preview(self, submission_id: str) -> None: + self._nostamp_preview.pop(submission_id, None) + def get_preview(self, submission_id: str) -> FileObj: data = self._preview.get(submission_id) return _InMemoryFileObj("preview.pdf", data) if data is not None else FileDoesNotExist(submission_id) diff --git a/submit_ce/ui/controllers/new/process.py b/submit_ce/ui/controllers/new/process.py index 7f7fbb38..165fdadd 100644 --- a/submit_ce/ui/controllers/new/process.py +++ b/submit_ce/ui/controllers/new/process.py @@ -2,7 +2,6 @@ from http import HTTPStatus as status from typing import Tuple, Dict, Any -from datetime import datetime, timezone import logging from flask import current_app from arxiv.base import alerts @@ -10,13 +9,14 @@ from markupsafe import Markup from submit_ce.domain.event.process import StartCompileSource -from submit_ce.domain.exceptions import SaveError +from submit_ce.domain.exceptions import InvalidEvent, SaveError from submit_ce.domain.uploads import SourceFormat from submit_ce.api.file_store import SubmissionFileStore from submit_ce.ui import SUPPORT from ...auth import user_and_client_from_session from submit_ce.domain.event import ConfirmSourceProcessed +from submit_ce.domain.event.process import InstallPdfPreview from arxiv.auth.domain import Session from werkzeug.datastructures import MultiDict from werkzeug.exceptions import InternalServerError, MethodNotAllowed @@ -86,59 +86,41 @@ def file_process(method: str, params: MultiDict, session: Session, def _install_pdf_only_preview(submission_id: str, session: Session) -> None: - """Promote a PDF-only submission's uploaded PDF to its preview slot. - - Submit 1.5's ``Source->process()`` copies a PDF-only upload to the - canonical ``.pdf`` so the submitter can review it before - submitting. Submit 2.0 does the equivalent for TeX (compile writes the - PDF to the top-level preview slot ``.pdf``), but PDF-only uploads - never run compile, so without this step the preview slot stays empty: - ``/preview.pdf`` 404s, ``ConfirmPreview`` never fires, and the Confirm - page's Submit button stays disabled. - - This copies the single uploaded PDF from the source workspace into the - preview slot via :meth:`store_preview` and fires - :class:`.ConfirmSourceProcessed` so ``submission.preview`` is populated - with the real preview checksum and size. It is idempotent -- it no-ops - once a preview already exists (e.g., on repeat visits to this stage). + """Install a PDF-only submission's PDF as its stamped preview. + + Dispatches :class:`.InstallPdfPreview`, whose ``execute()`` runs under + ``SubmitApi.save``'s submission row lock (see the critical-section note in + ``CLAUDE.md``): it stamps the uploaded PDF with the temporary submission + watermark and writes the stamped PDF -- or the unstamped fallback if + stamping fails -- to the preview slot ``.pdf``. Without this the + preview slot stays empty, ``/preview.pdf`` 404s, ``ConfirmPreview`` never + fires, and the Confirm page's Submit button stays disabled. + + Idempotent: no-ops once a preview already exists (e.g., on repeat visits + to this stage). """ file_store: SubmissionFileStore = current_app.api.get_file_store() if file_store.does_preview_exist(submission_id): return - workspace = file_store.get_workspace(submission_id=submission_id) - pdfs = [f for f in workspace.files if f.name.lower().endswith('.pdf')] - if len(pdfs) != 1: - # Not a clean single-PDF submission; nothing to promote. Log so the - # gap is visible rather than silently leaving an empty preview slot. - logger.warning( - 'PDF-only preview install skipped for %s: expected exactly one ' - 'PDF in workspace, found %d', submission_id, len(pdfs)) - return - - pdf = pdfs[0] - source = file_store.get_source_file(submission_id, pdf.path) - with source.open('rb') as stream: - preview_checksum = file_store.store_preview(submission_id, stream) - submitter, client = user_and_client_from_session(session) - command = ConfirmSourceProcessed( - creator=submitter, - client=client, - source_checksum=pdf.crc32c, - preview_checksum=preview_checksum, - size_bytes=pdf.bytes, - added=datetime.now(timezone.utc), - ) try: - current_app.api.save(command, submission_id=submission_id) + current_app.api.save( + InstallPdfPreview(creator=submitter, client=client), + submission_id=submission_id, + ) + except InvalidEvent as e: + # Precondition failed under the lock (e.g. not exactly one PDF in the + # workspace). Unreachable in normal PDF-only flow; log and skip rather + # than 500 -- the empty preview slot keeps Submit disabled. [SUBMISSION-196] + logger.warning('Skipped PDF-only preview install for %s: %s', + submission_id, e) + return except SaveError as e: - logger.error('Failed to confirm PDF-only source processed for %s: %s', + logger.error('Failed to install PDF-only preview for %s: %s', submission_id, e) raise InternalServerError('Could not install PDF preview') from e - logger.info('Installed PDF-only preview for submission %s ' - '(preview_checksum=%s, size_bytes=%d)', - submission_id, preview_checksum, pdf.bytes) + logger.info('Installed PDF-only preview for submission %s', submission_id) def _check_status(params: MultiDict, session: Session, submission_id: str, diff --git a/submit_ce/ui/controllers/new/tests/test_process.py b/submit_ce/ui/controllers/new/tests/test_process.py index a5e466e2..2c3c4622 100644 --- a/submit_ce/ui/controllers/new/tests/test_process.py +++ b/submit_ce/ui/controllers/new/tests/test_process.py @@ -8,10 +8,18 @@ from submit_ce.domain.agent import InternalClient from submit_ce.domain.event import SetSourceFormat from submit_ce.domain.uploads import SourceFormat +from submit_ce.implementations.compile.mock_compile_mimesis_pdf import MockCompileMimesisPdf from submit_ce.implementations.file_store.mock_file_store import MockFileStore from submit_ce.ui.controllers.new.process import file_process +class _StampFails(MockCompileMimesisPdf): + """A compiler whose stamp() always fails, to exercise the fallback.""" + + def stamp(self, pdf_bytes, watermark_text, watermark_link=None): + raise RuntimeError("stamp service unavailable") + + def test_no_sub(app, authorized_client): resp = authorized_client.get("/93489292/file_process") assert resp.status_code == status.NOT_FOUND @@ -33,50 +41,154 @@ def test_process(app, authorized_client, sub_reviewfiles): and b"
.pdf``) via InstallPdfPreview under the submission + lock. No redundant unstamped copy is stored -- the original stays in + ``src/``. [SUBMISSION-196]""" + session, _ = authorized_user_session + ua = InternalClient(name="test_pdf_only") + src = b"%PDF-1.4\n1 0 obj\n%%EOF\n" + with app.app_context(): + sid = str(sub_primary.submission_id) - Before the fix, the PDF branch of ``file_process`` just advanced without - installing anything, leaving the preview slot empty -- ``/preview.pdf`` - 404'd, ``ConfirmPreview`` never fired, and Submit stayed disabled. - """ + store = MockFileStore() + current_app.api.store = store + current_app.api.compiler = MockCompileMimesisPdf() # stamp() -> b"STAMPED:" + bytes + store._source[sid] = {"paper.pdf": src} + current_app.api.save( + SetSourceFormat(creator=authorized_user, client=ua, + source_format=SourceFormat.PDF.value), + submission_id=sid) + + assert not store.does_preview_exist(sid) + + _, code, _ = file_process("GET", MultiDict(), session, sid, token="") + + assert code == status.OK + # Stamped PDF is in the preview slot ... + assert store.get_preview(sid).download_as_bytes() == b"STAMPED:" + src + # ... and no redundant unstamped copy was written (original is in src/). + assert sid not in store._nostamp_preview + # ... and, on a fresh reload (bypassing the per-request g cache), the + # submission is marked source-processed (round-trips via must_process). + submission, _ = current_app.api.get_with_history(sid) + assert submission.is_source_processed + + +def test_file_process_pdf_only_removes_stale_nostamp_from_prior_tex( + app, authorized_user, authorized_user_session, sub_primary): + """If a prior TeX compile left an ``-nostamp.pdf`` and the submitter + then switched to PDF-only, InstallPdfPreview removes the stale unstamped + copy so it can't linger under the submission. [SUBMISSION-196]""" session, _ = authorized_user_session ua = InternalClient(name="test_pdf_only") + src = b"%PDF-1.4\n1 0 obj\n%%EOF\n" with app.app_context(): sid = str(sub_primary.submission_id) store = MockFileStore() current_app.api.store = store - # Seed a single uploaded PDF in the source workspace. - store._source[sid] = {"paper.pdf": b"%PDF-1.4\n1 0 obj\n%%EOF\n"} + current_app.api.compiler = MockCompileMimesisPdf() + store._source[sid] = {"paper.pdf": src} + # Simulate a leftover unstamped PDF from a previous TeX compile. + store._nostamp_preview[sid] = b"OLD-TEX-UNSTAMPED" + current_app.api.save( + SetSourceFormat(creator=authorized_user, client=ua, + source_format=SourceFormat.PDF.value), + submission_id=sid) + + _, code, _ = file_process("GET", MultiDict(), session, sid, token="") + + assert code == status.OK + # Stale unstamped copy removed; stamped preview installed. + assert sid not in store._nostamp_preview + assert store.get_preview(sid).download_as_bytes() == b"STAMPED:" + src + + +def test_file_process_pdf_only_skips_install_when_not_one_pdf( + app, authorized_user, authorized_user_session, sub_primary): + """If a PDF-only submission has other than exactly one PDF, the install is + rejected under the lock (InvalidEvent) and handled gracefully: no 500, no + preview installed, submission not marked source-processed. [SUBMISSION-196]""" + session, _ = authorized_user_session + ua = InternalClient(name="test_pdf_only") + with app.app_context(): + sid = str(sub_primary.submission_id) - # Mark the submission as PDF-only. + store = MockFileStore() + current_app.api.store = store + current_app.api.compiler = MockCompileMimesisPdf() + # PDF-only by format, but two PDFs in the workspace (invariant violated). + store._source[sid] = {"a.pdf": b"%PDF-1.4\n%%EOF\n", + "b.pdf": b"%PDF-1.4\n%%EOF\n"} current_app.api.save( SetSourceFormat(creator=authorized_user, client=ua, source_format=SourceFormat.PDF.value), submission_id=sid) + _, code, _ = file_process("GET", MultiDict(), session, sid, token="") + + assert code == status.OK assert not store.does_preview_exist(sid) + submission, _ = current_app.api.get_with_history(sid) + assert submission.is_source_processed is False + + +def test_file_process_pdf_only_falls_back_to_unstamped_on_stamp_failure( + app, authorized_user, authorized_user_session, sub_primary): + """If stamping fails, the preview slot must still be populated -- with the + unstamped PDF -- so the submitter can review and Submit stays reachable.""" + session, _ = authorized_user_session + ua = InternalClient(name="test_pdf_only") + src = b"%PDF-1.4\n%%EOF\n" + with app.app_context(): + sid = str(sub_primary.submission_id) + + store = MockFileStore() + current_app.api.store = store + current_app.api.compiler = _StampFails() + store._source[sid] = {"paper.pdf": src} + current_app.api.save( + SetSourceFormat(creator=authorized_user, client=ua, + source_format=SourceFormat.PDF.value), + submission_id=sid) - data, code, headers = file_process( - "GET", MultiDict(), session, sid, token="") + _, code, _ = file_process("GET", MultiDict(), session, sid, token="") assert code == status.OK - # The uploaded PDF is now installed at the top-level preview slot ... - assert store.does_preview_exist(sid) - assert store.get_preview(sid).download_as_bytes().startswith(b"%PDF") - # ... and, on a fresh reload, the submission is marked source-processed. - # NOTE: reload via get_with_history to bypass the per-request `g` - # cache in backend.get_submission (which still holds the pre-event - # snapshot inside this single app context). The legacy backend - # round-trips is_source_processed via the must_process column; - # submission.preview is derived state and is intentionally not - # persisted, so we don't assert on it here (the Confirm-page gate - # uses does_preview_exist + submitter_confirmed_preview instead). + # Fallback: preview slot holds the unstamped bytes (no STAMPED marker). + assert store.get_preview(sid).download_as_bytes() == src + # No redundant unstamped copy is stored (original stays in src/). + assert sid not in store._nostamp_preview + + +def test_file_process_pdf_only_does_not_confirm_preview( + app, authorized_user, authorized_user_session, sub_primary): + """Installing/stamping must NOT mark the preview as viewed. The submitter + still has to open it to fire ConfirmPreview (the review gate) -- this + guards against stamping accidentally bypassing the 'must review' rule.""" + session, _ = authorized_user_session + ua = InternalClient(name="test_pdf_only") + with app.app_context(): + sid = str(sub_primary.submission_id) + + store = MockFileStore() + current_app.api.store = store + current_app.api.compiler = MockCompileMimesisPdf() + store._source[sid] = {"paper.pdf": b"%PDF-1.4\n%%EOF\n"} + current_app.api.save( + SetSourceFormat(creator=authorized_user, client=ua, + source_format=SourceFormat.PDF.value), + submission_id=sid) + + file_process("GET", MultiDict(), session, sid, token="") + submission, _ = current_app.api.get_with_history(sid) assert submission.is_source_processed + # Viewable, but not yet viewed. + assert submission.submitter_confirmed_preview is False def test_file_process_pdf_only_is_idempotent( @@ -90,6 +202,7 @@ def test_file_process_pdf_only_is_idempotent( store = MockFileStore() current_app.api.store = store + current_app.api.compiler = MockCompileMimesisPdf() store._source[sid] = {"paper.pdf": b"%PDF-1.4\n%%EOF\n"} current_app.api.save( SetSourceFormat(creator=authorized_user, client=ua, @@ -101,8 +214,7 @@ def test_file_process_pdf_only_is_idempotent( # Change the source so an unexpected re-copy would be visible. store._source[sid] = {"paper.pdf": b"%PDF-DIFFERENT\n%%EOF\n"} - data, code, headers = file_process( - "GET", MultiDict(), session, sid, token="") + _, code, _ = file_process("GET", MultiDict(), session, sid, token="") assert code == status.OK # Idempotent: preview unchanged because it already existed.