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