Files
brain-of-reese/tests/e2e/test_chat_save_payload_limits.py
T
ducoterra fa189dede7 phase: 83_chat_save_payload_limits
All verification criteria are confirmed green. Final report:

**Phase 83 — final verification pass (all tasks already complete; verified, nothing to fix)**

- Verified `app/schemas.py` carries every A1 cap (text/thinking 32 000, sources 20, suggestions 50×200, tools 50, SourceRef 120/1000/500, ToolCall 100/2000, messages ≤200) — boundary-only, `app/api/chats.py` untouched
- Verified all test pins exist and pass: 32 unit boundary tests (both edges of every cap + round-trip), 4 oversized-422 integration pins (text/201-msgs/21-sources/PUT, each with nothing-stored/row-unchanged assertions), A3 SSE done-event pin at column maxima, dedicated E2E (anon 40 000-char POST → 422 + small save → 201)
- `uv run pytest tests/unit/test_schemas.py -v` — 32 passed
- `uv run pytest tests/integration/test_chats_api.py` — exit 0; A3 pin — passed
- `uv run pytest tests/e2e/test_chat_save_payload_limits.py -v --no-cov` — 2 passed (isolation); `uv run pytest tests/e2e/test_chat_history.py -v --no-cov` — 5 passed
- `uv run pytest` — exit 0 (~1 704 tests, 0 fail/skip); `uv run pytest --cov=app` — TOTAL 99%, `app/schemas.py` 100% (>90% ✓)
- `uv run ruff check . && uv run pyright` — clean (0 errors)
- `git diff --stat` — only `app/schemas.py`, 3 test files (+`test_chat_api.py` A3 pin, sanctioned by task 02), phase files; no `app/api/chats.py`/`alembic`/`frontend`/`pyproject`/`uv.lock` diff ✓
- All completion criteria met; commit + phase move left to the harness per pipeline rules (changes stay in working tree)
- Deviation note: list caps use `max_length` instead of `max_items` — identical in pydantic 2.13 (`max_items` is a deprecated alias); both boundaries behaviorally pinned
- Next pending phase: `84_docs_push_error_sanitization`
2026-09-08 00:33:21 -04:00

133 lines
4.9 KiB
Python

"""Phase 83 E2E (Playwright): the anonymous saved-chat payload
boundary (SEC-05) — the audit vector through a real browser's network
layer.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_chat_save_payload_limits.py -v --no-cov
The contract under test (phase 83 — boundary-only hardening of the
PUBLIC write surface ``POST/PUT /api/chats``; the caps live in
``app/schemas.py`` and FastAPI rejects before the handler runs, A2):
* **Oversized save 422s, anonymously** — the exact audit vector (one
40_000-char message, NO session, driven through the page's
``page.request`` context — the browser's own network layer) is
rejected at the schema boundary with a 422 and NOTHING is stored
(the admin list carries no row for the probe's distinctive
auto-title — the shared e2e DB may hold other suites' rows, so the
"nothing stored" proof is title-scoped, the house convention);
* **Small save still 201s** — a normal in-cap save through the same
anonymous network layer lands 201 with a valid ``id``: the boundary
tightened the DoS surface without breaking the real (in-cap) flow
the UI produces.
No LLM dependency — both endpoints are DB-only (the session-scoped
mock LLM stays up as an ``app_server`` dependency but is never
called).
"""
from __future__ import annotations
import uuid
import httpx
from playwright.sync_api import Page, expect
from e2e.auth_helpers import login
#: The probe's distinctive marker: if the oversized row had been
#: stored, its auto-title (first user message, whitespace-collapsed,
#: 120-char cap) would start with exactly this string — unique in the
#: shared e2e DB.
PROBE_MARKER = "payload-limit-probe (phase 83 e2e)"
def _admin_cookies(page: Page) -> dict[str, str]:
"""The signed session cookies the browser holds after a form login."""
return {
c["name"]: c["value"]
for c in page.context.cookies()
if "name" in c and "value" in c
}
def _settled_anonymous(page: Page, app_url: str) -> None:
"""Land on the chat page in the settled anonymous state (fresh
context — no login): the whoami round-trip has landed, so the
``page.request`` calls below carry no session cookie (the write
surface is public — anonymity is the audit vector)."""
page.goto(app_url + "/")
expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000)
def test_anonymous_oversized_save_422s_and_stores_nothing(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_settled_anonymous(page, app_url)
text = PROBE_MARKER + " " + "x" * (40_000 - len(PROBE_MARKER) - 1)
assert len(text) == 40_000
# The audit vector, end-to-end and anonymous: one 40_000-char
# message through the page's request context (no session cookie).
r = page.request.post(
app_url + "/api/chats",
data={
"messages": [
{"who": "user", "text": text},
{"who": "brain", "text": "ok"},
]
},
)
assert r.status == 422, (
f"the oversized body must 422 at the schema boundary: {r.text()}"
)
# Nothing stored: the list surface is admin-only, so sign in now
# (the 422 above happened BEFORE any session existed — the write
# surface is public, exactly the audit vector) and prove no row
# carries the probe's distinctive auto-title.
login(page, app_url, next="/")
cookies = _admin_cookies(page)
body = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
assert body.status_code == 200
rows = body.json()["chats"]
assert not any(c["title"].startswith(PROBE_MARKER) for c in rows), (
f"the oversized probe must not have been stored: {rows}"
)
def test_small_anonymous_save_still_201s(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_settled_anonymous(page, app_url)
q = "How do I prune deleted docs? (phase 83 e2e small save)"
created: str | None = None
try:
# The happy path from the same anonymous network layer: the
# boundary tightened the DoS surface without breaking the
# real (in-cap) save flow the UI produces.
r = page.request.post(
app_url + "/api/chats",
data={
"messages": [
{"who": "user", "text": q},
{"who": "brain", "text": "Use --prune. (phase 83 e2e)"},
]
},
)
assert r.status == 201, f"the in-cap save must still land: {r.text()}"
body = r.json()
uuid.UUID(body["id"]) # a valid row id
assert body["title"] == q # auto-title = first user message
assert body["message_count"] == 2
created = body["id"]
finally:
if created is not None:
login(page, app_url, next="/")
httpx.delete(
f"{app_url}/api/chats/{created}", timeout=10, cookies=_admin_cookies(page)
)