1188 lines
48 KiB
Go
1188 lines
48 KiB
Go
package webhook
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/gochat/gochat/internal/automation"
|
|
"github.com/gochat/gochat/internal/channel"
|
|
linechannel "github.com/gochat/gochat/internal/channel/line"
|
|
channelprovider "github.com/gochat/gochat/internal/channel/provider"
|
|
tiktokchannel "github.com/gochat/gochat/internal/channel/tiktok"
|
|
twiliochannel "github.com/gochat/gochat/internal/channel/twilio"
|
|
whatsappchannel "github.com/gochat/gochat/internal/channel/whatsapp"
|
|
"github.com/gochat/gochat/internal/model"
|
|
channelmodel "github.com/gochat/gochat/internal/model/channel"
|
|
"github.com/gochat/gochat/internal/worker"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type recordingListener struct {
|
|
events []*channel.ChannelEvent
|
|
}
|
|
|
|
type webhookAutomationDBProvider struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func (p webhookAutomationDBProvider) DB() *gorm.DB { return p.db }
|
|
|
|
func (l *recordingListener) Name() string { return "recording-listener" }
|
|
|
|
func (l *recordingListener) OnEvent(ctx context.Context, event *channel.ChannelEvent) error {
|
|
l.events = append(l.events, event)
|
|
return nil
|
|
}
|
|
|
|
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{},
|
|
&model.DeliveryStatus{},
|
|
&model.BackgroundJob{},
|
|
&automation.AutomationRule{},
|
|
&automation.AutomationExecution{},
|
|
&channelmodel.ChannelTelegram{},
|
|
&channelmodel.ChannelLINE{},
|
|
&channelmodel.ChannelTwilioSMS{},
|
|
&channelmodel.ChannelWhatsApp{},
|
|
&channelmodel.ChannelTikTok{},
|
|
&channelmodel.ChannelInstagram{},
|
|
&model.IntegrationHook{},
|
|
); err != nil {
|
|
t.Fatalf("migrate webhook lookup models: %v", err)
|
|
}
|
|
return db
|
|
}
|
|
|
|
func TestIncomingPersisterTriggersAutomationRuleFromMessageCreated(t *testing.T) {
|
|
db := newWebhookLookupTestDB(t)
|
|
inbox := seedWebhookInbox(t, db, "telegram")
|
|
dispatcher := channel.NewDispatcher()
|
|
automation.RegisterAutomationRuleListener(dispatcher, webhookAutomationDBProvider{db: db})
|
|
persister := NewIncomingPersister(db, dispatcher)
|
|
|
|
rule := &automation.AutomationRule{
|
|
AccountID: inbox.AccountID,
|
|
EventName: "message_created",
|
|
Name: "provider message created",
|
|
Conditions: automation.Conditions{},
|
|
Actions: automation.Actions{},
|
|
Active: true,
|
|
}
|
|
if err := automation.NewAutomationRuleService(webhookAutomationDBProvider{db: db}).Create(t.Context(), rule); err != nil {
|
|
t.Fatalf("create automation rule: %v", err)
|
|
}
|
|
|
|
msg := &channel.IncomingMessage{
|
|
ChannelType: channel.ChannelTelegram,
|
|
SourceID: "tg-automation-1",
|
|
SenderID: "tg-automation-user",
|
|
SenderName: "Automation User",
|
|
SenderType: channel.SenderContact,
|
|
Content: "trigger automation",
|
|
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)
|
|
}
|
|
|
|
var exec automation.AutomationExecution
|
|
if err := db.Where("rule_id = ? AND conversation_id = ?", rule.ID, result.Conversation.ID).First(&exec).Error; err != nil {
|
|
t.Fatalf("expected automation execution from provider message_created: %v", err)
|
|
}
|
|
if exec.Status != automation.ExecutionStatusSuccess {
|
|
t.Fatalf("expected success execution, got %s", exec.Status)
|
|
}
|
|
}
|
|
|
|
func TestIncomingPersisterUpdatesMessageStatus(t *testing.T) {
|
|
db := newWebhookLookupTestDB(t)
|
|
inbox := seedWebhookInbox(t, db, "telegram")
|
|
dispatcher := channel.NewDispatcher()
|
|
listener := &recordingListener{}
|
|
dispatcher.Register(listener)
|
|
persister := NewIncomingPersister(db, dispatcher)
|
|
|
|
msg := &channel.IncomingMessage{
|
|
ChannelType: channel.ChannelTelegram,
|
|
SourceID: "tg-status-1",
|
|
SenderID: "tg-user-status",
|
|
SenderName: "Status User",
|
|
SenderType: channel.SenderContact,
|
|
Content: "status me",
|
|
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 err := persister.UpdateMessageStatus(t.Context(), &inbox, "tg-status-1", model.MessageStatusRead, nil); err != nil {
|
|
t.Fatalf("update status: %v", err)
|
|
}
|
|
var message model.Message
|
|
if err := db.First(&message, result.Message.ID).Error; err != nil {
|
|
t.Fatalf("load message: %v", err)
|
|
}
|
|
if message.Status != string(model.MessageStatusRead) {
|
|
t.Fatalf("expected read status, got %s", message.Status)
|
|
}
|
|
var delivery model.DeliveryStatus
|
|
if err := db.Where("message_id = ? AND contact_id = ?", message.ID, result.Contact.ID).First(&delivery).Error; err != nil {
|
|
t.Fatalf("expected delivery status: %v", err)
|
|
}
|
|
if delivery.Status != model.MessageStatusRead {
|
|
t.Fatalf("expected delivery read, got %s", delivery.Status)
|
|
}
|
|
if !listenerSaw(listener, channel.EventMessageStatusUpdated) {
|
|
t.Fatalf("expected message.status_updated event, got %#v", listener.events)
|
|
}
|
|
}
|
|
|
|
func TestIncomingPersisterQueuesMessageStatusUpdateWithWorker(t *testing.T) {
|
|
db := newWebhookLookupTestDB(t)
|
|
inbox := seedWebhookInbox(t, db, "telegram")
|
|
dispatcher := channel.NewDispatcher()
|
|
listener := &recordingListener{}
|
|
dispatcher.Register(listener)
|
|
wp := worker.NewWorkerPoolWithOptions(db, worker.WithQueues("low"))
|
|
persister := NewIncomingPersister(db, dispatcher).SetWorkerPool(wp)
|
|
|
|
msg := &channel.IncomingMessage{
|
|
ChannelType: channel.ChannelTelegram,
|
|
SourceID: "tg-status-job-1",
|
|
SenderID: "tg-user-status-job",
|
|
SenderName: "Status Worker User",
|
|
SenderType: channel.SenderContact,
|
|
Content: "status me async",
|
|
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)
|
|
}
|
|
|
|
occurredAt := time.Now().UTC().Add(-time.Minute)
|
|
if err := persister.UpdateMessageStatus(t.Context(), &inbox, "tg-status-job-1", model.MessageStatusDelivered, &occurredAt); err != nil {
|
|
t.Fatalf("enqueue status: %v", err)
|
|
}
|
|
var queued model.BackgroundJob
|
|
if err := db.Where("job_type = ? AND queue = ? AND status = ?", TaskTypeProviderMessageStatusUpdate, "low", model.BackgroundJobStatusQueued).First(&queued).Error; err != nil {
|
|
t.Fatalf("expected queued status job: %v", err)
|
|
}
|
|
var before model.Message
|
|
if err := db.First(&before, result.Message.ID).Error; err != nil {
|
|
t.Fatalf("load before message: %v", err)
|
|
}
|
|
if before.Status != string(model.MessageStatusSent) {
|
|
t.Fatalf("expected status to remain sent before worker, got %s", before.Status)
|
|
}
|
|
|
|
processed, err := wp.ProcessOne(t.Context())
|
|
if err != nil || !processed {
|
|
t.Fatalf("process status job processed=%v err=%v", processed, err)
|
|
}
|
|
var updated model.Message
|
|
if err := db.First(&updated, result.Message.ID).Error; err != nil {
|
|
t.Fatalf("load updated message: %v", err)
|
|
}
|
|
if updated.Status != string(model.MessageStatusDelivered) {
|
|
t.Fatalf("expected delivered status, got %s", updated.Status)
|
|
}
|
|
var delivery model.DeliveryStatus
|
|
if err := db.Where("message_id = ? AND contact_id = ?", updated.ID, result.Contact.ID).First(&delivery).Error; err != nil {
|
|
t.Fatalf("expected delivery status: %v", err)
|
|
}
|
|
if delivery.Status != model.MessageStatusDelivered || delivery.DeliveredAt == nil {
|
|
t.Fatalf("expected delivered timestamp, got %#v", delivery)
|
|
}
|
|
if !listenerSaw(listener, channel.EventMessageStatusUpdated) {
|
|
t.Fatalf("expected message.status_updated event, got %#v", listener.events)
|
|
}
|
|
}
|
|
|
|
func TestIncomingPersisterStatusJobDoesNotDowngradeRead(t *testing.T) {
|
|
db := newWebhookLookupTestDB(t)
|
|
inbox := seedWebhookInbox(t, db, "telegram")
|
|
contact := model.Contact{AccountID: inbox.AccountID, Name: "Read User"}
|
|
if err := db.Create(&contact).Error; err != nil {
|
|
t.Fatalf("create contact: %v", err)
|
|
}
|
|
conversation := model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
|
|
if err := db.Create(&conversation).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
message := model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, SenderID: &contact.ID, SenderType: string(model.SenderTypeContact), Content: "already read", ContentType: string(model.MessageContentTypeText), MessageType: string(model.MessageTypeOutgoing), Status: string(model.MessageStatusRead), SourceID: "provider-read-1"}
|
|
if err := db.Create(&message).Error; err != nil {
|
|
t.Fatalf("create message: %v", err)
|
|
}
|
|
wp := worker.NewWorkerPoolWithOptions(db, worker.WithQueues("low"))
|
|
persister := NewIncomingPersister(db).SetWorkerPool(wp)
|
|
|
|
if err := persister.UpdateMessageStatus(t.Context(), &inbox, "provider-read-1", model.MessageStatusDelivered, nil); err != nil {
|
|
t.Fatalf("enqueue downgrade: %v", err)
|
|
}
|
|
processed, err := wp.ProcessOne(t.Context())
|
|
if err != nil || !processed {
|
|
t.Fatalf("process downgrade job processed=%v err=%v", processed, err)
|
|
}
|
|
var updated model.Message
|
|
if err := db.First(&updated, message.ID).Error; err != nil {
|
|
t.Fatalf("load message: %v", err)
|
|
}
|
|
if updated.Status != string(model.MessageStatusRead) {
|
|
t.Fatalf("expected read to remain read, got %s", updated.Status)
|
|
}
|
|
}
|
|
|
|
func TestIncomingPersisterStatusJobStoresExternalError(t *testing.T) {
|
|
db := newWebhookLookupTestDB(t)
|
|
inbox := seedWebhookInbox(t, db, "twilio_sms")
|
|
contact := model.Contact{AccountID: inbox.AccountID, Name: "SMS User"}
|
|
if err := db.Create(&contact).Error; err != nil {
|
|
t.Fatalf("create contact: %v", err)
|
|
}
|
|
conversation := model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
|
|
if err := db.Create(&conversation).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
message := model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, SenderID: &contact.ID, SenderType: string(model.SenderTypeContact), Content: "failed", ContentType: string(model.MessageContentTypeText), MessageType: string(model.MessageTypeOutgoing), Status: string(model.MessageStatusSent), SourceID: "SMFAIL1"}
|
|
if err := db.Create(&message).Error; err != nil {
|
|
t.Fatalf("create message: %v", err)
|
|
}
|
|
wp := worker.NewWorkerPoolWithOptions(db, worker.WithQueues("low"))
|
|
persister := NewIncomingPersister(db).SetWorkerPool(wp)
|
|
|
|
if err := persister.UpdateMessageStatusWithError(t.Context(), &inbox, "SMFAIL1", model.MessageStatusFailed, nil, "30003 - unreachable handset"); err != nil {
|
|
t.Fatalf("enqueue failure: %v", err)
|
|
}
|
|
processed, err := wp.ProcessOne(t.Context())
|
|
if err != nil || !processed {
|
|
t.Fatalf("process failure job processed=%v err=%v", processed, err)
|
|
}
|
|
var updated model.Message
|
|
if err := db.First(&updated, message.ID).Error; err != nil {
|
|
t.Fatalf("load failed message: %v", err)
|
|
}
|
|
if updated.Status != string(model.MessageStatusFailed) {
|
|
t.Fatalf("expected failed status, got %s", updated.Status)
|
|
}
|
|
attrs := map[string]any{}
|
|
if err := json.Unmarshal(updated.ContentAttributes, &attrs); err != nil {
|
|
t.Fatalf("unmarshal content attrs: %v", err)
|
|
}
|
|
if attrs["external_error"] != "30003 - unreachable handset" {
|
|
t.Fatalf("expected external_error, got %#v", attrs)
|
|
}
|
|
}
|
|
|
|
func TestIncomingPersisterQueuesContactMessagesStatusUpdateWithWorker(t *testing.T) {
|
|
db := newWebhookLookupTestDB(t)
|
|
inbox := seedWebhookInbox(t, db, "facebook")
|
|
contact := model.Contact{AccountID: inbox.AccountID, Name: "Meta User"}
|
|
if err := db.Create(&contact).Error; err != nil {
|
|
t.Fatalf("create contact: %v", err)
|
|
}
|
|
contactInbox := model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "meta-user-1", PubsubToken: "pub-meta"}
|
|
if err := db.Create(&contactInbox).Error; err != nil {
|
|
t.Fatalf("create contact inbox: %v", err)
|
|
}
|
|
conversation := model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &contactInbox.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
|
|
if err := db.Create(&conversation).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
cutoff := time.Now().UTC()
|
|
beforeSent := createWebhookStatusMessage(t, db, inbox, conversation, "sent-before", model.MessageStatusSent, cutoff.Add(-time.Minute))
|
|
beforeDelivered := createWebhookStatusMessage(t, db, inbox, conversation, "delivered-before", model.MessageStatusDelivered, cutoff.Add(-30*time.Second))
|
|
alreadyRead := createWebhookStatusMessage(t, db, inbox, conversation, "read-before", model.MessageStatusRead, cutoff.Add(-20*time.Second))
|
|
afterCutoff := createWebhookStatusMessage(t, db, inbox, conversation, "sent-after", model.MessageStatusSent, cutoff.Add(time.Minute))
|
|
wp := worker.NewWorkerPoolWithOptions(db, worker.WithQueues("low"))
|
|
persister := NewIncomingPersister(db).SetWorkerPool(wp)
|
|
|
|
if err := persister.UpdateContactConversationMessagesStatus(t.Context(), &inbox, "meta-user-1", model.MessageStatusRead, &cutoff); err != nil {
|
|
t.Fatalf("enqueue contact read: %v", err)
|
|
}
|
|
var queued model.BackgroundJob
|
|
if err := db.Where("job_type = ? AND queue = ?", TaskTypeProviderContactMessagesStatusUpdate, "low").First(&queued).Error; err != nil {
|
|
t.Fatalf("expected queued contact status job: %v", err)
|
|
}
|
|
processed, err := wp.ProcessOne(t.Context())
|
|
if err != nil || !processed {
|
|
t.Fatalf("process contact status job processed=%v err=%v", processed, err)
|
|
}
|
|
assertWebhookMessageStatus(t, db, beforeSent.ID, model.MessageStatusRead)
|
|
assertWebhookMessageStatus(t, db, beforeDelivered.ID, model.MessageStatusRead)
|
|
assertWebhookMessageStatus(t, db, alreadyRead.ID, model.MessageStatusRead)
|
|
assertWebhookMessageStatus(t, db, afterCutoff.ID, model.MessageStatusSent)
|
|
|
|
var deliveryCount int64
|
|
if err := db.Model(&model.DeliveryStatus{}).Where("contact_id = ? AND status = ?", contact.ID, model.MessageStatusRead).Count(&deliveryCount).Error; err != nil {
|
|
t.Fatalf("count delivery statuses: %v", err)
|
|
}
|
|
if deliveryCount != 3 {
|
|
t.Fatalf("expected 3 read delivery statuses, got %d", deliveryCount)
|
|
}
|
|
}
|
|
|
|
func TestIncomingPersisterCreatesConversationMessageAndDedupes(t *testing.T) {
|
|
db := newWebhookLookupTestDB(t)
|
|
inbox := seedWebhookInbox(t, db, "telegram")
|
|
dispatcher := channel.NewDispatcher()
|
|
listener := &recordingListener{}
|
|
dispatcher.Register(listener)
|
|
persister := NewIncomingPersister(db, dispatcher)
|
|
|
|
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)
|
|
}
|
|
for _, eventType := range []channel.EventType{channel.EventContactCreated, channel.EventConversationCreated, channel.EventConversationOpened, channel.EventMessageCreated, channel.EventMessageIncoming} {
|
|
if !listenerSaw(listener, eventType) {
|
|
t.Fatalf("expected event %s, got %#v", eventType, listener.events)
|
|
}
|
|
}
|
|
}
|
|
|
|
func listenerSaw(listener *recordingListener, eventType channel.EventType) bool {
|
|
for _, event := range listener.events {
|
|
if event.Type == eventType {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func createWebhookStatusMessage(t *testing.T, db *gorm.DB, inbox model.Inbox, conversation model.Conversation, sourceID string, status model.MessageStatus, createdAt time.Time) model.Message {
|
|
t.Helper()
|
|
message := model.Message{
|
|
ConversationID: conversation.ID,
|
|
AccountID: inbox.AccountID,
|
|
InboxID: inbox.ID,
|
|
Content: sourceID,
|
|
ContentType: string(model.MessageContentTypeText),
|
|
MessageType: string(model.MessageTypeOutgoing),
|
|
Status: string(status),
|
|
SourceID: sourceID,
|
|
}
|
|
message.CreatedAt = createdAt
|
|
message.UpdatedAt = createdAt
|
|
if err := db.Create(&message).Error; err != nil {
|
|
t.Fatalf("create status message: %v", err)
|
|
}
|
|
return message
|
|
}
|
|
|
|
func assertWebhookMessageStatus(t *testing.T, db *gorm.DB, messageID uint, want model.MessageStatus) {
|
|
t.Helper()
|
|
var message model.Message
|
|
if err := db.First(&message, messageID).Error; err != nil {
|
|
t.Fatalf("load message %d: %v", messageID, err)
|
|
}
|
|
if message.Status != string(want) {
|
|
t.Fatalf("message %d expected %s, got %s", messageID, want, message.Status)
|
|
}
|
|
}
|
|
|
|
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 lineSignature(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 tiktokSignature(secret string, timestamp int64, body []byte) string {
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
mac.Write([]byte(strconv.FormatInt(timestamp, 10) + "." + string(body)))
|
|
return "t=" + strconv.FormatInt(timestamp, 10) + ",s=" + 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 TestLineWebhookPersistsIncomingMessage(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db := newWebhookLookupTestDB(t)
|
|
inbox := seedWebhookInbox(t, db, "line")
|
|
inbox.ChannelConfig = `{"channel_secret":"line-secret"}`
|
|
if err := db.Save(&inbox).Error; err != nil {
|
|
t.Fatalf("update line inbox config: %v", err)
|
|
}
|
|
channelRecord := channelmodel.ChannelLINE{AccountID: 1, InboxID: inbox.ID, ChannelID: "line-channel-1", Name: "LINE OA"}
|
|
if err := db.Create(&channelRecord).Error; err != nil {
|
|
t.Fatalf("create line channel: %v", err)
|
|
}
|
|
|
|
lineRepo := linechannel.NewRepository(db)
|
|
lineService := linechannel.NewLineService(lineRepo)
|
|
linePipeline := linechannel.NewIncomingProcessor(lineService)
|
|
h := NewLineWebhookHandler(nil, linePipeline, lineService, db)
|
|
r := gin.New()
|
|
r.POST("/webhooks/line/:line_channel_id", h.HandleLineWebhook)
|
|
body := []byte(`{"destination":"line-channel-1","events":[{"type":"message","replyToken":"reply-1","timestamp":1710000000000,"source":{"type":"user","userId":"line-user-1"},"message":{"type":"text","id":"line-msg-1","text":"hello line"}}]}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/webhooks/line/line-channel-1", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Line-Signature", lineSignature("line-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())
|
|
}
|
|
assertPersistedMessage(t, db, inbox.ID, "line-msg-1", "hello line")
|
|
}
|
|
|
|
func TestLineWebhookRejectsMissingSignatureWhenSecretConfigured(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db := newWebhookLookupTestDB(t)
|
|
inbox := seedWebhookInbox(t, db, "line")
|
|
inbox.ChannelConfig = `{"channel_secret":"line-secret"}`
|
|
if err := db.Save(&inbox).Error; err != nil {
|
|
t.Fatalf("update line inbox config: %v", err)
|
|
}
|
|
channelRecord := channelmodel.ChannelLINE{AccountID: 1, InboxID: inbox.ID, ChannelID: "line-channel-1", Name: "LINE OA"}
|
|
if err := db.Create(&channelRecord).Error; err != nil {
|
|
t.Fatalf("create line channel: %v", err)
|
|
}
|
|
|
|
lineRepo := linechannel.NewRepository(db)
|
|
lineService := linechannel.NewLineService(lineRepo)
|
|
linePipeline := linechannel.NewIncomingProcessor(lineService)
|
|
h := NewLineWebhookHandler(nil, linePipeline, lineService, db)
|
|
r := gin.New()
|
|
r.POST("/webhooks/line/:line_channel_id", h.HandleLineWebhook)
|
|
body := []byte(`{"destination":"line-channel-1","events":[{"type":"message","replyToken":"reply-1","timestamp":1710000000000,"source":{"type":"user","userId":"line-user-1"},"message":{"type":"text","id":"line-msg-missing-sig","text":"hello line"}}]}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/webhooks/line/line-channel-1", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var count int64
|
|
if err := db.Model(&model.Message{}).Where("inbox_id = ? AND source_id = ?", inbox.ID, "line-msg-missing-sig").Count(&count).Error; err != nil {
|
|
t.Fatalf("count message: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("expected no persisted message, got %d", count)
|
|
}
|
|
}
|
|
|
|
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 TestTwilioWebhookPersistsIncomingMessage(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db := newWebhookLookupTestDB(t)
|
|
inbox := seedWebhookInbox(t, db, "twilio_sms")
|
|
channelRecord := channelmodel.ChannelTwilioSMS{AccountID: 1, InboxID: inbox.ID, AccountSID: "AC123", PhoneNumber: "+15551234567"}
|
|
if err := db.Create(&channelRecord).Error; err != nil {
|
|
t.Fatalf("create twilio channel: %v", err)
|
|
}
|
|
|
|
twilioRepo := twiliochannel.NewRepository(db)
|
|
twilioService := twiliochannel.NewTwilioService(twilioRepo)
|
|
twilioPipeline := twiliochannel.NewIncomingProcessor(twilioService)
|
|
twilioWebhook := twiliochannel.NewWebhookHandler(twilioPipeline, twilioService)
|
|
h := NewTwilioWebhookHandler(twilioWebhook, db)
|
|
r := gin.New()
|
|
r.POST("/webhooks/sms/:phone_number", h.HandleTwilioInboundSMS)
|
|
form := url.Values{}
|
|
form.Set("MessageSid", "SMIN1")
|
|
form.Set("AccountSid", "AC123")
|
|
form.Set("From", "+15550002222")
|
|
form.Set("To", "+15551234567")
|
|
form.Set("Body", "hello sms")
|
|
req := httptest.NewRequest(http.MethodPost, "/webhooks/sms/+15551234567", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
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())
|
|
}
|
|
assertPersistedMessage(t, db, inbox.ID, "SMIN1", "hello sms")
|
|
}
|
|
|
|
func TestTwilioDeliveryStatusUpdatesExistingMessage(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db := newWebhookLookupTestDB(t)
|
|
inbox := seedWebhookInbox(t, db, "twilio_sms")
|
|
channelRecord := channelmodel.ChannelTwilioSMS{AccountID: 1, InboxID: inbox.ID, AccountSID: "AC123", PhoneNumber: "+15551234567"}
|
|
if err := db.Create(&channelRecord).Error; err != nil {
|
|
t.Fatalf("create twilio channel: %v", err)
|
|
}
|
|
contact := model.Contact{AccountID: inbox.AccountID, Name: "SMS Contact", Identifier: "+15550001111"}
|
|
if err := db.Create(&contact).Error; err != nil {
|
|
t.Fatalf("create contact: %v", err)
|
|
}
|
|
contactInbox := model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "+15550001111", PubsubToken: "pub-twilio"}
|
|
if err := db.Create(&contactInbox).Error; err != nil {
|
|
t.Fatalf("create contact inbox: %v", err)
|
|
}
|
|
conversation := model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &contactInbox.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
|
|
if err := db.Create(&conversation).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
message := model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, SenderID: &contact.ID, SenderType: string(model.SenderTypeContact), Content: "out", ContentType: string(model.MessageContentTypeText), MessageType: string(model.MessageTypeOutgoing), Status: string(model.MessageStatusSent), SourceID: "SM123"}
|
|
if err := db.Create(&message).Error; err != nil {
|
|
t.Fatalf("create message: %v", err)
|
|
}
|
|
|
|
h := NewTwilioWebhookHandler(nil, db)
|
|
r := gin.New()
|
|
r.POST("/webhooks/twilio/status/:phone_number", h.HandleTwilioDeliveryStatus)
|
|
req := httptest.NewRequest(http.MethodPost, "/webhooks/twilio/status/+15551234567", strings.NewReader("MessageSid=SM123&MessageStatus=delivered"))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
w := httptest.NewRecorder()
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("expected 204, got %d", w.Code)
|
|
}
|
|
var updated model.Message
|
|
if err := db.First(&updated, message.ID).Error; err != nil {
|
|
t.Fatalf("load message: %v", err)
|
|
}
|
|
if updated.Status != string(model.MessageStatusDelivered) {
|
|
t.Fatalf("expected delivered, got %s", updated.Status)
|
|
}
|
|
}
|
|
|
|
func TestTwilioDeliveryStatusQueuesExactRouteWithWorker(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db := newWebhookLookupTestDB(t)
|
|
inbox := seedWebhookInbox(t, db, "twilio_sms")
|
|
channelRecord := channelmodel.ChannelTwilioSMS{AccountID: 1, InboxID: inbox.ID, AccountSID: "AC123", PhoneNumber: "+15551234567", MessagingServiceSID: "MG123"}
|
|
if err := db.Create(&channelRecord).Error; err != nil {
|
|
t.Fatalf("create twilio channel: %v", err)
|
|
}
|
|
contact := model.Contact{AccountID: inbox.AccountID, Name: "SMS Contact", Identifier: "+15550001111"}
|
|
if err := db.Create(&contact).Error; err != nil {
|
|
t.Fatalf("create contact: %v", err)
|
|
}
|
|
conversation := model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
|
|
if err := db.Create(&conversation).Error; err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
message := model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, SenderID: &contact.ID, SenderType: string(model.SenderTypeContact), Content: "out", ContentType: string(model.MessageContentTypeText), MessageType: string(model.MessageTypeOutgoing), Status: string(model.MessageStatusSent), SourceID: "SMFAILED"}
|
|
if err := db.Create(&message).Error; err != nil {
|
|
t.Fatalf("create message: %v", err)
|
|
}
|
|
wp := worker.NewWorkerPoolWithOptions(db, worker.WithQueues("low"))
|
|
h := NewTwilioWebhookHandler(nil, db).WithWorkerPool(wp)
|
|
r := gin.New()
|
|
r.POST("/twilio/delivery_status", h.HandleTwilioDeliveryStatus)
|
|
form := url.Values{}
|
|
form.Set("MessagingServiceSid", "MG123")
|
|
form.Set("MessageSid", "SMFAILED")
|
|
form.Set("MessageStatus", "undelivered")
|
|
form.Set("ErrorCode", "30003")
|
|
form.Set("ErrorMessage", "Unreachable handset")
|
|
req := httptest.NewRequest(http.MethodPost, "/twilio/delivery_status", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
w := httptest.NewRecorder()
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("expected 204, got %d", w.Code)
|
|
}
|
|
var queued model.BackgroundJob
|
|
if err := db.Where("job_type = ? AND queue = ? AND status = ?", TaskTypeProviderMessageStatusUpdate, "low", model.BackgroundJobStatusQueued).First(&queued).Error; err != nil {
|
|
t.Fatalf("expected queued twilio status job: %v", err)
|
|
}
|
|
var before model.Message
|
|
if err := db.First(&before, message.ID).Error; err != nil {
|
|
t.Fatalf("load before message: %v", err)
|
|
}
|
|
if before.Status != string(model.MessageStatusSent) {
|
|
t.Fatalf("expected message to remain sent before worker, got %s", before.Status)
|
|
}
|
|
processed, err := wp.ProcessOne(t.Context())
|
|
if err != nil || !processed {
|
|
t.Fatalf("process twilio status job processed=%v err=%v", processed, err)
|
|
}
|
|
var updated model.Message
|
|
if err := db.First(&updated, message.ID).Error; err != nil {
|
|
t.Fatalf("load updated message: %v", err)
|
|
}
|
|
if updated.Status != string(model.MessageStatusFailed) {
|
|
t.Fatalf("expected failed status, got %s", updated.Status)
|
|
}
|
|
attrs := map[string]any{}
|
|
if err := json.Unmarshal(updated.ContentAttributes, &attrs); err != nil {
|
|
t.Fatalf("unmarshal attrs: %v", err)
|
|
}
|
|
if attrs["external_error"] != "30003 - Unreachable handset" {
|
|
t.Fatalf("expected twilio external error, got %#v", attrs)
|
|
}
|
|
}
|
|
|
|
func TestWhatsAppWebhookPersistsIncomingMessage(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db := newWebhookLookupTestDB(t)
|
|
inbox := seedWebhookInbox(t, db, "whatsapp")
|
|
waChannel := channelmodel.ChannelWhatsApp{AccountID: 1, InboxID: inbox.ID, PhoneNumber: "+15551230000", PhoneNumberID: "phone-id-1", AccessToken: "token", Provider: "whatsapp_cloud", ProviderConfig: `{"app_secret":"wa-secret"}`, WebhookVerifyToken: "verify-token"}
|
|
if err := db.Create(&waChannel).Error; err != nil {
|
|
t.Fatalf("create whatsapp channel: %v", err)
|
|
}
|
|
|
|
waRepo := whatsappchannel.NewRepository(db)
|
|
waService := whatsappchannel.NewWhatsAppService(waRepo)
|
|
waPipeline := whatsappchannel.NewIncomingPipeline(waService)
|
|
waProvider := whatsappchannel.NewWhatsAppProvider(waService, waRepo, waPipeline)
|
|
waWebhook := whatsappchannel.NewWebhookHandler(waProvider)
|
|
h := NewWhatsAppWebhookHandler(waProvider, waWebhook, db)
|
|
r := gin.New()
|
|
r.POST("/webhooks/whatsapp/:phone_number", h.HandleWhatsAppWebhook)
|
|
body := []byte(`{"object":"whatsapp_business_account","entry":[{"id":"waba-1","changes":[{"field":"messages","value":{"messaging_product":"whatsapp","metadata":{"display_phone_number":"+15551230000","phone_number_id":"phone-id-1"},"contacts":[{"wa_id":"15550001111","profile":{"name":"WhatsApp User"}}],"messages":[{"from":"15550001111","id":"wamid-1","timestamp":"1710000000","type":"text","text":{"body":"hello whatsapp"}}]}}]}]}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/webhooks/whatsapp/+15551230000", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Hub-Signature-256", metaSignature("wa-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())
|
|
}
|
|
assertPersistedMessage(t, db, inbox.ID, "wamid-1", "hello whatsapp")
|
|
}
|
|
|
|
func TestWhatsAppWebhookVerificationEchoesChallenge(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db := newWebhookLookupTestDB(t)
|
|
inbox := seedWebhookInbox(t, db, "whatsapp")
|
|
waChannel := channelmodel.ChannelWhatsApp{AccountID: 1, InboxID: inbox.ID, PhoneNumber: "+15551230000", PhoneNumberID: "phone-id-1", AccessToken: "token", Provider: "whatsapp_cloud", WebhookVerifyToken: "verify-token"}
|
|
if err := db.Create(&waChannel).Error; err != nil {
|
|
t.Fatalf("create whatsapp channel: %v", err)
|
|
}
|
|
|
|
waRepo := whatsappchannel.NewRepository(db)
|
|
waService := whatsappchannel.NewWhatsAppService(waRepo)
|
|
waPipeline := whatsappchannel.NewIncomingPipeline(waService)
|
|
waProvider := whatsappchannel.NewWhatsAppProvider(waService, waRepo, waPipeline)
|
|
waWebhook := whatsappchannel.NewWebhookHandler(waProvider)
|
|
h := NewWhatsAppWebhookHandler(waProvider, waWebhook, db)
|
|
r := gin.New()
|
|
r.GET("/webhooks/whatsapp/:phone_number", h.HandleWhatsAppVerification)
|
|
req := httptest.NewRequest(http.MethodGet, "/webhooks/whatsapp/+15551230000?hub.mode=subscribe&hub.verify_token=verify-token&hub.challenge=challenge-wa", nil)
|
|
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())
|
|
}
|
|
if w.Body.String() != "challenge-wa" {
|
|
t.Fatalf("unexpected challenge body: %q", w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestWhatsAppCloudWebhookRejectsMissingSignature(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db := newWebhookLookupTestDB(t)
|
|
inbox := seedWebhookInbox(t, db, "whatsapp")
|
|
waChannel := channelmodel.ChannelWhatsApp{AccountID: 1, InboxID: inbox.ID, PhoneNumber: "+15551230000", PhoneNumberID: "phone-id-1", AccessToken: "token", Provider: "whatsapp_cloud", ProviderConfig: `{"app_secret":"wa-secret"}`, WebhookVerifyToken: "verify-token"}
|
|
if err := db.Create(&waChannel).Error; err != nil {
|
|
t.Fatalf("create whatsapp channel: %v", err)
|
|
}
|
|
|
|
waRepo := whatsappchannel.NewRepository(db)
|
|
waService := whatsappchannel.NewWhatsAppService(waRepo)
|
|
waPipeline := whatsappchannel.NewIncomingPipeline(waService)
|
|
waProvider := whatsappchannel.NewWhatsAppProvider(waService, waRepo, waPipeline)
|
|
waWebhook := whatsappchannel.NewWebhookHandler(waProvider)
|
|
h := NewWhatsAppWebhookHandler(waProvider, waWebhook, db)
|
|
r := gin.New()
|
|
r.POST("/webhooks/whatsapp/:phone_number", h.HandleWhatsAppWebhook)
|
|
body := []byte(`{"object":"whatsapp_business_account","entry":[{"id":"waba-1","changes":[{"field":"messages","value":{"messaging_product":"whatsapp","metadata":{"display_phone_number":"+15551230000","phone_number_id":"phone-id-1"},"contacts":[{"wa_id":"15550001111","profile":{"name":"WhatsApp User"}}],"messages":[{"from":"15550001111","id":"wamid-missing-sig","timestamp":"1710000000","type":"text","text":{"body":"hello whatsapp"}}]}}]}]}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/webhooks/whatsapp/+15551230000", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var count int64
|
|
if err := db.Model(&model.Message{}).Where("inbox_id = ? AND source_id = ?", inbox.ID, "wamid-missing-sig").Count(&count).Error; err != nil {
|
|
t.Fatalf("count message: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("expected no persisted message, got %d", count)
|
|
}
|
|
}
|
|
|
|
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 TestTikTokWebhookPersistsIncomingMessage(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("TIKTOK_APP_SECRET", "tiktok-secret")
|
|
db := newWebhookLookupTestDB(t)
|
|
inbox := seedWebhookInbox(t, db, "tiktok")
|
|
channelRecord := channelmodel.ChannelTikTok{AccountID: 1, InboxID: inbox.ID, TikTokBusinessID: "biz-123", WebhookVerifyToken: "verify-token"}
|
|
if err := db.Create(&channelRecord).Error; err != nil {
|
|
t.Fatalf("create tiktok channel: %v", err)
|
|
}
|
|
|
|
ttRepo := tiktokchannel.NewRepository(db)
|
|
ttService := tiktokchannel.NewTikTokService(ttRepo)
|
|
ttPipeline := tiktokchannel.NewIncomingProcessor(ttService, ttRepo)
|
|
ttWebhook := tiktokchannel.NewWebhookHandler(ttService, ttPipeline)
|
|
h := NewTikTokWebhookHandler(ttWebhook, ttPipeline, db)
|
|
r := gin.New()
|
|
r.POST("/webhooks/tiktok", h.HandleTikTokWebhook)
|
|
body := []byte(`{"type":"message.received","timestamp":1710000000,"biz_id":"biz-123","data":{"message_id":"tt-msg-1","from_user_id":"tt-user-1","to_user_id":"biz-123","content_type":"text","content":"hello tiktok","timestamp":1710000000,"conversation_id":"tt-conv-1"}}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/webhooks/tiktok", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Tiktok-Signature", tiktokSignature("tiktok-secret", time.Now().Unix(), 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())
|
|
}
|
|
assertPersistedMessage(t, db, inbox.ID, "tt-msg-1", "hello tiktok")
|
|
}
|
|
|
|
func TestTikTokWebhookRejectsInvalidSignature(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("TIKTOK_APP_SECRET", "tiktok-secret")
|
|
db := newWebhookLookupTestDB(t)
|
|
inbox := seedWebhookInbox(t, db, "tiktok")
|
|
channelRecord := channelmodel.ChannelTikTok{AccountID: 1, InboxID: inbox.ID, TikTokBusinessID: "biz-123", WebhookVerifyToken: "verify-token"}
|
|
if err := db.Create(&channelRecord).Error; err != nil {
|
|
t.Fatalf("create tiktok channel: %v", err)
|
|
}
|
|
|
|
ttRepo := tiktokchannel.NewRepository(db)
|
|
ttService := tiktokchannel.NewTikTokService(ttRepo)
|
|
ttPipeline := tiktokchannel.NewIncomingProcessor(ttService, ttRepo)
|
|
ttWebhook := tiktokchannel.NewWebhookHandler(ttService, ttPipeline)
|
|
h := NewTikTokWebhookHandler(ttWebhook, ttPipeline, db)
|
|
r := gin.New()
|
|
r.POST("/webhooks/tiktok", h.HandleTikTokWebhook)
|
|
body := []byte(`{"type":"message.received","timestamp":1710000000,"biz_id":"biz-123","data":{"message_id":"tt-msg-invalid","from_user_id":"tt-user-1","to_user_id":"biz-123","content_type":"text","content":"hello tiktok","timestamp":1710000000,"conversation_id":"tt-conv-1"}}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/webhooks/tiktok", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Tiktok-Signature", "t="+strconv.FormatInt(time.Now().Unix(), 10)+",s=bad")
|
|
w := httptest.NewRecorder()
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var count int64
|
|
if err := db.Model(&model.Message{}).Where("inbox_id = ? AND source_id = ?", inbox.ID, "tt-msg-invalid").Count(&count).Error; err != nil {
|
|
t.Fatalf("count message: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("expected no persisted message, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestTikTokWebhookRejectsStaleSignature(t *testing.T) {
|
|
t.Setenv("TIKTOK_APP_SECRET", "tiktok-secret")
|
|
body := []byte(`{"type":"message.received","biz_id":"biz-123"}`)
|
|
timestamp := time.Now().Add(-10 * time.Second).Unix()
|
|
if err := verifyTikTokSignature(tiktokSignature("tiktok-secret", timestamp, body), body, time.Now()); err == nil {
|
|
t.Fatal("expected stale signature rejection")
|
|
}
|
|
}
|
|
|
|
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())
|
|
}
|
|
assertPersistedMessage(t, db, inbox.ID, "mid-1", "hello")
|
|
}
|
|
|
|
func TestInstagramWebhookRejectsMissingSignature(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-missing-sig","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))
|
|
w := httptest.NewRecorder()
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var count int64
|
|
if err := db.Model(&model.Message{}).Where("inbox_id = ? AND source_id = ?", inbox.ID, "mid-missing-sig").Count(&count).Error; err != nil {
|
|
t.Fatalf("count message: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("expected no persisted message, got %d", count)
|
|
}
|
|
}
|
|
|
|
func assertPersistedMessage(t *testing.T, db *gorm.DB, inboxID uint, sourceID string, content string) model.Message {
|
|
t.Helper()
|
|
var message model.Message
|
|
if err := db.Where("inbox_id = ? AND source_id = ?", inboxID, sourceID).First(&message).Error; err != nil {
|
|
t.Fatalf("expected message source_id=%s persisted: %v", sourceID, err)
|
|
}
|
|
if message.Content != content || message.MessageType != string(model.MessageTypeIncoming) {
|
|
t.Fatalf("unexpected message source_id=%s: %#v", sourceID, message)
|
|
}
|
|
return message
|
|
}
|