Files
Cyrene_v2/plugins/channels/kook/webhook_test.go
T
Aska 617157262b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channel): KOOK Webhook(WH) 接收模式——与 WS 配置切换(ReceiveMode)
- kook.Config 加 ReceiveMode("ws"默认/"webhook") + WebhookPath(默认/webhook/kook) + WebhookVerifyToken + WebhookEncryptKey(AES-256-CBC)
- 按权威文档(Reference/kaiheila webhook.md)实现: 无X-Signature HMAC, 真实校验=d.verify_token比对(不匹配403)+可选AES解密+zlib解压+challenge回显
- webhook.go: InboundHandler+WebhookPath, POST→zlib解压→AES解密→verify_token校验→challenge回显→归一UnifiedMessage→SetMessageHandler; WS模式Connect不拨网关仅标记connected
- channelbridge/webhookAdapter 接口改用 WebhookPath(); WebhookHandlers 收KOOK WH(ws模式跳过); 既有webhook通道补WebhookPath照常挂载
- namedAdapter 转发InboundHandler/WebhookPath; kook env兜底 +4个CHANNEL_KOOK_*键
- 测试: verify_token 403/challenge/zlib/AES往返/WebhookPath按模式/WH不拨gateway/WebhookHandlers收KOOK; 全仓build/vet/test绿
- 待真token端到端实测(需主人提供CHANNEL_KOOK_TOKEN+开发者后台切WebHook配回调URL)
2026-09-07 14:10:03 +08:00

364 lines
10 KiB
Go

package kook
import (
"bytes"
"compress/zlib"
"context"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// kookEventBody builds a KOOK s=0 message-event payload (channel_type GROUP)
// with the given content and verify token, matching the WebSocket event shape so
// the webhook path normalizes it identically.
func kookEventBody(content, verifyToken string) []byte {
extra := map[string]any{
"type": 1,
"guild_id": "g-8",
"channel_name": "test",
"mention": []string{},
"author": map[string]any{
"id": "u-8", "username": "u", "nickname": "Nick",
},
}
d := map[string]any{
"channel_type": "GROUP",
"type": 1,
"target_id": "chan-8",
"author_id": "u-8",
"content": content,
"msg_id": "m-8",
"msg_timestamp": 1700000000000,
"nonce": "",
"extra": extra,
}
if verifyToken != "" {
d["verify_token"] = verifyToken
}
body, _ := json.Marshal(map[string]any{"s": 0, "sn": 7, "d": d})
return body
}
func webhookAdapter(t *testing.T, cfg Config, mh adapter.MessageHandler) *Adapter {
t.Helper()
a := New(cfg, WithLogger(discardLogger()))
if mh != nil {
a.SetMessageHandler(mh)
}
return a
}
func TestWebhookPathByMode(t *testing.T) {
t.Parallel()
ws := New(Config{BotToken: "t", ReceiveMode: modeWS})
if p := ws.WebhookPath(); p != "" {
t.Errorf("ws mode WebhookPath() = %q, want empty", p)
}
wh := New(Config{BotToken: "t", ReceiveMode: modeWebhook})
if p := wh.WebhookPath(); p != defaultWebhookPath {
t.Errorf("webhook mode WebhookPath() = %q, want %q", p, defaultWebhookPath)
}
custom := New(Config{BotToken: "t", ReceiveMode: modeWebhook, WebhookPath: "/cb"})
if p := custom.WebhookPath(); p != "/cb" {
t.Errorf("custom WebhookPath() = %q, want /cb", p)
}
}
func TestWebhookHandlerDeliversEvent(t *testing.T) {
t.Parallel()
var (
mu sync.Mutex
delivered []adapter.UnifiedMessage
)
mh := func(_ context.Context, msg adapter.UnifiedMessage) {
mu.Lock()
delivered = append(delivered, msg)
mu.Unlock()
}
a := webhookAdapter(t, Config{BotToken: "t", ReceiveMode: modeWebhook, WebhookVerifyToken: "vt-123"}, mh)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, defaultWebhookPath,
bytes.NewReader(kookEventBody("webhook hi", "vt-123")))
req.Header.Set("Content-Type", "application/json")
a.InboundHandler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
mu.Lock()
defer mu.Unlock()
if len(delivered) != 1 {
t.Fatalf("delivered %d messages, want 1", len(delivered))
}
msg := delivered[0]
if msg.Platform != "kook" || msg.ChannelID != "chan-8" || msg.GroupID != "g-8" {
t.Errorf("identity = %q/%q/%q, want kook/chan-8/g-8", msg.Platform, msg.ChannelID, msg.GroupID)
}
if msg.SenderName != "Nick" || msg.SenderID != "u-8" {
t.Errorf("sender = %q/%q, want Nick/u-8", msg.SenderName, msg.SenderID)
}
if msg.Content != "webhook hi" || msg.ContentType != adapter.ContentText {
t.Errorf("content = %q/%q, want webhook hi/text", msg.Content, msg.ContentType)
}
}
func TestWebhookHandlerChallenge(t *testing.T) {
t.Parallel()
a := webhookAdapter(t, Config{BotToken: "t", ReceiveMode: modeWebhook, WebhookVerifyToken: "vt-123"}, nil)
challenge := "bkes654x09XY"
body, _ := json.Marshal(map[string]any{
"s": 0,
"d": map[string]any{
"type": 255,
"channel_type": webhookChallengeChannel,
"challenge": challenge,
"verify_token": "vt-123",
},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, defaultWebhookPath, bytes.NewReader(body))
a.InboundHandler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("challenge status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var out struct {
Challenge string `json:"challenge"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode challenge response: %v", err)
}
if out.Challenge != challenge {
t.Errorf("challenge echo = %q, want %q", out.Challenge, challenge)
}
}
func TestWebhookHandlerVerifyTokenMismatch403(t *testing.T) {
t.Parallel()
a := webhookAdapter(t, Config{BotToken: "t", ReceiveMode: modeWebhook, WebhookVerifyToken: "vt-123"}, nil)
body := kookEventBody("spoofed", "wrong-token")
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, defaultWebhookPath, bytes.NewReader(body))
a.InboundHandler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body=%s", rec.Code, rec.Body.String())
}
}
func TestWebhookHandlerVerifyTokenDisabledWhenUnset(t *testing.T) {
t.Parallel()
a := webhookAdapter(t, Config{BotToken: "t", ReceiveMode: modeWebhook}, nil)
// No token configured → any callback is accepted regardless of its (absent)
// verify_token. The event is still normalized; the handler is nil so it is
// dropped without an error.
body := kookEventBody("hi", "")
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, defaultWebhookPath, bytes.NewReader(body))
a.InboundHandler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
}
func TestWebhookHandlerZlibCompressed(t *testing.T) {
t.Parallel()
var (
mu sync.Mutex
delivered []adapter.UnifiedMessage
)
a := webhookAdapter(t, Config{BotToken: "t", ReceiveMode: modeWebhook}, func(_ context.Context, msg adapter.UnifiedMessage) {
mu.Lock()
delivered = append(delivered, msg)
mu.Unlock()
})
compressed := zlibCompress(t, kookEventBody("zlib hi", ""))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, defaultWebhookPath, bytes.NewReader(compressed))
req.Header.Set("Content-Type", "application/json")
a.InboundHandler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
mu.Lock()
defer mu.Unlock()
if len(delivered) != 1 || delivered[0].Content != "zlib hi" {
t.Fatalf("delivered = %+v, want one 'zlib hi'", delivered)
}
}
func TestWebhookHandlerEncrypted(t *testing.T) {
t.Parallel()
var (
mu sync.Mutex
delivered []adapter.UnifiedMessage
)
key := "test-encrypt-key"
a := webhookAdapter(t, Config{BotToken: "t", ReceiveMode: modeWebhook, WebhookEncryptKey: key},
func(_ context.Context, msg adapter.UnifiedMessage) {
mu.Lock()
delivered = append(delivered, msg)
mu.Unlock()
})
wrapped, err := json.Marshal(map[string]any{"encrypt": encryptKOOK(t, kookEventBody("secret hi", ""), key)})
if err != nil {
t.Fatalf("marshal encrypt wrapper: %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, defaultWebhookPath, bytes.NewReader(wrapped))
req.Header.Set("Content-Type", "application/json")
a.InboundHandler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
mu.Lock()
defer mu.Unlock()
if len(delivered) != 1 || delivered[0].Content != "secret hi" {
t.Fatalf("delivered = %+v, want one 'secret hi'", delivered)
}
}
func TestWebhookHandlerMethodNotAllowed(t *testing.T) {
t.Parallel()
a := webhookAdapter(t, Config{BotToken: "t", ReceiveMode: modeWebhook}, nil)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, defaultWebhookPath, nil)
a.InboundHandler().ServeHTTP(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want 405", rec.Code)
}
}
func TestConnectWebhookModeSkipsGateway(t *testing.T) {
t.Parallel()
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
kookFail(w, 1, "should not be called")
}))
defer srv.Close()
a := New(
Config{BotToken: "t", ReceiveMode: modeWebhook},
WithBaseURL(srv.URL),
WithHTTPClient(srv.Client()),
WithLogger(discardLogger()),
)
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect (webhook) = %v", err)
}
if !a.IsConnected() {
t.Fatal("webhook-mode Connect should mark connected")
}
if calls != 0 {
t.Fatalf("gateway was dialed %d time(s) in webhook mode, want 0", calls)
}
}
func TestDecryptKOOK(t *testing.T) {
t.Parallel()
key := "test-encrypt-key"
plain := []byte(`{"hello":"world"}`)
enc := encryptKOOK(t, plain, key)
got, err := decryptKOOK(enc, key)
if err != nil {
t.Fatalf("decryptKOOK: %v", err)
}
if !bytes.Equal(got, plain) {
t.Errorf("decryptKOOK = %q, want %q", got, plain)
}
}
// zlibCompress zlib-compresses data (KOOK's default callback framing).
func zlibCompress(t *testing.T, data []byte) []byte {
t.Helper()
var buf bytes.Buffer
zw := zlib.NewWriter(&buf)
if _, err := zw.Write(data); err != nil {
t.Fatalf("zlib write: %v", err)
}
if err := zw.Close(); err != nil {
t.Fatalf("zlib close: %v", err)
}
return buf.Bytes()
}
// encryptKOOK produces the KOOK {"encrypt": ...} value: base64(iv || base64(ciphertext))
// with AES-256-CBC, key right-padded with NUL to 32 bytes, PKCS7 padding.
func encryptKOOK(t *testing.T, plain []byte, key string) string {
t.Helper()
iv := []byte("0123456789abcdef")
k := padKey(key, 32)
block, err := aes.NewCipher(k)
if err != nil {
t.Fatalf("aes new cipher: %v", err)
}
padded := pkcs7Pad(plain, aes.BlockSize)
ct := make([]byte, len(padded))
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ct, padded)
inner := base64.StdEncoding.EncodeToString(ct)
return base64.StdEncoding.EncodeToString(append(append([]byte{}, iv...), []byte(inner)...))
}
// pkcs7Pad pads in to a multiple of size with PKCS7 padding.
func pkcs7Pad(in []byte, size int) []byte {
padding := size - len(in)%size
out := make([]byte, len(in)+padding)
copy(out, in)
for i := len(in); i < len(out); i++ {
out[i] = byte(padding)
}
return out
}
// TestWebhookReadMessagesBlocks exercises that ReadMessages still blocks until
// ctx cancellation in webhook mode (delivery is push via SetMessageHandler).
func TestWebhookReadMessagesBlocksUntilCancel(t *testing.T) {
a := New(Config{BotToken: "t", ReceiveMode: modeWebhook}, WithLogger(discardLogger()))
ctx, cancel := context.WithCancel(context.Background())
cancel()
done := make(chan struct{})
go func() {
a.ReadMessages(ctx, func(adapter.UnifiedMessage) {})
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("ReadMessages() did not return on canceled context")
}
}