Files
gochat/backend/internal/service/shangwutong_webhook_delivery_test.go
T

258 lines
13 KiB
Go

package service
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gochat/gochat/internal/channel"
"github.com/gochat/gochat/internal/model"
channelmodel "github.com/gochat/gochat/internal/model/channel"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/worker"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestShangwutongWebhookDeliveryUsesVersionedSecretFreeEnvelope(t *testing.T) {
var body []byte
var headers http.Header
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
body, _ = io.ReadAll(request.Body)
headers = request.Header.Clone()
response.WriteHeader(http.StatusAccepted)
}))
defer server.Close()
db := newShangwutongDeliveryTestDB(t)
inbox := seedShangwutongDeliveryInbox(t, db, server.URL, "current-secret")
now := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)
runner := &shangwutongWebhookDeliveryRunner{db: db, client: server.Client(), now: func() time.Time { return now }}
payload := newShangwutongLifecycleJob("inbox_updated", inbox, 7)
payload.OccurredAt = now
encoded, _ := json.Marshal(payload)
require.NoError(t, runner.perform(context.Background(), &model.BackgroundJob{Payload: encoded}))
require.Equal(t, "1", headers.Get("X-GoChat-Webhook-Version"))
require.Equal(t, "1785578400", headers.Get("X-Chatwoot-Timestamp"))
require.NotEmpty(t, headers.Get("X-Chatwoot-Delivery"))
require.Equal(t, testWebhookSignature(body, "current-secret", "1785578400"), headers.Get("X-Chatwoot-Signature"))
require.JSONEq(t, `{"schema_version":1,"event":"inbox_updated","event_id":"inbox:1:config:7","occurred_at":"2026-08-01T10:00:00Z","account_id":1,"inbox_id":1,"data":{"channel_type":"shangwutong","config_version":7}}`, string(body))
for _, secret := range []string{"password", "username", "session_id", "hmac_token", "webhook_secret", "current-secret"} {
require.NotContains(t, string(body), secret)
}
}
func TestShangwutongWebhookDeliveryUsesOldSecretAndDeleteSnapshot(t *testing.T) {
var signatures []string
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
payload, _ := io.ReadAll(request.Body)
timestamp := request.Header.Get("X-Chatwoot-Timestamp")
signatures = append(signatures, request.Header.Get("X-Chatwoot-Signature")+"|"+string(payload)+"|"+timestamp)
response.WriteHeader(http.StatusOK)
}))
defer server.Close()
db := newShangwutongDeliveryTestDB(t)
inbox := seedShangwutongDeliveryInbox(t, db, server.URL, "new-secret")
runner := &shangwutongWebhookDeliveryRunner{db: db, client: server.Client(), now: func() time.Time { return time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC) }}
rotation := newShangwutongLifecycleJob("inbox_updated", inbox, 8)
rotation.SigningSecret = "old-secret"
encoded, _ := json.Marshal(rotation)
require.NoError(t, runner.perform(context.Background(), &model.BackgroundJob{Payload: encoded}))
parts := strings.SplitN(signatures[0], "|", 3)
require.Equal(t, testWebhookSignature([]byte(parts[1]), "old-secret", parts[2]), parts[0])
tombstone := newShangwutongLifecycleJob("inbox_deleted", inbox, 9)
tombstone.Tombstone, tombstone.WebhookURL, tombstone.SigningSecret = true, server.URL, "new-secret"
require.NoError(t, db.Delete(&model.Inbox{}, inbox.ID).Error)
encoded, _ = json.Marshal(tombstone)
require.NoError(t, runner.perform(context.Background(), &model.BackgroundJob{Payload: encoded}))
require.Len(t, signatures, 2)
}
func TestShangwutongMessageWebhookReloadsCompleteContract(t *testing.T) {
var body []byte
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
body, _ = io.ReadAll(request.Body)
response.WriteHeader(http.StatusAccepted)
}))
defer server.Close()
db := newShangwutongDeliveryTestDB(t)
inbox := seedShangwutongDeliveryInbox(t, db, server.URL, "secret")
contact := &model.Contact{AccountID: inbox.AccountID, Name: "访客", SourceID: "visitor-session"}
require.NoError(t, db.Create(contact).Error)
displayID := uint(88)
conversation := &model.Conversation{
AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, DisplayID: &displayID, Status: "open",
CustomAttributes: []byte(`{"swt_sid":"visitor-session"}`),
}
require.NoError(t, db.Create(conversation).Error)
user := &model.User{AccountID: inbox.AccountID, Name: "坐席", Email: "agent@example.com"}
require.NoError(t, db.Create(user).Error)
message := &model.Message{
AccountID: inbox.AccountID, InboxID: inbox.ID, ConversationID: conversation.ID, SenderID: &user.ID, SenderType: "user",
MessageType: "outgoing", ContentType: "text", Content: "您好", Status: "progress",
ContentAttributes: []byte(`{"business":"keep"}`), AdditionalAttributes: []byte(`{"audit":"yes"}`),
}
require.NoError(t, db.Create(message).Error)
require.NoError(t, db.Create(&model.Attachment{
AccountID: inbox.AccountID, MessageID: message.ID, FileType: "file", FileURL: "https://gochat/files/1",
FileName: "manual.pdf", FileSize: 12, Metadata: `{}`,
}).Error)
runner := &shangwutongWebhookDeliveryRunner{db: db, client: server.Client(), now: time.Now}
job := newShangwutongMessageJob("message_created", message, 0)
encoded, _ := json.Marshal(job)
require.NoError(t, runner.perform(context.Background(), &model.BackgroundJob{Payload: encoded}))
var envelope map[string]any
require.NoError(t, json.Unmarshal(body, &envelope))
require.EqualValues(t, 1, envelope["schema_version"])
require.Equal(t, "message_created", envelope["event"])
data := envelope["data"].(map[string]any)
messagePayload := data["message"].(map[string]any)
require.Equal(t, "progress", messagePayload["status"])
require.Equal(t, false, messagePayload["external"])
require.Len(t, messagePayload["attachments"], 1)
require.EqualValues(t, conversation.ID, data["conversation"].(map[string]any)["id"])
require.EqualValues(t, displayID, data["conversation"].(map[string]any)["display_id"])
require.Equal(t, "visitor-session", data["contact"].(map[string]any)["source_id"])
require.Equal(t, "坐席", data["sender"].(map[string]any)["name"])
}
func TestShangwutongMessageWebhookPermanentFailureMarksMessageFailed(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
response.WriteHeader(http.StatusUnprocessableEntity)
_, _ = response.Write([]byte(`{"error":{"code":"unsupported_outbound_content","message":"unsupported","retryable":false}}`))
}))
defer server.Close()
db := newShangwutongDeliveryTestDB(t)
inbox := seedShangwutongDeliveryInbox(t, db, server.URL, "secret")
contact := &model.Contact{AccountID: inbox.AccountID, Name: "访客"}
require.NoError(t, db.Create(contact).Error)
conversation := &model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open"}
require.NoError(t, db.Create(conversation).Error)
message := &model.Message{
AccountID: inbox.AccountID, InboxID: inbox.ID, ConversationID: conversation.ID,
MessageType: "outgoing", ContentType: "text", Content: "unsupported", Status: "progress",
}
require.NoError(t, db.Create(message).Error)
runner := &shangwutongWebhookDeliveryRunner{db: db, client: server.Client(), now: time.Now}
payload, _ := json.Marshal(newShangwutongMessageJob("message_created", message, 0))
err := runner.perform(context.Background(), &model.BackgroundJob{Payload: payload, Attempts: 1, MaxAttempts: 10})
require.Error(t, err)
require.NoError(t, db.First(message, message.ID).Error)
require.Equal(t, "failed", message.Status)
require.Contains(t, string(message.ContentAttributes), "unsupported_outbound_content")
}
func TestShangwutongStaleLifecycleJobIsObsoleteAfterInboxDeletion(t *testing.T) {
db := newShangwutongDeliveryTestDB(t)
runner := &shangwutongWebhookDeliveryRunner{db: db, client: http.DefaultClient, now: time.Now}
payload, err := json.Marshal(shangwutongWebhookDeliveryJob{
Event: "inbox_created", EventID: "inbox:99:config:1", AccountID: 1, InboxID: 99, ConfigVersion: 1,
})
require.NoError(t, err)
require.NoError(t, runner.perform(context.Background(), &model.BackgroundJob{Payload: payload}))
}
func TestShangwutongConversationStatusQueuesOnceAndSkipsConnectorOrigin(t *testing.T) {
db := setupServiceTestDB(t)
wp := worker.NewWorkerPool(db)
svc := NewConversationService(repository.NewConversationRepo(db), repository.NewMessageRepo(db), channel.NewDispatcher(), nil, nil, nil, nil)
svc.SetWorkerPool(wp)
account := createTestAccount(t, db)
user := createTestUser(t, db, account.ID)
inbox := createTestInbox(t, db, account.ID, "shangwutong")
contact := createTestContact(t, db, account.ID)
conversation := createTestConversation(t, db, account.ID, inbox.ID, contact.ID)
conversation.Status = "open"
require.NoError(t, db.Save(conversation).Error)
ctx := WithShangwutongRequestMetadata(context.Background(), false, user.ID)
updated, err := svc.ToggleStatus(ctx, account.ID, conversation.ID, ToggleStatusRequest{Status: "resolved"})
require.NoError(t, err)
require.Equal(t, "resolved", updated.Status)
var jobs []model.BackgroundJob
require.NoError(t, db.Where("job_type = ?", TaskTypeShangwutongWebhookDelivery).Find(&jobs).Error)
require.Len(t, jobs, 1)
var payload shangwutongWebhookDeliveryJob
require.NoError(t, json.Unmarshal(jobs[0].Payload, &payload))
require.Equal(t, "conversation_status_changed", payload.Event)
require.Equal(t, "open", payload.PreviousStatus)
require.Equal(t, user.ID, payload.ActorID)
_, err = svc.ToggleStatus(ctx, account.ID, conversation.ID, ToggleStatusRequest{Status: "resolved"})
require.NoError(t, err)
require.NoError(t, db.Where("job_type = ?", TaskTypeShangwutongWebhookDelivery).Find(&jobs).Error)
require.Len(t, jobs, 1)
connectorContext := WithShangwutongRequestMetadata(context.Background(), true, 0)
_, err = svc.ToggleStatus(connectorContext, account.ID, conversation.ID, ToggleStatusRequest{Status: "open"})
require.NoError(t, err)
require.NoError(t, db.Where("job_type = ?", TaskTypeShangwutongWebhookDelivery).Find(&jobs).Error)
require.Len(t, jobs, 1)
}
func TestShangwutongTypingWebhookIsBestEffortAndSkipsPrivateOrVisitorEvents(t *testing.T) {
calls := 0
var body []byte
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
calls++
body, _ = io.ReadAll(request.Body)
response.WriteHeader(http.StatusAccepted)
}))
defer server.Close()
db := newShangwutongDeliveryTestDB(t)
inbox := seedShangwutongDeliveryInbox(t, db, server.URL, "secret")
contact := &model.Contact{AccountID: inbox.AccountID, Name: "访客"}
require.NoError(t, db.Create(contact).Error)
conversation := &model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open", CustomAttributes: []byte(`{"swt_sid":"visitor"}`)}
require.NoError(t, db.Create(conversation).Error)
listener := NewShangwutongTypingWebhookListener(db)
listener.runner.client = server.Client()
event := channel.NewChannelEvent(channel.EventConversationTypingOn, channel.ChannelShangwutong, inbox.AccountID, inbox.ID)
event.ConversationID, event.UserID = conversation.ID, 9
event.Data["is_private"] = false
require.NoError(t, listener.OnEvent(context.Background(), event))
require.Equal(t, 1, calls)
var envelope map[string]any
require.NoError(t, json.Unmarshal(body, &envelope))
require.Equal(t, "conversation_typing_on", envelope["event"])
event.Data["is_private"] = true
require.NoError(t, listener.OnEvent(context.Background(), event))
event.Data["is_private"], event.UserID = false, 0
require.NoError(t, listener.OnEvent(context.Background(), event))
require.Equal(t, 1, calls)
}
func newShangwutongDeliveryTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&model.Account{}, &model.Inbox{}, &channelmodel.ChannelAPI{}, &model.Contact{}, &model.Conversation{},
&model.User{}, &model.Message{}, &model.Attachment{}, &model.AgentBot{},
))
return db
}
func seedShangwutongDeliveryInbox(t *testing.T, db *gorm.DB, webhookURL, secret string) *model.Inbox {
t.Helper()
account := &model.Account{Name: "Account", Active: true}
require.NoError(t, db.Create(account).Error)
inbox := &model.Inbox{AccountID: account.ID, Name: "SWT", ChannelType: "shangwutong", Enabled: true}
require.NoError(t, db.Create(inbox).Error)
require.NoError(t, db.Create(&channelmodel.ChannelAPI{InboxID: inbox.ID, WebhookURL: webhookURL, Secret: secret, Identifier: "identifier"}).Error)
return inbox
}
func testWebhookSignature(body []byte, secret, timestamp string) string {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(timestamp + "."))
_, _ = mac.Write(body)
return "sha256=" + hex.EncodeToString(mac.Sum(nil))
}