| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234 |
- import asyncio
- import hashlib
- import hmac
- import json
- import os
- import secrets
- from datetime import UTC, datetime, timedelta
- from uuid import UUID, uuid4
- import pytest
- import pytest_asyncio
- from langchain_core.messages import AIMessage, message_to_dict
- from sqlalchemy import func, select
- from test_after_sales import make_proposal, scenario # noqa: F401
- from test_platform_management import receiver # noqa: F401
- from test_storefront import challenge, prepare_channel, proof
- from zhifu.modules.after_sales.models import Approval, BusinessEvent
- from zhifu.modules.agent_runtime.models import AgentRun
- from zhifu.modules.agent_runtime.runtime import build_tool_graph
- from zhifu.modules.agent_runtime.worker import claim
- from zhifu.modules.channels.models import ChannelInbox, ChannelKey
- from zhifu.modules.conversations.models import Conversation
- from zhifu.modules.identity.access_models import StoreGrant
- from zhifu.modules.merchant.models import ChannelApplication
- from zhifu.modules.platform.delivery import enqueue, execute_one
- from zhifu.modules.platform.models import ChannelDelivery
- from zhifu.operations.channel_keys import KeyConfiguration, configure
- pytestmark = pytest.mark.integration
- @pytest_asyncio.fixture
- async def channel(scenario): # noqa: F811
- await prepare_channel(scenario)
- call = scenario["call"]
- item = await challenge(scenario)
- path = f"/openapi/v1/channel/identity-challenges/{item['challenge_id']}/verify"
- assert (await call("POST", path, "connector", proof(scenario))).status_code == 200
- confirm_path = f"/openapi/v1/me/identity-challenges/{item['challenge_id']}/confirm"
- assert (await call("POST", confirm_path)).status_code == 200
- sessions = scenario["app"].state.sessions
- async with sessions.begin() as session:
- row = await session.get(
- ChannelApplication, (scenario["tenant"].bytes, scenario["store"].bytes))
- row.scopes = ["conversations:messages", "customers:actions", "tickets:results"]
- env = "CHANNEL_TEST_" + uuid4().hex.upper()
- secret = secrets.token_urlsafe(32)
- os.environ[env] = secret
- config = KeyConfiguration(key_id="key-1", credential_env=env,
- valid_from=datetime.now(UTC) - timedelta(minutes=1),
- valid_until=datetime.now(UTC) + timedelta(hours=1))
- await configure(sessions, scenario["tenant"].bytes, scenario["store"].bytes, config)
- endpoint = f"/channel/v1/bindings/{scenario['store']}/messages"
- async def send(body, *, nonce=None, stamp=None, key_id="key-1", signing_secret=None):
- content = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode()
- stamp = int(datetime.now(UTC).timestamp()) if stamp is None else stamp
- nonce = nonce or uuid4().hex
- signed = b"\n".join([str(stamp).encode(), nonce.encode(), b"POST", endpoint.encode(),
- hashlib.sha256(content).hexdigest().encode()])
- signature = hmac.new(
- (signing_secret or secret).encode(), signed, hashlib.sha256).hexdigest()
- return await scenario["client"].post(endpoint, content=content, headers={
- "Content-Type": "application/json", "X-Key-Id": key_id,
- "X-Timestamp": str(stamp),
- "X-Nonce": nonce, "X-Signature": signature})
- try:
- yield {**scenario, "send": send, "config": config, "secret": secret}
- finally:
- os.environ.pop(env)
- def envelope(kind="message", payload=None):
- return {"external_message_id": uuid4().hex, "external_user_id": "verified-channel-account",
- "external_conversation_id": "conversation-1",
- "occurred_at": datetime.now(UTC).isoformat(),
- "type": kind, "payload": payload or {"text": "请查询我的订单"}}
- async def test_signed_message_concurrent_dedup_and_channel_history_isolation(channel):
- body = envelope()
- results = await asyncio.gather(*[channel["send"](body) for _ in range(4)])
- assert all(response.status_code == 202 for response in results), [r.text for r in results]
- assert len({response.json()["inbox_id"] for response in results}) == 1
- result = results[0].json()
- different = await channel["send"]({**body, "payload": {"text": "修改消息正文"}})
- assert different.status_code == 409 and different.json()["code"] == "CHANNEL_MESSAGE_CONFLICT"
- h5 = await channel["call"]("GET", "/openapi/v1/conversations?store_id=" + str(channel["store"]))
- assert not h5.json()["items"]
- history_path = f"/openapi/v1/conversations/{result['conversation_id']}"
- assert (await channel["call"]("GET", history_path)).status_code == 404
- async with channel["app"].state.sessions() as session:
- for model in (ChannelInbox, AgentRun, Conversation):
- assert await session.scalar(select(func.count()).select_from(model).where(
- model.tenant_id == channel["tenant"].bytes)) == 1
- async def test_signature_replay_mapping_and_disabled_key(channel):
- body = envelope()
- invalid = await channel["send"](body, signing_secret="x" * 32)
- assert invalid.json()["code"] == "CHANNEL_SIGNATURE_INVALID"
- assert (await channel["send"](body, stamp=1)).json()["code"] == "CHANNEL_TIMESTAMP_EXPIRED"
- assert (await channel["send"](body, key_id="unknown")).json()["code"] == "CHANNEL_KEY_DISABLED"
- assert (await channel["send"]({**body, "external_user_id": "unlinked"})).status_code == 404
- assert (await channel["send"]({**body, "tenant_id": str(uuid4())})).status_code == 422
- nonce = uuid4().hex
- assert (await channel["send"](body, nonce=nonce)).status_code == 202
- assert (await channel["send"](body, nonce=nonce)).json()["code"] == "CHANNEL_REPLAY"
- sessions = channel["app"].state.sessions
- rotated = channel["config"].model_copy(update={"key_id": "key-2"})
- await configure(sessions, channel["tenant"].bytes, channel["store"].bytes, rotated)
- assert (await channel["send"](body, key_id="key-2")).status_code == 202
- await configure(sessions, channel["tenant"].bytes, channel["store"].bytes,
- channel["config"], enabled=False)
- assert (await channel["send"](body)).json()["code"] == "CHANNEL_KEY_DISABLED"
- async with sessions.begin() as session:
- row = await session.get(
- ChannelKey, (channel["tenant"].bytes, channel["store"].bytes, "key-2"))
- row.valid_until = datetime.now(UTC).replace(tzinfo=None) - timedelta(seconds=1)
- assert (await channel["send"](body, key_id="key-2")).status_code == 401
- async def test_explicit_channel_confirmation_uses_real_business_service(channel):
- response = await channel["send"](envelope(payload={"text": "确认及批准换货"}))
- assert response.status_code == 202, response.text
- async with channel["app"].state.sessions() as session:
- assert await session.scalar(select(func.count()).select_from(Approval).where(
- Approval.tenant_id == channel["tenant"].bytes)) == 0
- proposal = await make_proposal(channel)
- body = envelope("proposal_confirmation", {
- "proposal_id": proposal["proposal_id"], "proposal_version": proposal["proposal_version"],
- "expected_ticket_version": proposal["version"],
- })
- responses = await asyncio.gather(*[channel["send"](body) for _ in range(3)])
- assert all(item.status_code == 202 for item in responses), [r.text for r in responses]
- assert responses[0].json()["result"]["status"] == "WAITING_REVIEW"
- async with channel["app"].state.sessions() as session:
- assert await session.scalar(select(func.count()).select_from(Approval).where(
- Approval.tenant_id == channel["tenant"].bytes)) == 1
- invalid = await channel["send"]({**body, "payload": {**body["payload"], "approved": True}})
- assert invalid.status_code == 422
- async with channel["app"].state.sessions.begin() as session:
- app = await session.get(
- ChannelApplication, (channel["tenant"].bytes, channel["store"].bytes))
- app.enabled = False
- assert (await channel["send"](body)).json()["code"] == "CHANNEL_UNAVAILABLE"
- async def pause_channel(channel):
- response = await channel["send"](envelope())
- assert response.status_code == 202, response.text
- conversation_id = UUID(response.json()["conversation_id"])
- sessions = channel["app"].state.sessions
- async with sessions.begin() as session:
- conversation = await session.get(Conversation, conversation_id.bytes)
- run = await session.get(AgentRun, conversation.active_run_id)
- # 直接提交工具命令,验证渠道身份与真实图节点,生成模型调用次数为0。
- run.messages = run.messages + [message_to_dict(AIMessage(content="", tool_calls=[{
- "name": "request_clarification", "id": uuid4().hex,
- "args": {"question": "请补充换货原因", "missing_fields": ["reason"]},
- "type": "tool_call",
- }]))]
- run.next_node = "tools"
- run_id, token = await claim(sessions, 2)
- result = await build_tool_graph(sessions).ainvoke({"run_id": run_id, "token": token})
- assert result["next_node"] == "wait"
- async with sessions() as session:
- run = await session.get(AgentRun, run_id)
- assert run.status == "WAITING_USER" and run.model_calls == 0
- return conversation_id, run_id, run.waiting
- async def test_channel_clarification_resume_and_revoked_scope(channel):
- _, run_id, waiting = await pause_channel(channel)
- body = envelope("clarification", {
- "interrupt_id": waiting["interrupt_id"],
- "expected_run_version": waiting["run_version"], "answer": "按键失灵",
- })
- sessions = channel["app"].state.sessions
- async with sessions.begin() as session:
- app = await session.get(ChannelApplication,
- (channel["tenant"].bytes, channel["store"].bytes))
- app.scopes = ["conversations:messages"]
- assert (await channel["send"](body)).status_code == 403
- async with sessions.begin() as session:
- app = await session.get(ChannelApplication,
- (channel["tenant"].bytes, channel["store"].bytes))
- app.scopes = ["conversations:messages", "customers:actions"]
- responses = await asyncio.gather(*[channel["send"](body) for _ in range(3)])
- assert all(response.status_code == 202 for response in responses), [r.text for r in responses]
- assert len({response.json()["inbox_id"] for response in responses}) == 1
- assert responses[0].json()["result"]["run_id"] == str(UUID(bytes=run_id))
- async with sessions() as session:
- run = await session.get(AgentRun, run_id)
- assert run.status == "QUEUED" and run.waiting is None and run.model_calls == 0
- async def test_channel_conversation_tls_order_and_takeover(channel, receiver): # noqa: F811
- conversation_id, _, _ = await pause_channel(channel)
- sessions = channel["app"].state.sessions
- async with sessions.begin() as session:
- app = await session.get(ChannelApplication,
- (channel["tenant"].bytes, channel["store"].bytes))
- app.callback_url, app.credential_env = receiver["url"], receiver["credential_env"]
- session.add(StoreGrant(
- tenant_id=channel["tenant"].bytes, principal_id=channel["accounts"]["agent"].bytes,
- store_id=channel["store"].bytes, permission="conversations:manage",
- ))
- assert await enqueue(sessions, tenant_id=channel["tenant"].bytes) == 2
- async with sessions() as session:
- deliveries = (await session.scalars(select(ChannelDelivery).join(
- BusinessEvent, BusinessEvent.id == ChannelDelivery.event_id
- ).where(
- ChannelDelivery.tenant_id == channel["tenant"].bytes
- ).order_by(BusinessEvent.aggregate_version))).all()
- first, second = deliveries
- assert await execute_one(sessions, second.id) is False
- receiver["accepted"].set()
- assert await execute_one(sessions, first.id) is True
- assert len(receiver["received"]) == 1
- received = receiver["received"][0]
- assert received["event_type"] == "conversation.message.assistant"
- assert received["payload"]["external_subject"] == "verified-channel-account"
- assert received["payload"]["event"]["payload"]["text"] == "请补充换货原因"
- path = f"/openapi/v1/conversations/{conversation_id}/takeover"
- response = await channel["call"]("POST", path, "agent", {"expected_control_version": 1})
- assert response.status_code == 200, response.text
- assert await execute_one(sessions, second.id) is True
- async with sessions() as session:
- stale = await session.get(ChannelDelivery, second.id)
- assert stale.status == "FAILED" and stale.last_error == "CONVERSATION_CONTROL_LOST"
- assert stale.attempts == 0
- assert len(receiver["received"]) == 1
|