258 lines
13 KiB
Go
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))
|
|
}
|