fix: blank inbox previews, two unauthenticated 500s, and the missing social tests

- Every conversation row in the inbox rendered an empty preview: the
  frontend read `last_body`, which the API has never sent. It sends
  `excerpt` plus `truncated`.
- `GET /api/codex?limit=-1` passed a negative LIMIT to Postgres -> 500.
  Now bounded by Query(ge=1, le=200) -> 422.
- Registering with a >255 char email hit the VARCHAR(255) column and
  surfaced as a 500. PATCH /api/profile already validated this; only
  /auth/register was open.

The social layer had zero tests, despite routes/messages.py citing
"tests/test_messages.py's cross-user leak tests" and routes/profile.py
claiming "privacy rules that the tests pin". Neither file existed. Both now
do: auth required on every route, email only on /api/profile/me, no id or
password_hash on any public payload, third-party thread reads empty,
payload-supplied sender inert, veiled profiles 404 and stay off the roster.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-08-01 01:45:04 +00:00
parent a727858a62
commit 605f32f20e
5 changed files with 270 additions and 5 deletions

View File

@@ -0,0 +1,131 @@
"""Cross-user leak and impersonation pins for hunter-to-hunter whispers.
routes/messages.py's docstring points at this file ("See
tests/test_messages.py's cross-user leak tests") — it did not exist.
"""
import pytest
async def _register(client, username, password="spookyspooky"):
resp = await client.post(
"/auth/register", json={"username": username, "password": password}
)
assert resp.status_code == 201, resp.text
async def _login(client, username, password="spookyspooky"):
resp = await client.post(
"/auth/login", json={"username": username, "password": password}
)
assert resp.status_code == 200, resp.text
@pytest.mark.asyncio
@pytest.mark.parametrize(
"method,path",
[("get", "/api/messages"), ("get", "/api/messages/someone")],
)
async def test_message_reads_require_auth(client, method, path):
assert (await getattr(client, method)(path)).status_code == 401
@pytest.mark.asyncio
async def test_send_requires_auth(client):
resp = await client.post("/api/messages", json={"to": "anyone", "body": "hi"})
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_third_party_cannot_read_a_thread(client):
await _register(client, "alice")
await _register(client, "bob")
await _register(client, "mallory")
await _login(client, "alice")
sent = await client.post("/api/messages", json={"to": "bob", "body": "the cellar is cold"})
assert sent.status_code == 201
# Mallory asks for the thread with each participant: the query is pinned
# to (caller, other) in both directions, so neither leg can match a
# message between two other people.
await _login(client, "mallory")
for name in ("alice", "bob"):
thread = await client.get(f"/api/messages/{name}")
assert thread.status_code == 200
assert thread.json()["messages"] == []
assert (await client.get("/api/messages")).json()["conversations"] == []
@pytest.mark.asyncio
async def test_sender_is_always_the_session_user(client):
await _register(client, "alice2")
await _register(client, "bob2")
await _register(client, "mallory2")
# An extra `from`/`sender` in the body must be inert — sender_id comes
# from the cookie, never the payload.
await _login(client, "mallory2")
resp = await client.post(
"/api/messages",
json={"to": "bob2", "body": "trust me", "from": "alice2", "sender": "alice2"},
)
assert resp.status_code == 201
await _login(client, "bob2")
thread = (await client.get("/api/messages/alice2")).json()
assert thread["messages"] == []
thread = (await client.get("/api/messages/mallory2")).json()
assert [m["body"] for m in thread["messages"]] == ["trust me"]
@pytest.mark.asyncio
async def test_body_length_is_capped_and_empty_rejected(client):
await _register(client, "alice3")
await _register(client, "bob3")
await _login(client, "alice3")
assert (
await client.post("/api/messages", json={"to": "bob3", "body": " "})
).status_code == 400
assert (
await client.post("/api/messages", json={"to": "bob3", "body": "x" * 1001})
).status_code == 400
@pytest.mark.asyncio
async def test_send_is_rate_limited(client, monkeypatch):
from app.routes import messages as messages_module
from app.rate_limit import RateLimiter
monkeypatch.setattr(
messages_module, "send_limiter", RateLimiter(max_requests=2, window_seconds=3600)
)
await _register(client, "alice4")
await _register(client, "bob4")
await _login(client, "alice4")
for _ in range(2):
assert (
await client.post("/api/messages", json={"to": "bob4", "body": "again"})
).status_code == 201
assert (
await client.post("/api/messages", json={"to": "bob4", "body": "again"})
).status_code == 429
@pytest.mark.asyncio
async def test_conversation_row_carries_an_excerpt(client):
# The inbox preview key is `excerpt` (+ `truncated`); the frontend read a
# nonexistent `last_body` and rendered blank previews. Pin the name.
await _register(client, "alice5")
await _register(client, "bob5")
await _login(client, "alice5")
await client.post("/api/messages", json={"to": "bob5", "body": "z" * 300})
row = (await client.get("/api/messages")).json()["conversations"][0]
assert row["excerpt"] == "z" * 160
assert row["truncated"] is True
assert row["hunter"]["username"] == "bob5"
assert "email" not in row["hunter"]
assert "id" not in row["hunter"]

View File

@@ -0,0 +1,125 @@
"""Privacy and authorization pins for the hunter profile layer.
The module docstring of routes/profile.py promises three things that are
easy to regress and were never actually tested: email appears on
/api/profile/me and nowhere else, a veiled profile is indistinguishable
from one that never existed, and editing is authenticated.
"""
import pytest
async def _register(client, username, password="spookyspooky", **extra):
resp = await client.post(
"/auth/register", json={"username": username, "password": password, **extra}
)
assert resp.status_code == 201, resp.text
return resp.json()
async def _login(client, username, password="spookyspooky"):
resp = await client.post(
"/auth/login", json={"username": username, "password": password}
)
assert resp.status_code == 200, resp.text
@pytest.mark.asyncio
async def test_profile_me_requires_auth(client):
assert (await client.get("/api/profile/me")).status_code == 401
@pytest.mark.asyncio
async def test_profile_patch_requires_auth(client):
resp = await client.patch("/api/profile", json={"display_name": "nobody"})
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_email_only_on_own_profile(client):
await _register(client, "seer_a", email="seer@example.com")
await _login(client, "seer_a")
mine = (await client.get("/api/profile/me")).json()
assert mine["email"] == "seer@example.com"
public = (await client.get("/api/hunters/seer_a")).json()
assert "email" not in public
assert "password_hash" not in public
assert "id" not in public
roster = (await client.get("/api/hunters")).json()["hunters"]
for hunter in roster:
assert "email" not in hunter
assert "password_hash" not in hunter
assert "id" not in hunter
@pytest.mark.asyncio
async def test_patch_cannot_touch_another_hunter(client):
await _register(client, "seer_b")
await _register(client, "seer_c")
await _login(client, "seer_b")
# There is no user selector on PATCH at all — the payload is applied to
# the session's own row, so a username in the body is inert.
resp = await client.patch(
"/api/profile", json={"username": "seer_c", "display_name": "impostor"}
)
assert resp.status_code == 200
assert resp.json()["username"] == "seer_b"
victim = (await client.get("/api/hunters/seer_c")).json()
assert victim["display_name"] is None
@pytest.mark.asyncio
async def test_veiled_profile_is_404_and_absent_from_roster(client):
await _register(client, "seer_d")
await _login(client, "seer_d")
assert (await client.patch("/api/profile", json={"profile_public": False})).status_code == 200
assert (await client.get("/api/hunters/seer_d")).status_code == 404
roster = (await client.get("/api/hunters")).json()["hunters"]
assert all(h["username"] != "seer_d" for h in roster)
@pytest.mark.asyncio
async def test_text_fields_are_capped(client):
await _register(client, "seer_e")
await _login(client, "seer_e")
assert (
await client.patch("/api/profile", json={"display_name": "x" * 49})
).status_code == 422
assert (await client.patch("/api/profile", json={"bio": "y" * 281})).status_code == 422
assert (await client.patch("/api/profile", json={"avatar_hue": 400})).status_code == 422
assert (await client.patch("/api/profile", json={"gender": "wraith"})).status_code == 422
@pytest.mark.asyncio
async def test_markup_in_profile_text_is_stored_verbatim_not_interpreted(client):
# The API is a JSON API: it stores exactly what was sent. The XSS defence
# is that every consumer renders it as a text node (see MessagesPage /
# HunterPage). Pinned so nobody "helpfully" starts emitting HTML here.
await _register(client, "seer_f")
await _login(client, "seer_f")
payload = "<img src=x onerror=alert(1)>"
resp = await client.patch("/api/profile", json={"bio": payload})
assert resp.json()["bio"] == payload
assert (await client.get("/api/hunters/seer_f")).json()["bio"] == payload
@pytest.mark.asyncio
async def test_register_rejects_absurdly_long_email(client):
# users.email is VARCHAR(255); without a schema cap this reached the
# database and came back as a 500 instead of a validation error.
resp = await client.post(
"/auth/register",
json={
"username": "seer_g",
"password": "spookyspooky",
"email": "a" * 300 + "@example.com",
},
)
assert resp.status_code == 422