287 lines
9.3 KiB
Python
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
|