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.
This commit is contained in:
@@ -653,18 +653,30 @@ def encode_cursor(keys: list[dict], row, window: datetime | None = None) -> str:
|
|||||||
return base64.urlsafe_b64encode(payload.encode()).decode()
|
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]:
|
def decode_cursor(keys: list[dict], cursor: str) -> tuple[list, str, datetime | None]:
|
||||||
try:
|
try:
|
||||||
payload = json.loads(base64.urlsafe_b64decode(cursor.encode()))
|
payload = json.loads(base64.urlsafe_b64decode(cursor.encode()))
|
||||||
raw = payload["k"]
|
raw = payload["k"]
|
||||||
if len(raw) != len(keys):
|
if len(raw) != len(keys):
|
||||||
raise ValueError("key arity mismatch")
|
raise ValueError("key arity mismatch")
|
||||||
vals = [
|
vals = [_cursor_dt(v) if k["kind"] == "dt" else v for k, v in zip(keys, raw)]
|
||||||
datetime.fromisoformat(v) if (v is not None and k["kind"] == "dt") else v
|
for k, v in zip(keys, vals):
|
||||||
for k, v in zip(keys, raw)
|
if v is None and not k["nullable"]:
|
||||||
]
|
raise ValueError("null value for a non-nullable key")
|
||||||
window = payload.get("w")
|
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:
|
except (binascii.Error, json.JSONDecodeError, KeyError, ValueError, TypeError) as exc:
|
||||||
# Framework-free: the route handler translates this into an HTTP 400 so the
|
# Framework-free: the route handler translates this into an HTTP 400 so the
|
||||||
# read-model stays web-layer-agnostic (see routes/feed.get_feed).
|
# read-model stays web-layer-agnostic (see routes/feed.get_feed).
|
||||||
|
|||||||
@@ -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.
|
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
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import pytest
|
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)
|
page, cursor = _ids(client, cursor, limit=limit)
|
||||||
walked.extend(page)
|
walked.extend(page)
|
||||||
assert walked == full
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user