diff --git a/booth/app.py b/booth/app.py index 1e6d841..098a1f5 100644 --- a/booth/app.py +++ b/booth/app.py @@ -355,6 +355,35 @@ def record_seen(booth: Path, rel: str, items: Sequence[Item]) -> None: pass +def wants_json(accept: str | None) -> bool: + """Whether a mark POST asked for the in-place answer (R2 C3). + + True ONLY when the Accept header lists `application/json` exactly — + parameters stripped — with a q-value that is absent or above zero. Absent, + empty, wildcard, `application/*`, a near miss like `application/jsonx`, an + explicit `q=0`, a malformed q: all False. It FAILS TOWARD THE 303, because + the plain form's redirect is the no-JS guarantee and a mis-parse must land + there, never on a 204 a browser would render as nothing happening. + """ + if not accept: + return False + try: + for entry in accept.split(","): + mtype, *params = entry.split(";") + if mtype.strip().lower() != "application/json": + continue + q = 1.0 + for param in params: + key, _, value = param.partition("=") + if key.strip().lower() == "q": + q = float(value.strip()) + if q > 0: + return True + except ValueError: + return False + return False + + HOLD_UNREADABLE = "unreadable" HOLD_OPEN = "open" @@ -1183,8 +1212,32 @@ def create_app( base = f"/b/{quote(name, safe='')}/" if form.get("back") == "marks": base = f"/b/{quote(name, safe='')}/marks" + elif form.get("back") == "view": + # R2 C3: judgment made at full size lands back at full size — the + # JS-off fix for being thrown out to the grid. Only for a MEDIA item + # of this booth; anything else takes the no-`back` landing above. + # Built from the resolved rel, never echoed from the form. + f = form.get("f") + if isinstance(f, str) and f: + try: + ring = review_chain(booth_items(resolve_booth(name))) + except HTTPException: + ring = [] + if f in ring: + return RedirectResponse( + url=f"/b/{quote(name, safe='')}/view?f={quote(f, safe='/')}#rail", + status_code=303) return RedirectResponse(url=f"{base}#{anchor}", status_code=303) + def _mark_done(request: Request, name: str, form, anchor: str) -> Response: + """The one exit for every mark route (R2 C3). A request that asked for + the in-place answer gets 204 and no body — the page fetches its own + fresh regions. Everything else gets `_mark_redirect`, byte for byte what + it got before R2 (INV-4).""" + if wants_json(request.headers.get("accept")): + return Response(status_code=204) + return _mark_redirect(name, form, anchor) + @app.post("/b/{name}/answer") async def booth_answer(request: Request, name: str): """Record the operator's pick — one of N options a session declared in @@ -1235,7 +1288,7 @@ def create_app( _form_text(form, "choice"), notes, who=who) except AskError as exc: raise HTTPException(status_code=400, detail=str(exc)) - return _mark_redirect(name, form, f"mark-{quote(mark_id, safe='')}") + return _mark_done(request, name, form, f"mark-{quote(mark_id, safe='')}") @app.post("/b/{name}/note") async def booth_note(request: Request, name: str): @@ -1256,7 +1309,7 @@ def create_app( who=request.client.host if request.client else "") except AskError as exc: raise HTTPException(status_code=400, detail=str(exc)) - return _mark_redirect(name, form, f"mark-{quote(mark.id, safe='')}") + return _mark_done(request, name, form, f"mark-{quote(mark.id, safe='')}") @app.post("/b/{name}/flag") async def booth_flag(request: Request, name: str): @@ -1278,7 +1331,7 @@ def create_app( who=request.client.host if request.client else "") except AskError as exc: raise HTTPException(status_code=400, detail=str(exc)) - return _mark_redirect(name, form, f"item-{quote(target, safe='')}") + return _mark_done(request, name, form, f"item-{quote(target, safe='')}") @app.post("/b/{name}/unmark") async def booth_unmark(request: Request, name: str): @@ -1290,7 +1343,7 @@ def create_app( if not isinstance(mark_id, str) or not mark_id: raise HTTPException(status_code=400, detail="which mark?") await run_in_threadpool(delete_mark, booth, mark_id) - return _mark_redirect(name, form, "marks") + return _mark_done(request, name, form, "marks") @app.post("/b/{name}/import-asks") async def booth_import_asks(request: Request, name: str): diff --git a/docs/contracts/r2_flow.contract.md b/docs/contracts/r2_flow.contract.md index dafd6c4..0aef900 100644 --- a/docs/contracts/r2_flow.contract.md +++ b/docs/contracts/r2_flow.contract.md @@ -129,10 +129,13 @@ no route derives it. ### C3 — in-place judgment (app.py, base.html) **`wants_json(request) -> bool`** is True **only** when the `Accept` header, -split on commas with parameters stripped, contains the exact media type -`application/json`. +split on commas, contains an entry whose media type, parameters stripped, is +exactly `application/json` and whose q-value is absent or greater than 0. - Absent, empty, `*/*` or `application/*` → False. +- `application/json;q=0` → False. A client that explicitly refuses JSON gets + the redirect. +- A near miss such as `application/jsonx` → False. - Any header that fails to parse → False. - **It fails toward the 303.** diff --git a/tests/golden/r2_mark_303.json b/tests/golden/r2_mark_303.json new file mode 100644 index 0000000..aa9d3e6 --- /dev/null +++ b/tests/golden/r2_mark_303.json @@ -0,0 +1,2180 @@ +[ + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x" + }, + "accept": null, + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x" + }, + "accept": "", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x" + }, + "accept": "*/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x" + }, + "accept": "text/html", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x" + }, + "accept": "application/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x" + }, + "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x" + }, + "accept": "application/jsonx", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x" + }, + "accept": ";;;", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x" + }, + "accept": "application/json;q=0", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x", + "back": "marks" + }, + "accept": null, + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x", + "back": "marks" + }, + "accept": "", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x", + "back": "marks" + }, + "accept": "*/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x", + "back": "marks" + }, + "accept": "text/html", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x", + "back": "marks" + }, + "accept": "application/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x", + "back": "marks" + }, + "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x", + "back": "marks" + }, + "accept": "application/jsonx", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x", + "back": "marks" + }, + "accept": ";;;", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x", + "back": "marks" + }, + "accept": "application/json;q=0", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello" + }, + "accept": null, + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello" + }, + "accept": "", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello" + }, + "accept": "*/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello" + }, + "accept": "text/html", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello" + }, + "accept": "application/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello" + }, + "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello" + }, + "accept": "application/jsonx", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello" + }, + "accept": ";;;", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello" + }, + "accept": "application/json;q=0", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello", + "back": "marks" + }, + "accept": null, + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello", + "back": "marks" + }, + "accept": "", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello", + "back": "marks" + }, + "accept": "*/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello", + "back": "marks" + }, + "accept": "text/html", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello", + "back": "marks" + }, + "accept": "application/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello", + "back": "marks" + }, + "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello", + "back": "marks" + }, + "accept": "application/jsonx", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello", + "back": "marks" + }, + "accept": ";;;", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello", + "back": "marks" + }, + "accept": "application/json;q=0", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note" + }, + "accept": null, + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note" + }, + "accept": "", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note" + }, + "accept": "*/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note" + }, + "accept": "text/html", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note" + }, + "accept": "application/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note" + }, + "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note" + }, + "accept": "application/jsonx", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note" + }, + "accept": ";;;", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note" + }, + "accept": "application/json;q=0", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note", + "back": "marks" + }, + "accept": null, + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note", + "back": "marks" + }, + "accept": "", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note", + "back": "marks" + }, + "accept": "*/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note", + "back": "marks" + }, + "accept": "text/html", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note", + "back": "marks" + }, + "accept": "application/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note", + "back": "marks" + }, + "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note", + "back": "marks" + }, + "accept": "application/jsonx", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note", + "back": "marks" + }, + "accept": ";;;", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note", + "back": "marks" + }, + "accept": "application/json;q=0", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1" + }, + "accept": null, + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1" + }, + "accept": "", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1" + }, + "accept": "*/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1" + }, + "accept": "text/html", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1" + }, + "accept": "application/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1" + }, + "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1" + }, + "accept": "application/jsonx", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1" + }, + "accept": ";;;", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1" + }, + "accept": "application/json;q=0", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1", + "back": "marks" + }, + "accept": null, + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1", + "back": "marks" + }, + "accept": "", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1", + "back": "marks" + }, + "accept": "*/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1", + "back": "marks" + }, + "accept": "text/html", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1", + "back": "marks" + }, + "accept": "application/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1", + "back": "marks" + }, + "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1", + "back": "marks" + }, + "accept": "application/jsonx", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1", + "back": "marks" + }, + "accept": ";;;", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1", + "back": "marks" + }, + "accept": "application/json;q=0", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0" + }, + "accept": null, + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0" + }, + "accept": "", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0" + }, + "accept": "*/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0" + }, + "accept": "text/html", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0" + }, + "accept": "application/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0" + }, + "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0" + }, + "accept": "application/jsonx", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0" + }, + "accept": ";;;", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0" + }, + "accept": "application/json;q=0", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0", + "back": "marks" + }, + "accept": null, + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0", + "back": "marks" + }, + "accept": "", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0", + "back": "marks" + }, + "accept": "*/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0", + "back": "marks" + }, + "accept": "text/html", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0", + "back": "marks" + }, + "accept": "application/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0", + "back": "marks" + }, + "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0", + "back": "marks" + }, + "accept": "application/jsonx", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0", + "back": "marks" + }, + "accept": ";;;", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0", + "back": "marks" + }, + "accept": "application/json;q=0", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1" + }, + "accept": null, + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1" + }, + "accept": "", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1" + }, + "accept": "*/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1" + }, + "accept": "text/html", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1" + }, + "accept": "application/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1" + }, + "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1" + }, + "accept": "application/jsonx", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1" + }, + "accept": ";;;", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1" + }, + "accept": "application/json;q=0", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1", + "back": "marks" + }, + "accept": null, + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1", + "back": "marks" + }, + "accept": "", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1", + "back": "marks" + }, + "accept": "*/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1", + "back": "marks" + }, + "accept": "text/html", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1", + "back": "marks" + }, + "accept": "application/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1", + "back": "marks" + }, + "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1", + "back": "marks" + }, + "accept": "application/jsonx", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1", + "back": "marks" + }, + "accept": ";;;", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1", + "back": "marks" + }, + "accept": "application/json;q=0", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#marks" + ] + ], + "body": "" + } +] \ No newline at end of file diff --git a/tests/test_flow.py b/tests/test_flow.py index ce616eb..a872fdf 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -151,3 +151,76 @@ def test_a_look_that_cannot_be_recorded_still_serves_the_page(tmp_path): b.chmod(0o755) assert r.status_code == 200 assert not (b / ".seen").exists() + + +# ---- C3: in-place judgment -------------------------------------------------- + +GOLDEN = pathlib.Path(__file__).parent / "golden" / "r2_mark_303.json" + + +def _seed(root: pathlib.Path) -> TestClient: + """The golden's fixture, byte for byte (see golden_gen in the R2 notes).""" + from booth.marks import declare_pick, write_note + root.mkdir(parents=True, exist_ok=True) + b = _booth(root, "g", {"a.png": PNG, "b b.png": PNG, "c.md": PNG}) + declare_pick(b, "q", {"prompt": "Which?", "options": ["x", "y"]}, target="a.png") + set_flag(b, "a.png", True) + write_note(b, "a.png", "seed") + return TestClient(create_app(root, ttl_hours=24, start_sweeper=False), + follow_redirects=False) + + +def test_every_pre_r2_request_shape_gets_a_byte_identical_303(tmp_path): + """INV-4. The golden was recorded from the PRE-R2 code: every mark route, + with `back` absent and `back=marks`, under nine Accept headers that must + NOT count as asking for JSON. Status, every header, and the body must match + exactly — the no-JS guarantee lives in these bytes.""" + import json + cases = json.loads(GOLDEN.read_text()) + assert len(cases) == 108 + for i, case in enumerate(cases): + c = _seed(tmp_path / str(i)) + headers = {} if case["accept"] is None else {"accept": case["accept"]} + r = c.post(case["path"], data=case["form"], headers=headers) + got = {"status": r.status_code, + "headers": sorted([k.lower(), v] for k, v in r.headers.items()), + "body": r.content.decode("latin-1")} + want = {k: case[k] for k in ("status", "headers", "body")} + assert got == want, (case["path"], case["form"], case["accept"]) + + +@pytest.mark.parametrize("path,form", [ + ("/b/g/answer", {"ask": "q", "choice": "y"}), + ("/b/g/note", {"target": "a.png", "text": "in place"}), + ("/b/g/flag", {"target": "b b.png", "on": "1"}), + ("/b/g/unmark", {"mark": "note-1"}), +]) +@pytest.mark.parametrize("accept", ["application/json", "text/html, application/json;q=0.5"]) +def test_an_explicit_json_accept_gets_204_and_the_write_still_lands(tmp_path, path, form, accept): + """The in-place path: same write as the form, no redirect, no body.""" + from booth.marks import marks_for + c = _seed(tmp_path) + before = [(m.id, m.shape, m.answer, m.text) for m in marks_for(tmp_path / "g")] + r = c.post(path, data=form, headers={"accept": accept}) + assert r.status_code == 204 and r.content == b"" + assert "location" not in r.headers + after = [(m.id, m.shape, m.answer, m.text) for m in marks_for(tmp_path / "g")] + assert after != before, "the write must happen exactly as for the form" + + +@pytest.mark.parametrize("f,landing", [ + ("a.png", "/b/g/view?f=a.png#rail"), + ("b b.png", "/b/g/view?f=b%20b.png#rail"), + ("c.md", "/b/g/#item-b%20b.png"), # a doc is not in the review ring + ("gone.png", "/b/g/#item-b%20b.png"), # not an item + ("", "/b/g/#item-b%20b.png"), + ("../../etc/passwd", "/b/g/#item-b%20b.png"), +]) +def test_back_view_lands_on_the_review_only_for_a_media_item(tmp_path, f, landing): + """The JS-off fix for the bounce: a flag set at full size lands back at + full size. Anything that is not a media item in this booth falls back to + the booth page exactly as a form with no `back` does.""" + c = _seed(tmp_path) + r = c.post("/b/g/flag", data={"target": "b b.png", "on": "1", "back": "view", "f": f}) + assert r.status_code == 303 + assert r.headers["location"] == landing