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 gatewayCalls int var meCalls int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case gatewayPath: // Webhook and websocket are mutually exclusive: the gateway must never be // dialed in webhook mode. gatewayCalls++ kookFail(w, 1, "should not be called") case userMePath: // The bot-identity lookup still runs so the adapter resolves who it is. meCalls++ kookOK(w, map[string]any{"id": "bot-1", "username": "bot", "nickname": "Bot"}) default: http.NotFound(w, r) } })) 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 gatewayCalls != 0 { t.Fatalf("gateway was dialed %d time(s) in webhook mode, want 0", gatewayCalls) } if meCalls != 1 { t.Fatalf("user/me was called %d time(s), want 1", meCalls) } if id, _, _ := a.BotIdentity(); id != "bot-1" { t.Errorf("BotIdentity id = %q, want bot-1", id) } } 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") } }