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()) } }