mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
fix: align feishu webhook verification flow with sdk behavior (#250)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -843,6 +843,8 @@
|
||||
"webhookCallback": "WebHook 回调地址",
|
||||
"webhookCallbackHint": "将该地址配置到飞书/Lark 事件订阅的请求 URL。",
|
||||
"webhookCallbackPending": "保存平台配置后会生成回调地址。",
|
||||
"feishuWebhookSecurityHint": "出于安全考虑,Webhook 模式必须配置 Encrypt Key 或 Verification Token 之一;未受保护的回调地址不应直接暴露在公网上。",
|
||||
"feishuWebhookSecretRequired": "出于安全考虑,请至少配置 Encrypt Key 或 Verification Token 之一。",
|
||||
"noAvailableTypes": "所有平台类型均已配置",
|
||||
"types": {
|
||||
"feishu": "飞书",
|
||||
|
||||
@@ -88,6 +88,12 @@
|
||||
<h4 class="text-sm font-medium">
|
||||
{{ $t('bots.channels.credentials') }}
|
||||
</h4>
|
||||
<p
|
||||
v-if="showWebhookCallback"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ $t('bots.channels.feishuWebhookSecurityHint') }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-for="(field, key) in orderedFields"
|
||||
@@ -364,6 +370,19 @@ function validateRequired(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
function validateFeishuWebhookSecrets(): boolean {
|
||||
if (props.channelItem.meta.type !== 'feishu' || currentInboundMode.value !== 'webhook') {
|
||||
return true
|
||||
}
|
||||
const encryptKey = String(form.credentials.encryptKey ?? form.credentials.encrypt_key ?? '').trim()
|
||||
const verificationToken = String(form.credentials.verificationToken ?? form.credentials.verification_token ?? '').trim()
|
||||
if (encryptKey !== '' || verificationToken !== '') {
|
||||
return true
|
||||
}
|
||||
toast.error(t('bots.channels.feishuWebhookSecretRequired'))
|
||||
return false
|
||||
}
|
||||
|
||||
function buildCredentials(): Record<string, unknown> {
|
||||
const credentials: Record<string, unknown> = {}
|
||||
for (const [key, val] of Object.entries(form.credentials)) {
|
||||
@@ -376,6 +395,7 @@ function buildCredentials(): Record<string, unknown> {
|
||||
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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\":\"<at user_id=\\\"ou_other_user\\\"></at> 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 {
|
||||
|
||||
Reference in New Issue
Block a user