345 lines
14 KiB
Python
345 lines
14 KiB
Python
"""镜生域 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
|