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`
This commit is contained in:
2026-09-08 00:33:21 -04:00
parent e29d68d9f0
commit fa189dede7
21 changed files with 1103 additions and 16 deletions
+91
View File
@@ -1062,3 +1062,94 @@ def test_page_route_serves_shared_html_when_present(
assert r.status_code == 200
assert r.text == "<html>shared page</html>"
assert "text/html" in r.headers["content-type"]
# ---------- payload boundary caps (phase 83, SEC-05) — the anonymous
# write surface 422s on oversized bodies and stores NOTHING (the caps
# live in app/schemas.py, A2 boundary-only: FastAPI rejects before the
# handler runs — no route change, nothing ever lands in the JSONB) ----------
def test_post_rejects_text_over_32000_and_stores_nothing(
client: TestClient, admin_client: TestClient
) -> None:
"""The audit vector (SEC-05): one 32_001-char message 422s at the
boundary (``ChatMessage.text`` mirrors ``HistoryTurn``'s 32 000
cap) and NOTHING is stored — the admin list is unchanged (the happy
path for in-cap bodies stays the existing guest/admin pins)."""
baseline = admin_client.get("/api/chats").json()["chats"]
r = client.post(
"/api/chats",
json={"messages": [_user("a" * 32_001), _user("follow-up")]},
)
assert r.status_code == 422
listing = admin_client.get("/api/chats").json()["chats"]
assert listing == baseline, "an oversized POST must not store a row"
def test_post_rejects_more_than_200_messages_and_stores_nothing(
client: TestClient, admin_client: TestClient
) -> None:
"""The unbounded message list is the second DoS lever (SEC-05):
201 minimally-valid messages 422 (``SavedChatCreate.messages``
``max_items=200``) and nothing lands."""
baseline = len(admin_client.get("/api/chats").json()["chats"])
r = client.post(
"/api/chats", json={"messages": [_user(f"question {i}") for i in range(201)]}
)
assert r.status_code == 422
assert len(admin_client.get("/api/chats").json()["chats"]) == baseline
def test_post_rejects_sources_over_20_and_stores_nothing(
client: TestClient, admin_client: TestClient
) -> None:
"""One brain message carrying a 21-item ``sources`` list (all valid
``SourceRef`` shapes) 422 (``ChatMessage.sources`` ``max_items=20``
— top-N docs + agent reads) and nothing lands."""
baseline = len(admin_client.get("/api/chats").json()["chats"])
sources = [
{"source": f"src{i}", "path": f"docs{i}.md", "title": f"Doc {i}"}
for i in range(21)
]
r = client.post(
"/api/chats",
json={
"messages": [
_user("hi"),
{"who": "brain", "text": "answer", "sources": sources},
]
},
)
assert r.status_code == 422
assert len(admin_client.get("/api/chats").json()["chats"]) == baseline
def test_put_rejects_oversized_message_and_leaves_row_unchanged(
client: TestClient, admin_client: TestClient
) -> None:
"""The re-Save path is gated by the SAME caps (A1: create AND update
carry the bounds): a 32_001-char message 422s and the row keeps its
original payload byte-for-byte (the stored shape is untouched — the
rejected body never reaches the JSONB)."""
created = client.post(
"/api/chats", json={"messages": _simple_conversation()}
).json()
r = client.put(
f"/api/chats/{created['id']}",
json={"messages": [_user("b" * 32_001)]},
)
assert r.status_code == 422
got = admin_client.get(f"/api/chats/{created['id']}")
assert got.status_code == 200
assert got.json()["messages"] == _expect(_simple_conversation())
assert got.json()["message_count"] == 2 # original count, not the rejected 1