"""镜生域 M1 测试。 对应 rewrite-plan M1 验收: - 启镜前无正式人物 ID(灵胚/试演阶段零 character 行、零 character.activated 事件); - 并发启镜只有一个赢家(原子闸门 409);命台租约冲突被拒且留痕(seat.contested); - 魂籍可验证、篡改与移植冒充均被拒(摘要+签名双校验)。 """ from __future__ import annotations import asyncio import json from collections.abc import AsyncGenerator from datetime import timedelta import httpx import pytest from httpx import ASGITransport, AsyncClient from app.db.session import AsyncSessionLocal from app.domains.jingsheng import bind_event_store from app.kernel import Kernel, KernelEventLog from app.models.character import Character from app.models.jingsheng import Custodianship, IdentityRecord, RuntimeSeat from app.services.event_store import DbEventStore from app.shenshi_client import EchoShenshi @pytest.fixture async def jingsheng_client( monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str] ) -> AsyncGenerator[AsyncClient, None]: from app.main import app event_store = DbEventStore(AsyncSessionLocal) bind_event_store(event_store) app.state.kernel = Kernel( shenshi=EchoShenshi(), session_factory=AsyncSessionLocal, event_log=KernelEventLog(event_store), timeout_seconds=10.0, ) transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test", headers=auth_headers) as c: yield c app.state.kernel = None async def _create_embryo(client: AsyncClient, name: str = "试灵") -> dict: resp = await client.post( "/api/v1/embryo-drafts", json={"source": "original", "name": name, "identity_baseline": {"锚点": "温润"}}, ) assert resp.status_code == 201 return resp.json() async def _characters_count() -> int: from sqlalchemy import func, select async with AsyncSessionLocal() as session: return int(await session.scalar(select(func.count(Character.id)))) async def _events_of_type(event_type: str) -> list[dict]: store = DbEventStore(AsyncSessionLocal) all_events = [] async with AsyncSessionLocal() as session: from sqlalchemy import select from app.models.runtime import Event rows = ( (await session.execute(select(Event).where(Event.type == event_type))).scalars().all() ) all_events = [json.loads(r.payload_json) for r in rows] _ = store return all_events async def _wait_run(client: AsyncClient, run_id: str) -> str: for _ in range(300): resp = await client.get(f"/api/v1/runs/{run_id}") status = resp.json()["status"] if status in ("completed", "failed", "interrupted", "cancelled"): return status await asyncio.sleep(0.02) raise AssertionError("Run 未到达终态") # ── 启镜前无正式人物 ────────────────────────────────────────────── async def test_embryo_and_rehearsal_create_no_character(jingsheng_client: AsyncClient) -> None: before = await _characters_count() embryo = await _create_embryo(jingsheng_client) patched = await jingsheng_client.patch( f"/api/v1/embryo-drafts/{embryo['id']}", json={"description": "照心共塑"} ) assert patched.status_code == 200 rehearsed = await jingsheng_client.post( f"/api/v1/embryo-drafts/{embryo['id']}/rehearse", json={"prompt": "你是谁?"} ) assert rehearsed.status_code == 200 run_id = rehearsed.json()["run_id"] assert await _wait_run(jingsheng_client, run_id) == "completed" run_detail = (await jingsheng_client.get(f"/api/v1/runs/{run_id}")).json() assert run_detail["kind"] == "rehearsal" embryo_now = (await jingsheng_client.get(f"/api/v1/embryo-drafts/{embryo['id']}")).json() assert embryo_now["status"] == "previewing" assert embryo_now["rehearsals"][0]["run_id"] == run_id # 验收:启镜前零人物行、零 character.activated 事件 assert await _characters_count() == before assert await _events_of_type("character.activated") == [] # ── 启镜与并发防护 ────────────────────────────────────────────── async def test_activate_issues_identity_and_guards(jingsheng_client: AsyncClient) -> None: embryo = await _create_embryo(jingsheng_client, name="镜心") activated = await jingsheng_client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate") assert activated.status_code == 200 character_id = activated.json()["character_id"] assert activated.json()["source"] == "original" async with AsyncSessionLocal() as session: from sqlalchemy import select character = await session.get(Character, character_id) assert character is not None and character.status == "active" assert character.identity_baseline_json == json.dumps({"锚点": "温润"}, ensure_ascii=False) cust = ( await session.execute( select(Custodianship).where(Custodianship.character_id == character_id) ) ).scalar_one() assert cust.custodian_scope == character.owner_scope seat = ( await session.execute(select(RuntimeSeat).where(RuntimeSeat.character_id == character_id)) ).scalar_one() assert seat.epoch == 1 and seat.holder is None record = ( await session.execute( select(IdentityRecord).where(IdentityRecord.character_id == character_id) ) ).scalar_one() assert record.version == 1 events = await _events_of_type("character.activated") assert any(e.get("embryo_id") == embryo["id"] for e in events) # 二次启镜被拒 again = await jingsheng_client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate") assert again.status_code == 409 assert "HJ-1103" in again.json()["error"]["code"] async def test_concurrent_activation_single_winner(jingsheng_client: AsyncClient) -> None: embryo = await _create_embryo(jingsheng_client, name="并发灵") results = await asyncio.gather( jingsheng_client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate"), jingsheng_client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate"), ) statuses = sorted(r.status_code for r in results) assert statuses == [200, 409] winner = next(r for r in results if r.status_code == 200) loser = next(r for r in results if r.status_code == 409) assert winner.json()["character_id"] assert "HJ-1103" in loser.json()["error"]["code"] # ── 魂籍:验证、篡改与冒充 ────────────────────────────────────────────── async def test_identity_record_verify_and_tamper(jingsheng_client: AsyncClient) -> None: embryo = await _create_embryo(jingsheng_client, name="魂籍灵") character_id = ( (await jingsheng_client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate")).json()[ "character_id" ] ) exported = await jingsheng_client.post(f"/api/v1/characters/{character_id}/identity-records") assert exported.status_code == 200 record_id = exported.json()["id"] ok = await jingsheng_client.post( f"/api/v1/characters/{character_id}/identity-records/{record_id}/verify" ) assert ok.status_code == 200 and ok.json()["valid"] is True # 篡改 payload:摘要不匹配 async with AsyncSessionLocal() as session: record = await session.get(IdentityRecord, record_id) payload = json.loads(record.payload_json) payload["identity"]["name"] = "冒充者" record.payload_json = json.dumps(payload, ensure_ascii=False) await session.commit() tampered = await jingsheng_client.post( f"/api/v1/characters/{character_id}/identity-records/{record_id}/verify" ) assert tampered.status_code == 422 # 移植冒充:把整条记录复制到另一人物名下,签名不匹配 other = await _create_embryo(jingsheng_client, name="另一灵") other_character = ( (await jingsheng_client.post(f"/api/v1/embryo-drafts/{other['id']}/activate")).json()[ "character_id" ] ) async with AsyncSessionLocal() as session: original = await session.get(IdentityRecord, record_id) session.add( IdentityRecord( character_id=other_character, version=9, # 避开另一人物已有的 v1;冒充场景本身与版本号无关 payload_json=original.payload_json, digest=original.digest, signature=original.signature, # 原样复制——文件复制不能冒充 ) ) await session.commit() async with AsyncSessionLocal() as session: from sqlalchemy import select forged = ( await session.execute( select(IdentityRecord).where( IdentityRecord.character_id == other_character, IdentityRecord.version == 9, ) ) ).scalar_one() forged_id = forged.id forged_check = await jingsheng_client.post( f"/api/v1/characters/{other_character}/identity-records/{forged_id}/verify" ) assert forged_check.status_code == 422 # ── 命台租约:冲突留痕、过期接管 ────────────────────────────────────────────── async def _activated_character(client: AsyncClient, name: str) -> str: embryo = await _create_embryo(client, name=name) return ( (await client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate")).json()["character_id"] ) async def test_seat_lease_conflict_and_takeover(jingsheng_client: AsyncClient) -> None: character_id = await _activated_character(jingsheng_client, "命台灵") first = await jingsheng_client.post( f"/api/v1/characters/{character_id}/seat/acquire", json={"holder": "device-A"} ) assert first.status_code == 200 and first.json()["epoch"] == 1 conflict = await jingsheng_client.post( f"/api/v1/characters/{character_id}/seat/acquire", json={"holder": "device-B"} ) assert conflict.status_code == 429 assert conflict.json()["error"]["code"] == "HJ-1101" # 验收:抢占被拒且留痕 async with AsyncSessionLocal() as session: from sqlalchemy import select from app.models.runtime import Event rows = ( await session.execute(select(Event).where(Event.type == "seat.contested")) ).scalars().all() assert any(json.loads(r.payload_json).get("requester") == "device-B" for r in rows) # 同持有者续期:不冲突 renew = await jingsheng_client.post( f"/api/v1/characters/{character_id}/seat/acquire", json={"holder": "device-A"} ) assert renew.status_code == 200 # 租约过期后接管:epoch 递增 async with AsyncSessionLocal() as session: seat = ( await session.execute(select(RuntimeSeat).where(RuntimeSeat.character_id == character_id)) ).scalar_one() seat.lease_expires_at = seat.lease_expires_at - timedelta(seconds=9999) await session.commit() takeover = await jingsheng_client.post( f"/api/v1/characters/{character_id}/seat/acquire", json={"holder": "device-B"} ) assert takeover.status_code == 200 and takeover.json()["epoch"] == 2 # ── 死亡:冷静期、撤销与落定 ────────────────────────────────────────────── async def test_death_calm_period_flow(jingsheng_client: AsyncClient) -> None: character_id = await _activated_character(jingsheng_client, "将别灵") requested = await jingsheng_client.post(f"/api/v1/characters/{character_id}/death-request") assert requested.status_code == 200 assert requested.json()["status"] == "death_pending" early = await jingsheng_client.post( f"/api/v1/characters/{character_id}/death-finalize", json={"confirm_phrase": "将别灵"} ) assert early.status_code == 422 # 冷静期未满 async with AsyncSessionLocal() as session: character = await session.get(Character, character_id) character.calm_period_until = character.calm_period_until - timedelta(hours=100) await session.commit() wrong_phrase = await jingsheng_client.post( f"/api/v1/characters/{character_id}/death-finalize", json={"confirm_phrase": "错误名"} ) assert wrong_phrase.status_code == 400 finalized = await jingsheng_client.post( f"/api/v1/characters/{character_id}/death-finalize", json={"confirm_phrase": "将别灵"} ) assert finalized.status_code == 200 assert finalized.json()["status"] == "deceased" # 死亡是身份闭合,不是数据删除:行仍在 async with AsyncSessionLocal() as session: character = await session.get(Character, character_id) assert character is not None and character.status == "deceased" assert character.died_at is not None events = await _events_of_type("character.deceased") assert events async def test_death_cancel_restores_active(jingsheng_client: AsyncClient) -> None: character_id = await _activated_character(jingsheng_client, "回心灵") await jingsheng_client.post(f"/api/v1/characters/{character_id}/death-request") cancelled = await jingsheng_client.post(f"/api/v1/characters/{character_id}/death-cancel") assert cancelled.status_code == 200 and cancelled.json()["status"] == "active" events = await _events_of_type("character.death_cancelled") assert events