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:
125
backend/tests/test_profile.py
Normal file
125
backend/tests/test_profile.py
Normal 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
|
||||
Reference in New Issue
Block a user