test_channel_inbox.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. import asyncio
  2. import hashlib
  3. import hmac
  4. import json
  5. import os
  6. import secrets
  7. from datetime import UTC, datetime, timedelta
  8. from uuid import UUID, uuid4
  9. import pytest
  10. import pytest_asyncio
  11. from langchain_core.messages import AIMessage, message_to_dict
  12. from sqlalchemy import func, select
  13. from test_after_sales import make_proposal, scenario # noqa: F401
  14. from test_platform_management import receiver # noqa: F401
  15. from test_storefront import challenge, prepare_channel, proof
  16. from zhifu.modules.after_sales.models import Approval, BusinessEvent
  17. from zhifu.modules.agent_runtime.models import AgentRun
  18. from zhifu.modules.agent_runtime.runtime import build_tool_graph
  19. from zhifu.modules.agent_runtime.worker import claim
  20. from zhifu.modules.channels.models import ChannelInbox, ChannelKey
  21. from zhifu.modules.conversations.models import Conversation
  22. from zhifu.modules.identity.access_models import StoreGrant
  23. from zhifu.modules.merchant.models import ChannelApplication
  24. from zhifu.modules.platform.delivery import enqueue, execute_one
  25. from zhifu.modules.platform.models import ChannelDelivery
  26. from zhifu.operations.channel_keys import KeyConfiguration, configure
  27. pytestmark = pytest.mark.integration
  28. @pytest_asyncio.fixture
  29. async def channel(scenario): # noqa: F811
  30. await prepare_channel(scenario)
  31. call = scenario["call"]
  32. item = await challenge(scenario)
  33. path = f"/openapi/v1/channel/identity-challenges/{item['challenge_id']}/verify"
  34. assert (await call("POST", path, "connector", proof(scenario))).status_code == 200
  35. confirm_path = f"/openapi/v1/me/identity-challenges/{item['challenge_id']}/confirm"
  36. assert (await call("POST", confirm_path)).status_code == 200
  37. sessions = scenario["app"].state.sessions
  38. async with sessions.begin() as session:
  39. row = await session.get(
  40. ChannelApplication, (scenario["tenant"].bytes, scenario["store"].bytes))
  41. row.scopes = ["conversations:messages", "customers:actions", "tickets:results"]
  42. env = "CHANNEL_TEST_" + uuid4().hex.upper()
  43. secret = secrets.token_urlsafe(32)
  44. os.environ[env] = secret
  45. config = KeyConfiguration(key_id="key-1", credential_env=env,
  46. valid_from=datetime.now(UTC) - timedelta(minutes=1),
  47. valid_until=datetime.now(UTC) + timedelta(hours=1))
  48. await configure(sessions, scenario["tenant"].bytes, scenario["store"].bytes, config)
  49. endpoint = f"/channel/v1/bindings/{scenario['store']}/messages"
  50. async def send(body, *, nonce=None, stamp=None, key_id="key-1", signing_secret=None):
  51. content = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode()
  52. stamp = int(datetime.now(UTC).timestamp()) if stamp is None else stamp
  53. nonce = nonce or uuid4().hex
  54. signed = b"\n".join([str(stamp).encode(), nonce.encode(), b"POST", endpoint.encode(),
  55. hashlib.sha256(content).hexdigest().encode()])
  56. signature = hmac.new(
  57. (signing_secret or secret).encode(), signed, hashlib.sha256).hexdigest()
  58. return await scenario["client"].post(endpoint, content=content, headers={
  59. "Content-Type": "application/json", "X-Key-Id": key_id,
  60. "X-Timestamp": str(stamp),
  61. "X-Nonce": nonce, "X-Signature": signature})
  62. try:
  63. yield {**scenario, "send": send, "config": config, "secret": secret}
  64. finally:
  65. os.environ.pop(env)
  66. def envelope(kind="message", payload=None):
  67. return {"external_message_id": uuid4().hex, "external_user_id": "verified-channel-account",
  68. "external_conversation_id": "conversation-1",
  69. "occurred_at": datetime.now(UTC).isoformat(),
  70. "type": kind, "payload": payload or {"text": "请查询我的订单"}}
  71. async def test_signed_message_concurrent_dedup_and_channel_history_isolation(channel):
  72. body = envelope()
  73. results = await asyncio.gather(*[channel["send"](body) for _ in range(4)])
  74. assert all(response.status_code == 202 for response in results), [r.text for r in results]
  75. assert len({response.json()["inbox_id"] for response in results}) == 1
  76. result = results[0].json()
  77. different = await channel["send"]({**body, "payload": {"text": "修改消息正文"}})
  78. assert different.status_code == 409 and different.json()["code"] == "CHANNEL_MESSAGE_CONFLICT"
  79. h5 = await channel["call"]("GET", "/openapi/v1/conversations?store_id=" + str(channel["store"]))
  80. assert not h5.json()["items"]
  81. history_path = f"/openapi/v1/conversations/{result['conversation_id']}"
  82. assert (await channel["call"]("GET", history_path)).status_code == 404
  83. async with channel["app"].state.sessions() as session:
  84. for model in (ChannelInbox, AgentRun, Conversation):
  85. assert await session.scalar(select(func.count()).select_from(model).where(
  86. model.tenant_id == channel["tenant"].bytes)) == 1
  87. async def test_signature_replay_mapping_and_disabled_key(channel):
  88. body = envelope()
  89. invalid = await channel["send"](body, signing_secret="x" * 32)
  90. assert invalid.json()["code"] == "CHANNEL_SIGNATURE_INVALID"
  91. assert (await channel["send"](body, stamp=1)).json()["code"] == "CHANNEL_TIMESTAMP_EXPIRED"
  92. assert (await channel["send"](body, key_id="unknown")).json()["code"] == "CHANNEL_KEY_DISABLED"
  93. assert (await channel["send"]({**body, "external_user_id": "unlinked"})).status_code == 404
  94. assert (await channel["send"]({**body, "tenant_id": str(uuid4())})).status_code == 422
  95. nonce = uuid4().hex
  96. assert (await channel["send"](body, nonce=nonce)).status_code == 202
  97. assert (await channel["send"](body, nonce=nonce)).json()["code"] == "CHANNEL_REPLAY"
  98. sessions = channel["app"].state.sessions
  99. rotated = channel["config"].model_copy(update={"key_id": "key-2"})
  100. await configure(sessions, channel["tenant"].bytes, channel["store"].bytes, rotated)
  101. assert (await channel["send"](body, key_id="key-2")).status_code == 202
  102. await configure(sessions, channel["tenant"].bytes, channel["store"].bytes,
  103. channel["config"], enabled=False)
  104. assert (await channel["send"](body)).json()["code"] == "CHANNEL_KEY_DISABLED"
  105. async with sessions.begin() as session:
  106. row = await session.get(
  107. ChannelKey, (channel["tenant"].bytes, channel["store"].bytes, "key-2"))
  108. row.valid_until = datetime.now(UTC).replace(tzinfo=None) - timedelta(seconds=1)
  109. assert (await channel["send"](body, key_id="key-2")).status_code == 401
  110. async def test_explicit_channel_confirmation_uses_real_business_service(channel):
  111. response = await channel["send"](envelope(payload={"text": "确认及批准换货"}))
  112. assert response.status_code == 202, response.text
  113. async with channel["app"].state.sessions() as session:
  114. assert await session.scalar(select(func.count()).select_from(Approval).where(
  115. Approval.tenant_id == channel["tenant"].bytes)) == 0
  116. proposal = await make_proposal(channel)
  117. body = envelope("proposal_confirmation", {
  118. "proposal_id": proposal["proposal_id"], "proposal_version": proposal["proposal_version"],
  119. "expected_ticket_version": proposal["version"],
  120. })
  121. responses = await asyncio.gather(*[channel["send"](body) for _ in range(3)])
  122. assert all(item.status_code == 202 for item in responses), [r.text for r in responses]
  123. assert responses[0].json()["result"]["status"] == "WAITING_REVIEW"
  124. async with channel["app"].state.sessions() as session:
  125. assert await session.scalar(select(func.count()).select_from(Approval).where(
  126. Approval.tenant_id == channel["tenant"].bytes)) == 1
  127. invalid = await channel["send"]({**body, "payload": {**body["payload"], "approved": True}})
  128. assert invalid.status_code == 422
  129. async with channel["app"].state.sessions.begin() as session:
  130. app = await session.get(
  131. ChannelApplication, (channel["tenant"].bytes, channel["store"].bytes))
  132. app.enabled = False
  133. assert (await channel["send"](body)).json()["code"] == "CHANNEL_UNAVAILABLE"
  134. async def pause_channel(channel):
  135. response = await channel["send"](envelope())
  136. assert response.status_code == 202, response.text
  137. conversation_id = UUID(response.json()["conversation_id"])
  138. sessions = channel["app"].state.sessions
  139. async with sessions.begin() as session:
  140. conversation = await session.get(Conversation, conversation_id.bytes)
  141. run = await session.get(AgentRun, conversation.active_run_id)
  142. # 直接提交工具命令,验证渠道身份与真实图节点,生成模型调用次数为0。
  143. run.messages = run.messages + [message_to_dict(AIMessage(content="", tool_calls=[{
  144. "name": "request_clarification", "id": uuid4().hex,
  145. "args": {"question": "请补充换货原因", "missing_fields": ["reason"]},
  146. "type": "tool_call",
  147. }]))]
  148. run.next_node = "tools"
  149. run_id, token = await claim(sessions, 2)
  150. result = await build_tool_graph(sessions).ainvoke({"run_id": run_id, "token": token})
  151. assert result["next_node"] == "wait"
  152. async with sessions() as session:
  153. run = await session.get(AgentRun, run_id)
  154. assert run.status == "WAITING_USER" and run.model_calls == 0
  155. return conversation_id, run_id, run.waiting
  156. async def test_channel_clarification_resume_and_revoked_scope(channel):
  157. _, run_id, waiting = await pause_channel(channel)
  158. body = envelope("clarification", {
  159. "interrupt_id": waiting["interrupt_id"],
  160. "expected_run_version": waiting["run_version"], "answer": "按键失灵",
  161. })
  162. sessions = channel["app"].state.sessions
  163. async with sessions.begin() as session:
  164. app = await session.get(ChannelApplication,
  165. (channel["tenant"].bytes, channel["store"].bytes))
  166. app.scopes = ["conversations:messages"]
  167. assert (await channel["send"](body)).status_code == 403
  168. async with sessions.begin() as session:
  169. app = await session.get(ChannelApplication,
  170. (channel["tenant"].bytes, channel["store"].bytes))
  171. app.scopes = ["conversations:messages", "customers:actions"]
  172. responses = await asyncio.gather(*[channel["send"](body) for _ in range(3)])
  173. assert all(response.status_code == 202 for response in responses), [r.text for r in responses]
  174. assert len({response.json()["inbox_id"] for response in responses}) == 1
  175. assert responses[0].json()["result"]["run_id"] == str(UUID(bytes=run_id))
  176. async with sessions() as session:
  177. run = await session.get(AgentRun, run_id)
  178. assert run.status == "QUEUED" and run.waiting is None and run.model_calls == 0
  179. async def test_channel_conversation_tls_order_and_takeover(channel, receiver): # noqa: F811
  180. conversation_id, _, _ = await pause_channel(channel)
  181. sessions = channel["app"].state.sessions
  182. async with sessions.begin() as session:
  183. app = await session.get(ChannelApplication,
  184. (channel["tenant"].bytes, channel["store"].bytes))
  185. app.callback_url, app.credential_env = receiver["url"], receiver["credential_env"]
  186. session.add(StoreGrant(
  187. tenant_id=channel["tenant"].bytes, principal_id=channel["accounts"]["agent"].bytes,
  188. store_id=channel["store"].bytes, permission="conversations:manage",
  189. ))
  190. assert await enqueue(sessions, tenant_id=channel["tenant"].bytes) == 2
  191. async with sessions() as session:
  192. deliveries = (await session.scalars(select(ChannelDelivery).join(
  193. BusinessEvent, BusinessEvent.id == ChannelDelivery.event_id
  194. ).where(
  195. ChannelDelivery.tenant_id == channel["tenant"].bytes
  196. ).order_by(BusinessEvent.aggregate_version))).all()
  197. first, second = deliveries
  198. assert await execute_one(sessions, second.id) is False
  199. receiver["accepted"].set()
  200. assert await execute_one(sessions, first.id) is True
  201. assert len(receiver["received"]) == 1
  202. received = receiver["received"][0]
  203. assert received["event_type"] == "conversation.message.assistant"
  204. assert received["payload"]["external_subject"] == "verified-channel-account"
  205. assert received["payload"]["event"]["payload"]["text"] == "请补充换货原因"
  206. path = f"/openapi/v1/conversations/{conversation_id}/takeover"
  207. response = await channel["call"]("POST", path, "agent", {"expected_control_version": 1})
  208. assert response.status_code == 200, response.text
  209. assert await execute_one(sessions, second.id) is True
  210. async with sessions() as session:
  211. stale = await session.get(ChannelDelivery, second.id)
  212. assert stale.status == "FAILED" and stale.last_error == "CONVERSATION_CONTROL_LOST"
  213. assert stale.attempts == 0
  214. assert len(receiver["received"]) == 1