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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user