From ca598bb0a538105d9fd49cb86b80a4374f3781ee Mon Sep 17 00:00:00 2001 From: "Ringo.Typowriter" Date: Sun, 15 Mar 2026 19:39:13 +0800 Subject: [PATCH] fix: align feishu webhook verification flow with sdk behavior (#250) --- apps/web/src/i18n/locales/en.json | 2 + apps/web/src/i18n/locales/zh.json | 2 + .../components/channel-settings-panel.vue | 23 +- internal/channel/adapters/feishu/config.go | 3 + .../channel/adapters/feishu/config_test.go | 14 + .../adapters/feishu/connect_mode_test.go | 7 +- .../adapters/feishu/webhook_handler.go | 110 +++++-- .../adapters/feishu/webhook_handler_test.go | 311 ++++++++++++------ 8 files changed, 341 insertions(+), 131 deletions(-) diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json index 81cc1bbb..21e97925 100644 --- a/apps/web/src/i18n/locales/en.json +++ b/apps/web/src/i18n/locales/en.json @@ -847,6 +847,8 @@ "webhookCallback": "WebHook Callback URL", "webhookCallbackHint": "Use this URL as the event subscription request URL in Feishu/Lark.", "webhookCallbackPending": "Save this platform configuration to generate the callback URL.", + "feishuWebhookSecurityHint": "For security, webhook mode requires either an Encrypt Key or a Verification Token; an unprotected public callback URL should not be exposed.", + "feishuWebhookSecretRequired": "For security, configure at least one of Encrypt Key or Verification Token.", "noAvailableTypes": "All platform types have been configured", "types": { "feishu": "Feishu", diff --git a/apps/web/src/i18n/locales/zh.json b/apps/web/src/i18n/locales/zh.json index 06ba29bd..bfabb038 100644 --- a/apps/web/src/i18n/locales/zh.json +++ b/apps/web/src/i18n/locales/zh.json @@ -843,6 +843,8 @@ "webhookCallback": "WebHook 回调地址", "webhookCallbackHint": "将该地址配置到飞书/Lark 事件订阅的请求 URL。", "webhookCallbackPending": "保存平台配置后会生成回调地址。", + "feishuWebhookSecurityHint": "出于安全考虑,Webhook 模式必须配置 Encrypt Key 或 Verification Token 之一;未受保护的回调地址不应直接暴露在公网上。", + "feishuWebhookSecretRequired": "出于安全考虑,请至少配置 Encrypt Key 或 Verification Token 之一。", "noAvailableTypes": "所有平台类型均已配置", "types": { "feishu": "飞书", diff --git a/apps/web/src/pages/bots/components/channel-settings-panel.vue b/apps/web/src/pages/bots/components/channel-settings-panel.vue index b72290ac..dc75e20d 100644 --- a/apps/web/src/pages/bots/components/channel-settings-panel.vue +++ b/apps/web/src/pages/bots/components/channel-settings-panel.vue @@ -88,6 +88,12 @@

{{ $t('bots.channels.credentials') }}

+

+ {{ $t('bots.channels.feishuWebhookSecurityHint') }} +

{ const credentials: Record = {} for (const [key, val] of Object.entries(form.credentials)) { @@ -376,6 +395,7 @@ function buildCredentials(): Record { async function saveChannel(disabled: boolean, nextAction: 'save' | 'toggle') { if (!validateRequired()) return + if (!validateFeishuWebhookSecrets()) return action.value = nextAction try { const result = await upsertChannel({ @@ -413,9 +433,10 @@ async function handleEditSave() { } async function handleToggleDisabled() { - action.value = 'toggle' try { const nextDisabled = !form.disabled + if (!nextDisabled && !validateFeishuWebhookSecrets()) return + action.value = 'toggle' const result = await updateChannelStatus({ platform: props.channelItem.meta.type, disabled: nextDisabled, diff --git a/internal/channel/adapters/feishu/config.go b/internal/channel/adapters/feishu/config.go index d96bec75..790dda2e 100644 --- a/internal/channel/adapters/feishu/config.go +++ b/internal/channel/adapters/feishu/config.go @@ -128,6 +128,9 @@ func parseConfig(raw map[string]any) (Config, error) { if appID == "" || appSecret == "" { return Config{}, errors.New("feishu appId and appSecret are required") } + if inboundMode == inboundModeWebhook && encryptKey == "" && verificationToken == "" { + return Config{}, errors.New("feishu webhook mode requires encrypt_key or verification_token") + } return Config{ AppID: appID, AppSecret: appSecret, diff --git a/internal/channel/adapters/feishu/config_test.go b/internal/channel/adapters/feishu/config_test.go index 3aa90509..a977adec 100644 --- a/internal/channel/adapters/feishu/config_test.go +++ b/internal/channel/adapters/feishu/config_test.go @@ -45,6 +45,7 @@ func TestNormalizeConfigSupportsLarkAndWebhook(t *testing.T) { "app_secret": "secret", "region": "lark", "inbound_mode": "webhook", + "encrypt_key": "enc", }) if err != nil { t.Fatalf("expected no error, got %v", err) @@ -57,6 +58,19 @@ func TestNormalizeConfigSupportsLarkAndWebhook(t *testing.T) { } } +func TestNormalizeConfigRejectsWebhookWithoutSecrets(t *testing.T) { + t.Parallel() + + _, err := normalizeConfig(map[string]any{ + "app_id": "app", + "app_secret": "secret", + "inbound_mode": "webhook", + }) + if err == nil { + t.Fatal("expected webhook secret error") + } +} + func TestNormalizeConfigRejectsInvalidRegion(t *testing.T) { t.Parallel() diff --git a/internal/channel/adapters/feishu/connect_mode_test.go b/internal/channel/adapters/feishu/connect_mode_test.go index 8b40ec96..fa325a27 100644 --- a/internal/channel/adapters/feishu/connect_mode_test.go +++ b/internal/channel/adapters/feishu/connect_mode_test.go @@ -16,9 +16,10 @@ func TestConnectWebhookModeDoesNotStartWebsocket(t *testing.T) { BotID: "bot-1", ChannelType: Type, Credentials: map[string]any{ - "app_id": "app", - "app_secret": "secret", - "inbound_mode": "webhook", + "app_id": "app", + "app_secret": "secret", + "verification_token": "verify-token", + "inbound_mode": "webhook", }, } conn, err := adapter.Connect(context.Background(), cfg, func(_ context.Context, _ channel.ChannelConfig, _ channel.InboundMessage) error { diff --git a/internal/channel/adapters/feishu/webhook_handler.go b/internal/channel/adapters/feishu/webhook_handler.go index 2f464e15..31818e53 100644 --- a/internal/channel/adapters/feishu/webhook_handler.go +++ b/internal/channel/adapters/feishu/webhook_handler.go @@ -96,14 +96,21 @@ func (h *WebhookHandler) Handle(c echo.Context) error { if int64(len(payload)) > webhookMaxBodyBytes { return echo.NewHTTPError(http.StatusRequestEntityTooLarge, fmt.Sprintf("payload too large: max %d bytes", webhookMaxBodyBytes)) } - if err := validateWebhookCallbackAuth(payload, feishuCfg); err != nil { - return err - } botOpenID := h.adapter.resolveBotOpenID(context.WithoutCancel(c.Request().Context()), cfg) reqCtx := c.Request().Context() eventDispatcher := dispatcher.NewEventDispatcher(feishuCfg.VerificationToken, feishuCfg.EncryptKey) + webhookReq, err := inspectWebhookRequest(reqCtx, eventDispatcher, c.Request(), payload) + if err != nil { + return err + } + if err := validateWebhookCallbackAuth(webhookReq, feishuCfg); err != nil { + return err + } + if challengeResp := buildWebhookChallengeResponse(webhookReq); challengeResp != nil { + return writeEventResponse(c, challengeResp) + } eventDispatcher.OnP2MessageReceiveV1(func(_ context.Context, event *larkim.P2MessageReceiveV1) error { msg := extractFeishuInbound(event, botOpenID, h.adapter.logger) if strings.TrimSpace(msg.Message.PlainText()) == "" && len(msg.Message.Attachments) == 0 { @@ -123,6 +130,75 @@ func (h *WebhookHandler) Handle(c echo.Context) error { if resp == nil { return c.NoContent(http.StatusOK) } + return writeEventResponse(c, resp) +} + +func inspectWebhookRequest(ctx context.Context, eventDispatcher *dispatcher.EventDispatcher, req *http.Request, payload []byte) (larkevent.EventFuzzy, error) { + plainPayload, err := parseWebhookPayload(ctx, eventDispatcher, req, payload) + if err != nil { + return larkevent.EventFuzzy{}, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid feishu webhook payload: %v", err)) + } + + var fuzzy larkevent.EventFuzzy + if err := json.Unmarshal([]byte(plainPayload), &fuzzy); err != nil { + return larkevent.EventFuzzy{}, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid feishu webhook payload: %v", err)) + } + return fuzzy, nil +} + +func validateWebhookCallbackAuth(fuzzy larkevent.EventFuzzy, cfg Config) error { + expectedToken := strings.TrimSpace(cfg.VerificationToken) + encryptKey := strings.TrimSpace(cfg.EncryptKey) + if expectedToken == "" && encryptKey == "" { + return echo.NewHTTPError(http.StatusBadRequest, "feishu webhook requires encrypt_key or verification_token") + } + + requestToken := webhookRequestToken(fuzzy) + if expectedToken == "" { + return nil + } + if requestToken == "" || requestToken != expectedToken { + return echo.NewHTTPError(http.StatusUnauthorized, "invalid feishu webhook token") + } + return nil +} + +func buildWebhookChallengeResponse(fuzzy larkevent.EventFuzzy) *larkevent.EventResp { + if webhookRequestType(fuzzy) != larkevent.ReqTypeChallenge { + return nil + } + return &larkevent.EventResp{ + Header: http.Header{larkevent.ContentTypeHeader: []string{larkevent.DefaultContentType}}, + Body: []byte(fmt.Sprintf(larkevent.ChallengeResponseFormat, fuzzy.Challenge)), + StatusCode: http.StatusOK, + } +} + +func webhookRequestToken(fuzzy larkevent.EventFuzzy) string { + requestToken := strings.TrimSpace(fuzzy.Token) + if fuzzy.Header != nil && strings.TrimSpace(fuzzy.Header.Token) != "" { + requestToken = strings.TrimSpace(fuzzy.Header.Token) + } + return requestToken +} + +func webhookRequestType(fuzzy larkevent.EventFuzzy) larkevent.ReqType { + return larkevent.ReqType(strings.TrimSpace(fuzzy.Type)) +} + +func parseWebhookPayload(ctx context.Context, eventDispatcher *dispatcher.EventDispatcher, req *http.Request, payload []byte) (string, error) { + cipherPayload, err := eventDispatcher.ParseReq(ctx, &larkevent.EventReq{ + Header: req.Header, + Body: payload, + RequestURI: req.RequestURI, + }) + if err != nil { + return "", err + } + return eventDispatcher.DecryptEvent(ctx, cipherPayload) +} + +func writeEventResponse(c echo.Context, resp *larkevent.EventResp) error { for key, values := range resp.Header { for _, value := range values { c.Response().Header().Add(key, value) @@ -132,36 +208,10 @@ func (h *WebhookHandler) Handle(c echo.Context) error { if len(resp.Body) == 0 { return nil } - _, err = c.Response().Write(resp.Body) + _, err := c.Response().Write(resp.Body) return err } -func validateWebhookCallbackAuth(payload []byte, cfg Config) error { - if strings.TrimSpace(cfg.EncryptKey) != "" { - // Lark SDK signature verification is enabled only when encryptKey is configured. - return nil - } - var fuzzy larkevent.EventFuzzy - if err := json.Unmarshal(payload, &fuzzy); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid feishu webhook payload: %v", err)) - } - if larkevent.ReqType(strings.TrimSpace(fuzzy.Type)) == larkevent.ReqTypeChallenge { - return nil - } - expectedToken := strings.TrimSpace(cfg.VerificationToken) - if expectedToken == "" { - return echo.NewHTTPError(http.StatusForbidden, "feishu webhook requires verification_token when encrypt_key is empty") - } - requestToken := strings.TrimSpace(fuzzy.Token) - if fuzzy.Header != nil && strings.TrimSpace(fuzzy.Header.Token) != "" { - requestToken = strings.TrimSpace(fuzzy.Header.Token) - } - if requestToken == "" || requestToken != expectedToken { - return echo.NewHTTPError(http.StatusUnauthorized, "invalid feishu webhook token") - } - return nil -} - func (h *WebhookHandler) findConfigByID(ctx context.Context, configID string) (channel.ChannelConfig, error) { items, err := h.store.ListConfigsByType(ctx, Type) if err != nil { diff --git a/internal/channel/adapters/feishu/webhook_handler_test.go b/internal/channel/adapters/feishu/webhook_handler_test.go index ee593ea6..e37a783d 100644 --- a/internal/channel/adapters/feishu/webhook_handler_test.go +++ b/internal/channel/adapters/feishu/webhook_handler_test.go @@ -9,10 +9,13 @@ import ( "testing" "github.com/labstack/echo/v4" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" "github.com/memohai/memoh/internal/channel" ) +const testWebhookConfigID = "cfg-1" + type fakeWebhookStore struct { configs []channel.ChannelConfig err error @@ -44,17 +47,111 @@ func (m *fakeWebhookManager) HandleInbound(_ context.Context, cfg channel.Channe func TestWebhookHandler_URLVerification(t *testing.T) { t.Parallel() + cases := []struct { + name string + credentials map[string]any + body string + wantStatus int + wantError bool + }{ + { + name: "with verification token", + credentials: map[string]any{ + "app_id": "app", + "app_secret": "secret", + "verification_token": "verify-token", + "inbound_mode": "webhook", + }, + body: `{"schema":"2.0","header":{"event_type":"im.message.receive_v1","token":"verify-token"},"type":"url_verification","challenge":"hello"}`, + wantStatus: http.StatusOK, + }, + { + name: "without webhook secrets", + credentials: map[string]any{ + "app_id": "app", + "app_secret": "secret", + "inbound_mode": "webhook", + }, + body: `{"type":"url_verification","challenge":"hello"}`, + wantStatus: http.StatusBadRequest, + wantError: true, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + store := &fakeWebhookStore{ + configs: []channel.ChannelConfig{ + { + ID: testWebhookConfigID, + BotID: "bot-1", + ChannelType: Type, + Credentials: tc.credentials, + }, + }, + } + manager := &fakeWebhookManager{} + h := NewWebhookHandler(nil, store, manager) + + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/"+testWebhookConfigID, strings.NewReader(tc.body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("config_id") + c.SetParamValues(testWebhookConfigID) + + err := h.Handle(c) + if tc.wantError { + if err == nil { + t.Fatal("expected error") + } + he := &echo.HTTPError{} + if !errors.As(err, &he) { + t.Fatalf("expected HTTPError, got %T", err) + } + if he.Code != tc.wantStatus { + t.Fatalf("unexpected status code: %d", he.Code) + } + if len(manager.calls) != 0 { + t.Fatalf("expected no inbound calls, got %d", len(manager.calls)) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != tc.wantStatus { + t.Fatalf("unexpected status code: %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), `"challenge":"hello"`) { + t.Fatalf("unexpected challenge response: %s", rec.Body.String()) + } + if len(manager.calls) != 0 { + t.Fatalf("expected no inbound calls, got %d", len(manager.calls)) + } + }) + } +} + +func TestWebhookHandler_URLVerificationWithEncryptKeyWithoutVerificationToken(t *testing.T) { + t.Parallel() + store := &fakeWebhookStore{ configs: []channel.ChannelConfig{ { - ID: "cfg-1", + ID: testWebhookConfigID, BotID: "bot-1", ChannelType: Type, Credentials: map[string]any{ - "app_id": "app", - "app_secret": "secret", - "verification_token": "verify-token", - "inbound_mode": "webhook", + "app_id": "app", + "app_secret": "secret", + "encrypt_key": "encrypt-key", + "inbound_mode": "webhook", }, }, }, @@ -62,13 +159,18 @@ func TestWebhookHandler_URLVerification(t *testing.T) { manager := &fakeWebhookManager{} h := NewWebhookHandler(nil, store, manager) + encrypt, err := larkcore.EncryptedEventMsg(context.Background(), `{"challenge":"hello","token":"verify-token","type":"url_verification"}`, "encrypt-key") + if err != nil { + t.Fatalf("failed to encrypt challenge payload: %v", err) + } + e := echo.New() - req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/cfg-1", strings.NewReader(`{"schema":"2.0","header":{"event_type":"im.message.receive_v1","token":"verify-token"},"type":"url_verification","challenge":"hello"}`)) + req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/"+testWebhookConfigID, strings.NewReader(`{"encrypt":"`+encrypt+`"}`)) req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) rec := httptest.NewRecorder() c := e.NewContext(req, rec) c.SetParamNames("config_id") - c.SetParamValues("cfg-1") + c.SetParamValues(testWebhookConfigID) if err := h.Handle(c); err != nil { t.Fatalf("unexpected error: %v", err) @@ -90,11 +192,11 @@ func TestWebhookHandler_Probe(t *testing.T) { h := NewWebhookHandler(nil, &fakeWebhookStore{}, &fakeWebhookManager{}) e := echo.New() - req := httptest.NewRequest(http.MethodGet, "/channels/feishu/webhook/cfg-1", nil) + req := httptest.NewRequest(http.MethodGet, "/channels/feishu/webhook/"+testWebhookConfigID, nil) rec := httptest.NewRecorder() c := e.NewContext(req, rec) c.SetParamNames("config_id") - c.SetParamValues("cfg-1") + c.SetParamValues(testWebhookConfigID) if err := h.HandleProbe(c); err != nil { t.Fatalf("unexpected error: %v", err) @@ -107,13 +209,40 @@ func TestWebhookHandler_Probe(t *testing.T) { } } +func TestWebhookHandler_ConfigLookupRejectsNotFound(t *testing.T) { + t.Parallel() + + store := &fakeWebhookStore{} + h := NewWebhookHandler(nil, store, &fakeWebhookManager{}) + + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/not-found", strings.NewReader(`{}`)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("config_id") + c.SetParamValues("not-found") + + err := h.Handle(c) + if err == nil { + t.Fatal("expected not found error") + } + he := &echo.HTTPError{} + if !errors.As(err, &he) { + t.Fatalf("expected HTTPError, got %T", err) + } + if he.Code != http.StatusNotFound { + t.Fatalf("unexpected status code: %d", he.Code) + } +} + func TestWebhookHandler_EventCallbackDispatchesInbound(t *testing.T) { t.Parallel() store := &fakeWebhookStore{ configs: []channel.ChannelConfig{ { - ID: "cfg-1", + ID: testWebhookConfigID, BotID: "bot-1", ChannelType: Type, SelfIdentity: map[string]any{ @@ -133,12 +262,12 @@ func TestWebhookHandler_EventCallbackDispatchesInbound(t *testing.T) { e := echo.New() body := `{"schema":"2.0","header":{"event_id":"evt_1","event_type":"im.message.receive_v1","token":"verify-token"},"event":{"sender":{"sender_id":{"open_id":"ou_user_1","user_id":"u_user_1"}},"message":{"message_id":"om_1","chat_id":"oc_1","chat_type":"p2p","message_type":"text","content":"{\"text\":\"hello\"}"}},"type":"event_callback"}` - req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/cfg-1", strings.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/"+testWebhookConfigID, strings.NewReader(body)) req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) rec := httptest.NewRecorder() c := e.NewContext(req, rec) c.SetParamNames("config_id") - c.SetParamValues("cfg-1") + c.SetParamValues(testWebhookConfigID) if err := h.Handle(c); err != nil { t.Fatalf("unexpected error: %v", err) @@ -164,7 +293,7 @@ func TestWebhookHandler_EventCallbackUsesExternalIdentityForMentionFilter(t *tes store := &fakeWebhookStore{ configs: []channel.ChannelConfig{ { - ID: "cfg-1", + ID: testWebhookConfigID, BotID: "bot-1", ChannelType: Type, ExternalIdentity: "open_id:ou_bot_1", @@ -182,12 +311,12 @@ func TestWebhookHandler_EventCallbackUsesExternalIdentityForMentionFilter(t *tes e := echo.New() body := `{"schema":"2.0","header":{"event_id":"evt_2","event_type":"im.message.receive_v1","token":"verify-token"},"event":{"sender":{"sender_id":{"open_id":"ou_user_2","user_id":"u_user_2"}},"message":{"message_id":"om_2","chat_id":"oc_group_1","chat_type":"group","message_type":"text","content":"{\"text\":\" hello\"}"}},"type":"event_callback"}` - req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/cfg-1", strings.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/"+testWebhookConfigID, strings.NewReader(body)) req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) rec := httptest.NewRecorder() c := e.NewContext(req, rec) c.SetParamNames("config_id") - c.SetParamValues("cfg-1") + c.SetParamValues(testWebhookConfigID) if err := h.Handle(c); err != nil { t.Fatalf("unexpected error: %v", err) @@ -207,93 +336,81 @@ func TestWebhookHandler_EventCallbackUsesExternalIdentityForMentionFilter(t *tes func TestWebhookHandler_EventCallbackRejectsInvalidTokenWhenEncryptKeyMissing(t *testing.T) { t.Parallel() - store := &fakeWebhookStore{ - configs: []channel.ChannelConfig{ - { - ID: "cfg-1", - BotID: "bot-1", - ChannelType: Type, - Credentials: map[string]any{ - "app_id": "app", - "app_secret": "secret", - "verification_token": "verify-token", - "inbound_mode": "webhook", - }, + cases := []struct { + name string + credentials map[string]any + body string + }{ + { + name: "plaintext callback", + credentials: map[string]any{ + "app_id": "app", + "app_secret": "secret", + "verification_token": "verify-token", + "inbound_mode": "webhook", }, + body: `{"schema":"2.0","header":{"event_id":"evt_1","event_type":"im.message.receive_v1","token":"forged-token"},"event":{"sender":{"sender_id":{"open_id":"ou_user_1"}},"message":{"message_id":"om_1","chat_id":"oc_1","chat_type":"p2p","message_type":"text","content":"{\"text\":\"hello\"}"}},"type":"event_callback"}`, + }, + { + name: "encrypted callback", + credentials: map[string]any{ + "app_id": "app", + "app_secret": "secret", + "encrypt_key": "encrypt-key", + "verification_token": "verify-token", + "inbound_mode": "webhook", + }, + body: func() string { + encrypt, err := larkcore.EncryptedEventMsg(context.Background(), `{"schema":"2.0","header":{"event_id":"evt_1","event_type":"im.message.receive_v1","token":"forged-token"},"event":{"sender":{"sender_id":{"open_id":"ou_user_1"}},"message":{"message_id":"om_1","chat_id":"oc_1","chat_type":"p2p","message_type":"text","content":"{\"text\":\"hello\"}"}},"type":"event_callback"}`, "encrypt-key") + if err != nil { + t.Fatalf("failed to encrypt event payload: %v", err) + } + return `{"encrypt":"` + encrypt + `"}` + }(), }, } - manager := &fakeWebhookManager{} - h := NewWebhookHandler(nil, store, manager) - e := echo.New() - body := `{"schema":"2.0","header":{"event_id":"evt_1","event_type":"im.message.receive_v1","token":"forged-token"},"event":{"sender":{"sender_id":{"open_id":"ou_user_1"}},"message":{"message_id":"om_1","chat_id":"oc_1","chat_type":"p2p","message_type":"text","content":"{\"text\":\"hello\"}"}},"type":"event_callback"}` - req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/cfg-1", strings.NewReader(body)) - req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) - rec := httptest.NewRecorder() - c := e.NewContext(req, rec) - c.SetParamNames("config_id") - c.SetParamValues("cfg-1") + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() - err := h.Handle(c) - if err == nil { - t.Fatal("expected unauthorized error") - } - he := &echo.HTTPError{} - ok := errors.As(err, &he) - if !ok { - t.Fatalf("expected HTTPError, got %T", err) - } - if he.Code != http.StatusUnauthorized { - t.Fatalf("unexpected status code: %d", he.Code) - } - if len(manager.calls) != 0 { - t.Fatalf("expected no inbound calls, got %d", len(manager.calls)) - } -} - -func TestWebhookHandler_EventCallbackRequiresVerificationTokenWhenEncryptKeyMissing(t *testing.T) { - t.Parallel() - - store := &fakeWebhookStore{ - configs: []channel.ChannelConfig{ - { - ID: "cfg-1", - BotID: "bot-1", - ChannelType: Type, - Credentials: map[string]any{ - "app_id": "app", - "app_secret": "secret", - "inbound_mode": "webhook", + store := &fakeWebhookStore{ + configs: []channel.ChannelConfig{ + { + ID: testWebhookConfigID, + BotID: "bot-1", + ChannelType: Type, + Credentials: tc.credentials, + }, }, - }, - }, - } - manager := &fakeWebhookManager{} - h := NewWebhookHandler(nil, store, manager) + } + manager := &fakeWebhookManager{} + h := NewWebhookHandler(nil, store, manager) - e := echo.New() - body := `{"schema":"2.0","header":{"event_id":"evt_1","event_type":"im.message.receive_v1","token":"verify-token"},"event":{"sender":{"sender_id":{"open_id":"ou_user_1"}},"message":{"message_id":"om_1","chat_id":"oc_1","chat_type":"p2p","message_type":"text","content":"{\"text\":\"hello\"}"}},"type":"event_callback"}` - req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/cfg-1", strings.NewReader(body)) - req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) - rec := httptest.NewRecorder() - c := e.NewContext(req, rec) - c.SetParamNames("config_id") - c.SetParamValues("cfg-1") + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/"+testWebhookConfigID, strings.NewReader(tc.body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("config_id") + c.SetParamValues(testWebhookConfigID) - err := h.Handle(c) - if err == nil { - t.Fatal("expected forbidden error") - } - he := &echo.HTTPError{} - ok := errors.As(err, &he) - if !ok { - t.Fatalf("expected HTTPError, got %T", err) - } - if he.Code != http.StatusForbidden { - t.Fatalf("unexpected status code: %d", he.Code) - } - if len(manager.calls) != 0 { - t.Fatalf("expected no inbound calls, got %d", len(manager.calls)) + err := h.Handle(c) + if err == nil { + t.Fatal("expected unauthorized error") + } + he := &echo.HTTPError{} + if !errors.As(err, &he) { + t.Fatalf("expected HTTPError, got %T", err) + } + if he.Code != http.StatusUnauthorized { + t.Fatalf("unexpected status code: %d", he.Code) + } + if len(manager.calls) != 0 { + t.Fatalf("expected no inbound calls, got %d", len(manager.calls)) + } + }) } } @@ -303,7 +420,7 @@ func TestWebhookHandler_RejectsOversizedBody(t *testing.T) { store := &fakeWebhookStore{ configs: []channel.ChannelConfig{ { - ID: "cfg-1", + ID: testWebhookConfigID, BotID: "bot-1", ChannelType: Type, Credentials: map[string]any{ @@ -319,12 +436,12 @@ func TestWebhookHandler_RejectsOversizedBody(t *testing.T) { h := NewWebhookHandler(nil, store, manager) e := echo.New() - req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/cfg-1", strings.NewReader(strings.Repeat("x", int(webhookMaxBodyBytes)+1))) + req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/"+testWebhookConfigID, strings.NewReader(strings.Repeat("x", int(webhookMaxBodyBytes)+1))) req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) rec := httptest.NewRecorder() c := e.NewContext(req, rec) c.SetParamNames("config_id") - c.SetParamValues("cfg-1") + c.SetParamValues(testWebhookConfigID) err := h.Handle(c) if err == nil {