Files
gochat/backend/internal/channel/whatsapp/webhook_handler_test.go
T
Rogeeandrogee f719529d66 fix(security): harden auth and secret handling (HH-444) (#101)
* fix(security): harden auth and credential handling (HH-444)

* fix(security): address HH-444 review blockers

* fix(security): close remaining HH-444 review blockers

---------

Co-authored-by: Rogee <rogee@ipao.vip>
2026-08-22 15:45:06 +08:00

169 lines
5.5 KiB
Go

package whatsapp
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/model"
channelmodel "github.com/gochat/gochat/internal/model/channel"
"github.com/gochat/gochat/internal/security"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func newWhatsAppWebhookTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.Inbox{}, &channelmodel.ChannelWhatsApp{}); err != nil {
t.Fatalf("migrate whatsapp models: %v", err)
}
return db
}
func TestWhatsAppWebhookLookupByVerifyToken(t *testing.T) {
db := newWhatsAppWebhookTestDB(t)
inbox := model.Inbox{AccountID: 1, Name: "wa", ChannelType: "whatsapp", ChannelID: 1, Enabled: true}
if err := db.Create(&inbox).Error; err != nil {
t.Fatalf("create inbox: %v", err)
}
channel := channelmodel.ChannelWhatsApp{
AccountID: 1,
InboxID: inbox.ID,
PhoneNumber: "+15551234567",
PhoneNumberID: "phone-number-id",
AccessToken: "access-token",
Provider: "whatsapp_cloud",
WebhookVerifyToken: "verify-token",
ProviderConfig: `{"app_secret":"app-secret"}`,
BusinessAccountID: "waba-id",
WhatsAppAccountName: "WA",
}
if err := db.Create(&channel).Error; err != nil {
t.Fatalf("create whatsapp channel: %v", err)
}
repo := NewRepository(db)
provider := NewWhatsAppProvider(nil, repo, nil)
h := NewWebhookHandler(provider)
found, err := h.lookupByVerifyToken("verify-token")
if err != nil {
t.Fatalf("lookup verify token: %v", err)
}
if found.ID != channel.ID {
t.Fatalf("unexpected channel id: %d", found.ID)
}
if secret := resolveCloudAppSecret(found); secret != "app-secret" {
t.Fatalf("unexpected app secret: %q", secret)
}
}
func TestWhatsAppWebhookLookupUsesDigestForEncryptedToken(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:encrypted-whatsapp?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Inbox{}, &channelmodel.ChannelWhatsApp{}); err != nil {
t.Fatal(err)
}
key := base64.StdEncoding.EncodeToString([]byte("11111111111111111111111111111111"))
encryptor, err := security.NewEncryptor(security.EncryptionConfig{Enabled: true, AESKey: key, KeyVersion: 1})
if err != nil {
t.Fatal(err)
}
if err := security.RegisterGORMEncryption(db, encryptor); err != nil {
t.Fatal(err)
}
inbox := model.Inbox{AccountID: 1, Name: "encrypted-wa", ChannelType: "whatsapp", ChannelID: 1, Enabled: true}
if err := db.Create(&inbox).Error; err != nil {
t.Fatal(err)
}
record := channelmodel.ChannelWhatsApp{AccountID: 1, InboxID: inbox.ID, PhoneNumber: "+15551234568", AccessToken: "access-token", WebhookVerifyToken: "verify-token"}
if err := db.Create(&record).Error; err != nil {
t.Fatal(err)
}
handler := NewWebhookHandler(NewWhatsAppProvider(nil, NewRepository(db), nil))
found, err := handler.lookupByVerifyToken("verify-token")
if err != nil {
t.Fatal(err)
}
if found.ID != record.ID || found.WebhookVerifyToken != "verify-token" {
t.Fatalf("unexpected encrypted lookup result: %#v", found)
}
}
func TestWhatsAppCloudSignatureUsesAppSecret(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"object":"whatsapp_business_account"}`)
mac := hmac.New(sha256.New, []byte("app-secret"))
mac.Write(body)
signature := "sha256=" + hex.EncodeToString(mac.Sum(nil))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/webhooks/whatsapp/phone", nil)
c.Request.Header.Set("X-Hub-Signature-256", signature)
h := NewWebhookHandler(nil)
if err := h.verifyCloudSignature(c, body, "app-secret"); err != nil {
t.Fatalf("verify signature: %v", err)
}
if err := h.verifyCloudSignature(c, body, "wrong-secret"); err == nil {
t.Fatal("expected wrong app secret to fail")
}
}
func TestWhatsAppWebhookRetriesWhenChannelConfigLookupFails(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWhatsAppWebhookTestDB(t)
inbox := model.Inbox{AccountID: 1, Name: "wa-retry", ChannelType: "whatsapp", ChannelID: 1, Enabled: true}
if err := db.Create(&inbox).Error; err != nil {
t.Fatal(err)
}
channel := channelmodel.ChannelWhatsApp{
AccountID: 1,
InboxID: inbox.ID,
PhoneNumber: "+15550000000",
PhoneNumberID: "retry-phone-id",
AccessToken: "access-token",
Provider: "whatsapp_cloud",
}
if err := db.Create(&channel).Error; err != nil {
t.Fatal(err)
}
channelQueries := 0
temporaryErr := errors.New("temporary channel lookup failure")
if err := db.Callback().Query().Before("gorm:query").Register("fail_second_channel_query", func(tx *gorm.DB) {
if tx.Statement.Table == channel.TableName() {
channelQueries++
if channelQueries == 2 {
_ = tx.AddError(temporaryErr)
}
}
}); err != nil {
t.Fatal(err)
}
body := []byte(`{"object":"whatsapp_business_account","entry":[{"changes":[{"value":{"metadata":{"phone_number_id":"retry-phone-id"}}}]}]}`)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/webhooks/whatsapp", bytes.NewReader(body))
h := NewWebhookHandler(NewWhatsAppProvider(nil, NewRepository(db), nil))
h.HandleWebhookEvent(c)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d; body=%s", w.Code, http.StatusServiceUnavailable, w.Body.String())
}
}