mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
348 lines
12 KiB
Go
348 lines
12 KiB
Go
package feishu
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"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 fakeWebhookManager struct {
|
|
calls []struct {
|
|
cfg channel.ChannelConfig
|
|
msg channel.InboundMessage
|
|
}
|
|
err error
|
|
}
|
|
|
|
func (m *fakeWebhookManager) HandleInbound(_ context.Context, cfg channel.ChannelConfig, msg channel.InboundMessage) error {
|
|
m.calls = append(m.calls, struct {
|
|
cfg channel.ChannelConfig
|
|
msg channel.InboundMessage
|
|
}{cfg: cfg, msg: msg})
|
|
return m.err
|
|
}
|
|
|
|
func TestHandleWebhook_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()
|
|
|
|
cfg := newWebhookConfig(tc.credentials)
|
|
manager := &fakeWebhookManager{}
|
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/channels/feishu/webhook/"+testWebhookConfigID, strings.NewReader(tc.body))
|
|
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
|
rec := httptest.NewRecorder()
|
|
adapter := NewFeishuAdapter(nil)
|
|
|
|
err := adapter.HandleWebhook(context.Background(), cfg, manager.HandleInbound, req, rec)
|
|
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 TestHandleWebhook_URLVerificationWithEncryptKeyWithoutVerificationToken(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := newWebhookConfig(map[string]any{
|
|
"app_id": "app",
|
|
"app_secret": "secret",
|
|
"encrypt_key": "encrypt-key",
|
|
"inbound_mode": "webhook",
|
|
})
|
|
manager := &fakeWebhookManager{}
|
|
|
|
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)
|
|
}
|
|
|
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/channels/feishu/webhook/"+testWebhookConfigID, strings.NewReader(`{"encrypt":"`+encrypt+`"}`))
|
|
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
|
rec := httptest.NewRecorder()
|
|
adapter := NewFeishuAdapter(nil)
|
|
|
|
if err := adapter.HandleWebhook(context.Background(), cfg, manager.HandleInbound, req, rec); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if rec.Code != http.StatusOK {
|
|
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 TestHandleWebhook_Probe(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := newWebhookConfig(map[string]any{
|
|
"app_id": "app",
|
|
"app_secret": "secret",
|
|
"verification_token": "verify-token",
|
|
"inbound_mode": "webhook",
|
|
})
|
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/channels/feishu/webhook/"+testWebhookConfigID, nil)
|
|
rec := httptest.NewRecorder()
|
|
adapter := NewFeishuAdapter(nil)
|
|
|
|
if err := adapter.HandleWebhook(context.Background(), cfg, (&fakeWebhookManager{}).HandleInbound, req, rec); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("unexpected status code: %d", rec.Code)
|
|
}
|
|
if strings.TrimSpace(rec.Body.String()) != "ok" {
|
|
t.Fatalf("unexpected probe response: %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleWebhook_EventCallbackDispatchesInbound(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := newWebhookConfig(map[string]any{
|
|
"app_id": "app",
|
|
"app_secret": "secret",
|
|
"verification_token": "verify-token",
|
|
"inbound_mode": "webhook",
|
|
})
|
|
cfg.SelfIdentity = map[string]any{"open_id": "ou_bot_1"}
|
|
manager := &fakeWebhookManager{}
|
|
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.NewRequestWithContext(context.Background(), http.MethodPost, "/channels/feishu/webhook/"+testWebhookConfigID, strings.NewReader(body))
|
|
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
|
rec := httptest.NewRecorder()
|
|
adapter := NewFeishuAdapter(nil)
|
|
|
|
if err := adapter.HandleWebhook(context.Background(), cfg, manager.HandleInbound, req, rec); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("unexpected status code: %d", rec.Code)
|
|
}
|
|
if len(manager.calls) != 1 {
|
|
t.Fatalf("expected one inbound call, got %d", len(manager.calls))
|
|
}
|
|
got := manager.calls[0].msg
|
|
if got.BotID != "bot-1" {
|
|
t.Fatalf("unexpected bot id: %s", got.BotID)
|
|
}
|
|
if got.Message.PlainText() != "hello" {
|
|
t.Fatalf("unexpected message text: %q", got.Message.PlainText())
|
|
}
|
|
}
|
|
|
|
func TestHandleWebhook_EventCallbackUsesExternalIdentityForMentionFilter(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := newWebhookConfig(map[string]any{
|
|
"app_id": "app",
|
|
"app_secret": "secret",
|
|
"verification_token": "verify-token",
|
|
"inbound_mode": "webhook",
|
|
})
|
|
cfg.ExternalIdentity = "open_id:ou_bot_1"
|
|
manager := &fakeWebhookManager{}
|
|
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.NewRequestWithContext(context.Background(), http.MethodPost, "/channels/feishu/webhook/"+testWebhookConfigID, strings.NewReader(body))
|
|
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
|
rec := httptest.NewRecorder()
|
|
adapter := NewFeishuAdapter(nil)
|
|
|
|
if err := adapter.HandleWebhook(context.Background(), cfg, manager.HandleInbound, req, rec); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("unexpected status code: %d", rec.Code)
|
|
}
|
|
if len(manager.calls) != 1 {
|
|
t.Fatalf("expected one inbound call, got %d", len(manager.calls))
|
|
}
|
|
mentioned, _ := manager.calls[0].msg.Metadata["is_mentioned"].(bool)
|
|
if mentioned {
|
|
t.Fatalf("expected mention flag=false when mentioning another user")
|
|
}
|
|
}
|
|
|
|
func TestHandleWebhook_EventCallbackRejectsInvalidTokenWhenEncryptKeyMissing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
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 + `"}`
|
|
}(),
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := newWebhookConfig(tc.credentials)
|
|
manager := &fakeWebhookManager{}
|
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/channels/feishu/webhook/"+testWebhookConfigID, strings.NewReader(tc.body))
|
|
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
|
rec := httptest.NewRecorder()
|
|
adapter := NewFeishuAdapter(nil)
|
|
|
|
err := adapter.HandleWebhook(context.Background(), cfg, manager.HandleInbound, req, rec)
|
|
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))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandleWebhook_RejectsOversizedBody(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := newWebhookConfig(map[string]any{
|
|
"app_id": "app",
|
|
"app_secret": "secret",
|
|
"verification_token": "verify-token",
|
|
"inbound_mode": "webhook",
|
|
})
|
|
manager := &fakeWebhookManager{}
|
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/channels/feishu/webhook/"+testWebhookConfigID, strings.NewReader(strings.Repeat("x", int(webhookMaxBodyBytes)+1)))
|
|
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
|
rec := httptest.NewRecorder()
|
|
adapter := NewFeishuAdapter(nil)
|
|
|
|
err := adapter.HandleWebhook(context.Background(), cfg, manager.HandleInbound, req, rec)
|
|
if err == nil {
|
|
t.Fatal("expected payload-too-large error")
|
|
}
|
|
he := &echo.HTTPError{}
|
|
ok := errors.As(err, &he)
|
|
if !ok {
|
|
t.Fatalf("expected HTTPError, got %T", err)
|
|
}
|
|
if he.Code != http.StatusRequestEntityTooLarge {
|
|
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 newWebhookConfig(credentials map[string]any) channel.ChannelConfig {
|
|
return channel.ChannelConfig{
|
|
ID: testWebhookConfigID,
|
|
BotID: "bot-1",
|
|
ChannelType: Type,
|
|
Credentials: credentials,
|
|
}
|
|
}
|