Files
hunjing/backend/tests/test_conversations.py
T
chenxin 37c53862c4 ✨ feat: WP-03 主体——ContentBlock 全类型、Run 事件重放、分页与分支(WP-03)
- ContentBlock 全 21 类型 Pydantic 判别联合(与 openapi 契约一一对齐,extra=forbid);系统产生类 block(tool_call/tool_result/agent_status/approval_request/artifact)拒绝客户端伪造(契约 3.5)
- 迁移 4:run_event 表((run_id, sequence) 唯一、事件类型检查约束)+ conversation.current_branch_id
- Run 事件持久化:状态事件、每 block completed、message.completed 与权威状态同事务写入;sequence 单调从 0;SSE 改为重放持久化事件(不再现场构造),delta/progress 留待真实模型流(WP-05),不伪造
- GET messages:opaque cursor 分页((created_at,id) 复合键、base64 编码、limit 1-200、has_more)
- branches:列表 + 当前路径切换(采用≠归真);跨会话/不存在分支 422 fail-closed
- 测试 36 项全绿:21 类型接受、runtime block 拒绝、事件重放序号单调、分页无重叠、cursor 非法拒绝、分支切换与跨域
2026-09-10 13:17:34 +08:00

329 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""垂直切片测试:幂等原子事务、IDOR fail-closed、CSRF、SSE 与回滚。"""
import json
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from app import models
from tests.conftest import login
MESSAGE_BODY = {"content": [{"type": "text", "text": "你好,魂镜"}], "start_run": True}
async def _create_conversation(client: AsyncClient, headers: dict) -> str:
resp = await client.post(
"/v1/conversations", json={"title": "切片"}, headers=headers
)
assert resp.status_code == 201, resp.text
return resp.json()["id"]
async def _post_message(client: AsyncClient, headers: dict, cid: str, key: str) -> object:
return await client.post(
f"/v1/conversations/{cid}/messages",
json=MESSAGE_BODY,
headers={**headers, "Idempotency-Key": key},
)
class TestMessagePipeline:
async def test_create_and_post_message(self, client: AsyncClient) -> None:
headers = await login(client, "alice@example.com")
cid = await _create_conversation(client, headers)
resp = await _post_message(client, headers, cid, "idem-1")
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["message"]["id"].startswith("message_")
assert body["message"]["content"][0]["id"].startswith("block_")
assert body["run"]["status"] == "completed" # 空状态闭环
async def test_idempotent_replay_returns_original(self, client: AsyncClient) -> None:
headers = await login(client, "bob@example.com")
cid = await _create_conversation(client, headers)
first = await _post_message(client, headers, cid, "idem-replay")
assert first.status_code == 201
second = await _post_message(client, headers, cid, "idem-replay")
assert second.status_code == 200 # 重放返回原资源
assert second.json()["message"]["id"] == first.json()["message"]["id"]
async def test_same_key_different_body_conflicts(self, client: AsyncClient) -> None:
headers = await login(client, "carol@example.com")
cid = await _create_conversation(client, headers)
await _post_message(client, headers, cid, "idem-conflict")
resp = await client.post(
f"/v1/conversations/{cid}/messages",
json={"content": [{"type": "text", "text": "不同内容"}], "start_run": True},
headers={**headers, "Idempotency-Key": "idem-conflict"},
)
assert resp.status_code == 409
async def test_missing_idempotency_key_rejected(self, client: AsyncClient) -> None:
headers = await login(client, "dave@example.com")
cid = await _create_conversation(client, headers)
resp = await client.post(
f"/v1/conversations/{cid}/messages", json=MESSAGE_BODY, headers=headers
)
assert resp.status_code == 422
async def test_unknown_block_type_rejected(self, client: AsyncClient) -> None:
headers = await login(client, "erin@example.com")
cid = await _create_conversation(client, headers)
resp = await client.post(
f"/v1/conversations/{cid}/messages",
json={"content": [{"type": "hologram", "file_id": "x"}]},
headers={**headers, "Idempotency-Key": "idem-bad"},
)
assert resp.status_code == 422
async def test_cross_domain_conversation_returns_404(self, client: AsyncClient) -> None:
alice = await login(client, "ida@example.com")
cid = await _create_conversation(client, alice)
mallory = await login(client, "mallory@example.com")
resp = await _post_message(client, mallory, cid, "idem-idor")
assert resp.status_code == 404 # fail-closed,不泄露存在性
async def test_csrf_token_required_for_state_change(self, client: AsyncClient) -> None:
await login(client, "frank@example.com")
resp = await client.post("/v1/conversations", json={"title": "无 CSRF"})
assert resp.status_code == 403
async def test_unauthenticated_rejected(self, client: AsyncClient) -> None:
resp = await client.post("/v1/conversations", json={"title": "匿名"})
assert resp.status_code == 401
class TestRunSse:
async def test_sse_snapshot_and_terminal(self, client: AsyncClient) -> None:
headers = await login(client, "grace@example.com")
cid = await _create_conversation(client, headers)
resp = await _post_message(client, headers, cid, "idem-sse")
run_id = resp.json()["run"]["id"]
sse = await client.get(f"/v1/runs/{run_id}/events")
assert sse.status_code == 200
assert sse.headers["content-type"].startswith("text/event-stream")
lines = [ln for ln in sse.text.splitlines() if ln.startswith("data: ")]
events = [json.loads(ln.removeprefix("data: ")) for ln in lines]
assert events[0]["type"] == "run.status.changed"
assert events[0]["data"]["status"] == "completed"
assert events[-1]["type"] == "message.completed"
async def test_cross_domain_run_returns_404(self, client: AsyncClient) -> None:
headers = await login(client, "heidi@example.com")
cid = await _create_conversation(client, headers)
resp = await _post_message(client, headers, cid, "idem-sse2")
run_id = resp.json()["run"]["id"]
await login(client, "eve@example.com")
sse = await client.get(f"/v1/runs/{run_id}/events")
assert sse.status_code == 404
class TestAtomicity:
async def test_failure_leaves_no_orphans(self, client: AsyncClient, db) -> None:
from app.conversations import service
headers = await login(client, "ivan@example.com")
cid = await _create_conversation(client, headers)
class BoomRun:
def __init__(self, *args, **kwargs):
raise RuntimeError("注入失败:Run 写入阶段崩溃")
original = service.Run
service.Run = BoomRun # type: ignore[assignment]
try:
resp = await _post_message(client, headers, cid, "idem-atomic")
assert resp.status_code == 500
finally:
service.Run = original # type: ignore[assignment]
async with db() as session:
# 会话创建的合法审计/Outbox 保留;消息相关记录必须为零
assert (await session.scalars(select(models.Message))).all() == []
assert (await session.scalars(select(models.Run))).all() == []
audits = (await session.scalars(select(models.AuditEvent))).all()
assert [a for a in audits if a.aggregate_type == "message"] == []
outbox = (await session.scalars(select(models.OutboxEvent))).all()
assert [e for e in outbox if e.aggregate_type == "message"] == []
assert (await session.scalars(select(models.IdempotencyRecord))).all() == []
@pytest.mark.usefixtures("client")
class TestLingtaiNormalization:
async def test_markdown_input_expanded_by_engine(self, client: AsyncClient) -> None:
headers = await login(client, "norm@example.com")
cid = await _create_conversation(client, headers)
resp = await client.post(
f"/v1/conversations/{cid}/messages",
json={
"content": [
{"type": "markdown", "text": "说明\n```mermaid\nA-->B\n```\n$$\nz\n$$"}
]
},
headers={**headers, "Idempotency-Key": "idem-norm"},
)
assert resp.status_code == 201, resp.text
blocks = resp.json()["message"]["content"]
assert [b["type"] for b in blocks] == ["markdown", "mermaid", "math"]
assert all(b["id"].startswith("block_") for b in blocks)
assert resp.json()["message"].get("parser_version") == "hunjing-markdown-v1"
async def test_python_cpp_golden_consistency(self) -> None:
"""绑定输出与 C++ doctest 期望字节一致(跨端一致性最小 golden)。"""
from app.core import lingtai
out = lingtai.transform_markdown("x\n```mermaid\nA-->B\n```\n$$\nz\n$$\n")
assert out["parser_version"] == "hunjing-markdown-v1"
assert [b["type"] for b in out["blocks"]] == ["markdown", "mermaid", "math"]
# 与 engine/tests/test_transform.cpp 的确定性用例保持同一输入输出
out2 = lingtai.transform_markdown("x\n```mermaid\nA-->B\n```\n$$\nz\n$$\n")
assert out == out2
async def test_engine_failure_fails_closed(self, client: AsyncClient, monkeypatch) -> None:
from app.core import lingtai as lt
def boom(text: str):
raise lt.LingtaiUnavailable("灵台引擎调用失败:注入")
monkeypatch.setattr(lt, "transform_markdown", boom)
headers = await login(client, "fc@example.com")
cid = await _create_conversation(client, headers)
resp = await client.post(
f"/v1/conversations/{cid}/messages",
json={"content": [{"type": "markdown", "text": "hi"}]},
headers={**headers, "Idempotency-Key": "idem-fc"},
)
# fail closed:引擎不可用时显式失败,不静默降级直存
assert resp.status_code == 500
@pytest.mark.usefixtures("client")
class TestContentBlocks:
async def test_media_blocks_accepted(self, client: AsyncClient) -> None:
headers = await login(client, "blocks@example.com")
cid = await _create_conversation(client, headers)
resp = await client.post(
f"/v1/conversations/{cid}/messages",
json={
"content": [
{"type": "image", "file_id": "file_1", "alt": "图"},
{"type": "math", "notation": "latex", "display": "inline", "source": "a+b"},
{"type": "table", "columns": ["a"], "rows": [["1"]]},
{"type": "citation", "source_id": "s1", "title": "t", "quoted_text": "q"},
{"type": "error", "message": "块错误"},
]
},
headers={**headers, "Idempotency-Key": "idem-blocks"},
)
assert resp.status_code == 201, resp.text
types = [b["type"] for b in resp.json()["message"]["content"]]
assert types == ["image", "math", "table", "citation", "error"]
async def test_runtime_blocks_rejected_from_client(self, client: AsyncClient) -> None:
"""契约 3.5:tool_call/agent_status 等系统 block 不允许客户端伪造。"""
headers = await login(client, "rtb@example.com")
cid = await _create_conversation(client, headers)
for block in [
{"type": "tool_call", "tool_name": "t", "call_id": "c1"},
{"type": "agent_status", "stage": "s"},
{"type": "approval_request", "approval_id": "a1", "action": "do"},
{"type": "artifact", "artifact_id": "ar1", "artifact_type": "file"},
]:
resp = await client.post(
f"/v1/conversations/{cid}/messages",
json={"content": [block]},
headers={**headers, "Idempotency-Key": f"idem-rtb-{block['type']}"},
)
assert resp.status_code == 422, (block["type"], resp.text)
@pytest.mark.usefixtures("client")
class TestSseReplay:
async def test_events_replayed_in_order_with_sequence(self, client: AsyncClient) -> None:
headers = await login(client, "sseq@example.com")
cid = await _create_conversation(client, headers)
resp = await _post_message(client, headers, cid, "idem-seq")
run_id = resp.json()["run"]["id"]
sse = await client.get(f"/v1/runs/{run_id}/events")
lines = [ln for ln in sse.text.splitlines() if ln.startswith("data: ")]
events = [json.loads(ln.removeprefix("data: ")) for ln in lines]
sequences = [e["sequence"] for e in events]
assert sequences == sorted(sequences) == list(range(len(events)))
assert events[0]["type"] == "run.status.changed"
assert events[-1]["type"] == "message.completed"
# 用户消息两个 block:text 与(无 markdown 时)content.block.completed 数对应
assert sum(1 for e in events if e["type"] == "content.block.completed") >= 1
@pytest.mark.usefixtures("client")
class TestPaginationAndBranches:
async def test_message_cursor_pagination(self, client: AsyncClient) -> None:
headers = await login(client, "page@example.com")
cid = await _create_conversation(client, headers)
for i in range(5):
await client.post(
f"/v1/conversations/{cid}/messages",
json={"content": [{"type": "text", "text": f"m{i}"}], "start_run": False},
headers={**headers, "Idempotency-Key": f"idem-p{i}"},
)
page1 = await client.get(f"/v1/conversations/{cid}/messages?limit=2", headers=headers)
assert page1.status_code == 200
body1 = page1.json()
assert len(body1["data"]) == 2 and body1["has_more"] is True
assert body1["next_cursor"]
page2 = await client.get(
f"/v1/conversations/{cid}/messages?limit=2&cursor={body1['next_cursor']}",
headers=headers,
)
body2 = page2.json()
ids1 = {m["id"] for m in body1["data"]}
ids2 = {m["id"] for m in body2["data"]}
assert not (ids1 & ids2)
last = await client.get(
f"/v1/conversations/{cid}/messages?limit=100&cursor={body2['next_cursor']}",
headers=headers,
)
assert last.json()["has_more"] is False
async def test_invalid_cursor_rejected(self, client: AsyncClient) -> None:
headers = await login(client, "badc@example.com")
cid = await _create_conversation(client, headers)
resp = await client.get(
f"/v1/conversations/{cid}/messages?cursor=%%%", headers=headers
)
assert resp.status_code == 422
async def test_branches_and_current_path(self, client: AsyncClient) -> None:
headers = await login(client, "branch@example.com")
cid = await _create_conversation(client, headers)
listing = await client.get(f"/v1/conversations/{cid}/branches", headers=headers)
branches = listing.json()["data"]
assert len(branches) == 1 and branches[0]["title"] == "主脉"
# 切换到不存在的分支:422 fail-closed
bad = await client.post(
f"/v1/conversations/{cid}/current-branch",
json={"branch_id": "branch_00000000-0000-7000-8000-000000000000"},
headers=headers,
)
assert bad.status_code == 422
# 切换到合法分支
ok = await client.post(
f"/v1/conversations/{cid}/current-branch",
json={"branch_id": branches[0]["id"]},
headers=headers,
)
assert ok.status_code == 200
assert ok.json()["current_branch_id"] == branches[0]["id"]
async def test_cross_domain_branch_rejected(self, client: AsyncClient) -> None:
alice = await login(client, "br-a@example.com")
cid = await _create_conversation(client, alice)
listing = await client.get(f"/v1/conversations/{cid}/branches", headers=alice)
bid = listing.json()["data"][0]["id"]
await login(client, "br-b@example.com")
resp = await client.post(
f"/v1/conversations/{cid}/current-branch", json={"branch_id": bid}
)
assert resp.status_code in (401, 403) # 未带新会话 Cookie:401;带错 Cookie:拒绝