"""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) )