372 lines
12 KiB
Go
372 lines
12 KiB
Go
package webhook
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/gochat/gochat/internal/channel"
|
|
channelprovider "github.com/gochat/gochat/internal/channel/provider"
|
|
"github.com/gochat/gochat/internal/model"
|
|
channelmodel "github.com/gochat/gochat/internal/model/channel"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func newWebhookLookupTestDB(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
|
|
dsn := "file:" + strings.NewReplacer("/", "_", " ", "_", ":", "_").Replace(t.Name()) + "?mode=memory&cache=shared"
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
if err := db.AutoMigrate(
|
|
&model.Inbox{},
|
|
&model.Contact{},
|
|
&model.ContactInbox{},
|
|
&model.Conversation{},
|
|
&model.Message{},
|
|
&channelmodel.ChannelTelegram{},
|
|
&channelmodel.ChannelLINE{},
|
|
&channelmodel.ChannelTwilioSMS{},
|
|
&channelmodel.ChannelTikTok{},
|
|
&channelmodel.ChannelInstagram{},
|
|
&model.IntegrationHook{},
|
|
); err != nil {
|
|
t.Fatalf("migrate webhook lookup models: %v", err)
|
|
}
|
|
return db
|
|
}
|
|
|
|
func TestIncomingPersisterCreatesConversationMessageAndDedupes(t *testing.T) {
|
|
db := newWebhookLookupTestDB(t)
|
|
inbox := seedWebhookInbox(t, db, "telegram")
|
|
persister := NewIncomingPersister(db)
|
|
|
|
msg := &channel.IncomingMessage{
|
|
ChannelType: channel.ChannelTelegram,
|
|
SourceID: "tg-msg-1",
|
|
SenderID: "tg-user-1",
|
|
SenderName: "Ada Lovelace",
|
|
SenderType: channel.SenderContact,
|
|
Content: "hello",
|
|
ContentType: channel.ContentText,
|
|
InboxID: inbox.ID,
|
|
AccountID: inbox.AccountID,
|
|
}
|
|
|
|
result, err := persister.PersistIncoming(t.Context(), &inbox, msg)
|
|
if err != nil {
|
|
t.Fatalf("persist incoming: %v", err)
|
|
}
|
|
if result.Contact == nil || result.ContactInbox == nil || result.Conversation == nil || result.Message == nil {
|
|
t.Fatalf("expected full persistence result: %#v", result)
|
|
}
|
|
if result.Message.Content != "hello" || result.Message.SourceID != "tg-msg-1" {
|
|
t.Fatalf("unexpected message: %#v", result.Message)
|
|
}
|
|
|
|
duplicate, err := persister.PersistIncoming(t.Context(), &inbox, msg)
|
|
if err != nil {
|
|
t.Fatalf("persist duplicate: %v", err)
|
|
}
|
|
if duplicate == nil || !duplicate.Duplicate {
|
|
t.Fatalf("expected duplicate result, got %#v", duplicate)
|
|
}
|
|
|
|
msg.SourceID = "tg-msg-2"
|
|
msg.Content = "second"
|
|
second, err := persister.PersistIncoming(t.Context(), &inbox, msg)
|
|
if err != nil {
|
|
t.Fatalf("persist second: %v", err)
|
|
}
|
|
if second.Contact.ID != result.Contact.ID || second.Conversation.ID != result.Conversation.ID {
|
|
t.Fatalf("expected contact/conversation reuse: first=%#v second=%#v", result, second)
|
|
}
|
|
|
|
var messageCount int64
|
|
if err := db.Model(&model.Message{}).Where("inbox_id = ?", inbox.ID).Count(&messageCount).Error; err != nil {
|
|
t.Fatalf("count messages: %v", err)
|
|
}
|
|
if messageCount != 2 {
|
|
t.Fatalf("expected 2 persisted messages after duplicate skip, got %d", messageCount)
|
|
}
|
|
}
|
|
|
|
func shopifyHMAC(secret string, body []byte) string {
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
mac.Write(body)
|
|
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
func metaSignature(secret string, body []byte) string {
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
mac.Write(body)
|
|
return "sha256=" + hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
func seedWebhookInbox(t *testing.T, db *gorm.DB, channelType string) model.Inbox {
|
|
t.Helper()
|
|
|
|
inbox := model.Inbox{
|
|
AccountID: 1,
|
|
Name: channelType + " inbox",
|
|
ChannelType: channelType,
|
|
ChannelID: 1,
|
|
Enabled: true,
|
|
}
|
|
if err := db.Create(&inbox).Error; err != nil {
|
|
t.Fatalf("create inbox: %v", err)
|
|
}
|
|
return inbox
|
|
}
|
|
|
|
func TestTelegramWebhookLookupInboxByBotToken(t *testing.T) {
|
|
db := newWebhookLookupTestDB(t)
|
|
inbox := seedWebhookInbox(t, db, "telegram")
|
|
channel := channelmodel.ChannelTelegram{
|
|
AccountID: 1,
|
|
InboxID: inbox.ID,
|
|
BotToken: "123:secret-token",
|
|
BotName: "support_bot",
|
|
}
|
|
if err := db.Create(&channel).Error; err != nil {
|
|
t.Fatalf("create telegram channel: %v", err)
|
|
}
|
|
|
|
h := NewTelegramWebhookHandler(nil, nil, db)
|
|
found, err := h.lookupInbox("123:secret-token")
|
|
if err != nil {
|
|
t.Fatalf("lookup inbox: %v", err)
|
|
}
|
|
if found.ID != inbox.ID || found.ChannelType != "telegram" {
|
|
t.Fatalf("unexpected inbox: id=%d type=%s", found.ID, found.ChannelType)
|
|
}
|
|
}
|
|
|
|
func TestTelegramWebhookPersistsIncomingMessage(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db := newWebhookLookupTestDB(t)
|
|
inbox := seedWebhookInbox(t, db, "telegram")
|
|
channelRecord := channelmodel.ChannelTelegram{
|
|
AccountID: 1,
|
|
InboxID: inbox.ID,
|
|
BotToken: "123:secret-token",
|
|
BotName: "support_bot",
|
|
}
|
|
if err := db.Create(&channelRecord).Error; err != nil {
|
|
t.Fatalf("create telegram channel: %v", err)
|
|
}
|
|
|
|
body := []byte(`{"update_id":1001,"message":{"message_id":2002,"from":{"id":3003,"first_name":"Ada","last_name":"Lovelace","username":"ada"},"chat":{"id":3003,"type":"private"},"date":1710000000,"text":"hello telegram"}}`)
|
|
h := NewTelegramWebhookHandler(channelprovider.NewTelegramProvider(), nil, db)
|
|
r := gin.New()
|
|
r.POST("/webhooks/telegram/:bot_token", h.HandleTelegramWebhook)
|
|
req := httptest.NewRequest(http.MethodPost, "/webhooks/telegram/123:secret-token", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var message model.Message
|
|
if err := db.Where("inbox_id = ? AND source_id = ?", inbox.ID, "2002").First(&message).Error; err != nil {
|
|
t.Fatalf("expected telegram message persisted: %v", err)
|
|
}
|
|
if message.Content != "hello telegram" || message.MessageType != string(model.MessageTypeIncoming) {
|
|
t.Fatalf("unexpected message: %#v", message)
|
|
}
|
|
}
|
|
|
|
func TestLineWebhookLookupInboxByLineChannelID(t *testing.T) {
|
|
db := newWebhookLookupTestDB(t)
|
|
inbox := seedWebhookInbox(t, db, "line")
|
|
channel := channelmodel.ChannelLINE{
|
|
AccountID: 1,
|
|
InboxID: inbox.ID,
|
|
ChannelID: "line-channel-1",
|
|
Name: "LINE OA",
|
|
}
|
|
if err := db.Create(&channel).Error; err != nil {
|
|
t.Fatalf("create line channel: %v", err)
|
|
}
|
|
|
|
h := NewLineWebhookHandler(nil, nil, nil, db)
|
|
found, err := h.lookupInboxByLineChannelID("line-channel-1")
|
|
if err != nil {
|
|
t.Fatalf("lookup inbox: %v", err)
|
|
}
|
|
if found.ID != inbox.ID || found.ChannelType != "line" {
|
|
t.Fatalf("unexpected inbox: id=%d type=%s", found.ID, found.ChannelType)
|
|
}
|
|
}
|
|
|
|
func TestTwilioWebhookLookupInboxByPhoneNumber(t *testing.T) {
|
|
db := newWebhookLookupTestDB(t)
|
|
inbox := seedWebhookInbox(t, db, "twilio_sms")
|
|
channel := channelmodel.ChannelTwilioSMS{
|
|
AccountID: 1,
|
|
InboxID: inbox.ID,
|
|
AccountSID: "AC123",
|
|
PhoneNumber: "+15551234567",
|
|
MessagingServiceSID: "MG123",
|
|
}
|
|
if err := db.Create(&channel).Error; err != nil {
|
|
t.Fatalf("create twilio channel: %v", err)
|
|
}
|
|
|
|
h := NewTwilioWebhookHandler(nil, db)
|
|
found, err := h.lookupInboxByPhoneNumber("+15551234567")
|
|
if err != nil {
|
|
t.Fatalf("lookup inbox: %v", err)
|
|
}
|
|
if found.ID != inbox.ID || found.ChannelType != "twilio_sms" {
|
|
t.Fatalf("unexpected inbox: id=%d type=%s", found.ID, found.ChannelType)
|
|
}
|
|
}
|
|
|
|
func TestTikTokWebhookLookupInboxByBusinessIDAndPayloadExtractor(t *testing.T) {
|
|
db := newWebhookLookupTestDB(t)
|
|
inbox := seedWebhookInbox(t, db, "tiktok")
|
|
channel := channelmodel.ChannelTikTok{
|
|
AccountID: 1,
|
|
InboxID: inbox.ID,
|
|
TikTokBusinessID: "biz-123",
|
|
WebhookVerifyToken: "verify-token",
|
|
}
|
|
if err := db.Create(&channel).Error; err != nil {
|
|
t.Fatalf("create tiktok channel: %v", err)
|
|
}
|
|
|
|
h := NewTikTokWebhookHandler(nil, nil, db)
|
|
found, err := h.lookupInboxByBusinessID("biz-123")
|
|
if err != nil {
|
|
t.Fatalf("lookup inbox: %v", err)
|
|
}
|
|
if found.ID != inbox.ID || found.ChannelType != "tiktok" {
|
|
t.Fatalf("unexpected inbox: id=%d type=%s", found.ID, found.ChannelType)
|
|
}
|
|
|
|
if got := extractTikTokBusinessID([]byte(`{"data":{"business_id":"biz-123"}}`)); got != "biz-123" {
|
|
t.Fatalf("unexpected extracted business id: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestShopifyWebhookShopRedactDeletesMatchingHook(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db := newWebhookLookupTestDB(t)
|
|
settings, _ := json.Marshal(model.ShopifySettings{ShopDomain: "store.myshopify.com"})
|
|
hook := model.IntegrationHook{
|
|
AccountID: 1,
|
|
HookType: model.HookTypeShopify,
|
|
Status: model.HookStatusActive,
|
|
AccessToken: "access-token",
|
|
Settings: settings,
|
|
}
|
|
if err := db.Create(&hook).Error; err != nil {
|
|
t.Fatalf("create shopify hook: %v", err)
|
|
}
|
|
|
|
body := []byte(`{"shop_domain":"store.myshopify.com"}`)
|
|
h := NewShopifyWebhookHandler(db, "client-secret")
|
|
r := gin.New()
|
|
r.POST("/webhooks/shopify", h.HandleShopifyWebhook)
|
|
req := httptest.NewRequest(http.MethodPost, "/webhooks/shopify", bytes.NewReader(body))
|
|
req.Header.Set("X-Shopify-Hmac-SHA256", shopifyHMAC("client-secret", body))
|
|
req.Header.Set("X-Shopify-Topic", "shop/redact")
|
|
w := httptest.NewRecorder()
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
var count int64
|
|
if err := db.Model(&model.IntegrationHook{}).Where("id = ?", hook.ID).Count(&count).Error; err != nil {
|
|
t.Fatalf("count hook: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("expected shopify hook to be deleted, count=%d", count)
|
|
}
|
|
}
|
|
|
|
func TestShopifyWebhookRejectsInvalidHMAC(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db := newWebhookLookupTestDB(t)
|
|
h := NewShopifyWebhookHandler(db, "client-secret")
|
|
r := gin.New()
|
|
r.POST("/webhooks/shopify", h.HandleShopifyWebhook)
|
|
req := httptest.NewRequest(http.MethodPost, "/webhooks/shopify", bytes.NewReader([]byte(`{}`)))
|
|
req.Header.Set("X-Shopify-Hmac-SHA256", "invalid")
|
|
w := httptest.NewRecorder()
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestInstagramWebhookVerificationUsesChatwootGlobalTokens(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("INSTAGRAM_VERIFY_TOKEN", "verify-me")
|
|
h := NewFacebookWebhookHandler(nil, nil, nil)
|
|
r := gin.New()
|
|
r.GET("/webhooks/instagram", h.HandleInstagramVerification)
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/webhooks/instagram?hub.verify_token=verify-me&hub.challenge=challenge-1", nil)
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
if w.Body.String() != "challenge-1" {
|
|
t.Fatalf("unexpected challenge body: %q", w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestInstagramWebhookEventsVerifySignatureAndResolveInbox(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db := newWebhookLookupTestDB(t)
|
|
inbox := seedWebhookInbox(t, db, "instagram")
|
|
channel := channelmodel.ChannelInstagram{
|
|
AccountID: 1,
|
|
InboxID: inbox.ID,
|
|
InstagramAccountID: "ig-123",
|
|
InstagramBusinessAccountID: "ig-business-123",
|
|
PageAccessToken: "page-token",
|
|
ConnectedFBPageID: "page-123",
|
|
InstagramAccountName: "gochat",
|
|
}
|
|
if err := db.Create(&channel).Error; err != nil {
|
|
t.Fatalf("create instagram channel: %v", err)
|
|
}
|
|
t.Setenv("INSTAGRAM_APP_SECRET", "ig-secret")
|
|
|
|
body := []byte(`{"object":"instagram","entry":[{"id":"ig-123","time":1,"messaging":[{"sender":{"id":"user-1"},"recipient":{"id":"ig-123"},"timestamp":1,"message":{"mid":"mid-1","text":"hello"}}]}]}`)
|
|
h := NewFacebookWebhookHandler(nil, nil, db)
|
|
r := gin.New()
|
|
r.POST("/webhooks/instagram", h.HandleInstagramWebhook)
|
|
req := httptest.NewRequest(http.MethodPost, "/webhooks/instagram", bytes.NewReader(body))
|
|
req.Header.Set("X-Hub-Signature-256", metaSignature("ig-secret", body))
|
|
w := httptest.NewRecorder()
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
}
|