From c4c442bc5b8759967242fbd660ddb46a87ba721e Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 3 Oct 2026 02:25:14 +0200 Subject: [PATCH] fix(feed): a hand-made relevance cursor is refused, not a crash The page halves compare the cursor's timestamp with the window's in Python; a null or naive one raised there, past the route's 400. decode_cursor now refuses a null non-nullable key and a naive timestamp. --- backend/app/feedquery.py | 22 ++++++++++++++++----- backend/tests/test_feed_relevance_window.py | 19 ++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/backend/app/feedquery.py b/backend/app/feedquery.py index 20665756..f612b8ca 100644 --- a/backend/app/feedquery.py +++ b/backend/app/feedquery.py @@ -653,18 +653,30 @@ def encode_cursor(keys: list[dict], row, window: datetime | None = None) -> str: return base64.urlsafe_b64encode(payload.encode()).decode() +def _cursor_dt(v): + """A cursor's timestamp: aware or refused. The server writes only aware ones (timestamptz), and + windowed_relevance_rows compares them in Python, where a naive one beside an aware one raises — a + 500 for a hand-made cursor instead of the 400 (E31 review 2).""" + if v is None: + return None + dt = datetime.fromisoformat(v) + if dt.tzinfo is None: + raise ValueError("naive cursor timestamp") + return dt + + def decode_cursor(keys: list[dict], cursor: str) -> tuple[list, str, datetime | None]: try: payload = json.loads(base64.urlsafe_b64decode(cursor.encode())) raw = payload["k"] if len(raw) != len(keys): raise ValueError("key arity mismatch") - vals = [ - datetime.fromisoformat(v) if (v is not None and k["kind"] == "dt") else v - for k, v in zip(keys, raw) - ] + vals = [_cursor_dt(v) if k["kind"] == "dt" else v for k, v in zip(keys, raw)] + for k, v in zip(keys, vals): + if v is None and not k["nullable"]: + raise ValueError("null value for a non-nullable key") window = payload.get("w") - return vals, payload["id"], datetime.fromisoformat(window) if window else None + return vals, payload["id"], _cursor_dt(window) if window else None except (binascii.Error, json.JSONDecodeError, KeyError, ValueError, TypeError) as exc: # Framework-free: the route handler translates this into an HTTP 400 so the # read-model stays web-layer-agnostic (see routes/feed.get_feed). diff --git a/backend/tests/test_feed_relevance_window.py b/backend/tests/test_feed_relevance_window.py index e8e34881..396d37b6 100644 --- a/backend/tests/test_feed_relevance_window.py +++ b/backend/tests/test_feed_relevance_window.py @@ -5,6 +5,8 @@ The ranking now covers the newest RANK_WINDOW matches and the rest follow newest rides the cursor, so a video ingested mid-scroll cannot push a row behind the cursor and repeat it. """ +import base64 +import json from datetime import datetime, timezone import pytest @@ -112,3 +114,20 @@ def test_cursor_walk_matches_the_unpaged_order(db, as_user, monkeypatch, window, page, cursor = _ids(client, cursor, limit=limit) walked.extend(page) assert walked == full + + +@pytest.mark.parametrize( + "payload", + [ + {"k": [5, None], "id": "m1", "w": "2024-01-01T00:00:00+00:00"}, # null published_at + {"k": [5, "2024-01-03T00:00:00+00:00"], "id": "m1", "w": "2024-01-01T00:00:00"}, # naive w + {"k": [5, "2024-01-03T00:00:00"], "id": "m1", "w": "2024-01-01T00:00:00+00:00"}, # naive k + ], +) +def test_a_hand_made_relevance_cursor_is_refused_not_a_crash(db, as_user, payload): + # The page halves compare the cursor's timestamp with the window's in Python: a null or a naive + # one raised there, past the route's 400 — a 500 (E31 review 2). + client = as_user(_seed(db)) + cursor = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode() + resp = client.get(f"/api/feed?q=foo&sort=relevance&limit=2&cursor={cursor}") + assert resp.status_code == 400, resp.text