From 1c3093337f1c939d1d2a15bc4e614302e87e5f76 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 5 Jun 2026 00:40:38 +0800 Subject: [PATCH] test(webhook): cover whatsapp ingress verification --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 5 +- .../handler/webhook/webhook_lookup_test.go | 69 ++++++++++++++++++- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 4822a941..5412eda1 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -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=,s=`, the HMAC is `sha256(TIKTOK_APP_SECRET, ".")`, 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. diff --git a/internal/handler/webhook/webhook_lookup_test.go b/internal/handler/webhook/webhook_lookup_test.go index 9c2d8608..cdda7383 100644 --- a/internal/handler/webhook/webhook_lookup_test.go +++ b/internal/handler/webhook/webhook_lookup_test.go @@ -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")