Files
hunjing/backend/tests/test_conversations.py
T
chenxin 603a683c14 ✨ feat: WP-03 三界归真作用域与消息版本谱系(ADR-020 落地)
- 迁移 5:conversation.realm(默认真界,检查约束);message.realm 快照、canonized_at、parent_message_id 自引用与 variant_kind(origin/revision/candidate)
- 界域端点:GET conversation(realm/当前分支)与 POST realm(入境/出境显式切换,留审计——ADR-020 切换必须可见)
- 归真端点 POST /v1/messages/{id}/canonizations:scope 必须与消息界域一致(不一致 422);幂等(重复归真 200 原状态);境界归真产出 world.canonized Outbox 事件(世界知识候选入口,WP-04 消费);幻境归真仅标记试演记录
- 编辑版本 POST /v1/messages/{id}/revisions:新消息 variant=revision 引用原消息(markdown 修订同样经灵台引擎规范化),原消息永不修改
- 测试 44 项全绿(+8):界域快照、归真正/反/幂等、幻境试演、revision 原消息不变、跨域 401/403/404 fail-closed
2026-09-10 13:28:25 +08:00

462 lines
22 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:拒绝
@pytest.mark.usefixtures("client")
class TestRealmsAndCanonization:
async def _make_message(self, client, headers, cid, key, text="hi") -> dict:
resp = await client.post(
f"/v1/conversations/{cid}/messages",
json={"content": [{"type": "text", "text": text}], "start_run": False},
headers={**headers, "Idempotency-Key": key},
)
assert resp.status_code == 201, resp.text
return resp.json()["message"]
async def test_realm_switch_snapshots_into_new_messages(self, client: AsyncClient) -> None:
headers = await login(client, "realm1@example.com")
cid = await _create_conversation(client, headers)
detail = await client.get(f"/v1/conversations/{cid}", headers=headers)
assert detail.json()["realm"] == "reality" # 默认真界
sw = await client.post(
f"/v1/conversations/{cid}/realm", json={"realm": "simulation"}, headers=headers
)
assert sw.status_code == 200 and sw.json()["realm"] == "simulation"
await self._make_message(client, headers, cid, "idem-sim1")
await client.get(f"/v1/conversations/{cid}/messages", headers=headers)
# (realm 字段在消息响应中暂未透出,以归真行为间接验证)
# 回到真界
await client.post(
f"/v1/conversations/{cid}/realm", json={"realm": "reality"}, headers=headers
)
async def test_canonize_reality_message(self, client: AsyncClient) -> None:
headers = await login(client, "can1@example.com")
cid = await _create_conversation(client, headers)
msg = await self._make_message(client, headers, cid, "idem-can1")
resp = await client.post(
f"/v1/messages/{msg['id']}/canonizations",
json={"scope": "reality"},
headers=headers,
)
assert resp.status_code == 201
assert resp.json()["canonical_scope"] == "reality"
assert resp.json()["canonized_at"] is not None
async def test_canonize_idempotent_replay(self, client: AsyncClient) -> None:
headers = await login(client, "can2@example.com")
cid = await _create_conversation(client, headers)
msg = await self._make_message(client, headers, cid, "idem-can2")
first = await client.post(
f"/v1/messages/{msg['id']}/canonizations", json={"scope": "reality"}, headers=headers
)
second = await client.post(
f"/v1/messages/{msg['id']}/canonizations", json={"scope": "reality"}, headers=headers
)
assert first.status_code == 201 and second.status_code == 200
assert second.json()["canonized_at"] == first.json()["canonized_at"]
async def test_canonize_scope_mismatch_rejected(self, client: AsyncClient) -> None:
"""真界消息不能归真到境界/幻境作用域(ADR-020 作用域一致)。"""
headers = await login(client, "can3@example.com")
cid = await _create_conversation(client, headers)
msg = await self._make_message(client, headers, cid, "idem-can3")
resp = await client.post(
f"/v1/messages/{msg['id']}/canonizations", json={"scope": "world"}, headers=headers
)
assert resp.status_code == 422
async def test_simulation_message_canonizes_as_rehearsal(self, client: AsyncClient) -> None:
"""幻境消息归真仅标记试演记录(ADR-020)。"""
headers = await login(client, "can4@example.com")
cid = await _create_conversation(client, headers)
await client.post(
f"/v1/conversations/{cid}/realm", json={"realm": "simulation"}, headers=headers
)
msg = await self._make_message(client, headers, cid, "idem-can4")
resp = await client.post(
f"/v1/messages/{msg['id']}/canonizations", json={"scope": "simulation"}, headers=headers
)
assert resp.status_code == 201
async def test_canonize_cross_domain_404(self, client: AsyncClient) -> None:
alice = await login(client, "can5@example.com")
cid = await _create_conversation(client, alice)
msg = await self._make_message(client, alice, cid, "idem-can5")
await login(client, "can5-evil@example.com")
resp = await client.post(
f"/v1/messages/{msg['id']}/canonizations", json={"scope": "reality"}
)
assert resp.status_code in (401, 403, 404) # 未携带新会话 Cookie/CSRF:拒绝
@pytest.mark.usefixtures("client")
class TestRevisions:
async def test_revision_keeps_original_intact(self, client: AsyncClient) -> None:
headers = await login(client, "rev1@example.com")
cid = await _create_conversation(client, headers)
original = None
resp = await client.post(
f"/v1/conversations/{cid}/messages",
json={"content": [{"type": "text", "text": "原始内容"}], "start_run": False},
headers={**headers, "Idempotency-Key": "idem-rev1"},
)
original = resp.json()["message"]
rev = await client.post(
f"/v1/messages/{original['id']}/revisions",
json={"content": [{"type": "markdown", "text": "修订后\n```python\nprint(2)\n```"}]},
headers=headers,
)
assert rev.status_code == 201, rev.text
body = rev.json()
assert body["parent_message_id"] == original["id"]
assert body["variant_kind"] == "revision"
# markdown 修订经引擎规范化为两块
assert [b["type"] for b in body["content"]] == ["markdown", "code"]
# 原消息内容不变:列表中两条,原消息文本仍是"原始内容"
listing = await client.get(f"/v1/conversations/{cid}/messages", headers=headers)
by_id = {m["id"]: m for m in listing.json()["data"]}
assert by_id[original["id"]]["content"][0]["text"] == "原始内容"
async def test_revision_cross_domain_404(self, client: AsyncClient) -> None:
alice = await login(client, "rev2@example.com")
cid = await _create_conversation(client, alice)
resp = await client.post(
f"/v1/conversations/{cid}/messages",
json={"content": [{"type": "text", "text": "x"}], "start_run": False},
headers={**alice, "Idempotency-Key": "idem-rev2"},
)
mid = resp.json()["message"]["id"]
await login(client, "rev2-evil@example.com")
rev = await client.post(
f"/v1/messages/{mid}/revisions", json={"content": [{"type": "text", "text": "篡改"}]}
)
assert rev.status_code in (401, 403, 404)