feat: add enterprise identity and channel integrations
This commit is contained in:
1
backend/apps/channels/tests/__init__.py
Normal file
1
backend/apps/channels/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
286
backend/apps/channels/tests/test_channel_runtime.py
Normal file
286
backend/apps/channels/tests/test_channel_runtime.py
Normal file
@@ -0,0 +1,286 @@
|
||||
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
|
||||
Reference in New Issue
Block a user