Files
ai-ticket-platform/backend/apps/channels/tests/test_channel_runtime.py

287 lines
9.3 KiB
Python

import hashlib
import hmac
import json
import pytest
from django.contrib.auth import get_user_model
from apps.audit.models import OperationAuditLog
from apps.channels.models import ChannelEndpoint, ChannelMessage, ChannelReceipt
from apps.channels.services import (
ChannelError,
queue_outbound_message,
record_channel_receipt,
)
from apps.modelhub.crypto import encrypt_secret
from apps.tickets.models import CustomerReplyTask, Ticket
from apps.workitems.models import WorkItem
class FakeResponse:
def __init__(self, payload: dict):
self.payload = payload
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
return False
def read(self):
return json.dumps(self.payload).encode()
def signature(secret: str, body: bytes) -> str:
digest = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return f"sha256={digest}"
@pytest.mark.django_db
def test_signed_inbound_webhook_is_idempotent(client):
secret = "channel-inbound-secret"
endpoint = ChannelEndpoint.objects.create(
name="客服群",
webhook_secret_encrypted=encrypt_secret(secret),
)
body = json.dumps(
{
"external_message_id": "msg-in-001",
"external_conversation_id": "group-001",
"text": "生产环境登录失败,错误码 AUTH-401",
"sender": "客户甲",
"participants": ["客户甲", "技术支持"],
"payload": {"source": "group"},
},
ensure_ascii=False,
separators=(",", ":"),
).encode()
headers = {"HTTP_X_CHANNEL_SIGNATURE": signature(secret, body)}
first = client.post(
f"/api/channels/webhooks/{endpoint.public_id}/messages",
data=body,
content_type="application/json",
**headers,
)
second = client.post(
f"/api/channels/webhooks/{endpoint.public_id}/messages",
data=body,
content_type="application/json",
**headers,
)
assert first.status_code == 200
assert second.status_code == 200
assert first.json()["public_id"] == second.json()["public_id"]
assert ChannelMessage.objects.count() == 1
message = ChannelMessage.objects.get()
assert message.status == "received"
assert message.conversation.external_conversation_id == "group-001"
assert OperationAuditLog.objects.filter(
module="channels",
action="message_received",
target_id=str(message.public_id),
).count() == 1
@pytest.mark.django_db
def test_inbound_webhook_rejects_invalid_signature(client):
endpoint = ChannelEndpoint.objects.create(
name="签名渠道",
webhook_secret_encrypted=encrypt_secret("correct-secret"),
)
response = client.post(
f"/api/channels/webhooks/{endpoint.public_id}/messages",
data={
"external_message_id": "msg-invalid",
"external_conversation_id": "group-invalid",
"text": "不应被接收",
},
content_type="application/json",
HTTP_X_CHANNEL_SIGNATURE="sha256=invalid",
)
assert response.status_code == 401
assert ChannelMessage.objects.count() == 0
@pytest.mark.django_db(transaction=True)
def test_outbound_delivery_and_receipt_are_idempotent(
admin_client,
django_capture_on_commit_callbacks,
monkeypatch,
):
actor = get_user_model().objects.filter(is_superuser=True).first()
assert actor is not None
endpoint = ChannelEndpoint.objects.create(
name="出站渠道",
outbound_url="https://channel.example/messages",
webhook_secret_encrypted=encrypt_secret("receipt-secret"),
outbound_token_encrypted=encrypt_secret("outbound-token"),
)
ticket = Ticket.objects.create(
ticket_no="TK-CHANNEL-0001",
title="生产环境登录失败",
description="生产环境登录失败,错误码 AUTH-401",
status="pending",
)
reply_task = CustomerReplyTask.objects.create(
ticket=ticket,
status="draft",
draft_text="已收到问题,正在处理。",
final_text="已收到问题,正在处理。",
created_by=actor,
)
requests = []
def fake_urlopen(request, timeout):
requests.append((request, timeout))
return FakeResponse(
{
"status": "sent",
"external_message_id": "external-out-001",
}
)
monkeypatch.setattr("apps.channels.services.urlopen", fake_urlopen)
payload = {
"external_conversation_id": "group-002",
"idempotency_key": "reply-001",
"customer_reply_task_id": reply_task.id,
"expected_revision": reply_task.revision,
"payload": {"template": "ack"},
}
with django_capture_on_commit_callbacks(execute=True):
first = queue_outbound_message(endpoint, payload, actor=actor)
with django_capture_on_commit_callbacks(execute=True):
second = queue_outbound_message(endpoint, payload, actor=actor)
first.refresh_from_db()
reply_task.refresh_from_db()
ticket.refresh_from_db()
assert first.id == second.id
assert first.status == "sent"
assert first.external_message_id == "external-out-001"
assert first.customer_reply_task == reply_task
assert first.reply_revision == 1
assert first.text == reply_task.final_text
assert reply_task.status == "approved"
assert reply_task.approved_by == actor
assert reply_task.approved_at is not None
assert ticket.replied_to_customer is False
assert ChannelMessage.objects.filter(direction="outbound").count() == 1
assert len(requests) == 1
request, timeout = requests[0]
assert timeout == 30
assert request.get_header("Idempotency-key") == "reply-001"
assert request.get_header("Authorization") == "Bearer outbound-token"
receipt_payload = {
"external_message_id": "external-out-001",
"external_receipt_id": "receipt-001",
"receipt_type": "delivered",
"payload": {"provider_status": "delivered"},
}
first_receipt = record_channel_receipt(endpoint, receipt_payload)
second_receipt = record_channel_receipt(endpoint, receipt_payload)
first.refresh_from_db()
reply_task.refresh_from_db()
ticket.refresh_from_db()
assert first_receipt.id == second_receipt.id
assert first.status == "delivered"
assert first.delivered_at is not None
assert reply_task.status == "sent"
assert reply_task.sent_at is not None
assert ticket.replied_to_customer is True
assert WorkItem.objects.filter(
ticket=ticket,
task_type="ticket_customer_reply",
status="completed",
).count() == 1
assert ChannelReceipt.objects.count() == 1
@pytest.mark.django_db
def test_customer_reply_version_cannot_be_queued_to_another_conversation(admin_client):
actor = get_user_model().objects.get(is_superuser=True)
endpoint = ChannelEndpoint.objects.create(
name="唯一回复渠道",
outbound_url="https://channel.example/messages",
)
ticket = Ticket.objects.create(
ticket_no="TK-CHANNEL-0002",
title="接口超时",
description="生产接口持续超时",
status="pending",
)
reply_task = CustomerReplyTask.objects.create(
ticket=ticket,
draft_text="已定位到上游依赖超时。",
final_text="已定位到上游依赖超时。",
created_by=actor,
)
first_payload = {
"external_conversation_id": "group-a",
"idempotency_key": "reply-version-a",
"customer_reply_task_id": reply_task.id,
"expected_revision": 1,
}
queue_outbound_message(endpoint, first_payload, actor=actor)
with pytest.raises(ChannelError, match="已进入其他渠道会话"):
queue_outbound_message(
endpoint,
{
**first_payload,
"external_conversation_id": "group-b",
"idempotency_key": "reply-version-b",
},
actor=actor,
)
@pytest.mark.django_db
def test_outbound_api_rejects_arbitrary_text(admin_client):
endpoint = ChannelEndpoint.objects.create(
name="受控发送渠道",
outbound_url="https://channel.example/messages",
)
response = admin_client.post(
f"/api/channels/endpoints/{endpoint.id}/messages",
{
"external_conversation_id": "group-uncontrolled",
"idempotency_key": "uncontrolled-001",
"ticket_id": 1,
"text": "绕过客户回复任务直接发送",
},
content_type="application/json",
)
assert response.status_code == 422
assert ChannelMessage.objects.count() == 0
@pytest.mark.django_db
def test_channel_management_api_hides_credentials(admin_client):
response = admin_client.post(
"/api/channels/endpoints",
{
"name": "治理渠道",
"outbound_url": "https://channel.example/send",
"webhook_secret": "webhook-secret",
"outbound_token": "outbound-token",
},
content_type="application/json",
)
assert response.status_code == 200
payload = response.json()
assert payload["webhook_secret_set"] is True
assert payload["outbound_token_set"] is True
assert "webhook_secret" not in payload
assert "outbound_token" not in payload