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:
@@ -22,6 +22,7 @@ from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import delete, func, select, text
|
||||
|
||||
from app.api import chat as chat_api
|
||||
@@ -32,6 +33,7 @@ from app.rag import agent
|
||||
from app.rag.agent import AGENT_TOOLS
|
||||
from app.rag.importer import import_sources
|
||||
from app.rag.llm import EmbeddingError, LLMError, StreamPiece, ToolCallPiece
|
||||
from app.schemas import ChatDoneEvent, SourceRef
|
||||
from tests.conftest import ADMIN_PASSWORD
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -1406,3 +1408,34 @@ def test_history_rejects_more_than_100_entries(client, db) -> None:
|
||||
},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_done_event_serializes_column_maximum_source_refs() -> None:
|
||||
"""Phase 83 A3 pin: ``SourceRef`` is SHARED by the SSE ``done``
|
||||
event and the saved-chat surface — the boundary caps added there
|
||||
(``source`` ≤ 120, ``path`` ≤ 1000, ``title`` ≤ 500) mirror the
|
||||
``documents`` column lengths EXACTLY, so a server-built event from
|
||||
a full-length row (values at the column maxima) still constructs
|
||||
and serializes byte-identical: the SSE contract is provably
|
||||
unaffected. The one-over caps raise — only client-saved refs can
|
||||
ever trip a cap, never a server-built ref."""
|
||||
event = ChatDoneEvent(
|
||||
deflected=False,
|
||||
sources=[SourceRef(source="s" * 120, path="p" * 1000, title="t" * 500)],
|
||||
suggestions=[],
|
||||
)
|
||||
assert event.model_dump() == {
|
||||
"type": "done",
|
||||
"deflected": False,
|
||||
"sources": [{"source": "s" * 120, "path": "p" * 1000, "title": "t" * 500}],
|
||||
"suggestions": [],
|
||||
}
|
||||
# The caps sit exactly ON the column maxima: one over any of them
|
||||
# is rejected (a row could never hold such a value in the first
|
||||
# place — the string columns enforce the same lengths).
|
||||
with pytest.raises(ValidationError):
|
||||
SourceRef(source="s" * 121, path="p" * 1000, title="t" * 500)
|
||||
with pytest.raises(ValidationError):
|
||||
SourceRef(source="s" * 120, path="p" * 1001, title="t" * 500)
|
||||
with pytest.raises(ValidationError):
|
||||
SourceRef(source="s" * 120, path="p" * 1000, title="t" * 501)
|
||||
|
||||
@@ -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