diff --git a/backend/app/routes/codex.py b/backend/app/routes/codex.py index f89ab93..2938cdc 100644 --- a/backend/app/routes/codex.py +++ b/backend/app/routes/codex.py @@ -3,7 +3,7 @@ contacted, shared across all users (spec §4).""" import uuid -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import desc, func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -126,7 +126,9 @@ def _entity_card(entity: Entity, discoverer: str | None) -> dict: async def list_codex( rarity: str | None = None, sort: str = "recent", - limit: int = 60, + # Bounded at the edge: an unvalidated `?limit=-1` reached Postgres as a + # negative LIMIT and returned a 500 to an unauthenticated caller. + limit: int = Query(default=60, ge=1, le=200), db: AsyncSession = Depends(get_db), ): query = select(Entity) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 5116b68..81adc5c 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -7,7 +7,9 @@ from pydantic import BaseModel, ConfigDict, Field class RegisterRequest(BaseModel): username: str = Field(min_length=3, max_length=32) password: str = Field(min_length=8, max_length=128) - email: str | None = None + # users.email is VARCHAR(255): without a cap here an over-long address + # reached Postgres and surfaced as a 500 instead of a validation error. + email: str | None = Field(default=None, max_length=255) class UserOut(BaseModel): diff --git a/backend/tests/test_messages.py b/backend/tests/test_messages.py new file mode 100644 index 0000000..20e648a --- /dev/null +++ b/backend/tests/test_messages.py @@ -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"] diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py new file mode 100644 index 0000000..f0959e6 --- /dev/null +++ b/backend/tests/test_profile.py @@ -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 = "" + 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 diff --git a/frontend/src/pages/MessagesPage.tsx b/frontend/src/pages/MessagesPage.tsx index fd29466..26ccd4d 100644 --- a/frontend/src/pages/MessagesPage.tsx +++ b/frontend/src/pages/MessagesPage.tsx @@ -28,7 +28,11 @@ type Hunter = { } type Conversation = { hunter: Hunter - last_body: string + // GET /api/messages calls this `excerpt` (first 160 chars) with a + // `truncated` flag beside it. This type used to say `last_body`, which the + // API has never sent — every inbox preview rendered as an empty string. + excerpt: string + truncated: boolean last_at: string last_from_me: boolean unread: number @@ -132,7 +136,8 @@ function Inbox() { {c.last_from_me ? t('messages.youPrefix') : ''} - {c.last_body} + {c.excerpt} + {c.truncated ? '…' : ''} {c.unread > 0 && (