All gates green. Report: - **Verified (phase 164, all 4 tasks already in `complete/`)**: `HistoryTurn`/`ChatMessage`/`ChatThinkingSegment` caps → 600 000, `history`/saved-messages lists → 500, new `SavedChatCreate/Update` total-8 000 000-char validator; exported `errorDetailText` at the two previously unguarded chat paths (pre-stream `!res.ok` + SSE `error` frame); `FAILED_REQUEST_TEXT` + `bubbleText` threading + phase-114 banner hint; dedicated E2E `tests/e2e/test_chat_error_honesty.py`. - **Final-pass additions**: `tests/integration/test_chat_api.py::test_history_rejects_one_char_over_the_text_cap` (endpoint-level >600k → 422 with the pydantic list-of-objects `msg` — the criterion's upper half was only unit-pinned); README env rows for `BOR_APP_VERSION` + `BOR_SESSION_COOKIE`; restored `test_document_viewer.py`'s tracked screenshot artifact dirtied by the E2E run (via read-only `git show`, no index/history touched). - **Tests/lint (exact commands)**: `uv run pytest --cov=app --cov-report=term-missing` → **3837 passed**, `app/` coverage **99%** (>90%); `uv run pytest tests/e2e/test_chat_error_honesty.py -v --no-cov` → **4 passed in isolation**; per-file loop over **all 148** E2E files (`uv run pytest tests/e2e/<f> -q --no-cov`) → **148 × exit 0**, incl. `test_failed_turn_retry`, `test_chat_save_payload_limits`, `test_edit_and_resend`, `test_llm_history`, `test_smoke`; `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors. - **Completion criteria**: pre-stream 422 shows the distinct line + verbatim `msg`, `[object Object]` unrepresentable + no `input` in DOM — **met** (unit totality/source pins + E2E asserts 1–2); 525k history accepted (200 settles) / >600k → 422 / saved chat 201 + 8M bound — **met**; transport + SSE-frame settles keep `FAILED_TURN_TEXT`, stop/abort untouched, §7.4 re-enables — **met**; budget trim, `message` 4000, the 7 guarded detail-readers and all SSE shapes byte-identical (diff touches only caps + the two chat paths) — **met**; README/AGENTS full review + proxy deploy note — **met**; commit/`complete/` move = pipeline gate. - **Docs**: README reviewed in full (env tables cross-checked programmatically: 74 `BOR_*` rows vs `Settings` defaults — no mismatches; every `.env.example` var documented; referenced scripts/pages/endpoints/`query_log` columns exist) — found & fixed the 2 undocumented real knobs above; the phase's payload-limits section, failed-turn copy and `client_max_body_size ≥ 16 MB` deploy note verified accurate. `AGENTS.md` verified consistent (rules, gitignore list, quick-reference commands, referenced paths all correct) — no changes needed. - **Deviations**: `ChatThinkingSegment.text` raised 32 000 → 600 000 too (not itemized in A1, but its own docstring mirrors `HistoryTurn.thinking`; without it a long-thinking turn 422s its own auto-save — same mirror invariant). No live-infra changes made (the proxy limit lives in the separate deployment repo; README note only). - **Next pending phase**: none — `164_chat_error_honesty` was the only entry in `todo/`.
140 lines
5.4 KiB
Python
140 lines
5.4 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
|
|
message PAST the per-message cap — 600 001 chars since phase 164
|
|
raised it to admit a full-length answer, 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);
|
|
the same suite's shape stays boundary-tied to ``app/schemas.py``:
|
|
re-cut the probe whenever the cap moves, it must always be ONE past
|
|
the current 600 000;
|
|
* **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)
|
|
|
|
# ONE past the per-message cap (phase 164, locked A1: 32 000 →
|
|
# 600 000, so a full-length answer saves — the boundary moves with
|
|
# the schema cap, this probe must always sit one char over it).
|
|
text = PROBE_MARKER + " " + "x" * (600_001 - len(PROBE_MARKER) - 1)
|
|
assert len(text) == 600_001
|
|
# The audit vector, end-to-end and anonymous: one over-cap 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)
|
|
)
|