test(webhook): cover whatsapp ingress verification
This commit is contained in:
@@ -66,6 +66,7 @@ This ledger records the committed parity checkpoints that future slices should b
|
||||
| `55295dd test(webhook): cover provider ingress persistence fixtures` | Added provider-specific webhook persistence fixture assertions for LINE, Twilio SMS, WhatsApp, Instagram, and TikTok, extending the existing Telegram fixture. | Focused webhook tests passed; full `go test ./...` passed. | Continue P6.7 review with signature edge fixtures and final provider Done/Review classification. |
|
||||
| `0ea7a08 feat(webhook): verify tiktok ingress signatures` | Added Chatwoot-style TikTok `Tiktok-Signature` HMAC verification using `TIKTOK_APP_SECRET`, timestamp freshness, and invalid-signature rejection coverage. | Focused webhook tests passed; full `go test ./...` passed. | Continue P6.7 review with LINE/WhatsApp missing-signature edge fixtures and final provider classification. |
|
||||
| `0439f3b feat(webhook): require line ingress signatures` | Tightened LINE webhook signature parity so configured `channel_secret` requires a present and valid `X-Line-Signature`, with missing-signature rejection coverage. | Focused webhook tests passed; full `go test ./...` passed. | Continue P6.7 review with WhatsApp missing-signature edge fixtures and Shopify/Twitter final classification. |
|
||||
| Working tree | Added WhatsApp route-level verification coverage for verify-token challenge echo, signed Cloud API POST persistence, and missing Meta signature rejection without persistence. | Focused webhook tests passed; full `go test ./...` passed. | Continue P6.7 review with Shopify/Twitter final classification. |
|
||||
|
||||
## Next Slice Contract
|
||||
|
||||
@@ -89,6 +90,7 @@ Current N1/N2 implementation checkpoint:
|
||||
- Added provider-specific persistence fixtures for Telegram, LINE, Twilio SMS, WhatsApp, Instagram, and TikTok. These tests assert durable `messages` rows by provider source ID instead of only checking webhook `200 OK` acknowledgements.
|
||||
- Added TikTok webhook signature verification to match `reference/chatwoot/app/controllers/webhooks/tiktok_controller.rb`: `Tiktok-Signature` must include `t=<unix>,s=<hmac>`, the HMAC is `sha256(TIKTOK_APP_SECRET, "<timestamp>.<raw_body>")`, and stale signatures older than five seconds are rejected.
|
||||
- Tightened LINE webhook signature verification to match `reference/chatwoot/app/jobs/webhooks/line_events_job.rb`: when `channel_secret` is configured, `X-Line-Signature` must be present and equal `base64(hmac_sha256(channel_secret, raw_body))` before parsing or persistence.
|
||||
- Added WhatsApp route-level fixtures matching `reference/chatwoot/app/controllers/webhooks/whatsapp_controller.rb` and `MetaTokenVerifyConcern`: verify-token requests echo `hub.challenge`, Cloud API posts require `X-Hub-Signature-256` when an app secret is configured, and signed payloads persist incoming messages by provider source ID.
|
||||
|
||||
## Immediate Execution Queue
|
||||
|
||||
@@ -515,7 +517,7 @@ Webhook ingress subtracking:
|
||||
| P6.7b | LINE `POST /webhooks/line/:line_channel_id` | `webhooks/line#process_payload` | Router param and handler lookup were mismatched; handler read an inbox-style param instead of line channel ID. | Handler resolves `ChannelLINE` by `channel_id`, verifies `X-Line-Signature`, and persists/dispatches like Chatwoot. | Done |
|
||||
| P6.7c | Telegram `POST /webhooks/telegram/:bot_token` | `webhooks/telegram#process_payload` | Handler lookup was a placeholder and did not resolve the real inbox by bot token. | Handler resolves `ChannelTelegram` by `bot_token`, loads inbox, processes update, and returns provider-safe `200 OK`. | Review |
|
||||
| P6.7d | SMS/Twilio `POST /webhooks/sms/:phone_number` | `webhooks/sms#process_payload` | Go path was `/webhooks/twilio/sms/:phone_number`; handler read an inbox-style param. | Chatwoot path is registered, phone number resolves `ChannelTwilioSMS`, signature verification is applied where configured, and message/status events dispatch. | Review |
|
||||
| P6.7e | WhatsApp `GET/POST /webhooks/whatsapp/:phone_number` | `webhooks/whatsapp#verify`, `#process_payload` | Verify-token lookup scanned only account `0`, and Cloud signature verification used access token as a placeholder secret. | Verify challenge and POST event ingestion match Chatwoot path, token, response, and inbox resolution behavior. | Review |
|
||||
| P6.7e | WhatsApp `GET/POST /webhooks/whatsapp/:phone_number` | `webhooks/whatsapp#verify`, `#process_payload` | Verify-token lookup scanned only account `0`, and Cloud signature verification used access token as a placeholder secret. | Verify challenge and POST event ingestion match Chatwoot path, token, response, and inbox resolution behavior. | Done |
|
||||
| P6.7f | Instagram `GET/POST /webhooks/instagram` | `webhooks/instagram#verify`, `#events` | Chatwoot no-param route was registered but still returned parity stub responses. | Verify/event routes exist at Chatwoot paths and resolve account/inbox from payload/subscription data. | Review |
|
||||
| P6.7g | TikTok `POST /webhooks/tiktok` | `webhooks/tiktok#events` | Go route expected `:business_id`; Chatwoot route has no path param and should derive identity from payload. | Handler accepts Chatwoot path, verifies `Tiktok-Signature`, resolves business/inbox from payload, and persists/dispatches provider events. | Done |
|
||||
| P6.7h | Shopify `POST /webhooks/shopify` | `webhooks/shopify#events` | Chatwoot route existed but returned parity stub responses. | Route either has a real verified handler or is explicitly tracked as unsupported without placeholder success. | Review |
|
||||
@@ -612,3 +614,4 @@ Verification milestone gates:
|
||||
- 2026-06-05: Broadened P6.7 provider webhook persistence fixtures. LINE, Twilio SMS, WhatsApp, Instagram, and TikTok webhook tests now assert persisted incoming `messages` by provider source ID, matching the existing Telegram persistence fixture and reducing the remaining provider-review surface to signature edge cases and final unsupported-provider classification. Focused webhook tests and full `go test ./...` passed.
|
||||
- 2026-06-05: Added TikTok webhook signature parity. `/webhooks/tiktok` now rejects missing, invalid, or stale `Tiktok-Signature` values using the same timestamp-plus-body HMAC shape as the Chatwoot reference controller, while valid signed payloads still resolve the inbox from `biz_id` and persist incoming messages. Focused webhook tests and full `go test ./...` passed.
|
||||
- 2026-06-05: Tightened LINE webhook signature parity. `/webhooks/line/:line_channel_id` now rejects missing signatures when a `channel_secret` is configured and only persists signed payloads whose `X-Line-Signature` matches the raw request body. Focused webhook tests and full `go test ./...` passed.
|
||||
- 2026-06-05: Completed WhatsApp route-level webhook verification coverage. `/webhooks/whatsapp/:phone_number` now has tests for verify-token challenge echo, signed Cloud API POST persistence, and missing `X-Hub-Signature-256` rejection without message persistence. Focused webhook tests and full `go test ./...` passed.
|
||||
|
||||
@@ -487,7 +487,7 @@ 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"}
|
||||
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)
|
||||
}
|
||||
@@ -503,6 +503,7 @@ func TestWhatsAppWebhookPersistsIncomingMessage(t *testing.T) {
|
||||
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)
|
||||
@@ -513,6 +514,72 @@ func TestWhatsAppWebhookPersistsIncomingMessage(t *testing.T) {
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user