"""垂直切片测试:幂等原子事务、IDOR fail-closed、CSRF、SSE 与回滚。""" import contextlib 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 _wait_run_terminal(db, run_id: str, deadline_s: float = 10.0) -> str: """轮询等待 Run 终态(后台任务推进)。""" import asyncio from app.models import Run as RunModel deadline = asyncio.get_event_loop().time() + deadline_s while asyncio.get_event_loop().time() < deadline: async with db() as session: run = await session.get(RunModel, run_id) if run is not None and run.status in {"completed", "failed", "cancelled"}: return run.status await asyncio.sleep(0.05) raise AssertionError(f"Run {run_id} 未在 {deadline_s}s 内到达终态") 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, db) -> 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"] == "queued" # 创建即返回;终态由后台执行推进 final_status = await _wait_run_terminal(db, body["run"]["id"]) assert final_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, db) -> 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"] await _wait_run_terminal(db, 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"] == "running" assert events[-1]["type"] == "run.status.changed" assert events[-1]["data"]["status"] == "completed" types = [e["type"] for e in events] assert "content.block.started" in types assert "content.block.delta" in types # 真实流产生 assert "message.completed" in types 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, db ) -> 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"] await _wait_run_terminal(db, 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"] == "run.status.changed" # delta 序号单调(契约 (block_id, delta_index) 去重基础) deltas = [e for e in events if e["type"] == "content.block.delta"] assert [d["data"]["delta_index"] for d in deltas] == list(range(len(deltas))) 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) @pytest.mark.usefixtures("client") class TestCharacters: async def _create(self, client, headers, body) -> dict: resp = await client.post("/v1/characters", json=body, headers=headers) assert resp.status_code == 201, resp.text return resp.json() async def test_manual_create_activate_and_first_statement(self, client: AsyncClient) -> None: headers = await login(client, "char1@example.com") c = await self._create( client, headers, {"name": "昭星", "identity_baseline": {"core_traits": ["沉静"]}} ) assert c["status"] == "draft" and c["origin"] == "original" act = await client.post(f"/v1/characters/{c['id']}/activations", headers=headers) assert act.status_code == 201 and act.json()["status"] == "active" assert act.json()["current_version_id"] is not None fs = await client.post( f"/v1/characters/{c['id']}/first-statement", json={"text": "初次见面,我是昭星。"}, headers=headers, ) assert fs.status_code == 201 and fs.json()["status"] == "pending" confirm = await client.post( f"/v1/characters/{c['id']}/first-statement/confirmations", json={"version_id": fs.json()["id"]}, headers=headers, ) assert confirm.status_code == 201 and confirm.json()["status"] == "approved" detail = await client.get(f"/v1/characters/{c['id']}", headers=headers) assert detail.json()["identity_baseline"]["first_statement"] == "初次见面,我是昭星。" async def test_serendipity_is_deterministic_single(self, client: AsyncClient) -> None: """随缘:同种子同候选(可复现),一次一个,不是抽卡。""" headers = await login(client, "char2@example.com") a = await self._create(client, headers, {"origin": "serendipity", "seed": 3}) b = await self._create(client, headers, {"origin": "serendipity", "seed": 3}) assert a["name"] == b["name"] and a["origin"] == "serendipity" assert a["drives"] and a["drives"][0]["name"] async def test_real_person_governance(self, client: AsyncClient) -> None: headers = await login(client, "char3@example.com") # 已故现实人物:拒绝 dead = await client.post( "/v1/characters", json={"name": "某人", "based_on_real_person": True, "deceased_real_person": True, "real_person_attested": True}, headers=headers, ) assert dead.status_code == 422 # 未声明授权:拒绝 no_attest = await client.post( "/v1/characters", json={"name": "某人", "based_on_real_person": True}, headers=headers ) assert no_attest.status_code == 422 # 本人声明:允许 ok = await self._create( client, headers, {"name": "我自己", "based_on_real_person": True, "real_person_attested": True}, ) assert ok["based_on_real_person"] is True async def test_lifecycle_full_path(self, client: AsyncClient) -> None: headers = await login(client, "char4@example.com") c = await self._create(client, headers, {"name": "汀棠"}) await client.post(f"/v1/characters/{c['id']}/activations", headers=headers) # 静修 → 归藏 → 复照 rest = await client.post(f"/v1/characters/{c['id']}/rests", headers=headers) assert rest.json()["status"] == "resting" arch = await client.post(f"/v1/characters/{c['id']}/archives", headers=headers) assert arch.json()["status"] == "archived" revive = await client.post(f"/v1/characters/{c['id']}/revivals", headers=headers) assert revive.json()["status"] == "active" # 死亡:请求 → 撤销(复照)→ 再请求 → 未满冷静期确认被拒 req = await client.post(f"/v1/characters/{c['id']}/death-requests", headers=headers) assert req.json()["status"] == "death_pending" cancel = await client.post(f"/v1/characters/{c['id']}/death-cancellations", headers=headers) assert cancel.json()["status"] == "active" await client.post(f"/v1/characters/{c['id']}/death-requests", headers=headers) early = await client.post(f"/v1/characters/{c['id']}/death-confirmations", headers=headers) assert early.status_code == 422 # 冷静期未满 async def test_invalid_transition_rejected(self, client: AsyncClient) -> None: headers = await login(client, "char5@example.com") c = await self._create(client, headers, {"name": "砚谷"}) # 灵胚不能直接归藏 resp = await client.post(f"/v1/characters/{c['id']}/archives", headers=headers) assert resp.status_code == 422 async def test_reforging_requires_confirm(self, client: AsyncClient) -> None: headers = await login(client, "char6@example.com") c = await self._create(client, headers, {"name": "朔川"}) await client.post(f"/v1/characters/{c['id']}/activations", headers=headers) no = await client.post( f"/v1/characters/{c['id']}/reforgings", json={"identity_baseline": {"core_traits": ["焕新"]}}, headers=headers, ) assert no.status_code == 422 yes = await client.post( f"/v1/characters/{c['id']}/reforgings", json={"confirm": True, "identity_baseline": {"core_traits": ["焕新"]}}, headers=headers, ) assert yes.status_code == 201 and yes.json()["kind"] == "reforging" detail = await client.get(f"/v1/characters/{c['id']}", headers=headers) assert detail.json()["identity_baseline"]["core_traits"] == ["焕新"] async def test_drives_update_and_persona_review(self, client: AsyncClient) -> None: headers = await login(client, "char7@example.com") c = await self._create(client, headers, {"name": "霁灯"}) await client.post(f"/v1/characters/{c['id']}/activations", headers=headers) upd = await client.patch( f"/v1/characters/{c['id']}/drives", json={"drives": [{"name": "求知", "description": "想弄明白一切", "intensity": 3}]}, headers=headers, ) assert upd.status_code == 200 and upd.json()["drives"][0]["name"] == "求知" # 人格候选:pending → 批准后并入道体 cand = await client.post( f"/v1/characters/{c['id']}/persona-candidates", json={"identity_baseline": {"core_traits": ["温柔"]}}, headers=headers, ) assert cand.status_code == 201 and cand.json()["status"] == "pending" approve = await client.post( f"/v1/characters/{c['id']}/versions/{cand.json()['id']}/decisions", json={"approve": True}, headers=headers, ) assert approve.json()["status"] == "approved" detail = await client.get(f"/v1/characters/{c['id']}", headers=headers) assert detail.json()["identity_baseline"]["core_traits"] == ["温柔"] # 已审核版本重复审核:409 again = await client.post( f"/v1/characters/{c['id']}/versions/{cand.json()['id']}/decisions", json={"approve": False}, headers=headers, ) assert again.status_code == 409 async def test_cross_domain_character_404(self, client: AsyncClient) -> None: alice = await login(client, "char8@example.com") c = await self._create(client, alice, {"name": "澜川"}) await login(client, "char8-evil@example.com") resp = await client.get(f"/v1/characters/{c['id']}") assert resp.status_code in (401, 403, 404) @pytest.mark.usefixtures("client") class TestGatewayExecution: async def test_model_call_usage_and_assistant_message(self, client, db) -> None: """端到端:mock 流 → ModelCall usage/TTFT + assistant 权威消息。""" from sqlalchemy import select as sa from app.models import Message as M from app.models import ModelCall headers = await login(client, "gw1@example.com") cid = await _create_conversation(client, headers) resp = await _post_message(client, headers, cid, "idem-gw1") run_id = resp.json()["run"]["id"] assert await _wait_run_terminal(db, run_id) == "completed" async with db() as session: calls = list((await session.execute(sa(ModelCall))).scalars().all()) assert len(calls) == 1 assert calls[0].status == "succeeded" assert calls[0].usage == { "prompt_tokens": 12, "completion_tokens": 6, "total_tokens": 18, } assert calls[0].ttft_ms is not None asst = list( ( await session.execute( sa(M).where(M.run_id == run_id, M.role == "assistant") ) ).scalars().all() ) assert len(asst) == 1 assert asst[0].content[0]["type"] == "text" assert asst[0].content[0]["status"] == "completed" async def test_no_endpoint_fails_closed(self, client, db) -> None: """无可用端点:Run 显式失败并留 error 事件,不静默降级(ADR-016)。""" from sqlalchemy import update from app.models import ProviderEndpoint async with db() as session: await session.execute(update(ProviderEndpoint).values(enabled=False)) await session.commit() try: headers = await login(client, "gw2@example.com") cid = await _create_conversation(client, headers) resp = await _post_message(client, headers, cid, "idem-gw2") run_id = resp.json()["run"]["id"] assert await _wait_run_terminal(db, run_id) == "failed" finally: async with db() as session: await session.execute(update(ProviderEndpoint).values(enabled=True)) await session.commit() from sqlalchemy import select as sa_select from app.models import RunEvent as RunEventModel async with db() as session: evs = list( ( await session.execute( sa_select(RunEventModel) .where(RunEventModel.run_id == run_id) .order_by(RunEventModel.sequence) ) ).scalars().all() ) types = [e.type for e in evs] assert "content.block.failed" in types failed = next(e for e in evs if e.type == "content.block.failed") assert failed.data["error"] == "no_endpoint_available" @contextlib.asynccontextmanager async def _test_db(): from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from tests.conftest import TEST_DATABASE_URL engine = create_async_engine(TEST_DATABASE_URL) factory = async_sessionmaker(engine, expire_on_commit=False) try: yield factory finally: await engine.dispose() @pytest.mark.usefixtures("client") class TestMemories: async def _submit(self, client, headers, body) -> dict: resp = await client.post("/v1/memories/candidates", json=body, headers=headers) assert resp.status_code == 201, resp.text return resp.json() async def test_manual_candidate_review_flow(self, client: AsyncClient) -> None: headers = await login(client, "mem1@example.com") c = await self._submit(client, headers, { "kind": "relationship", "sensitivity": "high", "payload": {"text": "用户偏好深夜交谈", "note": "高影响关系结论"}, }) assert c["status"] == "pending" # 敏感默认人工 approve = await client.post( f"/v1/memories/candidates/{c['id']}/decisions", json={"approve": True}, headers=headers, ) assert approve.status_code == 201 assert approve.json()["status"] == "approved" assert approve.json()["created_memory_id"] is not None async def test_whitelist_auto_approve_only_low_sensitivity_fact(self, client) -> None: headers = await login(client, "mem2@example.com") auto = await self._submit(client, headers, { "kind": "fact", "sensitivity": "low", "source": "auto", "payload": {"text": "用户住在杭州"}, }) assert auto["status"] == "approved" # 白名单自动写入 assert auto["created_memory_id"] is not None # 高敏感自动候选不自动批准 sensitive = await self._submit(client, headers, { "kind": "fact", "sensitivity": "high", "source": "auto", "payload": {"text": "敏感事实"}, }) assert sensitive["status"] == "pending" # 非 fact 也不自动 pref = await self._submit(client, headers, { "kind": "preference", "sensitivity": "low", "source": "auto", "payload": {"text": "喜欢猫"}, }) assert pref["status"] == "pending" async def test_correction_with_etag_and_conflict(self, client) -> None: headers = await login(client, "mem3@example.com") c = await self._submit(client, headers, { "kind": "fact", "sensitivity": "low", "source": "auto", "payload": {"text": "原记录"}, }) mid = c["created_memory_id"] # 缺 If-Match:412 no_header = await client.patch( f"/v1/memories/{mid}", json={"payload": {"text": "纠正"}}, headers=headers ) assert no_header.status_code == 412 # 错误版本:412 stale = await client.patch( f"/v1/memories/{mid}", json={"payload": {"text": "纠正"}}, headers={**headers, "If-Match": '"99"'}, ) assert stale.status_code == 412 # 正确 ETag → v2 ok = await client.patch( f"/v1/memories/{mid}", json={"payload": {"text": "纠正后"}}, headers={**headers, "If-Match": '"1"'}, ) assert ok.status_code == 200 body = ok.json() assert body["version"] == 2 and body["payload"]["text"] == "纠正后" assert body["resource_version"] == 2 async def test_search_filters_and_isolation(self, client) -> None: headers = await login(client, "mem4@example.com") for text in ("杭州西湖", "喜欢跑步", "工作在软件园"): await self._submit(client, headers, { "kind": "fact", "sensitivity": "low", "source": "auto", "payload": {"text": text}, }) hits = await client.get("/v1/memories?q=西湖", headers=headers) data = hits.json()["data"] assert len(data) == 1 and "西湖" in data[0]["payload"]["text"] assert data[0]["recall"]["channel"] == "text" # 召回证据 # 跨域隔离 other = await login(client, "mem4-evil@example.com") leak = await client.get("/v1/memories?q=西湖", headers=other) assert leak.json()["data"] == [] async def test_delete_purges_content_and_blocks_recall(self, client) -> None: from sqlalchemy import select as sa_select from app.models import MemoryVersion as MV headers = await login(client, "mem5@example.com") c = await self._submit(client, headers, { "kind": "fact", "sensitivity": "low", "source": "auto", "payload": {"text": "将被删除的秘密"}, }) mid = c["created_memory_id"] resp = await client.delete(f"/v1/memories/{mid}", headers=headers) assert resp.status_code == 204 # 不再召回 after = await client.get("/v1/memories?q=秘密", headers=headers) assert after.json()["data"] == [] # 墓碑:版本原文已清除(契约:墓碑不存被删原文) async with _test_db() as factory: async with factory() as session: versions = list( ( await session.execute(sa_select(MV).where(MV.memory_id == mid)) ).scalars().all() ) assert versions and all(v.payload == {} for v in versions) @pytest.mark.usefixtures("client") class TestBigCycle: async def test_manual_trigger_produces_pending_candidate(self, client, db) -> None: from app.models import BigCycle, CharacterVersion headers = await login(client, "bc1@example.com") c = await client.post("/v1/characters", json={"name": "砚舟"}, headers=headers) cid = c.json()["id"] await client.post(f"/v1/characters/{cid}/activations", headers=headers) # 先有一条对话消息供回顾 conv = await _create_conversation(client, headers) await client.post( f"/v1/conversations/{conv}/messages", json={"content": [{"type": "text", "text": "最近在想什么呢"}], "start_run": False}, headers={**headers, "Idempotency-Key": "idem-bc1"}, ) trigger = await client.post(f"/v1/characters/{cid}/big-cycles", headers=headers) assert trigger.status_code == 201, trigger.text cycle_id = trigger.json()["id"] # 轮询等待 cycle 终态 import asyncio deadline = asyncio.get_event_loop().time() + 10 cycle = None while asyncio.get_event_loop().time() < deadline: async with db() as session: cycle = await session.get(BigCycle, cycle_id) if cycle and cycle.status in {"completed", "failed"}: break await asyncio.sleep(0.05) assert cycle is not None and cycle.status == "completed", ( cycle.status if cycle else "missing" ) assert cycle.produced_version_ids async with db() as session: version = await session.get(CharacterVersion, cycle.produced_version_ids[0]) assert version is not None assert version.kind == "persona_candidate" assert version.status == "pending" # 候选先于写入 assert version.payload["source"] == "big_cycle" assert version.payload["proposal"] # 模型真实输出