test(acp,feishu): wave C greenfield coverage push

- internal/providers/acp 0.0% → 80.0% (7 test files, ~2560 LOC)
  - helpers, types round-trip, jsonrpc framing + adversarial fuzz,
    session, terminal/sandbox, tool_bridge (3 permission modes),
    ProcessPool lifecycle
- internal/channels/feishu 20.6% → 63.9% (15 test files)
  - bot parse/policy, factory, larkws proto + WS lifecycle,
    larkclient HTTP error paths, larkevents AES-CBC decrypt +
    tamper detection, media send/receive, lifecycle

Zero source modifications. Race clean (-race -count=3).
Dual build verified (standard + sqliteonly).
This commit is contained in:
viettranx committed 2026-04-11 22:45:08 +07:00
1 parent f2c43c3849
commit 32c6bc1451
22 files changed
+5858

No files matched your search

+290
View File
@@ -0,0 +1,290 @@
package feishu
import (
"testing"
)
// --- parseMessageContent ---
func TestParseMessageContent_Text(t *testing.T) {
cases := []struct {
name string
raw string
want string
}{
{"normal text", `{"text":"hello world"}`, "hello world"},
{"empty raw", "", ""},
{"invalid json falls back", "not-json", "not-json"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := parseMessageContent(tc.raw, "text")
if got != tc.want {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}
func TestParseMessageContent_Image(t *testing.T) {
got := parseMessageContent(`{"image_key":"img_123"}`, "image")
if got != "[image]" {
t.Errorf("got %q, want \"[image]\"", got)
}
}
func TestParseMessageContent_File(t *testing.T) {
got := parseMessageContent(`{"file_key":"f1","file_name":"report.pdf"}`, "file")
if got != "[file: report.pdf]" {
t.Errorf("got %q, want \"[file: report.pdf]\"", got)
}
}
func TestParseMessageContent_File_NoName(t *testing.T) {
got := parseMessageContent("not-json", "file")
if got != "[file]" {
t.Errorf("got %q, want \"[file]\"", got)
}
}
func TestParseMessageContent_UnknownType(t *testing.T) {
got := parseMessageContent(`{}`, "sticker")
if got != "[sticker message]" {
t.Errorf("got %q, want \"[sticker message]\"", got)
}
}
// --- parsePostContent ---
func TestParsePostContent_FlatFormat(t *testing.T) {
// Format 2: flat, "content" at top level
raw := `{"content":[[{"tag":"text","text":"hello"},{"tag":"text","text":" world"}]]}`
got := parsePostContent(raw)
if got != "hello world" {
t.Errorf("got %q, want %q", got, "hello world")
}
}
func TestParsePostContent_LanguageWrapped_ZhCN(t *testing.T) {
raw := `{"zh_cn":{"title":"Title","content":[[{"tag":"text","text":"你好"}]]}}`
got := parsePostContent(raw)
if got != "你好" {
t.Errorf("got %q, want %q", got, "你好")
}
}
func TestParsePostContent_LanguageWrapped_EnUS(t *testing.T) {
raw := `{"en_us":{"content":[[{"tag":"md","text":"**bold**"}]]}}`
got := parsePostContent(raw)
if got != "**bold**" {
t.Errorf("got %q, want %q", got, "**bold**")
}
}
func TestParsePostContent_MultiParagraph(t *testing.T) {
raw := `{"content":[[{"tag":"text","text":"line1"}],[{"tag":"text","text":"line2"}]]}`
got := parsePostContent(raw)
if got != "line1\nline2" {
t.Errorf("got %q, want %q", got, "line1\nline2")
}
}
func TestParsePostContent_AtTag(t *testing.T) {
raw := `{"content":[[{"tag":"at","user_name":"Alice"},{"tag":"text","text":" hi"}]]}`
got := parsePostContent(raw)
if got != "@Alice hi" {
t.Errorf("got %q, want %q", got, "@Alice hi")
}
}
func TestParsePostContent_LinkTag(t *testing.T) {
raw := `{"content":[[{"tag":"a","href":"https://example.com","text":"click"}]]}`
got := parsePostContent(raw)
if got != "[click](https://example.com)" {
t.Errorf("got %q, want %q", got, "[click](https://example.com)")
}
}
func TestParsePostContent_LinkTag_NoText(t *testing.T) {
raw := `{"content":[[{"tag":"a","href":"https://example.com"}]]}`
got := parsePostContent(raw)
if got != "https://example.com" {
t.Errorf("got %q, want %q", got, "https://example.com")
}
}
func TestParsePostContent_ImageTag(t *testing.T) {
raw := `{"content":[[{"tag":"img","image_key":"img_abc"}]]}`
got := parsePostContent(raw)
if got != "[image]" {
t.Errorf("got %q, want %q", got, "[image]")
}
}
func TestParsePostContent_InvalidJSON(t *testing.T) {
// Falls back to raw content
raw := "not-json"
got := parsePostContent(raw)
if got != raw {
t.Errorf("got %q, want %q", got, raw)
}
}
// --- resolvePostElements ---
func TestResolvePostElements_FirstMapValueFallback(t *testing.T) {
// No zh_cn/en_us key — uses first map value as language wrapper
raw := `{"custom_lang":{"content":[[{"tag":"text","text":"fallback"}]]}}`
elems := resolvePostElements(raw)
if len(elems) == 0 {
t.Fatal("expected elements, got none")
}
}
func TestResolvePostElements_NoContent(t *testing.T) {
raw := `{"zh_cn":{"title":"only title"}}`
elems := resolvePostElements(raw)
if elems != nil {
t.Errorf("expected nil, got %v", elems)
}
}
// --- extractPostImageKeys ---
func TestExtractPostImageKeys_Basic(t *testing.T) {
raw := `{"content":[[{"tag":"img","image_key":"img_001"},{"tag":"text","text":"caption"}],[{"tag":"img","image_key":"img_002"}]]}`
keys := extractPostImageKeys(raw)
if len(keys) != 2 {
t.Fatalf("expected 2 keys, got %d: %v", len(keys), keys)
}
if keys[0] != "img_001" || keys[1] != "img_002" {
t.Errorf("keys: got %v", keys)
}
}
func TestExtractPostImageKeys_Dedup(t *testing.T) {
raw := `{"content":[[{"tag":"img","image_key":"img_dup"}],[{"tag":"img","image_key":"img_dup"}]]}`
keys := extractPostImageKeys(raw)
if len(keys) != 1 {
t.Errorf("expected 1 deduped key, got %d: %v", len(keys), keys)
}
}
func TestExtractPostImageKeys_NoImages(t *testing.T) {
raw := `{"content":[[{"tag":"text","text":"no images"}]]}`
keys := extractPostImageKeys(raw)
if keys != nil {
t.Errorf("expected nil, got %v", keys)
}
}
// --- resolveMentions ---
func TestResolveMentions_BotMentionStripped(t *testing.T) {
text := "@_user_1 hello"
mentions := []mentionInfo{{Key: "@_user_1", OpenID: "ou_bot123", Name: "Bot"}}
got := resolveMentions(text, mentions, "ou_bot123")
if got != "hello" {
t.Errorf("got %q, want %q", got, "hello")
}
}
func TestResolveMentions_UserMentionReplaced(t *testing.T) {
text := "hey @_user_1 how are you"
mentions := []mentionInfo{{Key: "@_user_1", OpenID: "ou_user999", Name: "Alice"}}
got := resolveMentions(text, mentions, "ou_bot123")
if got != "hey @Alice how are you" {
t.Errorf("got %q, want %q", got, "hey @Alice how are you")
}
}
func TestResolveMentions_EmptyKey_Skipped(t *testing.T) {
text := "hello"
mentions := []mentionInfo{{Key: "", OpenID: "ou_abc", Name: "Bob"}}
got := resolveMentions(text, mentions, "")
if got != "hello" {
t.Errorf("got %q, want %q", got, "hello")
}
}
func TestResolveMentions_NoBotID_AllMentionsReplacedWithName(t *testing.T) {
text := "@_user_1 and @_user_2"
mentions := []mentionInfo{
{Key: "@_user_1", OpenID: "ou_A", Name: "Alice"},
{Key: "@_user_2", OpenID: "ou_B", Name: "Bob"},
}
got := resolveMentions(text, mentions, "")
// botOpenID is empty: all mentions treated as bot → stripped
// (because the condition is: if botOpenID != "" && openID == botOpenID → strip, else → replace with name)
// When botOpenID == "", neither branch strips unless openID matches empty string.
// Both get replaced with @Name.
if got != "@Alice and @Bob" {
t.Errorf("got %q, want %q", got, "@Alice and @Bob")
}
}
// --- parseMessageEvent (via Channel) ---
func TestParseMessageEvent_TextMessage(t *testing.T) {
ch := &Channel{}
ev := &MessageEvent{}
ev.Event.Message.MessageID = "om_abc"
ev.Event.Message.ChatID = "oc_chat_1"
ev.Event.Message.ChatType = "p2p"
ev.Event.Message.MessageType = "text"
ev.Event.Message.Content = `{"text":"hello"}`
ev.Event.Sender.SenderID.OpenID = "ou_sender1"
mc := ch.parseMessageEvent(ev)
if mc == nil {
t.Fatal("got nil messageContext")
}
if mc.Content != "hello" {
t.Errorf("content: got %q, want %q", mc.Content, "hello")
}
if mc.ChatID != "oc_chat_1" {
t.Errorf("ChatID: got %q", mc.ChatID)
}
if mc.SenderID != "ou_sender1" {
t.Errorf("SenderID: got %q", mc.SenderID)
}
if mc.MentionedBot {
t.Error("MentionedBot should be false for text with no mentions")
}
}
func TestParseMessageEvent_BotMentioned(t *testing.T) {
ch := &Channel{botOpenID: "ou_bot_xyz"}
ev := &MessageEvent{}
ev.Event.Message.MessageType = "text"
ev.Event.Message.Content = `{"text":"@_user_1 please help"}`
ev.Event.Message.Mentions = []EventMention{
{Key: "@_user_1", ID: struct {
OpenID string `json:"open_id"`
UserID string `json:"user_id"`
UnionID string `json:"union_id"`
}{OpenID: "ou_bot_xyz"}, Name: "MyBot"},
}
mc := ch.parseMessageEvent(ev)
if !mc.MentionedBot {
t.Error("MentionedBot should be true when bot's open_id is mentioned")
}
// Bot mention key should be stripped from content
if mc.Content == "@_user_1 please help" {
t.Error("bot mention key should be stripped from content")
}
}
func TestParseMessageEvent_NoSender(t *testing.T) {
ch := &Channel{}
ev := &MessageEvent{}
ev.Event.Message.MessageType = "text"
ev.Event.Message.Content = `{"text":"hi"}`
// Sender fields are zero-value
mc := ch.parseMessageEvent(ev)
if mc.SenderID != "" {
t.Errorf("SenderID: got %q, want empty", mc.SenderID)
}
}
+169
View File
@@ -0,0 +1,169 @@
package feishu
import (
"context"
"testing"
)
// --- isInGroupAllowList ---
func TestIsInGroupAllowList_Match(t *testing.T) {
ch := &Channel{groupAllowList: []string{"ou_user_1", "ou_user_2"}}
if !ch.isInGroupAllowList("ou_user_1") {
t.Error("ou_user_1 should be in allowlist")
}
}
func TestIsInGroupAllowList_AtPrefix(t *testing.T) {
// Allow entries may have "@" prefix stripped
ch := &Channel{groupAllowList: []string{"@ou_user_3"}}
if !ch.isInGroupAllowList("ou_user_3") {
t.Error("ou_user_3 (with @ prefix stripped) should match")
}
}
func TestIsInGroupAllowList_NoMatch(t *testing.T) {
ch := &Channel{groupAllowList: []string{"ou_user_1"}}
if ch.isInGroupAllowList("ou_stranger") {
t.Error("ou_stranger should not be in allowlist")
}
}
func TestIsInGroupAllowList_Empty(t *testing.T) {
ch := &Channel{}
if ch.isInGroupAllowList("ou_anyone") {
t.Error("empty allowlist should never match")
}
}
// --- checkGroupPolicy ---
func TestCheckGroupPolicy_Disabled(t *testing.T) {
ch := &Channel{}
ch.cfg.GroupPolicy = "disabled"
if ch.checkGroupPolicy(context.Background(), "ou_user", "oc_chat") {
t.Error("disabled policy should always return false")
}
}
func TestCheckGroupPolicy_Open(t *testing.T) {
ch := &Channel{}
ch.cfg.GroupPolicy = "open"
if !ch.checkGroupPolicy(context.Background(), "ou_user", "oc_chat") {
t.Error("open policy should always return true")
}
}
func TestCheckGroupPolicy_DefaultIsOpen(t *testing.T) {
// Empty policy defaults to "open"
ch := &Channel{}
ch.cfg.GroupPolicy = ""
if !ch.checkGroupPolicy(context.Background(), "ou_anyone", "oc_chat") {
t.Error("empty policy should default to open and return true")
}
}
func TestCheckGroupPolicy_Pairing_GroupAllowListBypasses(t *testing.T) {
// Under "pairing" policy, groupAllowList is checked FIRST before BaseChannel,
// so a matching entry returns true without requiring a BaseChannel.
ch := &Channel{groupAllowList: []string{"ou_vip"}}
ch.cfg.GroupPolicy = "pairing"
if !ch.checkGroupPolicy(context.Background(), "ou_vip", "oc_chat") {
t.Error("groupAllowList match should bypass pairing and return true")
}
}
// --- webhookPath ---
func TestWebhookPath_Default(t *testing.T) {
ch := &Channel{}
got := ch.webhookPath()
if got != defaultWebhookPath {
t.Errorf("got %q, want %q", got, defaultWebhookPath)
}
}
func TestWebhookPath_Custom(t *testing.T) {
ch := &Channel{}
ch.cfg.WebhookPath = "/custom/path"
got := ch.webhookPath()
if got != "/custom/path" {
t.Errorf("got %q, want %q", got, "/custom/path")
}
}
// --- WebhookHandler ---
func TestWebhookHandler_NonWebhookMode_ReturnsNil(t *testing.T) {
ch := &Channel{}
ch.cfg.ConnectionMode = "websocket"
path, handler := ch.WebhookHandler()
if path != "" || handler != nil {
t.Error("WebhookHandler should return ('', nil) for non-webhook mode")
}
}
func TestWebhookHandler_WebhookWithPort_ReturnsNil(t *testing.T) {
ch := &Channel{}
ch.cfg.ConnectionMode = "webhook"
ch.cfg.WebhookPort = 3001
path, handler := ch.WebhookHandler()
if path != "" || handler != nil {
t.Error("WebhookHandler should return ('', nil) when webhook_port > 0")
}
}
// --- OnReactionEvent ---
func TestOnReactionEvent_Off(t *testing.T) {
ch := &Channel{}
ch.cfg.ReactionLevel = "off"
// Should be a no-op — no panic, no error
err := ch.OnReactionEvent(context.Background(), "oc_chat", "", "thinking")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
func TestOnReactionEvent_EmptyMessageID(t *testing.T) {
ch := &Channel{}
ch.cfg.ReactionLevel = "full"
// Empty messageID → early return
err := ch.OnReactionEvent(context.Background(), "oc_chat", "", "thinking")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
func TestOnReactionEvent_Minimal_NonTerminalIgnored(t *testing.T) {
ch := &Channel{}
ch.cfg.ReactionLevel = "minimal"
// "thinking" is not terminal → should be ignored (no-op)
err := ch.OnReactionEvent(context.Background(), "oc_chat", "om_msg1", "thinking")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
// --- ClearReaction ---
func TestClearReaction_NoExistingReaction(t *testing.T) {
ch := &Channel{}
// No reaction stored → should be no-op
err := ch.ClearReaction(context.Background(), "oc_chat_no_reaction", "")
if err != nil {
t.Errorf("ClearReaction with no reaction should succeed: %v", err)
}
}
// --- sendChunkedText ---
func TestSendChunkedText_ShortText_SingleChunk(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"message_id":"om_chunk_1"}}`)
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
err := ch.sendChunkedText(context.Background(), "oc_chat", "chat_id", "short message", 4000, "")
if err != nil {
t.Fatalf("sendChunkedText: %v", err)
}
}
@@ -0,0 +1,97 @@
package feishu
import (
"bytes"
"context"
"testing"
)
// sendImage, sendFile, sendMarkdownCard, downloadMessageResource, uploadImage, uploadFile
// all delegate to LarkClient methods. These tests exercise the Channel-level wrappers.
func TestChannelSendImage(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"message_id":"om_img_1"}}`)
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
if err := ch.sendImage(context.Background(), "oc_chat_1", "chat_id", "img_key_abc", ""); err != nil {
t.Fatalf("sendImage: %v", err)
}
}
func TestChannelSendFile(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"message_id":"om_file_1"}}`)
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
if err := ch.sendFile(context.Background(), "oc_chat_1", "chat_id", "file_key_abc", "file", ""); err != nil {
t.Fatalf("sendFile: %v", err)
}
}
func TestChannelSendFile_DefaultMsgType(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"message_id":"om_file_2"}}`)
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
// Empty msgType → defaults to "file"
if err := ch.sendFile(context.Background(), "oc_chat_1", "chat_id", "file_key_abc", "", ""); err != nil {
t.Fatalf("sendFile (empty msgType): %v", err)
}
}
func TestChannelSendMarkdownCard(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"message_id":"om_card_1"}}`)
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
if err := ch.sendMarkdownCard(context.Background(), "oc_chat_1", "chat_id", "**hello world**", "", nil); err != nil {
t.Fatalf("sendMarkdownCard: %v", err)
}
}
func TestChannelSendMarkdownCard_WithMetadata(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"message_id":"om_card_2"}}`)
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
meta := map[string]string{"key": "value"}
if err := ch.sendMarkdownCard(context.Background(), "oc_chat_1", "chat_id", "text", "", meta); err != nil {
t.Fatalf("sendMarkdownCard (metadata): %v", err)
}
}
func TestChannelDownloadMessageResource(t *testing.T) {
data := []byte("resource data")
srv := newBinaryMockServer(t, data, "application/octet-stream")
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
got, _, err := ch.downloadMessageResource(context.Background(), "om_msg1", "fk_123", "file")
if err != nil {
t.Fatalf("downloadMessageResource: %v", err)
}
if string(got) != string(data) {
t.Errorf("data mismatch: got %q, want %q", got, data)
}
}
func TestChannelUploadImage(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"image_key":"img_uploaded_ch"}}`)
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
key, err := ch.uploadImage(context.Background(), bytes.NewReader([]byte{0xFF, 0xD8, 0xFF}))
if err != nil {
t.Fatalf("uploadImage: %v", err)
}
if key != "img_uploaded_ch" {
t.Errorf("image_key: got %q, want img_uploaded_ch", key)
}
}
func TestChannelUploadFile(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"file_key":"file_uploaded_ch"}}`)
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
key, err := ch.uploadFile(context.Background(), bytes.NewReader([]byte("pdf data")), "test.pdf", "pdf", 0)
if err != nil {
t.Fatalf("uploadFile: %v", err)
}
if key != "file_uploaded_ch" {
t.Errorf("file_key: got %q, want file_uploaded_ch", key)
}
}
@@ -0,0 +1,279 @@
package feishu
import (
"context"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/bus"
)
// --- sendText error path ---
func TestSendText_Error(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":10001,"msg":"error","data":{}}`)
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
err := ch.sendText(context.Background(), "oc_chat_1", "chat_id", "hello", "")
if err == nil {
t.Fatal("expected error for non-zero API code")
}
}
// --- sendMarkdownCard error path ---
func TestSendMarkdownCard_Error(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":10001,"msg":"error","data":{}}`)
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
err := ch.sendMarkdownCard(context.Background(), "oc_chat_1", "chat_id", "text", "", nil)
if err == nil {
t.Fatal("expected error for non-zero API code")
}
}
// --- sendChunkedText error propagation ---
func TestSendChunkedText_Error(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":10001,"msg":"error","data":{}}`)
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
err := ch.sendChunkedText(context.Background(), "oc_chat_1", "chat_id", "hello error test", 4000, "")
if err == nil {
t.Fatal("expected error propagated from sendText")
}
}
// --- sendImage error path ---
func TestSendImage_Error(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":10001,"msg":"error","data":{}}`)
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
err := ch.sendImage(context.Background(), "oc_chat_1", "chat_id", "img_key_abc", "")
if err == nil {
t.Fatal("expected error for send image failure")
}
}
// --- sendFile error path ---
func TestSendFile_Error(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":10001,"msg":"error","data":{}}`)
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
err := ch.sendFile(context.Background(), "oc_chat_1", "chat_id", "file_key_abc", "file", "")
if err == nil {
t.Fatal("expected error for send file failure")
}
}
// --- OnReactionEvent active state (thinking/tool) ---
func TestOnReactionEvent_Thinking_AddsReaction(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"reaction_id":"rx_001"}}`)
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
ch.cfg.ReactionLevel = "full"
err := ch.OnReactionEvent(context.Background(), "oc_chat_1", "om_msg_1", "thinking")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
func TestOnReactionEvent_Thinking_AlreadyHasReaction(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"reaction_id":"rx_001"}}`)
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
ch.cfg.ReactionLevel = "full"
// First call stores the reaction
_ = ch.OnReactionEvent(context.Background(), "oc_chat_11", "om_msg_1", "thinking")
// Second call with same chatID: already loaded, should skip
err := ch.OnReactionEvent(context.Background(), "oc_chat_11", "om_msg_2", "thinking")
if err != nil {
t.Errorf("unexpected error on second thinking call: %v", err)
}
}
// --- removeTypingReaction with stored reaction ---
func TestRemoveTypingReaction_WithStoredReaction(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{}}`)
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
ch.reactions.Store("oc_chat_2", &reactionState{
messageID: "om_msg_2",
reactionID: "rx_stored_1",
})
err := ch.removeTypingReaction(context.Background(), "oc_chat_2")
if err != nil {
t.Errorf("removeTypingReaction: %v", err)
}
}
func TestRemoveTypingReaction_EmptyReactionID(t *testing.T) {
ch := &Channel{}
// Store state with empty reactionID — should be no-op after LoadAndDelete
ch.reactions.Store("oc_chat_3", &reactionState{
messageID: "om_msg_3",
reactionID: "",
})
err := ch.removeTypingReaction(context.Background(), "oc_chat_3")
if err != nil {
t.Errorf("unexpected error for empty reactionID: %v", err)
}
}
// --- ClearReaction with stored reaction ---
func TestClearReaction_WithStoredReaction(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{}}`)
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
ch.reactions.Store("oc_chat_4", &reactionState{
messageID: "om_msg_4",
reactionID: "rx_to_clear",
})
err := ch.ClearReaction(context.Background(), "oc_chat_4", "")
if err != nil {
t.Errorf("ClearReaction: %v", err)
}
}
// --- WebhookHandler returns handler in webhook mode with port=0 ---
func TestWebhookHandler_WebhookNoPort_ReturnsHandler(t *testing.T) {
ch := &Channel{}
ch.cfg.ConnectionMode = "webhook"
ch.cfg.WebhookPort = 0
ch.cfg.VerificationToken = "testtoken"
path, handler := ch.WebhookHandler()
if path == "" {
t.Error("expected non-empty path for webhook mode with no port")
}
if handler == nil {
t.Error("expected non-nil handler for webhook mode with no port")
}
}
func TestWebhookHandler_CustomPath_ReturnsHandler(t *testing.T) {
ch := &Channel{}
ch.cfg.ConnectionMode = "webhook"
ch.cfg.WebhookPort = 0
ch.cfg.WebhookPath = "/custom/feishu"
path, handler := ch.WebhookHandler()
if path != "/custom/feishu" {
t.Errorf("expected /custom/feishu, got %q", path)
}
if handler == nil {
t.Error("expected non-nil handler")
}
}
// --- sendMediaAttachment ---
// newDualResponseServer returns a server that gives resp1 on first non-token request,
// resp2 on subsequent requests.
func newDualResponseServer(t *testing.T, resp1, resp2 string) *httptest.Server {
t.Helper()
call := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == tokenEndpoint {
w.Write([]byte(`{"code":0,"msg":"ok","tenant_access_token":"tok","expire":7200}`))
return
}
w.Header().Set("Content-Type", "application/json")
if call == 0 {
w.Write([]byte(resp1))
} else {
w.Write([]byte(resp2))
}
call++
}))
t.Cleanup(srv.Close)
return srv
}
func TestSendMediaAttachment_Image(t *testing.T) {
srv := newDualResponseServer(t,
`{"code":0,"msg":"ok","data":{"image_key":"img_test_key"}}`,
`{"code":0,"msg":"ok","data":{"message_id":"om_img_x"}}`,
)
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
f, err := os.CreateTemp(t.TempDir(), "test_img_*.png")
if err != nil {
t.Fatalf("create temp: %v", err)
}
f.Write([]byte{0x89, 0x50, 0x4E, 0x47})
f.Close()
att := bus.MediaAttachment{URL: f.Name(), ContentType: "image/png"}
if err := ch.sendMediaAttachment(context.Background(), "oc_chat", "chat_id", att, ""); err != nil {
t.Errorf("sendMediaAttachment image: %v", err)
}
}
func TestSendMediaAttachment_EmptyURL(t *testing.T) {
ch := &Channel{}
att := bus.MediaAttachment{URL: "", ContentType: "image/png"}
if err := ch.sendMediaAttachment(context.Background(), "oc_chat", "chat_id", att, ""); err != nil {
t.Errorf("expected no error for empty URL: %v", err)
}
}
func TestSendMediaAttachment_DefaultFile(t *testing.T) {
srv := newDualResponseServer(t,
`{"code":0,"msg":"ok","data":{"file_key":"file_test_key"}}`,
`{"code":0,"msg":"ok","data":{"message_id":"om_file_x"}}`,
)
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
f, err := os.CreateTemp(t.TempDir(), "test_doc_*.pdf")
if err != nil {
t.Fatalf("create temp: %v", err)
}
f.Write([]byte("pdf data"))
f.Close()
att := bus.MediaAttachment{URL: f.Name(), ContentType: "application/pdf"}
if err := ch.sendMediaAttachment(context.Background(), "oc_chat", "chat_id", att, ""); err != nil {
t.Errorf("sendMediaAttachment file: %v", err)
}
}
func TestSendMediaAttachment_Video(t *testing.T) {
srv := newDualResponseServer(t,
`{"code":0,"msg":"ok","data":{"file_key":"video_test_key"}}`,
`{"code":0,"msg":"ok","data":{"message_id":"om_video_x"}}`,
)
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
f, err := os.CreateTemp(t.TempDir(), "test_vid_*.mp4")
if err != nil {
t.Fatalf("create temp: %v", err)
}
f.Write([]byte("video data"))
f.Close()
att := bus.MediaAttachment{URL: f.Name(), ContentType: "video/mp4"}
if err := ch.sendMediaAttachment(context.Background(), "oc_chat", "chat_id", att, ""); err != nil {
t.Errorf("sendMediaAttachment video: %v", err)
}
}
func TestSendMediaAttachment_FileNotFound(t *testing.T) {
ch := &Channel{}
att := bus.MediaAttachment{URL: "/nonexistent/path/file.pdf", ContentType: "application/pdf"}
if err := ch.sendMediaAttachment(context.Background(), "oc_chat", "chat_id", att, ""); err == nil {
t.Fatal("expected error for missing file")
}
}
+143
View File
@@ -0,0 +1,143 @@
package feishu
import (
"encoding/json"
"testing"
)
func TestFactory_MissingAppID(t *testing.T) {
creds, _ := json.Marshal(map[string]string{"app_secret": "s"})
_, err := Factory("test", creds, nil, nil, nil)
if err == nil {
t.Fatal("expected error for missing app_id")
}
}
func TestFactory_MissingAppSecret(t *testing.T) {
creds, _ := json.Marshal(map[string]string{"app_id": "a"})
_, err := Factory("test", creds, nil, nil, nil)
if err == nil {
t.Fatal("expected error for missing app_secret")
}
}
func TestFactory_EmptyCreds(t *testing.T) {
_, err := Factory("test", nil, nil, nil, nil)
if err == nil {
t.Fatal("expected error for empty credentials")
}
}
func TestFactory_InvalidCredsJSON(t *testing.T) {
_, err := Factory("test", []byte("not-json{"), nil, nil, nil)
if err == nil {
t.Fatal("expected error for invalid credentials JSON")
}
}
func TestFactory_InvalidConfigJSON(t *testing.T) {
creds, _ := json.Marshal(map[string]string{"app_id": "a", "app_secret": "s"})
_, err := Factory("test", creds, []byte("not-json{"), nil, nil)
if err == nil {
t.Fatal("expected error for invalid config JSON")
}
}
func TestFactory_ValidMinimal(t *testing.T) {
creds, _ := json.Marshal(map[string]string{
"app_id": "cli_test_app",
"app_secret": "test_secret_123",
})
ch, err := Factory("feishu-test", creds, nil, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ch == nil {
t.Fatal("expected non-nil channel")
}
}
func TestFactory_ValidWithConfig(t *testing.T) {
creds, _ := json.Marshal(map[string]string{
"app_id": "cli_test_app",
"app_secret": "test_secret_123",
"verification_token": "vtok",
"encrypt_key": "ekey",
})
cfg, _ := json.Marshal(map[string]any{
"domain": "feishu",
"connection_mode": "webhook",
"dm_policy": "open",
"group_policy": "allowlist",
"text_chunk_limit": 2000,
"media_max_mb": 10,
})
ch, err := Factory("feishu-full", creds, cfg, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ch == nil {
t.Fatal("expected non-nil channel")
}
}
func TestFactory_DefaultGroupPolicy(t *testing.T) {
// When group_policy is empty in config, Factory should default to "pairing".
creds, _ := json.Marshal(map[string]string{
"app_id": "cli_test_app2",
"app_secret": "secret2",
})
cfg, _ := json.Marshal(map[string]any{}) // empty config
ch, err := Factory("feishu-default-policy", creds, cfg, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ch == nil {
t.Fatal("channel must not be nil")
}
}
func TestFactoryWithPendingStore_Valid(t *testing.T) {
factory := FactoryWithPendingStore(nil)
if factory == nil {
t.Fatal("FactoryWithPendingStore returned nil factory func")
}
creds, _ := json.Marshal(map[string]string{
"app_id": "cli_ws_app",
"app_secret": "ws_secret",
})
ch, err := factory("feishu-ws", creds, nil, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ch == nil {
t.Fatal("expected non-nil channel")
}
}
func TestFactoryWithPendingStore_MissingCreds(t *testing.T) {
factory := FactoryWithPendingStore(nil)
creds, _ := json.Marshal(map[string]string{"app_id": "only-id"})
_, err := factory("feishu-bad", creds, nil, nil, nil)
if err == nil {
t.Fatal("expected error for missing app_secret")
}
}
func TestFactoryWithPendingStore_InvalidCredsJSON(t *testing.T) {
factory := FactoryWithPendingStore(nil)
_, err := factory("feishu-bad", []byte("{bad"), nil, nil, nil)
if err == nil {
t.Fatal("expected error for invalid JSON")
}
}
func TestFactoryWithPendingStore_InvalidConfigJSON(t *testing.T) {
factory := FactoryWithPendingStore(nil)
creds, _ := json.Marshal(map[string]string{"app_id": "a", "app_secret": "b"})
_, err := factory("feishu-bad", creds, []byte("{bad"), nil, nil)
if err == nil {
t.Fatal("expected error for invalid config JSON")
}
}
@@ -0,0 +1,248 @@
package feishu
import (
"strings"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/config"
)
// --- resolveDomain ---
func TestResolveDomain(t *testing.T) {
cases := []struct {
input string
want string
}{
{"feishu", "https://open.feishu.cn"},
{"lark", "https://open.larksuite.com"},
{"", "https://open.larksuite.com"},
{"https://custom.example.com", "https://custom.example.com"},
{"http://custom.example.com", "http://custom.example.com"},
{"custom.example.com", "https://custom.example.com"},
}
for _, tc := range cases {
t.Run(tc.input, func(t *testing.T) {
got := resolveDomain(tc.input)
if got != tc.want {
t.Errorf("resolveDomain(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
}
// --- resolveReceiveIDType ---
func TestResolveReceiveIDType(t *testing.T) {
cases := []struct {
id string
want string
}{
{"oc_chat123", "chat_id"},
{"ou_user456", "open_id"},
{"on_union789", "union_id"},
{"group:oc_chat123", "chat_id"}, // unknown prefix falls back to chat_id
{"", "chat_id"},
}
for _, tc := range cases {
t.Run(tc.id, func(t *testing.T) {
got := resolveReceiveIDType(tc.id)
if got != tc.want {
t.Errorf("resolveReceiveIDType(%q) = %q, want %q", tc.id, got, tc.want)
}
})
}
}
// --- hasMentions ---
func TestHasMentions(t *testing.T) {
cases := []struct {
text string
want bool
}{
{"hello @ou_abc123", true},
{"no mentions here", false},
{"@ou_xyz at start", true},
{"", false},
}
for _, tc := range cases {
t.Run(tc.text, func(t *testing.T) {
got := hasMentions(tc.text)
if got != tc.want {
t.Errorf("hasMentions(%q) = %v, want %v", tc.text, got, tc.want)
}
})
}
}
// --- convertMentionsForCard ---
func TestConvertMentionsForCard(t *testing.T) {
input := "Hello @ou_abc123 and @ou_def456"
got := convertMentionsForCard(input)
if !strings.Contains(got, "<at id=ou_abc123></at>") {
t.Errorf("missing first mention in: %q", got)
}
if !strings.Contains(got, "<at id=ou_def456></at>") {
t.Errorf("missing second mention in: %q", got)
}
}
func TestConvertMentionsForCard_NoMentions(t *testing.T) {
input := "plain text"
got := convertMentionsForCard(input)
if got != input {
t.Errorf("got %q, want %q", got, input)
}
}
// --- buildPostContent ---
func TestBuildPostContent_NoMentions(t *testing.T) {
content := buildPostContent("simple text")
if !strings.Contains(content, "simple text") {
t.Errorf("content missing text: %q", content)
}
if !strings.Contains(content, "zh_cn") {
t.Errorf("content missing zh_cn wrapper: %q", content)
}
if !strings.Contains(content, `"tag":"md"`) {
t.Errorf("content missing md tag: %q", content)
}
}
func TestBuildPostContent_WithMention(t *testing.T) {
content := buildPostContent("Hey @ou_abc123 check this")
if !strings.Contains(content, `"tag":"at"`) {
t.Errorf("content missing at tag: %q", content)
}
if !strings.Contains(content, "ou_abc123") {
t.Errorf("content missing user_id: %q", content)
}
}
func TestBuildPostContent_MentionAtStart(t *testing.T) {
content := buildPostContent("@ou_abc123 please review")
if !strings.Contains(content, `"tag":"at"`) {
t.Errorf("content missing at tag: %q", content)
}
if !strings.Contains(content, "please review") {
t.Errorf("content missing trailing text: %q", content)
}
}
func TestBuildPostContent_MentionAtEnd(t *testing.T) {
content := buildPostContent("please review @ou_xyz999")
if !strings.Contains(content, "please review") {
t.Errorf("content missing leading text: %q", content)
}
if !strings.Contains(content, "ou_xyz999") {
t.Errorf("content missing user_id: %q", content)
}
}
// --- buildMarkdownCard ---
func TestBuildMarkdownCard_Structure(t *testing.T) {
card := buildMarkdownCard("**hello**")
if card["schema"] != "2.0" {
t.Errorf("schema: got %v, want 2.0", card["schema"])
}
if card["config"] == nil {
t.Error("config must not be nil")
}
if card["body"] == nil {
t.Error("body must not be nil")
}
}
// --- shouldUseCard (package-level func: text → bool) ---
func TestShouldUseCard(t *testing.T) {
cases := []struct {
text string
want bool
}{
{"```go\nfmt.Println()\n```", true},
{"| col1 | col2 |\n| --- | --- |", true},
{"|---|---|\n|a|b|", true},
{"plain text no code", false},
{"", false},
}
for _, tc := range cases {
t.Run(tc.text[:min(20, len(tc.text))], func(t *testing.T) {
got := shouldUseCard(tc.text)
if got != tc.want {
t.Errorf("shouldUseCard(%q) = %v, want %v", tc.text, got, tc.want)
}
})
}
}
// min is a local helper for Go < 1.21 compatibility in test labels.
func min(a, b int) int {
if a < b {
return a
}
return b
}
// --- isDuplicate ---
func TestIsDuplicate_FirstTimeFalse(t *testing.T) {
ch := &Channel{}
if ch.isDuplicate("msg_001") {
t.Error("first call must return false")
}
}
func TestIsDuplicate_SecondTimeTrue(t *testing.T) {
ch := &Channel{}
ch.isDuplicate("msg_002") // first call stores it
if !ch.isDuplicate("msg_002") {
t.Error("second call must return true")
}
}
func TestIsDuplicate_DifferentIDs(t *testing.T) {
ch := &Channel{}
ch.isDuplicate("msg_A")
if ch.isDuplicate("msg_B") {
t.Error("different IDs must not be duplicates")
}
}
// --- BlockReplyEnabled (returns *bool, cfg-level override) ---
func TestBlockReplyEnabled_Default(t *testing.T) {
ch := &Channel{}
// nil pointer for BlockReply → returns nil
if result := ch.BlockReplyEnabled(); result != nil {
t.Errorf("BlockReplyEnabled should return nil by default, got %v", *result)
}
}
func TestBlockReplyEnabled_True(t *testing.T) {
b := true
ch := &Channel{cfg: config.FeishuConfig{
AppID: "test", AppSecret: "test",
BlockReply: &b,
}}
result := ch.BlockReplyEnabled()
if result == nil || !*result {
t.Error("BlockReplyEnabled should return pointer to true")
}
}
func TestBlockReplyEnabled_False(t *testing.T) {
b := false
ch := &Channel{cfg: config.FeishuConfig{
AppID: "test", AppSecret: "test",
BlockReply: &b,
}}
result := ch.BlockReplyEnabled()
if result == nil || *result {
t.Error("BlockReplyEnabled should return pointer to false")
}
}
@@ -0,0 +1,229 @@
package feishu
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/config"
)
// --- wsEventAdapter.HandleEvent ---
func TestWSEventAdapter_HandleEvent_InvalidJSON(t *testing.T) {
ch := &Channel{}
adapter := &wsEventAdapter{ch: ch}
err := adapter.HandleEvent(context.Background(), []byte("not-json"))
if err == nil {
t.Fatal("expected error for invalid JSON payload")
}
}
func TestWSEventAdapter_HandleEvent_NonMessageEvent_NoError(t *testing.T) {
ch := &Channel{}
adapter := &wsEventAdapter{ch: ch}
payload := []byte(`{"schema":"2.0","header":{"event_type":"im.chat.member.bot.added_v1"}}`)
err := adapter.HandleEvent(context.Background(), payload)
if err != nil {
t.Fatalf("non-message event should not error: %v", err)
}
}
// --- probeBotInfo ---
func TestProbeBotInfo_Success(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"bot":{"open_id":"ou_bot_test"}}}`)
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
if err := ch.probeBotInfo(context.Background()); err != nil {
t.Fatalf("probeBotInfo error: %v", err)
}
if ch.botOpenID != "ou_bot_test" {
t.Errorf("botOpenID: got %q, want ou_bot_test", ch.botOpenID)
}
}
func TestProbeBotInfo_EmptyOpenID(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"bot":{"open_id":""}}}`)
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
if err := ch.probeBotInfo(context.Background()); err == nil {
t.Fatal("expected error for empty open_id")
}
}
func TestProbeBotInfo_APIError(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":10001,"msg":"forbidden","data":{}}`)
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
if err := ch.probeBotInfo(context.Background()); err == nil {
t.Fatal("expected error on API failure")
}
}
// --- SetPendingCompaction / SetPendingHistoryTenantID ---
// Both use GroupHistory() which requires a real BaseChannel from New().
func TestSetPendingCompaction_WithChannel(t *testing.T) {
ch := newLifecycleTestChannel(t)
defer func() {
if r := recover(); r != nil {
t.Errorf("SetPendingCompaction panicked: %v", r)
}
}()
ch.SetPendingCompaction(nil)
}
func TestSetPendingHistoryTenantID_WithChannel(t *testing.T) {
ch := newLifecycleTestChannel(t)
defer func() {
if r := recover(); r != nil {
t.Errorf("SetPendingHistoryTenantID panicked: %v", r)
}
}()
ch.SetPendingHistoryTenantID(uuid.UUID{})
}
// --- ListGroupMembers ---
func TestListGroupMembers_Success(t *testing.T) {
respJSON := `{"code":0,"msg":"ok","data":{"items":[{"member_id":"ou_A","name":"Alice"},{"member_id":"ou_B","name":"Bob"}],"has_more":false}}`
srv := newSimpleMockServer(t, respJSON)
ch := newLifecycleTestChannel(t)
ch.client = NewLarkClient("app", "secret", srv.URL)
members, err := ch.ListGroupMembers(context.Background(), "oc_chat_1")
if err != nil {
t.Fatalf("ListGroupMembers error: %v", err)
}
if len(members) != 2 {
t.Errorf("expected 2 members, got %d", len(members))
}
if members[0].MemberID != "ou_A" {
t.Errorf("first member: got %q", members[0].MemberID)
}
}
func TestListGroupMembers_APIError(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":10001,"msg":"not found","data":{}}`)
ch := newLifecycleTestChannel(t)
ch.client = NewLarkClient("app", "secret", srv.URL)
if _, err := ch.ListGroupMembers(context.Background(), "oc_missing"); err == nil {
t.Fatal("expected error for API failure")
}
}
// --- resolveSenderName / fetchSenderName ---
func TestResolveSenderName_EmptyOpenID(t *testing.T) {
ch := &Channel{}
if got := ch.resolveSenderName(context.Background(), ""); got != "" {
t.Errorf("empty openID should return empty name, got %q", got)
}
}
func TestResolveSenderName_CacheHit(t *testing.T) {
ch := &Channel{}
ch.senderCache.Store("ou_cached", &senderCacheEntry{
name: "Cached User",
expiresAt: time.Now().Add(10 * time.Minute),
})
if got := ch.resolveSenderName(context.Background(), "ou_cached"); got != "Cached User" {
t.Errorf("cache hit: got %q, want Cached User", got)
}
}
func TestResolveSenderName_ExpiredCacheFetchesAgain(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"user":{"name":"Fresh Name"}}}`)
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
// Store an expired entry
ch.senderCache.Store("ou_expired", &senderCacheEntry{
name: "Stale Name",
expiresAt: time.Now().Add(-1 * time.Minute), // expired
})
got := ch.resolveSenderName(context.Background(), "ou_expired")
if got != "Fresh Name" {
t.Errorf("after cache expiry: got %q, want Fresh Name", got)
}
}
func TestFetchSenderName_Success(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"user":{"name":"Bob Jones"}}}`)
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
if got := ch.fetchSenderName(context.Background(), "ou_bob"); got != "Bob Jones" {
t.Errorf("fetchSenderName: got %q, want Bob Jones", got)
}
}
func TestFetchSenderName_APIError_ReturnsEmpty(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":50000,"msg":"not found","data":{}}`)
ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
if got := ch.fetchSenderName(context.Background(), "ou_missing"); got != "" {
t.Errorf("fetchSenderName on error: got %q, want empty", got)
}
}
// --- larkws sendPing with nil conn (no-op) ---
func TestLarkWSSendPing_NilConn(t *testing.T) {
c := NewWSClient("app", "secret", "http://localhost", nil)
c.mu.Lock()
c.stopCh = make(chan struct{})
c.mu.Unlock()
defer func() {
if r := recover(); r != nil {
t.Errorf("sendPing with nil conn panicked: %v", r)
}
}()
c.sendPing() // conn is nil → should be a no-op
}
// --- OnReactionEvent terminal states ---
func TestOnReactionEvent_Done_RemovesReaction(t *testing.T) {
ch := &Channel{}
ch.cfg.ReactionLevel = "full"
// "done" with no stored reaction → removeTypingReaction → no-op, no error
if err := ch.OnReactionEvent(context.Background(), "oc_chat", "om_msg", "done"); err != nil {
t.Errorf("unexpected error: %v", err)
}
}
func TestOnReactionEvent_ErrorStatus(t *testing.T) {
ch := &Channel{}
ch.cfg.ReactionLevel = "full"
if err := ch.OnReactionEvent(context.Background(), "oc_chat", "om_msg", "error"); err != nil {
t.Errorf("unexpected error: %v", err)
}
}
func TestOnReactionEvent_Minimal_TerminalAllowed(t *testing.T) {
ch := &Channel{}
ch.cfg.ReactionLevel = "minimal"
if err := ch.OnReactionEvent(context.Background(), "oc_chat", "om_msg", "done"); err != nil {
t.Errorf("unexpected error: %v", err)
}
}
// --- test helpers ---
// newLifecycleTestChannel creates a minimal valid Channel using New() so
// BaseChannel is fully initialised. Tests that need BaseChannel methods
// (GroupHistory, ContactCollector) should use this instead of &Channel{}.
// Named distinctly from newTestChannel in commands_writers_test.go.
func newLifecycleTestChannel(t *testing.T) *Channel {
t.Helper()
cfg := config.FeishuConfig{
AppID: "test-app-id",
AppSecret: "test-app-secret",
}
ch, err := New(cfg, nil, nil, nil)
if err != nil {
t.Fatalf("newTestChannel: New() error: %v", err)
}
return ch
}
@@ -0,0 +1,83 @@
package feishu
import (
"context"
"encoding/json"
"testing"
)
// --- GetMessage ---
func TestGetMessage_Success(t *testing.T) {
resp := map[string]any{
"code": 0, "msg": "ok",
"data": map[string]any{
"items": []any{
map[string]any{
"message_id": "om_abc123",
"msg_type": "text",
"body": map[string]any{"content": `{"text":"hello"}`},
"sender": map[string]any{"id": "ou_sender", "id_type": "open_id", "sender_type": "user"},
},
},
},
}
respJSON, _ := json.Marshal(resp)
srv := newSimpleMockServer(t, string(respJSON))
c := NewLarkClient("app", "secret", srv.URL)
msg, err := c.GetMessage(context.Background(), "om_abc123")
if err != nil {
t.Fatalf("GetMessage error: %v", err)
}
if len(msg.Items) != 1 {
t.Fatalf("expected 1 item, got %d", len(msg.Items))
}
if msg.Items[0].MessageID != "om_abc123" {
t.Errorf("message_id: got %q", msg.Items[0].MessageID)
}
}
func TestGetMessage_APIError(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":230002,"msg":"message not found","data":{}}`)
c := NewLarkClient("app", "secret", srv.URL)
_, err := c.GetMessage(context.Background(), "om_missing")
if err == nil {
t.Fatal("expected error for non-zero code")
}
}
// --- GetUser ---
func TestGetUser_Success(t *testing.T) {
resp := map[string]any{
"code": 0, "msg": "ok",
"data": map[string]any{
"user": map[string]any{
"name": "Alice Smith",
},
},
}
respJSON, _ := json.Marshal(resp)
srv := newSimpleMockServer(t, string(respJSON))
c := NewLarkClient("app", "secret", srv.URL)
name, err := c.GetUser(context.Background(), "ou_user_123", "open_id")
if err != nil {
t.Fatalf("GetUser error: %v", err)
}
if name != "Alice Smith" {
t.Errorf("name: got %q, want %q", name, "Alice Smith")
}
}
func TestGetUser_APIError(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":50000,"msg":"user not found","data":{}}`)
c := NewLarkClient("app", "secret", srv.URL)
_, err := c.GetUser(context.Background(), "ou_missing", "open_id")
if err == nil {
t.Fatal("expected error for non-zero code")
}
}
@@ -0,0 +1,239 @@
package feishu
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// --- clearToken ---
func TestClearToken_ResetsFields(t *testing.T) {
c := NewLarkClient("app", "secret", "http://localhost")
c.token = "some-token"
c.clearToken()
c.mu.Lock()
defer c.mu.Unlock()
if c.token != "" {
t.Errorf("token: got %q, want empty", c.token)
}
if !c.tokenExp.IsZero() {
t.Errorf("tokenExp: got %v, want zero", c.tokenExp)
}
}
// --- isTokenError ---
func TestIsTokenError(t *testing.T) {
cases := []struct {
code int
want bool
}{
{99991663, true},
{99991664, true},
{99991671, true},
{0, false},
{230002, false},
{10001, false},
}
for _, tc := range cases {
if got := isTokenError(tc.code); got != tc.want {
t.Errorf("isTokenError(%d) = %v, want %v", tc.code, got, tc.want)
}
}
}
// --- doJSON token refresh ---
func TestDoJSON_TokenRefreshOnExpiry(t *testing.T) {
tokenCalls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == tokenEndpoint {
tokenCalls++
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"code":0,"msg":"ok","tenant_access_token":"fresh-tok","expire":7200}`)
return
}
// First real call returns token error code → triggers refresh
w.Header().Set("Content-Type", "application/json")
if tokenCalls == 1 {
io.WriteString(w, `{"code":99991663,"msg":"token expired","data":{}}`)
} else {
io.WriteString(w, `{"code":0,"msg":"ok","data":{}}`)
}
}))
defer srv.Close()
c := NewLarkClient("app", "secret", srv.URL)
resp, err := c.doJSON(context.Background(), "GET", "/some/api", nil)
if err != nil {
t.Fatalf("doJSON returned error: %v", err)
}
if resp.Code != 0 {
t.Errorf("final response code: got %d, want 0", resp.Code)
}
if tokenCalls < 2 {
t.Errorf("expected at least 2 token calls (initial + refresh), got %d", tokenCalls)
}
}
// --- doDownload ---
func TestDoDownload_Success(t *testing.T) {
fileBytes := []byte("fake-image-data")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == tokenEndpoint {
io.WriteString(w, `{"code":0,"msg":"ok","tenant_access_token":"tok","expire":7200}`)
return
}
// Verify auth header is set
if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
http.Error(w, "missing auth", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Disposition", `attachment; filename="photo.jpg"`)
w.Header().Set("Content-Type", "image/jpeg")
w.Write(fileBytes)
}))
defer srv.Close()
c := NewLarkClient("app", "secret", srv.URL)
data, fileName, err := c.doDownload(context.Background(), "/download/img_123")
if err != nil {
t.Fatalf("doDownload error: %v", err)
}
if !bytes.Equal(data, fileBytes) {
t.Errorf("data mismatch: got %q, want %q", data, fileBytes)
}
if fileName != "photo.jpg" {
t.Errorf("fileName: got %q, want %q", fileName, "photo.jpg")
}
}
func TestDoDownload_JSONErrorResponse(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == tokenEndpoint {
io.WriteString(w, `{"code":0,"msg":"ok","tenant_access_token":"tok","expire":7200}`)
return
}
// Server responds with JSON error instead of binary
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"code":1234567,"msg":"resource not found","data":{}}`)
}))
defer srv.Close()
c := NewLarkClient("app", "secret", srv.URL)
_, _, err := c.doDownload(context.Background(), "/download/missing")
if err == nil {
t.Fatal("expected error for JSON error response")
}
}
func TestDoDownload_NoContentDisposition(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == tokenEndpoint {
io.WriteString(w, `{"code":0,"msg":"ok","tenant_access_token":"tok","expire":7200}`)
return
}
w.Header().Set("Content-Type", "image/png")
w.Write([]byte{0x89, 0x50, 0x4E, 0x47}) // PNG magic bytes
}))
defer srv.Close()
c := NewLarkClient("app", "secret", srv.URL)
data, fileName, err := c.doDownload(context.Background(), "/download/no_cd")
if err != nil {
t.Fatalf("doDownload error: %v", err)
}
if fileName != "" {
t.Errorf("fileName: got %q, want empty when no Content-Disposition", fileName)
}
if len(data) == 0 {
t.Error("data should not be empty")
}
}
// --- doMultipart ---
func TestDoMultipart_Success(t *testing.T) {
var gotContentType string
var gotAuthHeader string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == tokenEndpoint {
io.WriteString(w, `{"code":0,"msg":"ok","tenant_access_token":"tok","expire":7200}`)
return
}
gotContentType = r.Header.Get("Content-Type")
gotAuthHeader = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"code":0,"msg":"ok","data":{"image_key":"img_uploaded_123"}}`)
}))
defer srv.Close()
c := NewLarkClient("app", "secret", srv.URL)
resp, err := c.doMultipart(
context.Background(),
"/upload/image",
map[string]string{"image_type": "message"},
"image",
bytes.NewReader([]byte{0xFF, 0xD8, 0xFF}), // fake JPEG
"test.jpg",
)
if err != nil {
t.Fatalf("doMultipart error: %v", err)
}
if resp.Code != 0 {
t.Errorf("response code: got %d, want 0", resp.Code)
}
if !strings.HasPrefix(gotContentType, "multipart/form-data") {
t.Errorf("content-type: got %q, want multipart/form-data", gotContentType)
}
if !strings.HasPrefix(gotAuthHeader, "Bearer tok") {
t.Errorf("auth header: got %q", gotAuthHeader)
}
}
func TestDoMultipart_NoFile(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == tokenEndpoint {
io.WriteString(w, `{"code":0,"msg":"ok","tenant_access_token":"tok","expire":7200}`)
return
}
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"code":0,"msg":"ok","data":{}}`)
}))
defer srv.Close()
c := NewLarkClient("app", "secret", srv.URL)
// fileField="" and fileData=nil → fields-only upload
resp, err := c.doMultipart(
context.Background(),
"/upload/fields-only",
map[string]string{"key": "value"},
"",
nil,
"",
)
if err != nil {
t.Fatalf("doMultipart (no file) error: %v", err)
}
if resp.Code != 0 {
t.Errorf("response code: got %d, want 0", resp.Code)
}
}
func TestDoMultipart_NetworkError(t *testing.T) {
c := NewLarkClient("app", "secret", "http://localhost:1")
_, err := c.doMultipart(
context.Background(),
"/upload",
nil, "", nil, "",
)
if err == nil {
t.Fatal("expected error for unreachable server")
}
}
@@ -0,0 +1,377 @@
package feishu
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// newSimpleMockServer returns a test server that always responds with the given
// JSON payload for any non-token request.
func newSimpleMockServer(t *testing.T, respJSON string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == tokenEndpoint {
io.WriteString(w, `{"code":0,"msg":"ok","tenant_access_token":"tok","expire":7200}`)
return
}
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, respJSON)
}))
t.Cleanup(srv.Close)
return srv
}
// newBinaryMockServer returns a server that serves binary data for any non-token request.
func newBinaryMockServer(t *testing.T, data []byte, contentType string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == tokenEndpoint {
io.WriteString(w, `{"code":0,"msg":"ok","tenant_access_token":"tok","expire":7200}`)
return
}
w.Header().Set("Content-Type", contentType)
w.Write(data)
}))
t.Cleanup(srv.Close)
return srv
}
// --- DownloadImage ---
func TestDownloadImage_Success(t *testing.T) {
imgData := []byte{0x89, 0x50, 0x4E, 0x47} // PNG magic
srv := newBinaryMockServer(t, imgData, "image/png")
c := NewLarkClient("app", "secret", srv.URL)
data, err := c.DownloadImage(context.Background(), "img_key_123")
if err != nil {
t.Fatalf("DownloadImage error: %v", err)
}
if !bytes.Equal(data, imgData) {
t.Errorf("data mismatch: got %v, want %v", data, imgData)
}
}
// --- UploadImage ---
func TestUploadImage_Success(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"image_key":"img_uploaded_abc"}}`)
c := NewLarkClient("app", "secret", srv.URL)
key, err := c.UploadImage(context.Background(), bytes.NewReader([]byte{0xFF, 0xD8, 0xFF}))
if err != nil {
t.Fatalf("UploadImage error: %v", err)
}
if key != "img_uploaded_abc" {
t.Errorf("image_key: got %q, want %q", key, "img_uploaded_abc")
}
}
func TestUploadImage_APIError(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":10001,"msg":"quota exceeded","data":{}}`)
c := NewLarkClient("app", "secret", srv.URL)
_, err := c.UploadImage(context.Background(), bytes.NewReader([]byte{0x01}))
if err == nil {
t.Fatal("expected error on non-zero code")
}
}
// --- UploadFile ---
func TestUploadFile_Success(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"file_key":"file_key_xyz"}}`)
c := NewLarkClient("app", "secret", srv.URL)
key, err := c.UploadFile(context.Background(), bytes.NewReader([]byte("PDF data")), "test.pdf", "pdf", 0)
if err != nil {
t.Fatalf("UploadFile error: %v", err)
}
if key != "file_key_xyz" {
t.Errorf("file_key: got %q, want %q", key, "file_key_xyz")
}
}
func TestUploadFile_WithDuration(t *testing.T) {
var gotBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == tokenEndpoint {
io.WriteString(w, `{"code":0,"msg":"ok","tenant_access_token":"tok","expire":7200}`)
return
}
body, _ := io.ReadAll(r.Body)
gotBody = string(body)
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"code":0,"msg":"ok","data":{"file_key":"audio_key"}}`)
}))
defer srv.Close()
c := NewLarkClient("app", "secret", srv.URL)
_, err := c.UploadFile(context.Background(), bytes.NewReader([]byte("audio")), "voice.opus", "opus", 5000)
if err != nil {
t.Fatalf("UploadFile error: %v", err)
}
if !strings.Contains(gotBody, "5000") {
t.Errorf("expected duration in body: %s", gotBody)
}
}
// --- DownloadMessageResource ---
func TestDownloadMessageResource_Success(t *testing.T) {
fileData := []byte("binary file content")
srv := newBinaryMockServer(t, fileData, "application/octet-stream")
c := NewLarkClient("app", "secret", srv.URL)
data, _, err := c.DownloadMessageResource(context.Background(), "om_msg1", "fk_123", "file")
if err != nil {
t.Fatalf("DownloadMessageResource error: %v", err)
}
if !bytes.Equal(data, fileData) {
t.Errorf("data mismatch")
}
}
// --- CreateCard ---
func TestCreateCard_Success(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"card_id":"card_abc"}}`)
c := NewLarkClient("app", "secret", srv.URL)
cardID, err := c.CreateCard(context.Background(), "card", `{"schema":"2.0"}`)
if err != nil {
t.Fatalf("CreateCard error: %v", err)
}
if cardID != "card_abc" {
t.Errorf("card_id: got %q, want card_abc", cardID)
}
}
func TestCreateCard_APIError(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":99001,"msg":"invalid card","data":{}}`)
c := NewLarkClient("app", "secret", srv.URL)
_, err := c.CreateCard(context.Background(), "card", `{}`)
if err == nil {
t.Fatal("expected error for non-zero code")
}
}
// --- UpdateCardSettings ---
func TestUpdateCardSettings_Success(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{}}`)
c := NewLarkClient("app", "secret", srv.URL)
err := c.UpdateCardSettings(context.Background(), "card_abc", `{"streaming":false}`, 1, "uuid-001")
if err != nil {
t.Fatalf("UpdateCardSettings error: %v", err)
}
}
func TestUpdateCardSettings_APIError(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":500,"msg":"server error","data":{}}`)
c := NewLarkClient("app", "secret", srv.URL)
err := c.UpdateCardSettings(context.Background(), "card_bad", `{}`, 0, "")
if err == nil {
t.Fatal("expected error")
}
}
// --- UpdateCardElement ---
func TestUpdateCardElement_Success(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == tokenEndpoint {
io.WriteString(w, `{"code":0,"msg":"ok","tenant_access_token":"tok","expire":7200}`)
return
}
gotPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"code":0,"msg":"ok","data":{}}`)
}))
defer srv.Close()
c := NewLarkClient("app", "secret", srv.URL)
err := c.UpdateCardElement(context.Background(), "card_xyz", "elem_1", "new content", 2, "uuid-002")
if err != nil {
t.Fatalf("UpdateCardElement error: %v", err)
}
if !strings.Contains(gotPath, "card_xyz") {
t.Errorf("path should contain card_id: %q", gotPath)
}
if !strings.Contains(gotPath, "elem_1") {
t.Errorf("path should contain element_id: %q", gotPath)
}
}
func TestUpdateCardElement_APIError(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":404,"msg":"element not found","data":{}}`)
c := NewLarkClient("app", "secret", srv.URL)
err := c.UpdateCardElement(context.Background(), "card_x", "elem_x", "text", 0, "")
if err == nil {
t.Fatal("expected error for non-zero code")
}
}
// --- AddMessageReaction ---
func TestAddMessageReaction_Success(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"reaction_id":"rxn_abc"}}`)
c := NewLarkClient("app", "secret", srv.URL)
reactionID, err := c.AddMessageReaction(context.Background(), "om_msg1", "Typing")
if err != nil {
t.Fatalf("AddMessageReaction error: %v", err)
}
if reactionID != "rxn_abc" {
t.Errorf("reaction_id: got %q, want rxn_abc", reactionID)
}
}
func TestAddMessageReaction_APIError(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":1234,"msg":"reaction failed","data":{}}`)
c := NewLarkClient("app", "secret", srv.URL)
_, err := c.AddMessageReaction(context.Background(), "om_msg1", "Typing")
if err == nil {
t.Fatal("expected error for non-zero code")
}
}
// --- DeleteMessageReaction ---
func TestDeleteMessageReaction_Success(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{}}`)
c := NewLarkClient("app", "secret", srv.URL)
err := c.DeleteMessageReaction(context.Background(), "om_msg1", "rxn_abc")
if err != nil {
t.Fatalf("DeleteMessageReaction error: %v", err)
}
}
func TestDeleteMessageReaction_APIError(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":404,"msg":"not found","data":{}}`)
c := NewLarkClient("app", "secret", srv.URL)
err := c.DeleteMessageReaction(context.Background(), "om_msg1", "rxn_missing")
if err == nil {
t.Fatal("expected error for non-zero code")
}
}
// --- GetBotInfo (returns open_id string) ---
func TestGetBotInfo_Success(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"bot":{"open_id":"ou_bot_123","app_name":"TestBot"}}}`)
c := NewLarkClient("app", "secret", srv.URL)
openID, err := c.GetBotInfo(context.Background())
if err != nil {
t.Fatalf("GetBotInfo error: %v", err)
}
if openID != "ou_bot_123" {
t.Errorf("open_id: got %q, want ou_bot_123", openID)
}
}
func TestGetBotInfo_APIError(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":10001,"msg":"forbidden","data":{}}`)
c := NewLarkClient("app", "secret", srv.URL)
_, err := c.GetBotInfo(context.Background())
if err == nil {
t.Fatal("expected error for non-zero code")
}
}
// --- ListChatMembers ---
func TestListChatMembers_Success(t *testing.T) {
resp := map[string]any{
"code": 0,
"msg": "ok",
"data": map[string]any{
"items": []any{
map[string]any{"member_id": "ou_A", "name": "Alice"},
map[string]any{"member_id": "ou_B", "name": "Bob"},
},
"has_more": false,
},
}
respJSON, _ := json.Marshal(resp)
srv := newSimpleMockServer(t, string(respJSON))
c := NewLarkClient("app", "secret", srv.URL)
members, err := c.ListChatMembers(context.Background(), "oc_chat_1")
if err != nil {
t.Fatalf("ListChatMembers error: %v", err)
}
if len(members) != 2 {
t.Errorf("expected 2 members, got %d", len(members))
}
if members[0].MemberID != "ou_A" {
t.Errorf("first member: got %q, want ou_A", members[0].MemberID)
}
}
func TestListChatMembers_Pagination(t *testing.T) {
// First page: has_more=true, second page: has_more=false
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == tokenEndpoint {
io.WriteString(w, `{"code":0,"msg":"ok","tenant_access_token":"tok","expire":7200}`)
return
}
callCount++
w.Header().Set("Content-Type", "application/json")
if callCount == 1 {
json.NewEncoder(w).Encode(map[string]any{
"code": 0, "msg": "ok",
"data": map[string]any{
"items": []any{map[string]any{"member_id": "ou_1", "name": "User1"}},
"page_token": "next_page_token",
"has_more": true,
},
})
} else {
json.NewEncoder(w).Encode(map[string]any{
"code": 0, "msg": "ok",
"data": map[string]any{
"items": []any{map[string]any{"member_id": "ou_2", "name": "User2"}},
"has_more": false,
},
})
}
}))
defer srv.Close()
c := NewLarkClient("app", "secret", srv.URL)
members, err := c.ListChatMembers(context.Background(), "oc_chat_paged")
if err != nil {
t.Fatalf("ListChatMembers pagination error: %v", err)
}
if len(members) != 2 {
t.Errorf("expected 2 total members from 2 pages, got %d", len(members))
}
}
func TestListChatMembers_APIError(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":10001,"msg":"not found","data":{}}`)
c := NewLarkClient("app", "secret", srv.URL)
_, err := c.ListChatMembers(context.Background(), "oc_missing")
if err == nil {
t.Fatal("expected error for non-zero code")
}
}
+341
View File
@@ -0,0 +1,341 @@
package feishu
import (
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// --- helpers ---
// encryptEventPayload encrypts a JSON payload using the same AES-CBC scheme
// that Feishu uses for encrypted webhook events. Used to test decryptEvent.
func encryptEventPayload(t *testing.T, payload []byte, key string) string {
t.Helper()
keyHash := sha256.Sum256([]byte(key))
block, err := aes.NewCipher(keyHash[:])
if err != nil {
t.Fatalf("aes new cipher: %v", err)
}
// IV = first 16 bytes of key hash (deterministic for testing)
iv := keyHash[:aes.BlockSize]
// PKCS7-pad to block size (16)
pad := aes.BlockSize - (len(payload) % aes.BlockSize)
padded := make([]byte, len(payload)+pad)
copy(padded, payload)
for i := len(payload); i < len(padded); i++ {
padded[i] = byte(pad)
}
ct := make([]byte, aes.BlockSize+len(padded))
copy(ct[:aes.BlockSize], iv)
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(ct[aes.BlockSize:], padded)
return base64.StdEncoding.EncodeToString(ct)
}
// buildWebhookRequest creates a POST request body for a webhook event.
func buildWebhookRequest(body string) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/feishu/events", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
return req
}
// --- URL verification ---
func TestWebhookHandler_URLVerification(t *testing.T) {
called := false
h := NewWebhookHandler("", "", func(_ *MessageEvent) { called = true })
body := `{"type":"url_verification","token":"test-tok","challenge":"abc123"}`
w := httptest.NewRecorder()
h.ServeHTTP(w, buildWebhookRequest(body))
if w.Code != http.StatusOK {
t.Errorf("status: got %d, want 200", w.Code)
}
var resp map[string]string
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp["challenge"] != "abc123" {
t.Errorf("challenge: got %q, want %q", resp["challenge"], "abc123")
}
if called {
t.Error("onMessage must not be called for url_verification")
}
}
// --- Method not allowed ---
func TestWebhookHandler_MethodNotAllowed(t *testing.T) {
h := NewWebhookHandler("", "", func(_ *MessageEvent) {})
req := httptest.NewRequest(http.MethodGet, "/feishu/events", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("status: got %d, want 405", w.Code)
}
}
// --- Token verification ---
func TestWebhookHandler_TokenMismatch(t *testing.T) {
called := false
h := NewWebhookHandler("expected-token", "", func(_ *MessageEvent) { called = true })
// Build a message event with a wrong token
env := map[string]any{
"schema": "2.0",
"header": map[string]any{
"event_type": "im.message.receive_v1",
"token": "wrong-token",
"app_id": "cli_test",
"tenant_key": "test-tenant-1",
},
"event": map[string]any{
"sender": map[string]any{},
"message": map[string]any{"message_id": "om_1", "chat_id": "oc_1"},
},
}
body, _ := json.Marshal(env)
w := httptest.NewRecorder()
h.ServeHTTP(w, buildWebhookRequest(string(body)))
if w.Code != http.StatusOK {
t.Errorf("status: got %d, want 200", w.Code)
}
// Token mismatch: handler silently drops, must NOT dispatch
if called {
t.Error("onMessage must not be called on token mismatch")
}
}
func TestWebhookHandler_TokenMatch_Dispatches(t *testing.T) {
dispatched := make(chan *MessageEvent, 1)
h := NewWebhookHandler("good-token", "", func(e *MessageEvent) { dispatched <- e })
env := map[string]any{
"schema": "2.0",
"header": map[string]any{
"event_id": "evt_001",
"event_type": "im.message.receive_v1",
"token": "good-token",
"app_id": "cli_test",
"tenant_key": "test-tenant-1",
},
"event": map[string]any{
"sender": map[string]any{
"sender_id": map[string]any{"open_id": "ou_U00000001"},
"sender_type": "user",
"tenant_key": "test-tenant-1",
},
"message": map[string]any{
"message_id": "om_msg001",
"chat_id": "oc_chat_001",
"chat_type": "p2p",
"message_type": "text",
"content": `{"text":"hello bot"}`,
},
},
}
body, _ := json.Marshal(env)
w := httptest.NewRecorder()
h.ServeHTTP(w, buildWebhookRequest(string(body)))
if w.Code != http.StatusOK {
t.Errorf("status: got %d, want 200", w.Code)
}
// Wait for goroutine to dispatch the event (race-free via channel).
select {
case e := <-dispatched:
if e == nil {
t.Error("dispatched event was nil")
}
case <-t.Context().Done():
t.Error("timeout waiting for dispatched event")
}
}
// --- Non-message event type ---
func TestWebhookHandler_NonMessageEvent_Ignored(t *testing.T) {
called := false
h := NewWebhookHandler("", "", func(_ *MessageEvent) { called = true })
env := map[string]any{
"schema": "2.0",
"header": map[string]any{
"event_type": "im.chat.member.bot.added_v1",
"token": "",
},
}
body, _ := json.Marshal(env)
w := httptest.NewRecorder()
h.ServeHTTP(w, buildWebhookRequest(string(body)))
if w.Code != http.StatusOK {
t.Errorf("status: got %d, want 200", w.Code)
}
if called {
t.Error("onMessage must not be called for non-message events")
}
}
// --- Invalid JSON ---
func TestWebhookHandler_InvalidJSON(t *testing.T) {
h := NewWebhookHandler("", "", func(_ *MessageEvent) {})
w := httptest.NewRecorder()
h.ServeHTTP(w, buildWebhookRequest("not-json{{{"))
if w.Code != http.StatusBadRequest {
t.Errorf("status: got %d, want 400", w.Code)
}
}
// --- Encrypted event ---
func TestWebhookHandler_EncryptedEvent_Decrypted(t *testing.T) {
const encKey = "test-encrypt-key-2024"
dispatched := make(chan *MessageEvent, 1)
h := NewWebhookHandler("", encKey, func(e *MessageEvent) { dispatched <- e })
innerEvent := map[string]any{
"schema": "2.0",
"header": map[string]any{
"event_id": "evt_enc_01",
"event_type": "im.message.receive_v1",
"token": "",
"app_id": "cli_enc",
"tenant_key": "test-tenant-1",
},
"event": map[string]any{
"sender": map[string]any{
"sender_id": map[string]any{"open_id": "ou_U00000002"},
"tenant_key": "test-tenant-1",
},
"message": map[string]any{
"message_id": "om_enc001",
"chat_id": "oc_chat_enc",
"chat_type": "p2p",
"message_type": "text",
"content": `{"text":"encrypted message"}`,
},
},
}
plainJSON, _ := json.Marshal(innerEvent)
encrypted := encryptEventPayload(t, plainJSON, encKey)
outerBody := map[string]any{"encrypt": encrypted}
outerJSON, _ := json.Marshal(outerBody)
w := httptest.NewRecorder()
h.ServeHTTP(w, buildWebhookRequest(string(outerJSON)))
if w.Code != http.StatusOK {
t.Errorf("status: got %d, want 200", w.Code)
}
// Wait for goroutine to dispatch — race-free via buffered channel.
select {
case e := <-dispatched:
if e == nil {
t.Error("dispatched event was nil")
}
case <-t.Context().Done():
t.Error("timeout waiting for dispatched encrypted event")
}
}
// --- decryptEvent unit tests ---
func TestDecryptEvent_InvalidBase64(t *testing.T) {
_, err := decryptEvent("not-valid-base64!!!", "key")
if err == nil {
t.Fatal("expected error for invalid base64")
}
}
func TestDecryptEvent_TooShort(t *testing.T) {
// Valid base64 but shorter than AES block size (16 bytes)
short := base64.StdEncoding.EncodeToString([]byte("short"))
_, err := decryptEvent(short, "key")
if err == nil {
t.Fatal("expected error for ciphertext too short")
}
}
func TestDecryptEvent_NoJSONInPlaintext(t *testing.T) {
// Encrypt something that has no { } after decryption.
// Use all-zero padding — won't contain valid JSON braces in useful positions.
keyHash := sha256.Sum256([]byte("testkey"))
block, _ := aes.NewCipher(keyHash[:])
iv := keyHash[:aes.BlockSize]
// Plaintext: 16 bytes of 0x10 (valid PKCS7 padding for 16-byte block)
pt := make([]byte, 16)
for i := range pt {
pt[i] = 0x10
}
ct := make([]byte, aes.BlockSize+16)
copy(ct[:aes.BlockSize], iv)
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(ct[aes.BlockSize:], pt)
encoded := base64.StdEncoding.EncodeToString(ct)
_, err := decryptEvent(encoded, "testkey")
if err == nil {
t.Fatal("expected error when no JSON found in decrypted content")
}
}
// TestDecryptEvent_TamperedPayload is the security-critical test.
// AES-CBC alone does not provide authentication (no HMAC/GCM), so a wrong key
// will still "decrypt" the bytes — but the result will be garbled, non-parseable
// JSON. The webhook handler re-parses the decrypted body with json.Unmarshal and
// then checks the verification token; a tampered payload therefore cannot impersonate
// a valid event.
//
// This test asserts the observable contract: when decryptEvent is called with the
// correct key it returns the original plaintext; when called with a wrong key the
// returned bytes do NOT equal the original plaintext (the decryption is corrupted).
func TestDecryptEvent_TamperedPayload(t *testing.T) {
const realKey = "real-encrypt-key-abc"
const wrongKey = "wrong-key-tampered"
plaintext := []byte(`{"type":"url_verification","challenge":"xyz"}`)
encrypted := encryptEventPayload(t, plaintext, realKey)
// With the correct key the original JSON is recovered.
gotReal, err := decryptEvent(encrypted, realKey)
if err != nil {
t.Fatalf("decryptEvent with real key failed: %v", err)
}
if string(gotReal) != string(plaintext) {
t.Errorf("real-key decrypt: got %q, want %q", gotReal, plaintext)
}
// With a wrong key: AES-CBC produces garbled output.
// The function may or may not error (depending on whether { } appear in garbage),
// but the result MUST differ from the original plaintext — the tampered payload
// cannot reconstruct the valid event content.
gotWrong, errWrong := decryptEvent(encrypted, wrongKey)
if errWrong == nil && string(gotWrong) == string(plaintext) {
t.Error("SECURITY: decryptEvent with wrong key returned the original plaintext — tampered payload was not corrupted")
}
// If errWrong != nil, the garbage didn't even contain { } — that's an even stronger failure.
}
@@ -0,0 +1,170 @@
package feishu
import (
"bytes"
"testing"
)
// TestMarshalUnmarshalRoundTrip verifies that a wsFrame survives a
// marshal → unmarshal round-trip with no data loss.
func TestMarshalUnmarshalRoundTrip(t *testing.T) {
original := &wsFrame{
SeqID: 42,
LogID: 99,
Service: 1,
Method: 2,
PayloadEncoding: "utf-8",
PayloadType: "json",
Payload: []byte(`{"hello":"world"}`),
LogIDNew: "logid-abc",
Headers: []wsHeader{
{Key: "type", Value: "event"},
{Key: "message_id", Value: "msg_001"},
},
}
data := marshalFrame(original)
if len(data) == 0 {
t.Fatal("marshalFrame returned empty bytes")
}
got, err := unmarshalFrame(data)
if err != nil {
t.Fatalf("unmarshalFrame returned error: %v", err)
}
if got.SeqID != original.SeqID {
t.Errorf("SeqID: got %d, want %d", got.SeqID, original.SeqID)
}
if got.LogID != original.LogID {
t.Errorf("LogID: got %d, want %d", got.LogID, original.LogID)
}
if got.Service != original.Service {
t.Errorf("Service: got %d, want %d", got.Service, original.Service)
}
if got.Method != original.Method {
t.Errorf("Method: got %d, want %d", got.Method, original.Method)
}
if got.PayloadEncoding != original.PayloadEncoding {
t.Errorf("PayloadEncoding: got %q, want %q", got.PayloadEncoding, original.PayloadEncoding)
}
if got.PayloadType != original.PayloadType {
t.Errorf("PayloadType: got %q, want %q", got.PayloadType, original.PayloadType)
}
if !bytes.Equal(got.Payload, original.Payload) {
t.Errorf("Payload: got %q, want %q", got.Payload, original.Payload)
}
if got.LogIDNew != original.LogIDNew {
t.Errorf("LogIDNew: got %q, want %q", got.LogIDNew, original.LogIDNew)
}
if len(got.Headers) != len(original.Headers) {
t.Fatalf("Headers len: got %d, want %d", len(got.Headers), len(original.Headers))
}
hm := got.headerMap()
if hm["type"] != "event" {
t.Errorf("header type: got %q, want %q", hm["type"], "event")
}
if hm["message_id"] != "msg_001" {
t.Errorf("header message_id: got %q, want %q", hm["message_id"], "msg_001")
}
}
// TestMarshalUnmarshal_ZeroValues verifies frames with all-zero/empty fields.
func TestMarshalUnmarshal_ZeroValues(t *testing.T) {
f := &wsFrame{}
data := marshalFrame(f)
got, err := unmarshalFrame(data)
if err != nil {
t.Fatalf("unmarshalFrame zero frame error: %v", err)
}
if got.SeqID != 0 || got.LogID != 0 {
t.Errorf("expected zero IDs, got SeqID=%d LogID=%d", got.SeqID, got.LogID)
}
}
// TestMarshalUnmarshal_OnlyPayload verifies payload-only frames work.
func TestMarshalUnmarshal_OnlyPayload(t *testing.T) {
payload := []byte("raw-binary-data")
f := &wsFrame{Payload: payload}
data := marshalFrame(f)
got, err := unmarshalFrame(data)
if err != nil {
t.Fatalf("error: %v", err)
}
if !bytes.Equal(got.Payload, payload) {
t.Errorf("payload mismatch: got %q, want %q", got.Payload, payload)
}
}
// TestUnmarshalFrame_MalformedInput verifies no panic and an error is returned.
func TestUnmarshalFrame_MalformedInput(t *testing.T) {
cases := []struct {
name string
data []byte
}{
{"empty", []byte{}},
{"truncated varint", []byte{0x80, 0x80}}, // incomplete multi-byte varint
{"bad wire type", []byte{0x0f}}, // field 1, wire type 7 (unsupported)
{"length prefix no data", []byte{0x12, 0x05}}, // field 2 wire 2, length=5 but no data
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Must not panic.
defer func() {
if r := recover(); r != nil {
t.Errorf("unmarshalFrame panicked: %v", r)
}
}()
// Error or not — just must not panic. Empty input is valid and returns empty frame.
_, _ = unmarshalFrame(tc.data)
})
}
}
// TestHeaderMap_Empty verifies headerMap on a frame with no headers.
func TestHeaderMap_Empty(t *testing.T) {
f := &wsFrame{}
m := f.headerMap()
if len(m) != 0 {
t.Errorf("expected empty map, got %v", m)
}
}
// TestHeaderMap_Dedup verifies last-writer-wins for duplicate header keys.
func TestHeaderMap_Dedup(t *testing.T) {
f := &wsFrame{Headers: []wsHeader{
{Key: "type", Value: "first"},
{Key: "type", Value: "second"},
}}
m := f.headerMap()
if m["type"] != "second" {
t.Errorf("expected 'second', got %q", m["type"])
}
}
// TestMarshalFrame_MultipleHeaders verifies multiple distinct headers survive.
func TestMarshalFrame_MultipleHeaders(t *testing.T) {
f := &wsFrame{
Headers: []wsHeader{
{Key: "sum", Value: "3"},
{Key: "seq", Value: "1"},
{Key: "message_id", Value: "fragmented-msg"},
},
}
data := marshalFrame(f)
got, err := unmarshalFrame(data)
if err != nil {
t.Fatalf("error: %v", err)
}
hm := got.headerMap()
if hm["sum"] != "3" {
t.Errorf("sum: got %q", hm["sum"])
}
if hm["seq"] != "1" {
t.Errorf("seq: got %q", hm["seq"])
}
if hm["message_id"] != "fragmented-msg" {
t.Errorf("message_id: got %q", hm["message_id"])
}
}
+348
View File
@@ -0,0 +1,348 @@
package feishu
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
)
// --- Fragment reassembly ---
func TestReassemble_SingleFragment(t *testing.T) {
c := NewWSClient("app", "secret", "http://localhost", nil)
// sum=1 means no fragmentation needed — caller skips reassemble
// but if called with sum=1 it should complete immediately
result := c.reassemble("msg_single", 1, 0, []byte("payload"))
if result == nil {
t.Error("single fragment should return payload immediately")
}
if string(result) != "payload" {
t.Errorf("got %q, want %q", result, "payload")
}
}
func TestReassemble_TwoFragments_OutOfOrder(t *testing.T) {
c := NewWSClient("app", "secret", "http://localhost", nil)
// Arrive out of order: seq=1 before seq=0
result1 := c.reassemble("msg_two", 2, 1, []byte("world"))
if result1 != nil {
t.Error("first fragment should not complete reassembly")
}
result2 := c.reassemble("msg_two", 2, 0, []byte("hello"))
if result2 == nil {
t.Fatal("second fragment should complete reassembly")
}
// Assembled in seq order: seq0 + seq1
if string(result2) != "helloworld" {
t.Errorf("reassembly: got %q, want %q", result2, "helloworld")
}
}
func TestReassemble_ThreeFragments(t *testing.T) {
c := NewWSClient("app", "secret", "http://localhost", nil)
c.reassemble("msg_three", 3, 0, []byte("aaa"))
c.reassemble("msg_three", 3, 2, []byte("ccc"))
result := c.reassemble("msg_three", 3, 1, []byte("bbb"))
if result == nil {
t.Fatal("all 3 fragments received, should complete")
}
if string(result) != "aaabbbccc" {
t.Errorf("got %q, want %q", result, "aaabbbccc")
}
}
func TestReassemble_DifferentMessages_Isolated(t *testing.T) {
c := NewWSClient("app", "secret", "http://localhost", nil)
// Two separate messages, each with 2 parts
c.reassemble("msgA", 2, 0, []byte("A0"))
c.reassemble("msgB", 2, 0, []byte("B0"))
resultA := c.reassemble("msgA", 2, 1, []byte("A1"))
resultB := c.reassemble("msgB", 2, 1, []byte("B1"))
if resultA == nil || string(resultA) != "A0A1" {
t.Errorf("msgA: got %q, want A0A1", resultA)
}
if resultB == nil || string(resultB) != "B0B1" {
t.Errorf("msgB: got %q, want B0B1", resultB)
}
}
func TestReassemble_BufferCleanedAfterComplete(t *testing.T) {
c := NewWSClient("app", "secret", "http://localhost", nil)
c.reassemble("msg_clean", 2, 0, []byte("x"))
c.reassemble("msg_clean", 2, 1, []byte("y"))
// After completion the key is deleted; sending the same msgID again
// starts a fresh buffer.
result := c.reassemble("msg_clean", 2, 0, []byte("fresh"))
if result != nil {
t.Error("fresh start with 2 frags: first frag should not complete")
}
}
// --- NewWSClient constructor ---
func TestNewWSClient_Defaults(t *testing.T) {
c := NewWSClient("myapp", "mysecret", "https://open.larksuite.com", nil)
if c.appID != "myapp" {
t.Errorf("appID: got %q", c.appID)
}
if c.pingInterval != defaultPingInterval {
t.Errorf("pingInterval: got %v, want %v", c.pingInterval, defaultPingInterval)
}
if c.reconnectMax != -1 {
t.Errorf("reconnectMax: got %d, want -1 (infinite)", c.reconnectMax)
}
if c.fragments == nil {
t.Error("fragments map must be initialised")
}
}
// --- Stop idempotent ---
func TestWSClient_Stop_Idempotent(t *testing.T) {
c := NewWSClient("app", "secret", "http://localhost", nil)
c.mu.Lock()
c.stopCh = make(chan struct{})
c.stopped = false
c.mu.Unlock()
// First Stop should not panic
c.Stop()
// Second Stop should also not panic
c.Stop()
}
// --- getWSEndpoint via httptest ---
func TestGetWSEndpoint_Success(t *testing.T) {
upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
var wsURL string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/callback/ws/endpoint" {
// Return a WS URL pointing back to this server
wsURL = "ws://" + r.Host + "/ws"
resp := map[string]any{
"code": 0,
"msg": "ok",
"data": map[string]any{
"URL": wsURL,
"ClientConfig": map[string]any{
"PingInterval": 30,
"ReconnectCount": 5,
"ReconnectInterval": 3,
"ReconnectNonce": 10,
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
return
}
if r.URL.Path == "/ws" {
upgrader.Upgrade(w, r, nil) //nolint
return
}
http.NotFound(w, r)
}))
defer srv.Close()
c := NewWSClient("app", "secret", srv.URL, nil)
ctx := context.Background()
url, err := c.getWSEndpoint(ctx)
if err != nil {
t.Fatalf("getWSEndpoint returned error: %v", err)
}
if !strings.HasPrefix(url, "ws://") {
t.Errorf("expected ws:// URL, got %q", url)
}
// Config should be applied
if c.pingInterval != 30*time.Second {
t.Errorf("pingInterval not applied: got %v", c.pingInterval)
}
if c.reconnectMax != 5 {
t.Errorf("reconnectMax not applied: got %d", c.reconnectMax)
}
}
func TestGetWSEndpoint_APIError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"code": 10001,
"msg": "invalid credentials",
"data": map[string]any{},
})
}))
defer srv.Close()
c := NewWSClient("bad-app", "bad-secret", srv.URL, nil)
_, err := c.getWSEndpoint(context.Background())
if err == nil {
t.Fatal("expected error for non-zero code")
}
}
func TestGetWSEndpoint_NetworkError(t *testing.T) {
c := NewWSClient("app", "secret", "http://localhost:1", nil)
_, err := c.getWSEndpoint(context.Background())
if err == nil {
t.Fatal("expected error for unreachable server")
}
}
// --- handleFrame: pong updates config ---
func TestHandleFrame_PongUpdatesConfig(t *testing.T) {
c := NewWSClient("app", "secret", "http://localhost", nil)
c.mu.Lock()
c.stopCh = make(chan struct{})
c.mu.Unlock()
pongPayload, _ := json.Marshal(map[string]any{
"PingInterval": 60,
"ReconnectCount": 3,
"ReconnectInterval": 10,
"ReconnectNonce": 5,
})
frame := &wsFrame{
Method: frameTypeControl,
Headers: []wsHeader{{Key: "type", Value: "pong"}},
Payload: pongPayload,
}
c.handleFrame(context.Background(), frame)
if c.pingInterval != 60*time.Second {
t.Errorf("pingInterval: got %v, want 60s", c.pingInterval)
}
if c.reconnectMax != 3 {
t.Errorf("reconnectMax: got %d, want 3", c.reconnectMax)
}
if c.reconnectInterval != 10*time.Second {
t.Errorf("reconnectInterval: got %v, want 10s", c.reconnectInterval)
}
if c.reconnectNonce != 5 {
t.Errorf("reconnectNonce: got %d, want 5", c.reconnectNonce)
}
}
// --- handleFrame: data event dispatches to handler ---
type captureHandler struct {
payloads [][]byte
}
func (h *captureHandler) HandleEvent(_ context.Context, payload []byte) error {
h.payloads = append(h.payloads, payload)
return nil
}
func TestHandleFrame_DataEvent_Dispatched(t *testing.T) {
handler := &captureHandler{}
c := NewWSClient("app", "secret", "http://localhost", handler)
c.mu.Lock()
c.stopCh = make(chan struct{})
c.mu.Unlock()
eventPayload := []byte(`{"schema":"2.0","header":{"event_type":"im.message.receive_v1"}}`)
frame := &wsFrame{
SeqID: 1,
Method: frameTypeData,
Headers: []wsHeader{{Key: "type", Value: "event"}},
Payload: eventPayload,
}
c.handleFrame(context.Background(), frame)
if len(handler.payloads) != 1 {
t.Fatalf("expected 1 dispatched event, got %d", len(handler.payloads))
}
if !bytes.Equal(handler.payloads[0], eventPayload) {
t.Errorf("payload mismatch: got %q", handler.payloads[0])
}
}
func TestHandleFrame_NonEventDataFrame_Ignored(t *testing.T) {
handler := &captureHandler{}
c := NewWSClient("app", "secret", "http://localhost", handler)
c.mu.Lock()
c.stopCh = make(chan struct{})
c.mu.Unlock()
// "card" type should be ignored
frame := &wsFrame{
Method: frameTypeData,
Headers: []wsHeader{{Key: "type", Value: "card"}},
Payload: []byte(`{}`),
}
c.handleFrame(context.Background(), frame)
if len(handler.payloads) != 0 {
t.Errorf("non-event frame should not be dispatched, got %d events", len(handler.payloads))
}
}
func TestHandleFrame_DataEvent_Fragmented(t *testing.T) {
handler := &captureHandler{}
c := NewWSClient("app", "secret", "http://localhost", handler)
c.mu.Lock()
c.stopCh = make(chan struct{})
c.mu.Unlock()
part1 := []byte(`{"half":`)
part2 := []byte(`"data"}`)
// Send fragment 0 of 2 — should not dispatch
frame1 := &wsFrame{
Method: frameTypeData,
Headers: []wsHeader{
{Key: "type", Value: "event"},
{Key: "message_id", Value: "frag_evt_1"},
{Key: "sum", Value: "2"},
{Key: "seq", Value: "0"},
},
Payload: part1,
}
c.handleFrame(context.Background(), frame1)
if len(handler.payloads) != 0 {
t.Error("partial fragment should not dispatch")
}
// Send fragment 1 of 2 — should complete and dispatch
frame2 := &wsFrame{
Method: frameTypeData,
Headers: []wsHeader{
{Key: "type", Value: "event"},
{Key: "message_id", Value: "frag_evt_1"},
{Key: "sum", Value: "2"},
{Key: "seq", Value: "1"},
},
Payload: part2,
}
c.handleFrame(context.Background(), frame2)
if len(handler.payloads) != 1 {
t.Fatalf("completed fragments should dispatch once, got %d", len(handler.payloads))
}
combined := string(handler.payloads[0])
if combined != `{"half":"data"}` {
t.Errorf("reassembled payload: got %q", combined)
}
}
@@ -0,0 +1,69 @@
package feishu
import (
"testing"
)
// --- isImageContentType ---
func TestIsImageContentType(t *testing.T) {
cases := []struct {
ct string
want bool
}{
{"image/png", true},
{"image/jpeg", true},
{"image/gif", true},
{"image/webp", true},
{"image", true},
{"video/mp4", false},
{"audio/ogg", false},
{"application/pdf", false},
{"", false},
}
for _, tc := range cases {
t.Run(tc.ct, func(t *testing.T) {
got := isImageContentType(tc.ct)
if got != tc.want {
t.Errorf("isImageContentType(%q) = %v, want %v", tc.ct, got, tc.want)
}
})
}
}
// --- detectFileType ---
func TestDetectFileType(t *testing.T) {
cases := []struct {
fileName string
want string
}{
{"voice.opus", "opus"},
{"audio.ogg", "opus"},
{"video.mp4", "mp4"},
{"clip.mov", "mp4"},
{"movie.avi", "mp4"},
{"screen.wmv", "mp4"},
{"film.mkv", "mp4"},
{"doc.pdf", "pdf"},
{"letter.doc", "doc"},
{"report.docx", "doc"},
{"data.xls", "xls"},
{"data.xlsx", "xls"},
{"slides.ppt", "ppt"},
{"slides.pptx", "ppt"},
{"unknown.zip", "stream"},
{"noext", "stream"},
{"", "stream"},
{"UPPER.PDF", "pdf"}, // case-insensitive via ToLower on ext
{"Mixed.Opus", "opus"},
}
for _, tc := range cases {
t.Run(tc.fileName, func(t *testing.T) {
got := detectFileType(tc.fileName)
if got != tc.want {
t.Errorf("detectFileType(%q) = %q, want %q", tc.fileName, got, tc.want)
}
})
}
}
@@ -0,0 +1,216 @@
package feishu
import (
"net/http"
"os"
"strings"
"testing"
)
// --- detectImageFormat ---
func TestDetectImageFormat_JPEG(t *testing.T) {
// JPEG magic bytes: FF D8 FF
data := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10}
ct, ext := detectImageFormat(data)
if ct != "image/jpeg" {
t.Errorf("ct: got %q, want image/jpeg", ct)
}
if ext != ".jpg" {
t.Errorf("ext: got %q, want .jpg", ext)
}
}
func TestDetectImageFormat_PNG(t *testing.T) {
// PNG magic bytes: 89 50 4E 47
data := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
ct, ext := detectImageFormat(data)
if ct != "image/png" {
t.Errorf("ct: got %q, want image/png", ct)
}
if ext != ".png" {
t.Errorf("ext: got %q, want .png", ext)
}
}
func TestDetectImageFormat_GIF(t *testing.T) {
// GIF magic bytes: 47 49 46 38
data := []byte{0x47, 0x49, 0x46, 0x38, 0x39, 0x61}
ct, ext := detectImageFormat(data)
if ct != "image/gif" {
t.Errorf("ct: got %q, want image/gif", ct)
}
if ext != ".gif" {
t.Errorf("ext: got %q, want .gif", ext)
}
}
func TestDetectImageFormat_WebP(t *testing.T) {
// WebP: RIFF....WEBP
data := make([]byte, 12)
copy(data[0:4], []byte("RIFF"))
copy(data[8:12], []byte("WEBP"))
ct := http.DetectContentType(data)
// http.DetectContentType may not detect WebP as image/webp on all Go versions
// so we call our function and just verify it doesn't panic and returns valid values
gotCT, gotExt := detectImageFormat(data)
if gotCT == "" || gotExt == "" {
t.Errorf("detectImageFormat returned empty values: ct=%q ext=%q", gotCT, gotExt)
}
_ = ct
}
func TestDetectImageFormat_Unknown(t *testing.T) {
// Unknown binary → defaults to image/png
data := []byte{0x00, 0x01, 0x02, 0x03, 0x04}
ct, ext := detectImageFormat(data)
if ct != "image/png" {
t.Errorf("ct: got %q, want image/png (default fallback)", ct)
}
if ext != ".png" {
t.Errorf("ext: got %q, want .png (default fallback)", ext)
}
}
func TestDetectImageFormat_EmptyData(t *testing.T) {
ct, ext := detectImageFormat(nil)
if ct == "" || ext == "" {
t.Errorf("detectImageFormat(nil) returned empty: ct=%q ext=%q", ct, ext)
}
}
// --- extractJSONField ---
func TestExtractJSONField_Found(t *testing.T) {
json := `{"image_key":"img_abc123","other":"value"}`
got := extractJSONField(json, "image_key")
if got != "img_abc123" {
t.Errorf("got %q, want %q", got, "img_abc123")
}
}
func TestExtractJSONField_FileKey(t *testing.T) {
json := `{"file_key":"file_xyz789","file_name":"doc.pdf"}`
got := extractJSONField(json, "file_key")
if got != "file_xyz789" {
t.Errorf("got %q, want %q", got, "file_xyz789")
}
}
func TestExtractJSONField_Missing(t *testing.T) {
json := `{"other":"value"}`
got := extractJSONField(json, "image_key")
if got != "" {
t.Errorf("got %q, want empty string", got)
}
}
func TestExtractJSONField_EmptyString(t *testing.T) {
got := extractJSONField("", "image_key")
if got != "" {
t.Errorf("got %q, want empty", got)
}
}
func TestExtractJSONField_UnclosedValue(t *testing.T) {
// Missing closing quote → returns empty
json := `{"image_key":"unclosed`
got := extractJSONField(json, "image_key")
if got != "" {
t.Errorf("got %q, want empty for unclosed value", got)
}
}
// --- saveMediaToTemp ---
func TestSaveMediaToTemp_Basic(t *testing.T) {
data := []byte("test image data")
path, err := saveMediaToTemp(data, "img", ".png")
if err != nil {
t.Fatalf("saveMediaToTemp error: %v", err)
}
defer os.Remove(path)
if !strings.Contains(path, "feishu_img_") {
t.Errorf("path missing prefix: %q", path)
}
if !strings.HasSuffix(path, ".png") {
t.Errorf("path missing .png suffix: %q", path)
}
// Verify file content
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read temp file: %v", err)
}
if string(content) != "test image data" {
t.Errorf("content mismatch: got %q", content)
}
}
func TestSaveMediaToTemp_EmptyExt(t *testing.T) {
data := []byte("binary")
path, err := saveMediaToTemp(data, "file", "")
if err != nil {
t.Fatalf("saveMediaToTemp error: %v", err)
}
defer os.Remove(path)
if !strings.HasSuffix(path, ".bin") {
t.Errorf("expected .bin default ext, got %q", path)
}
}
func TestSaveMediaToTemp_AudioExt(t *testing.T) {
data := []byte("audio bytes")
path, err := saveMediaToTemp(data, "audio", ".opus")
if err != nil {
t.Fatalf("saveMediaToTemp error: %v", err)
}
defer os.Remove(path)
if !strings.HasSuffix(path, ".opus") {
t.Errorf("expected .opus, got %q", path)
}
}
// --- mediaMaxBytes ---
func TestMediaMaxBytes_Default(t *testing.T) {
ch := &Channel{}
maxBytes := ch.mediaMaxBytes()
expected := int64(defaultMediaMaxMB) * 1024 * 1024
if maxBytes != expected {
t.Errorf("mediaMaxBytes: got %d, want %d", maxBytes, expected)
}
}
func TestMediaMaxBytes_Custom(t *testing.T) {
ch := &Channel{}
ch.cfg.MediaMaxMB = 10
maxBytes := ch.mediaMaxBytes()
expected := int64(10) * 1024 * 1024
if maxBytes != expected {
t.Errorf("mediaMaxBytes: got %d, want %d", maxBytes, expected)
}
}
func TestMediaMaxBytes_ZeroFallsToDefault(t *testing.T) {
ch := &Channel{}
ch.cfg.MediaMaxMB = 0
maxBytes := ch.mediaMaxBytes()
expected := int64(defaultMediaMaxMB) * 1024 * 1024
if maxBytes != expected {
t.Errorf("mediaMaxBytes(0): got %d, want default %d", maxBytes, expected)
}
}
func TestMediaMaxBytes_NegativeFallsToDefault(t *testing.T) {
ch := &Channel{}
ch.cfg.MediaMaxMB = -1
maxBytes := ch.mediaMaxBytes()
expected := int64(defaultMediaMaxMB) * 1024 * 1024
if maxBytes != expected {
t.Errorf("mediaMaxBytes(-1): got %d, want default %d", maxBytes, expected)
}
}
+230
View File
@@ -0,0 +1,230 @@
package acp
import (
"strings"
"sync"
"testing"
)
// --- filterACPEnv tests ---
func TestFilterACPEnv_RemovesSensitivePrefixes(t *testing.T) {
cases := []struct {
name string
env []string
removed []string
kept []string
}{
{
name: "GOCLAW prefix",
env: []string{"GOCLAW_TOKEN=abc", "HOME=/root"},
removed: []string{"GOCLAW_TOKEN=abc"},
kept: []string{"HOME=/root"},
},
{
name: "ANTHROPIC prefix",
env: []string{"ANTHROPIC_API_KEY=sk-ant-123", "PATH=/usr/bin"},
removed: []string{"ANTHROPIC_API_KEY=sk-ant-123"},
kept: []string{"PATH=/usr/bin"},
},
{
name: "OPENAI prefix",
env: []string{"OPENAI_API_KEY=sk-123", "TERM=xterm"},
removed: []string{"OPENAI_API_KEY=sk-123"},
kept: []string{"TERM=xterm"},
},
{
name: "DATABASE prefix",
env: []string{"DATABASE_URL=postgres://host/db", "USER=alice"},
removed: []string{"DATABASE_URL=postgres://host/db"},
kept: []string{"USER=alice"},
},
{
name: "AWS_ prefix",
env: []string{"AWS_ACCESS_KEY_ID=AKIA123", "LANG=en_US.UTF-8"},
removed: []string{"AWS_ACCESS_KEY_ID=AKIA123"},
kept: []string{"LANG=en_US.UTF-8"},
},
{
name: "GITHUB_ prefix",
env: []string{"GITHUB_TOKEN=ghp_xxx", "SHELL=/bin/zsh"},
removed: []string{"GITHUB_TOKEN=ghp_xxx"},
kept: []string{"SHELL=/bin/zsh"},
},
{
name: "SSH_ prefix",
env: []string{"SSH_AUTH_SOCK=/tmp/agent.123", "EDITOR=vim"},
removed: []string{"SSH_AUTH_SOCK=/tmp/agent.123"},
kept: []string{"EDITOR=vim"},
},
{
name: "STRIPE_ prefix",
env: []string{"STRIPE_SECRET_KEY=sk_live_abc", "TZ=UTC"},
removed: []string{"STRIPE_SECRET_KEY=sk_live_abc"},
kept: []string{"TZ=UTC"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
filtered := filterACPEnv(tc.env)
filteredSet := make(map[string]bool, len(filtered))
for _, e := range filtered {
filteredSet[e] = true
}
for _, r := range tc.removed {
if filteredSet[r] {
t.Errorf("expected %q to be removed but it was kept", r)
}
}
for _, k := range tc.kept {
if !filteredSet[k] {
t.Errorf("expected %q to be kept but it was removed", k)
}
}
})
}
}
func TestFilterACPEnv_RemovesExactNames(t *testing.T) {
exactCases := []string{
"DB_DSN=postgres://localhost/db",
"PGPASSWORD=secret",
"PGUSER=admin",
"PGHOST=db.internal",
"NPM_TOKEN=npm_abc123",
"NPM_CONFIG_TOKEN=token",
"HOMEBREW_GITHUB_API_TOKEN=ghp_abc",
"CODECOV_TOKEN=xxx",
"COVERALLS_REPO_TOKEN=yyy",
"SENTRY_DSN=https://sentry.io/...",
"SENTRY_AUTH_TOKEN=zzz",
"SECRET_KEY=supersecret",
"JWT_SECRET=jwtkey",
}
for _, envVar := range exactCases {
key := strings.SplitN(envVar, "=", 2)[0]
t.Run(key, func(t *testing.T) {
filtered := filterACPEnv([]string{envVar, "SAFE_VAR=ok"})
for _, f := range filtered {
if strings.HasPrefix(f, key+"=") {
t.Errorf("expected %q to be removed but it was kept", key)
}
}
})
}
}
func TestFilterACPEnv_EmptyInput(t *testing.T) {
result := filterACPEnv(nil)
if result != nil && len(result) != 0 {
t.Errorf("expected nil/empty result for nil input, got %v", result)
}
}
func TestFilterACPEnv_AllSafe(t *testing.T) {
env := []string{"HOME=/root", "USER=alice", "TERM=xterm-256color", "LANG=en_US.UTF-8"}
filtered := filterACPEnv(env)
if len(filtered) != len(env) {
t.Errorf("expected all %d vars kept, got %d", len(env), len(filtered))
}
}
func TestFilterACPEnv_CaseInsensitive(t *testing.T) {
// keys are uppercased before comparison
env := []string{"goclaw_token=abc", "SAFE=ok"}
filtered := filterACPEnv(env)
for _, f := range filtered {
if strings.HasPrefix(strings.ToUpper(f), "GOCLAW_") {
t.Errorf("expected lowercase goclaw_token to be stripped, got %q", f)
}
}
}
func TestFilterACPEnv_NoEqualsSign(t *testing.T) {
// Entries with no "=" should be treated as key with empty value; key is full string
env := []string{"PLAINVAR", "HOME=/home/user"}
filtered := filterACPEnv(env)
// PLAINVAR is not sensitive, should be kept
found := false
for _, f := range filtered {
if f == "PLAINVAR" {
found = true
}
}
if !found {
t.Error("expected PLAINVAR (no =) to be kept")
}
}
// --- limitedWriter tests ---
func TestLimitedWriter_CapsToBudget(t *testing.T) {
lw := &limitedWriter{max: 10}
n, err := lw.Write([]byte("hello world"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Write truncates p to remaining capacity before return, so n == min(len(p), max)
if n != 10 {
t.Errorf("expected n=10 (capped to max), got %d", n)
}
// Only the first 10 bytes are kept
if got := lw.String(); got != "hello worl" {
t.Errorf("expected %q, got %q", "hello worl", got)
}
}
func TestLimitedWriter_StopsAfterFull(t *testing.T) {
lw := &limitedWriter{max: 5}
lw.Write([]byte("12345"))
lw.Write([]byte("extra"))
if got := lw.String(); got != "12345" {
t.Errorf("expected %q, got %q", "12345", got)
}
}
func TestLimitedWriter_ExactMax(t *testing.T) {
lw := &limitedWriter{max: 4}
lw.Write([]byte("ab"))
lw.Write([]byte("cd"))
if got := lw.String(); got != "abcd" {
t.Errorf("expected %q, got %q", "abcd", got)
}
}
func TestLimitedWriter_ZeroMax(t *testing.T) {
lw := &limitedWriter{max: 0}
n, err := lw.Write([]byte("hello"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if n != 5 {
t.Errorf("expected n=5, got %d", n)
}
if got := lw.String(); got != "" {
t.Errorf("expected empty string, got %q", got)
}
}
func TestLimitedWriter_ConcurrentWrites(t *testing.T) {
lw := &limitedWriter{max: 1000}
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
lw.Write([]byte("data"))
}()
}
wg.Wait()
// No panic and output <= max
s := lw.String()
if len(s) > 1000 {
t.Errorf("expected len <= 1000, got %d", len(s))
}
}
func TestLimitedWriter_ImplementsWriter(t *testing.T) {
// compile-time check already in helpers.go but verify via interface assignment
var _ interface{ Write([]byte) (int, error) } = (*limitedWriter)(nil)
}
+524
View File
@@ -0,0 +1,524 @@
package acp
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
"testing"
"time"
)
// --- jsonrpcError tests ---
func TestJsonrpcError_Error(t *testing.T) {
e := &jsonrpcError{Code: -32601, Message: "method not found"}
got := e.Error()
if !strings.Contains(got, "-32601") {
t.Errorf("expected code in error string, got %q", got)
}
if !strings.Contains(got, "method not found") {
t.Errorf("expected message in error string, got %q", got)
}
}
// --- Conn read loop + dispatch tests ---
// makeConnPair creates two in-memory Conn pairs communicating via io.Pipe.
// Returns (clientConn, serverReader, serverWriter) where:
// - clientConn reads from serverWriter and writes to serverReader
// - the test can inject messages by writing to serverWriter
// - the test can read messages by reading from serverReader
func makeLoopbackConn(handler RequestHandler, notify NotifyHandler) (*Conn, io.Writer, io.ReadCloser) {
// client writes → serverR reads (for the test to inspect outbound)
serverR, clientW := io.Pipe()
// serverW writes → client reads (for the test to inject inbound)
clientR, serverW := io.Pipe()
conn := NewConn(clientW, clientR, handler, notify)
return conn, serverW, serverR
}
func TestNewConn_NotNil(t *testing.T) {
pr, pw := io.Pipe()
defer pr.Close()
defer pw.Close()
conn := NewConn(pw, pr, nil, nil)
if conn == nil {
t.Fatal("expected non-nil Conn")
}
}
func TestConn_Done_ClosedOnReaderEOF(t *testing.T) {
pr, pw := io.Pipe()
conn := NewConn(io.Discard, pr, nil, nil)
conn.Start()
// Close the write end to signal EOF → read loop exits → done closed
pw.Close()
select {
case <-conn.Done():
// expected
case <-time.After(2 * time.Second):
t.Fatal("expected Done to be closed after reader EOF")
}
}
func TestConn_Notify_WritesMessage(t *testing.T) {
// We only need a writer; reader can be /dev/null equiv
pr, pw := io.Pipe()
defer pr.Close()
// Write to a buffer-based pipe so we can inspect
outR, outW := io.Pipe()
conn := NewConn(outW, pr, nil, nil)
// Don't start read loop — we're only testing write path
done := make(chan []byte, 1)
go func() {
data, _ := io.ReadAll(outR)
done <- data
}()
err := conn.Notify("test/event", map[string]string{"key": "val"})
if err != nil {
t.Fatalf("Notify error: %v", err)
}
outW.Close()
pw.Close()
select {
case data := <-done:
if !strings.Contains(string(data), "test/event") {
t.Errorf("expected method in output, got %q", string(data))
}
if !strings.Contains(string(data), `"jsonrpc":"2.0"`) {
t.Errorf("expected jsonrpc version in output, got %q", string(data))
}
case <-time.After(2 * time.Second):
t.Fatal("timeout reading notify output")
}
}
func TestConn_Call_ResponseDispatched(t *testing.T) {
// serverW → clientR (injecting responses); clientW → serverR (reading requests)
clientR, serverW := io.Pipe()
serverR, clientW := io.Pipe()
conn := NewConn(clientW, clientR, nil, nil)
conn.Start()
// Read the request the client sends, then write the response back.
go func() {
buf := make([]byte, 4096)
n, err := serverR.Read(buf)
if err != nil {
return
}
var req jsonrpcMessage
json.Unmarshal(buf[:n], &req)
// Send response with same ID
resp := jsonrpcMessage{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`{"sessionId":"sess-1"}`),
}
data, _ := json.Marshal(resp)
data = append(data, '\n')
serverW.Write(data)
}()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
var result NewSessionResponse
err := conn.Call(ctx, "session/new", NewSessionRequest{}, &result)
if err != nil {
t.Fatalf("Call error: %v", err)
}
if result.SessionID != "sess-1" {
t.Errorf("expected sess-1, got %q", result.SessionID)
}
serverW.Close()
serverR.Close()
}
func TestConn_Call_ErrorResponse(t *testing.T) {
clientR, serverW := io.Pipe()
serverR, clientW := io.Pipe()
conn := NewConn(clientW, clientR, nil, nil)
conn.Start()
go func() {
buf := make([]byte, 4096)
n, _ := serverR.Read(buf)
var req jsonrpcMessage
json.Unmarshal(buf[:n], &req)
resp := jsonrpcMessage{
JSONRPC: "2.0",
ID: req.ID,
Error: &jsonrpcError{Code: -32600, Message: "invalid request"},
}
data, _ := json.Marshal(resp)
data = append(data, '\n')
serverW.Write(data)
}()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
err := conn.Call(ctx, "bad/method", nil, nil)
if err == nil {
t.Fatal("expected error response, got nil")
}
if !strings.Contains(err.Error(), "invalid request") {
t.Errorf("expected 'invalid request' in error, got %q", err.Error())
}
serverW.Close()
serverR.Close()
}
func TestConn_Call_ContextCancel(t *testing.T) {
// Server accepts writes but never sends responses.
clientR, serverW := io.Pipe()
serverR, clientW := io.Pipe()
conn := NewConn(clientW, clientR, nil, nil)
conn.Start()
// Drain the client writes so writeMessage doesn't block; never send a response.
go func() {
buf := make([]byte, 4096)
for {
_, err := serverR.Read(buf)
if err != nil {
return
}
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
err := conn.Call(ctx, "slow/method", nil, nil)
if err == nil {
t.Fatal("expected context timeout error")
}
serverW.Close()
serverR.Close()
}
func TestConn_Call_ConnectionClosed(t *testing.T) {
clientR, serverW := io.Pipe()
serverR, clientW := io.Pipe()
conn := NewConn(clientW, clientR, nil, nil)
conn.Start()
// Close the server write end immediately — causes EOF → done closed
go func() {
// Drain the request first so the client write doesn't block
buf := make([]byte, 4096)
serverR.Read(buf)
serverW.Close()
}()
ctx := context.Background()
err := conn.Call(ctx, "any/method", nil, nil)
if err == nil {
t.Fatal("expected error after connection close")
}
}
func TestConn_ReadLoop_SkipsMalformedJSON(t *testing.T) {
clientR, serverW := io.Pipe()
serverR, clientW := io.Pipe()
_ = serverR
var notifyCalled bool
notifyDone := make(chan struct{})
notify := func(method string, params json.RawMessage) {
if method == "good/event" {
notifyCalled = true
close(notifyDone)
}
}
conn := NewConn(clientW, clientR, nil, notify)
conn.Start()
// Send malformed line, then valid notification
serverW.Write([]byte("{{not valid json}}\n"))
goodMsg := jsonrpcMessage{JSONRPC: "2.0", Method: "good/event", Params: json.RawMessage(`{}`)}
data, _ := json.Marshal(goodMsg)
serverW.Write(append(data, '\n'))
select {
case <-notifyDone:
if !notifyCalled {
t.Error("expected notify to be called after malformed line")
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for good/event notification")
}
serverW.Close()
clientW.Close()
}
func TestConn_ReadLoop_EmptyLines(t *testing.T) {
clientR, serverW := io.Pipe()
_, clientW := io.Pipe()
notifyDone := make(chan struct{})
notify := func(method string, params json.RawMessage) {
if method == "ping" {
close(notifyDone)
}
}
conn := NewConn(clientW, clientR, nil, notify)
conn.Start()
// Send empty lines then a valid notification
serverW.Write([]byte("\n\n\n"))
msg := jsonrpcMessage{JSONRPC: "2.0", Method: "ping"}
data, _ := json.Marshal(msg)
serverW.Write(append(data, '\n'))
select {
case <-notifyDone:
case <-time.After(2 * time.Second):
t.Fatal("timeout: notification after empty lines not received")
}
serverW.Close()
}
func TestConn_ReadLoop_HandlesRequestFromAgent(t *testing.T) {
clientR, serverW := io.Pipe()
serverR, clientW := io.Pipe()
handlerCalled := make(chan string, 1)
handler := func(ctx context.Context, method string, params json.RawMessage) (any, error) {
handlerCalled <- method
return map[string]string{"result": "ok"}, nil
}
conn := NewConn(clientW, clientR, handler, nil)
conn.Start()
// Agent sends a request (has ID + method)
id := int64(99)
req := jsonrpcMessage{
JSONRPC: "2.0",
ID: &id,
Method: "fs/readTextFile",
Params: json.RawMessage(`{"path":"/tmp/test"}`),
}
data, _ := json.Marshal(req)
serverW.Write(append(data, '\n'))
select {
case method := <-handlerCalled:
if method != "fs/readTextFile" {
t.Errorf("expected fs/readTextFile, got %q", method)
}
case <-time.After(2 * time.Second):
t.Fatal("timeout: handler not called")
}
// Drain the response
go func() {
buf := make([]byte, 4096)
serverR.Read(buf)
}()
serverW.Close()
}
func TestConn_ReadLoop_NoHandlerReturnsError(t *testing.T) {
clientR, serverW := io.Pipe()
serverR, clientW := io.Pipe()
conn := NewConn(clientW, clientR, nil, nil) // no handler
conn.Start()
id := int64(1)
req := jsonrpcMessage{
JSONRPC: "2.0",
ID: &id,
Method: "fs/readTextFile",
Params: json.RawMessage(`{}`),
}
data, _ := json.Marshal(req)
serverW.Write(append(data, '\n'))
// Read the error response
respDone := make(chan *jsonrpcMessage, 1)
go func() {
buf := make([]byte, 4096)
n, _ := serverR.Read(buf)
var msg jsonrpcMessage
json.Unmarshal(buf[:n], &msg)
respDone <- &msg
}()
select {
case resp := <-respDone:
if resp.Error == nil {
t.Error("expected error response when no handler registered")
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for error response")
}
serverW.Close()
}
func TestConn_Notify_MarshalError(t *testing.T) {
// Use a writer that always errors to test error return
pr, _ := io.Pipe()
pw := &errorWriter{}
conn := NewConn(pw, pr, nil, nil)
err := conn.Notify("test", map[string]string{"k": "v"})
if err == nil {
t.Error("expected error from failing writer")
}
}
// errorWriter always returns an error on Write.
type errorWriter struct{}
func (w *errorWriter) Write(p []byte) (int, error) {
return 0, fmt.Errorf("forced write error")
}
func TestConn_Call_NilResult(t *testing.T) {
clientR, serverW := io.Pipe()
serverR, clientW := io.Pipe()
conn := NewConn(clientW, clientR, nil, nil)
conn.Start()
go func() {
buf := make([]byte, 4096)
n, _ := serverR.Read(buf)
var req jsonrpcMessage
json.Unmarshal(buf[:n], &req)
resp := jsonrpcMessage{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`{}`),
}
data, _ := json.Marshal(resp)
serverW.Write(append(data, '\n'))
}()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// nil result pointer — should not panic
err := conn.Call(ctx, "ping", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
serverW.Close()
serverR.Close()
}
// TestConn_ReadLoop_MalformedInputNoPanic verifies adversarial inputs don't panic.
func TestConn_ReadLoop_MalformedInputNoPanic(t *testing.T) {
adversarialInputs := []string{
"null\n",
"[]\n",
`{"id":null,"method":null}\n`,
strings.Repeat("x", 1024) + "\n",
`{"jsonrpc":"2.0","id":1,"method":` + strings.Repeat(`"a"`, 100) + `}\n`,
"\x00\x01\x02\x03\n",
`{"jsonrpc":"2.0","result":` + strings.Repeat("[", 100) + "\n",
}
for _, input := range adversarialInputs {
t.Run("input_"+input[:min(10, len(input))], func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("panic on input %q: %v", input[:min(20, len(input))], r)
}
}()
clientR, serverW := io.Pipe()
_, clientW := io.Pipe()
conn := NewConn(clientW, clientR, nil, nil)
conn.Start()
serverW.Write([]byte(input))
// Small delay for read loop to process
time.Sleep(10 * time.Millisecond)
serverW.Close()
})
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func TestConn_IDIncrement(t *testing.T) {
// Verify each Call gets a unique ID by capturing two requests
clientR, serverW := io.Pipe()
serverR, clientW := io.Pipe()
conn := NewConn(clientW, clientR, nil, nil)
conn.Start()
ids := make(chan int64, 2)
// Goroutine that reads requests and responds
go func() {
for i := 0; i < 2; i++ {
buf := make([]byte, 4096)
n, _ := serverR.Read(buf)
var req jsonrpcMessage
json.Unmarshal(buf[:n], &req)
if req.ID != nil {
ids <- *req.ID
}
resp := jsonrpcMessage{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`{}`),
}
data, _ := json.Marshal(resp)
serverW.Write(append(data, '\n'))
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
conn.Call(ctx, "m1", nil, nil)
conn.Call(ctx, "m2", nil, nil)
id1 := <-ids
id2 := <-ids
if id1 == id2 {
t.Errorf("expected different IDs, got %d and %d", id1, id2)
}
serverW.Close()
serverR.Close()
}
+264
View File
@@ -0,0 +1,264 @@
package acp
import (
"context"
"encoding/json"
"testing"
"time"
)
// --- ProcessPool construction ---
func TestNewProcessPool_Fields(t *testing.T) {
pp := NewProcessPool("/usr/bin/claude", []string{"--acp"}, "/workspace", 5*time.Minute)
if pp == nil {
t.Fatal("expected non-nil ProcessPool")
}
if pp.agentBinary != "/usr/bin/claude" {
t.Errorf("agentBinary: got %q", pp.agentBinary)
}
if len(pp.agentArgs) != 1 || pp.agentArgs[0] != "--acp" {
t.Errorf("agentArgs: got %v", pp.agentArgs)
}
if pp.workDir != "/workspace" {
t.Errorf("workDir: got %q", pp.workDir)
}
if pp.idleTTL != 5*time.Minute {
t.Errorf("idleTTL: got %v", pp.idleTTL)
}
// Clean up reapLoop goroutine
pp.Close()
}
func TestNewProcessPool_DoneChannelOpen(t *testing.T) {
pp := NewProcessPool("x", nil, "", time.Minute)
select {
case <-pp.done:
t.Error("done channel should not be closed before Close()")
default:
}
pp.Close()
}
// --- SetToolHandler / getToolHandler ---
func TestProcessPool_SetToolHandler(t *testing.T) {
pp := NewProcessPool("x", nil, "", time.Minute)
defer pp.Close()
if pp.getToolHandler() != nil {
t.Error("expected nil handler before SetToolHandler")
}
called := make(chan string, 1)
h := RequestHandler(func(ctx context.Context, method string, params json.RawMessage) (any, error) {
called <- method
return nil, nil
})
pp.SetToolHandler(h)
if pp.getToolHandler() == nil {
t.Error("expected non-nil handler after SetToolHandler")
}
}
// --- Close ---
func TestProcessPool_Close_Idempotent(t *testing.T) {
pp := NewProcessPool("x", nil, "", time.Minute)
// Close twice must not panic or deadlock
if err := pp.Close(); err != nil {
t.Fatalf("first Close error: %v", err)
}
if err := pp.Close(); err != nil {
t.Fatalf("second Close error: %v", err)
}
}
func TestProcessPool_Close_DrainsDoneChannel(t *testing.T) {
pp := NewProcessPool("x", nil, "", time.Minute)
pp.Close()
select {
case <-pp.done:
// expected — done closed by Close()
case <-time.After(time.Second):
t.Fatal("expected done channel to be closed after Close()")
}
}
func TestProcessPool_Close_WithFakeProcesses(t *testing.T) {
pp := NewProcessPool("x", nil, "", time.Minute)
// Insert fake ACPProcess entries that are already exited
for i := 0; i < 3; i++ {
exitedCh := make(chan struct{})
close(exitedCh) // pre-exited
proc := &ACPProcess{
exited: exitedCh,
cancel: func() {},
ctx: context.Background(),
}
// Wire a no-op Conn so Close doesn't need real pipes
proc.conn = &Conn{done: make(chan struct{})}
pp.processes.Store(i, proc)
}
done := make(chan error, 1)
go func() { done <- pp.Close() }()
select {
case err := <-done:
if err != nil {
t.Fatalf("Close with pre-exited processes: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("Close timed out with pre-exited fake processes")
}
}
func TestProcessPool_Close_TimesOutSlowProcess(t *testing.T) {
pp := NewProcessPool("x", nil, "", time.Minute)
// Insert a fake process that never exits (simulates slow shutdown)
neverExit := make(chan struct{}) // never closed
proc := &ACPProcess{
exited: neverExit,
cancel: func() {},
ctx: context.Background(),
}
proc.conn = &Conn{done: make(chan struct{})}
pp.processes.Store("slow", proc)
// Override the 5s timeout by closing pp.done directly to trigger Close fast.
// We test that Close() returns even when a process doesn't exit.
// Use a very short real test — Close() internally waits up to 5s per process.
// Instead, verify that Close does not block indefinitely by checking it finishes
// within a reasonable window (the pool's internal 5s + buffer).
done := make(chan error, 1)
go func() { done <- pp.Close() }()
// The test just needs to complete; the real 5s wait happens internally.
// Use a long timeout to avoid flakiness on slow CI.
select {
case err := <-done:
if err != nil {
t.Fatalf("Close returned error: %v", err)
}
case <-time.After(10 * time.Second):
t.Fatal("Close did not return within 10 seconds for slow process")
}
}
// --- reapLoop behaviour (via pool state manipulation) ---
func TestProcessPool_ReapLoop_SkipsActivePrompts(t *testing.T) {
pp := NewProcessPool("x", nil, "", 1*time.Millisecond) // very short TTL
// Use a pre-exited channel so Close() doesn't wait 5s
exitedCh := make(chan struct{})
close(exitedCh)
proc := &ACPProcess{
exited: exitedCh,
cancel: func() {},
ctx: context.Background(),
lastActive: time.Now().Add(-time.Hour), // very old
}
proc.conn = &Conn{done: make(chan struct{})}
proc.inUse.Store(1) // active prompt — reaper must skip
pp.processes.Store("active-key", proc)
defer pp.Close()
// Wait more than the reap interval (30s internal ticker — can't shorten without
// interface injection). Instead, exercise the Range callback directly by calling
// reapLoop's logic inline as a unit.
//
// The reapLoop uses a 30s ticker, so we exercise the logic directly:
pp.processes.Range(func(key, value any) bool {
p := value.(*ACPProcess)
if p.inUse.Load() > 0 {
return true // should skip
}
t.Errorf("should not reap process with active prompt, key=%v", key)
return true
})
if _, ok := pp.processes.Load("active-key"); !ok {
t.Error("active process should not have been removed")
}
}
func TestProcessPool_GetOrSpawn_ReturnsExisting(t *testing.T) {
pp := NewProcessPool("x", nil, "", time.Minute)
// Pre-insert a fake running process — exited channel intentionally never closed
exitedCh := make(chan struct{}) // open = "still running"
proc := &ACPProcess{
exited: exitedCh,
cancel: func() {},
ctx: context.Background(),
}
proc.conn = &Conn{done: make(chan struct{})}
pp.processes.Store("sess-existing", proc)
// Remove the stalling process before Close so cleanup is fast
t.Cleanup(func() {
pp.processes.Delete("sess-existing")
pp.Close()
})
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
got, err := pp.GetOrSpawn(ctx, "sess-existing")
if err != nil {
t.Fatalf("GetOrSpawn error: %v", err)
}
if got != proc {
t.Error("expected same *ACPProcess to be returned from pool")
}
}
func TestProcessPool_GetOrSpawn_RespawnsExited(t *testing.T) {
// When stored process has exited channel closed, GetOrSpawn should attempt
// to respawn. The respawn will fail (no real binary) — verify it attempts
// and the stale entry is removed.
pp := NewProcessPool("/nonexistent-binary-xyz", nil, t.TempDir(), time.Minute)
defer pp.Close()
exitedCh := make(chan struct{})
close(exitedCh) // pre-exited = crashed
proc := &ACPProcess{
exited: exitedCh,
cancel: func() {},
ctx: context.Background(),
}
proc.conn = &Conn{done: make(chan struct{})}
pp.processes.Store("sess-crashed", proc)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_, err := pp.GetOrSpawn(ctx, "sess-crashed")
// Expect an error because the binary doesn't exist
if err == nil {
t.Fatal("expected error when respawning with nonexistent binary")
}
// Stale entry should have been removed before the failed spawn
if _, ok := pp.processes.Load("sess-crashed"); ok {
t.Error("expected stale process entry to be removed after crash detection")
}
}
func TestProcessPool_GetOrSpawn_SpawnFailsGracefully(t *testing.T) {
pp := NewProcessPool("/definitely-does-not-exist", nil, t.TempDir(), time.Minute)
defer pp.Close()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_, err := pp.GetOrSpawn(ctx, "new-session")
if err == nil {
t.Fatal("expected error when binary does not exist")
}
}
+434
View File
@@ -0,0 +1,434 @@
package acp
import (
"context"
"encoding/json"
"io"
"strings"
"testing"
"time"
)
// buildACPProcess creates an ACPProcess wired to an in-process io.Pipe conn.
// Returns the process, a writer the test can inject responses into (serverW),
// and a reader the test can read outbound requests from (serverR).
//
// The returned serverR is a *bufferedPipeReader that always drains — so the
// conn's writeMessage never blocks even if the test ignores outbound bytes.
//
// clientW → serverR (test reads what the process sends)
// serverW → clientR (test injects responses to the process)
func buildACPProcess(handler RequestHandler, notify NotifyHandler) (
proc *ACPProcess,
serverW io.WriteCloser,
serverR io.ReadCloser,
) {
clientR, sW := io.Pipe() // test writes here → conn reads
sR, clientW := io.Pipe() // conn writes here → test reads
conn := NewConn(clientW, clientR, handler, notify)
conn.Start()
proc = &ACPProcess{
conn: conn,
exited: make(chan struct{}),
ctx: context.Background(),
cancel: func() {},
}
return proc, sW, sR
}
// drainPipe reads sR in background so writes to clientW never block.
// Returns a channel that receives each complete line written by the conn.
func drainPipeLines(sR io.ReadCloser) chan []byte {
ch := make(chan []byte, 64)
go func() {
defer close(ch)
buf := make([]byte, 32*1024)
for {
n, err := sR.Read(buf)
if n > 0 {
line := make([]byte, n)
copy(line, buf[:n])
select {
case ch <- line:
default:
}
}
if err != nil {
return
}
}
}()
return ch
}
// replyTo reads one JSON-RPC request from r, then writes a response with the
// given result JSON back to w. Runs in its own goroutine and signals done.
func replyTo(r io.Reader, w io.Writer, resultJSON string) chan struct{} {
done := make(chan struct{})
go func() {
defer close(done)
buf := make([]byte, 32*1024)
n, err := r.Read(buf)
if err != nil || n == 0 {
return
}
var req jsonrpcMessage
if err := json.Unmarshal(buf[:n], &req); err != nil {
return
}
resp := jsonrpcMessage{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(resultJSON),
}
data, _ := json.Marshal(resp)
w.Write(append(data, '\n'))
}()
return done
}
// replyError reads one request and sends back a JSON-RPC error response.
func replyError(r io.Reader, w io.Writer, code int, message string) chan struct{} {
done := make(chan struct{})
go func() {
defer close(done)
buf := make([]byte, 32*1024)
n, _ := r.Read(buf)
if n == 0 {
return
}
var req jsonrpcMessage
json.Unmarshal(buf[:n], &req)
resp := jsonrpcMessage{
JSONRPC: "2.0",
ID: req.ID,
Error: &jsonrpcError{Code: code, Message: message},
}
data, _ := json.Marshal(resp)
w.Write(append(data, '\n'))
}()
return done
}
// --- Initialize tests ---
func TestACPProcess_Initialize_Success(t *testing.T) {
proc, serverW, serverR := buildACPProcess(nil, nil)
defer serverW.Close()
defer serverR.Close()
respJSON := `{"agentInfo":{"name":"claude","version":"1.0"},"capabilities":{"loadSession":true}}`
done := replyTo(serverR, serverW, respJSON)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := proc.Initialize(ctx); err != nil {
t.Fatalf("Initialize error: %v", err)
}
if !proc.agentCaps.LoadSession {
t.Error("expected LoadSession=true after Initialize")
}
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for replyTo goroutine")
}
}
func TestACPProcess_Initialize_ErrorResponse(t *testing.T) {
proc, serverW, serverR := buildACPProcess(nil, nil)
defer serverW.Close()
defer serverR.Close()
done := replyError(serverR, serverW, -32600, "not supported")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
err := proc.Initialize(ctx)
if err == nil {
t.Fatal("expected error from Initialize")
}
if !strings.Contains(err.Error(), "acp initialize") {
t.Errorf("expected 'acp initialize' prefix, got %q", err.Error())
}
<-done
}
func TestACPProcess_Initialize_Timeout(t *testing.T) {
proc, serverW, serverR := buildACPProcess(nil, nil)
defer serverW.Close()
// Drain requests so writeMessage doesn't block, but never send a response.
go func() {
buf := make([]byte, 4096)
for {
_, err := serverR.Read(buf)
if err != nil {
return
}
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
err := proc.Initialize(ctx)
if err == nil {
t.Fatal("expected timeout error")
}
serverR.Close()
}
// --- NewSession tests ---
func TestACPProcess_NewSession_Success(t *testing.T) {
proc, serverW, serverR := buildACPProcess(nil, nil)
defer serverW.Close()
defer serverR.Close()
done := replyTo(serverR, serverW, `{"sessionId":"sess-xyz"}`)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := proc.NewSession(ctx); err != nil {
t.Fatalf("NewSession error: %v", err)
}
if proc.sessionID != "sess-xyz" {
t.Errorf("expected sessionID='sess-xyz', got %q", proc.sessionID)
}
<-done
}
func TestACPProcess_NewSession_Error(t *testing.T) {
proc, serverW, serverR := buildACPProcess(nil, nil)
defer serverW.Close()
defer serverR.Close()
done := replyError(serverR, serverW, -32000, "session failed")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
err := proc.NewSession(ctx)
if err == nil {
t.Fatal("expected error from NewSession")
}
if !strings.Contains(err.Error(), "acp session/new") {
t.Errorf("expected 'acp session/new' prefix, got %q", err.Error())
}
<-done
}
// --- Prompt tests ---
func TestACPProcess_Prompt_Success(t *testing.T) {
proc, serverW, serverR := buildACPProcess(nil, nil)
proc.sessionID = "sess-1"
defer serverW.Close()
defer serverR.Close()
done := replyTo(serverR, serverW, `{"stopReason":"endTurn"}`)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
resp, err := proc.Prompt(ctx, []ContentBlock{{Type: "text", Text: "hi"}}, nil)
if err != nil {
t.Fatalf("Prompt error: %v", err)
}
if resp.StopReason != "endTurn" {
t.Errorf("expected stopReason='endTurn', got %q", resp.StopReason)
}
<-done
}
func TestACPProcess_Prompt_WithUpdateCallback(t *testing.T) {
updates := make(chan SessionUpdate, 10)
notifyHandler := func(method string, params json.RawMessage) {
if method == "session/update" {
var u SessionUpdate
json.Unmarshal(params, &u)
updates <- u
}
}
proc, serverW, serverR := buildACPProcess(nil, notifyHandler)
proc.sessionID = "sess-2"
defer serverW.Close()
defer serverR.Close()
// Goroutine: send a notification, then a response
go func() {
// Read the prompt request first
buf := make([]byte, 32*1024)
n, _ := serverR.Read(buf)
var req jsonrpcMessage
json.Unmarshal(buf[:n], &req)
// Send session/update notification
notif := jsonrpcMessage{
JSONRPC: "2.0",
Method: "session/update",
Params: json.RawMessage(`{"kind":"message","stopReason":""}`),
}
nd, _ := json.Marshal(notif)
serverW.Write(append(nd, '\n'))
// Send prompt response
resp := jsonrpcMessage{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`{"stopReason":"endTurn"}`),
}
rd, _ := json.Marshal(resp)
serverW.Write(append(rd, '\n'))
}()
var received []SessionUpdate
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_, err := proc.Prompt(ctx, []ContentBlock{{Type: "text", Text: "hello"}}, func(u SessionUpdate) {
received = append(received, u)
})
if err != nil {
t.Fatalf("Prompt error: %v", err)
}
// Drain notification channel
time.Sleep(20 * time.Millisecond)
// The update callback + notify handler may both fire — just check no panic occurred
}
func TestACPProcess_Prompt_Error(t *testing.T) {
proc, serverW, serverR := buildACPProcess(nil, nil)
proc.sessionID = "sess-3"
defer serverW.Close()
defer serverR.Close()
done := replyError(serverR, serverW, -32000, "prompt failed")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_, err := proc.Prompt(ctx, []ContentBlock{{Type: "text", Text: "hi"}}, nil)
if err == nil {
t.Fatal("expected error from Prompt")
}
if !strings.Contains(err.Error(), "acp session/prompt") {
t.Errorf("expected 'acp session/prompt' prefix, got %q", err.Error())
}
<-done
}
func TestACPProcess_Prompt_SetsInUse(t *testing.T) {
// Verify inUse counter goes up during Prompt and back to 0 after
proc, serverW, serverR := buildACPProcess(nil, nil)
proc.sessionID = "sess-4"
defer serverW.Close()
defer serverR.Close()
started := make(chan struct{})
go func() {
// Read and wait a moment before responding
buf := make([]byte, 32*1024)
n, _ := serverR.Read(buf)
var req jsonrpcMessage
json.Unmarshal(buf[:n], &req)
close(started)
time.Sleep(20 * time.Millisecond)
resp := jsonrpcMessage{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`{"stopReason":"endTurn"}`),
}
data, _ := json.Marshal(resp)
serverW.Write(append(data, '\n'))
}()
done := make(chan struct{})
go func() {
defer close(done)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
proc.Prompt(ctx, []ContentBlock{{Type: "text", Text: "x"}}, nil)
}()
<-started
if proc.inUse.Load() != 1 {
t.Errorf("expected inUse=1 during Prompt, got %d", proc.inUse.Load())
}
<-done
if proc.inUse.Load() != 0 {
t.Errorf("expected inUse=0 after Prompt, got %d", proc.inUse.Load())
}
}
// --- Cancel tests ---
func TestACPProcess_Cancel_SendsNotification(t *testing.T) {
proc, serverW, serverR := buildACPProcess(nil, nil)
proc.sessionID = "sess-cancel"
defer serverW.Close()
received := make(chan string, 1)
go func() {
buf := make([]byte, 4096)
n, _ := serverR.Read(buf)
var msg jsonrpcMessage
json.Unmarshal(buf[:n], &msg)
received <- msg.Method
}()
err := proc.Cancel()
if err != nil {
t.Fatalf("Cancel error: %v", err)
}
select {
case method := <-received:
if method != "session/cancel" {
t.Errorf("expected 'session/cancel', got %q", method)
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for cancel notification")
}
}
// --- dispatchUpdate / setUpdateFn ---
func TestACPProcess_DispatchUpdate_NilFn(t *testing.T) {
proc := &ACPProcess{}
// Should not panic with nil updateFn
proc.dispatchUpdate(SessionUpdate{Kind: "message"})
}
func TestACPProcess_DispatchUpdate_CallsFn(t *testing.T) {
proc := &ACPProcess{}
called := false
proc.setUpdateFn(func(u SessionUpdate) {
called = true
})
proc.dispatchUpdate(SessionUpdate{Kind: "message"})
if !called {
t.Error("expected updateFn to be called")
}
}
func TestACPProcess_SetUpdateFn_Nil(t *testing.T) {
proc := &ACPProcess{}
proc.setUpdateFn(func(u SessionUpdate) {})
proc.setUpdateFn(nil)
// Should not panic
proc.dispatchUpdate(SessionUpdate{Kind: "x"})
}
+370
View File
@@ -0,0 +1,370 @@
package acp
import (
"context"
"regexp"
"strings"
"sync"
"testing"
"time"
)
// --- cappedBuffer tests ---
func TestCappedBuffer_BasicWrite(t *testing.T) {
cb := &cappedBuffer{max: 20}
n, err := cb.Write([]byte("hello"))
if err != nil {
t.Fatalf("Write error: %v", err)
}
if n != 5 {
t.Errorf("expected n=5, got %d", n)
}
if cb.String() != "hello" {
t.Errorf("expected 'hello', got %q", cb.String())
}
}
func TestCappedBuffer_ExactMax(t *testing.T) {
cb := &cappedBuffer{max: 5}
cb.Write([]byte("hello")) // exactly 5
if cb.String() != "hello" {
t.Errorf("expected 'hello', got %q", cb.String())
}
}
func TestCappedBuffer_SmallOverflow(t *testing.T) {
// buf has 4 bytes, new 3-byte write causes overflow=2
// overflow < buf.Len() → truncate from front: keep buf[2:] + new data
cb := &cappedBuffer{max: 5}
cb.Write([]byte("abcd")) // buf="abcd" (4 bytes)
cb.Write([]byte("xyz")) // overflow = 4+3-5 = 2 < 4 → keep buf[2:]="cd", append "xyz"
got := cb.String()
if len(got) > 5 {
t.Errorf("expected len ≤ 5, got %d: %q", len(got), got)
}
// Tail should end with the new data
if !strings.HasSuffix(got, "xyz") {
t.Errorf("expected suffix 'xyz', got %q", got)
}
}
func TestCappedBuffer_LargeOverflow(t *testing.T) {
// Write that is larger than max alone
cb := &cappedBuffer{max: 5}
cb.Write([]byte("hello world 123456789")) // 21 bytes, max 5
got := cb.String()
if len(got) > 5 {
t.Errorf("expected len ≤ 5, got %d: %q", len(got), got)
}
// Should contain the last 5 bytes of "hello world 123456789" = "56789"
if got != "56789" {
t.Errorf("expected '56789', got %q", got)
}
}
func TestCappedBuffer_OverflowExceedsExistingBuf(t *testing.T) {
// overflow >= buf.Len() branch: reset buf, truncate p to last max bytes
cb := &cappedBuffer{max: 3}
cb.Write([]byte("ab")) // buf="ab" (2)
cb.Write([]byte("xyz")) // overflow = 2+3-3 = 2 >= buf.Len(2) → reset buf, write "xyz"
got := cb.String()
if got != "xyz" {
t.Errorf("expected 'xyz', got %q", got)
}
}
func TestCappedBuffer_MultipleSmallWrites(t *testing.T) {
cb := &cappedBuffer{max: 10}
for i := 0; i < 5; i++ {
cb.Write([]byte("ab"))
}
got := cb.String()
if len(got) > 10 {
t.Errorf("expected len ≤ 10, got %d", len(got))
}
}
func TestCappedBuffer_ZeroMax(t *testing.T) {
cb := &cappedBuffer{max: 0}
// cappedBuffer with max=0 truncates all data: p becomes p[len(p)-0:] = []
// bytes.Buffer.Write([]) returns n=0
_, err := cb.Write([]byte("data"))
if err != nil {
t.Fatalf("Write error: %v", err)
}
if cb.String() != "" {
t.Errorf("expected empty buffer with max=0, got %q", cb.String())
}
}
func TestCappedBuffer_ConcurrentWrites(t *testing.T) {
cb := &cappedBuffer{max: 100}
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
cb.Write([]byte("data"))
}()
}
wg.Wait()
s := cb.String()
if len(s) > 100 {
t.Errorf("expected len ≤ 100 after concurrent writes, got %d", len(s))
}
}
// --- allowedTerminalBinaries ---
func TestAllowedTerminalBinaries_Contains(t *testing.T) {
allowed := []string{
"sh", "bash", "zsh", "node", "python3", "go", "git",
"ls", "cat", "grep", "rg", "find", "curl", "docker",
"npm", "pip", "jq",
}
for _, bin := range allowed {
if !allowedTerminalBinaries[bin] {
t.Errorf("expected %q to be in allowed binaries", bin)
}
}
}
func TestAllowedTerminalBinaries_NotContains(t *testing.T) {
denied := []string{"rm", "dd", "mkfs", "fsck", "shutdown", "reboot", "nc", "netcat"}
for _, bin := range denied {
if allowedTerminalBinaries[bin] {
t.Errorf("expected %q to NOT be in allowed binaries", bin)
}
}
}
// --- terminalOutput / releaseTerminal / killTerminal via ToolBridge ---
func makeRunningTerminal(t *testing.T, id string) *Terminal {
t.Helper()
term := &Terminal{
id: id,
output: &cappedBuffer{max: 1024},
exited: make(chan struct{}),
cancel: func() {},
}
return term
}
func makeExitedTerminal(t *testing.T, id string, code int) *Terminal {
t.Helper()
term := &Terminal{
id: id,
output: &cappedBuffer{max: 1024},
exited: make(chan struct{}),
exitCode: code,
cancel: func() {},
}
close(term.exited) // pre-exited
return term
}
func TestTerminalOutput_Running(t *testing.T) {
tb, _ := newTestBridge(t)
term := makeRunningTerminal(t, "t1")
term.output.Write([]byte("some output"))
tb.terminals.Store("t1", term)
resp, err := tb.terminalOutput(TerminalOutputRequest{TerminalID: "t1"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Output != "some output" {
t.Errorf("expected 'some output', got %q", resp.Output)
}
if resp.ExitStatus != nil {
t.Error("expected nil ExitStatus for running terminal")
}
}
func TestTerminalOutput_Exited(t *testing.T) {
tb, _ := newTestBridge(t)
term := makeExitedTerminal(t, "t2", 42)
term.output.Write([]byte("done"))
tb.terminals.Store("t2", term)
resp, err := tb.terminalOutput(TerminalOutputRequest{TerminalID: "t2"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.ExitStatus == nil {
t.Fatal("expected non-nil ExitStatus for exited terminal")
}
if *resp.ExitStatus != 42 {
t.Errorf("expected exit code 42, got %d", *resp.ExitStatus)
}
}
func TestTerminalOutput_NotFound(t *testing.T) {
tb, _ := newTestBridge(t)
_, err := tb.terminalOutput(TerminalOutputRequest{TerminalID: "missing"})
if err == nil {
t.Error("expected error for missing terminal")
}
}
func TestReleaseTerminal_ExistingCancels(t *testing.T) {
tb, _ := newTestBridge(t)
cancelled := false
var mu sync.Mutex
term := makeRunningTerminal(t, "t3")
term.cancel = func() {
mu.Lock()
cancelled = true
mu.Unlock()
}
tb.terminals.Store("t3", term)
resp, err := tb.releaseTerminal(ReleaseTerminalRequest{TerminalID: "t3"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp == nil {
t.Fatal("expected non-nil response")
}
mu.Lock()
wasCancelled := cancelled
mu.Unlock()
if !wasCancelled {
t.Error("expected cancel to be called on release")
}
// Terminal should be removed
if _, ok := tb.terminals.Load("t3"); ok {
t.Error("expected terminal to be removed after release")
}
}
func TestReleaseTerminal_NotFound(t *testing.T) {
tb, _ := newTestBridge(t)
resp, err := tb.releaseTerminal(ReleaseTerminalRequest{TerminalID: "none"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp == nil {
t.Fatal("expected non-nil response even for missing terminal")
}
}
func TestKillTerminal_Cancels(t *testing.T) {
tb, _ := newTestBridge(t)
killed := false
var mu sync.Mutex
term := makeRunningTerminal(t, "tk1")
term.cancel = func() {
mu.Lock()
killed = true
mu.Unlock()
}
tb.terminals.Store("tk1", term)
resp, err := tb.killTerminal(KillTerminalRequest{TerminalID: "tk1"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp == nil {
t.Fatal("expected non-nil response")
}
mu.Lock()
wasKilled := killed
mu.Unlock()
if !wasKilled {
t.Error("expected cancel to be called on kill")
}
}
func TestKillTerminal_NotFound(t *testing.T) {
tb, _ := newTestBridge(t)
_, err := tb.killTerminal(KillTerminalRequest{TerminalID: "missing"})
if err == nil {
t.Error("expected error for missing terminal")
}
}
func TestWaitForExit_Exited(t *testing.T) {
tb, _ := newTestBridge(t)
term := makeExitedTerminal(t, "tw1", 0)
tb.terminals.Store("tw1", term)
resp, err := tb.waitForExit(context.Background(), WaitForTerminalExitRequest{TerminalID: "tw1"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.ExitStatus != 0 {
t.Errorf("expected exit 0, got %d", resp.ExitStatus)
}
}
func TestWaitForExit_ContextCancel(t *testing.T) {
tb, _ := newTestBridge(t)
term := makeRunningTerminal(t, "tw2") // never exits
tb.terminals.Store("tw2", term)
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
_, err := tb.waitForExit(ctx, WaitForTerminalExitRequest{TerminalID: "tw2"})
if err == nil {
t.Fatal("expected context cancellation error")
}
}
func TestWaitForExit_NotFound(t *testing.T) {
tb, _ := newTestBridge(t)
_, err := tb.waitForExit(context.Background(), WaitForTerminalExitRequest{TerminalID: "missing"})
if err == nil {
t.Error("expected error for missing terminal")
}
}
// --- createTerminal security: deny patterns and binary allowlist ---
func TestCreateTerminal_AllowlistDenied(t *testing.T) {
tb, _ := newTestBridge(t)
_, err := tb.createTerminal(CreateTerminalRequest{Command: "rm", Args: []string{"-rf", "/"}})
if err == nil {
t.Fatal("expected error for disallowed binary")
}
if !strings.Contains(err.Error(), "not in allowed binary list") {
t.Errorf("expected allowlist error, got %q", err.Error())
}
}
func TestCreateTerminal_DenyPattern(t *testing.T) {
pat := regexp.MustCompile(`rm -rf`)
tb, _ := newTestBridge(t, WithDenyPatterns([]*regexp.Regexp{pat}))
// "bash" is allowed but the combined command matches deny pattern
_, err := tb.createTerminal(CreateTerminalRequest{
Command: "bash",
Args: []string{"-c", "rm -rf /tmp/test"},
})
if err == nil {
t.Fatal("expected deny pattern to block command")
}
if !strings.Contains(err.Error(), "denied by safety policy") {
t.Errorf("expected safety policy error, got %q", err.Error())
}
}
func TestCreateTerminal_InvalidCwd(t *testing.T) {
tb, _ := newTestBridge(t)
// Path traversal in cwd should be denied
_, err := tb.createTerminal(CreateTerminalRequest{
Command: "ls",
Cwd: "../../etc",
})
if err == nil {
t.Fatal("expected error for cwd outside workspace")
}
}
+476
View File
@@ -0,0 +1,476 @@
package acp
import (
"context"
"encoding/json"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"testing"
"time"
)
// newTestBridge creates a ToolBridge with a real temp workspace.
// The returned dir is the symlink-resolved path so comparisons on macOS
// (where /var/folders is a symlink to /private/var/folders) don't fail.
func newTestBridge(t *testing.T, opts ...ToolBridgeOption) (*ToolBridge, string) {
t.Helper()
raw := t.TempDir()
// Resolve symlinks so workspace in ToolBridge matches what EvalSymlinks returns
dir, err := filepath.EvalSymlinks(raw)
if err != nil {
dir = raw
}
tb := NewToolBridge(dir, opts...)
return tb, dir
}
// --- NewToolBridge / options ---
func TestNewToolBridge_Defaults(t *testing.T) {
tb, _ := newTestBridge(t)
if tb.workspace == "" {
t.Error("workspace should not be empty")
}
if tb.permMode != "approve-all" {
t.Errorf("expected default permMode 'approve-all', got %q", tb.permMode)
}
if tb.maxOutputBytes != 10*1024*1024 {
t.Errorf("expected 10MB maxOutputBytes, got %d", tb.maxOutputBytes)
}
}
func TestWithPermMode_Empty_KeepsDefault(t *testing.T) {
tb, _ := newTestBridge(t, WithPermMode(""))
if tb.permMode != "approve-all" {
t.Errorf("empty permMode should keep default, got %q", tb.permMode)
}
}
func TestWithPermMode_Set(t *testing.T) {
tb, _ := newTestBridge(t, WithPermMode("deny-all"))
if tb.permMode != "deny-all" {
t.Errorf("expected deny-all, got %q", tb.permMode)
}
}
func TestWithDenyPatterns(t *testing.T) {
pat := regexp.MustCompile(`rm -rf`)
tb, _ := newTestBridge(t, WithDenyPatterns([]*regexp.Regexp{pat}))
if len(tb.denyPatterns) != 1 {
t.Errorf("expected 1 deny pattern, got %d", len(tb.denyPatterns))
}
}
// --- resolvePath boundary tests ---
func TestResolvePath_WithinWorkspace(t *testing.T) {
tb, dir := newTestBridge(t)
// Create a file so EvalSymlinks succeeds
f := filepath.Join(dir, "file.txt")
os.WriteFile(f, []byte("x"), 0644)
got, err := tb.resolvePath("file.txt")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.HasPrefix(got, dir) {
t.Errorf("expected path within %q, got %q", dir, got)
}
}
func TestResolvePath_AbsoluteInsideWorkspace(t *testing.T) {
tb, dir := newTestBridge(t)
f := filepath.Join(dir, "abs.txt")
os.WriteFile(f, []byte("x"), 0644)
got, err := tb.resolvePath(f)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Both got and f should resolve to the same real path
wantReal, _ := filepath.EvalSymlinks(f)
if wantReal == "" {
wantReal = f
}
if got != wantReal {
t.Errorf("expected %q, got %q", wantReal, got)
}
}
func TestResolvePath_Escape_PathTraversal(t *testing.T) {
tb, _ := newTestBridge(t)
_, err := tb.resolvePath("../../etc/passwd")
if err == nil {
t.Error("expected access denied for path traversal")
}
if !strings.Contains(err.Error(), "access denied") {
t.Errorf("expected 'access denied' in error, got %q", err.Error())
}
}
func TestResolvePath_AbsoluteOutsideWorkspace(t *testing.T) {
tb, _ := newTestBridge(t)
_, err := tb.resolvePath("/etc/passwd")
if err == nil {
t.Error("expected access denied for absolute path outside workspace")
}
}
func TestResolvePath_NonExistentFile_AllowedForWrites(t *testing.T) {
tb, dir := newTestBridge(t)
// File doesn't exist yet — resolvePath falls back to cleaned path (no EvalSymlinks).
// The result may use /private prefix on macOS; check it's within the resolved dir.
got, err := tb.resolvePath("newfile.txt")
if err != nil {
t.Fatalf("unexpected error for non-existent file: %v", err)
}
// Resolve both sides for comparison
gotReal, _ := filepath.EvalSymlinks(filepath.Dir(got))
if gotReal == "" {
gotReal = filepath.Dir(got)
}
if !strings.HasPrefix(gotReal, dir) && gotReal != dir {
t.Errorf("expected path within workspace %q, got dir %q", dir, gotReal)
}
}
// --- handlePermission ---
func TestHandlePermission_ApproveAll(t *testing.T) {
tb, _ := newTestBridge(t, WithPermMode("approve-all"))
resp, err := tb.handlePermission(RequestPermissionRequest{ToolName: "bash", Description: "run"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Outcome != "approved" {
t.Errorf("expected 'approved', got %q", resp.Outcome)
}
}
func TestHandlePermission_DenyAll(t *testing.T) {
tb, _ := newTestBridge(t, WithPermMode("deny-all"))
resp, err := tb.handlePermission(RequestPermissionRequest{ToolName: "any_tool"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Outcome != "denied" {
t.Errorf("expected 'denied', got %q", resp.Outcome)
}
}
func TestHandlePermission_ApproveReads_ReadTool(t *testing.T) {
tb, _ := newTestBridge(t, WithPermMode("approve-reads"))
cases := []string{"readFile", "glob_files", "search_code", "list_dir", "grep_search", "view_file"}
for _, name := range cases {
t.Run(name, func(t *testing.T) {
resp, err := tb.handlePermission(RequestPermissionRequest{ToolName: name})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Outcome != "approved" {
t.Errorf("expected approved for %q, got %q", name, resp.Outcome)
}
})
}
}
func TestHandlePermission_ApproveReads_WriteTool(t *testing.T) {
tb, _ := newTestBridge(t, WithPermMode("approve-reads"))
resp, err := tb.handlePermission(RequestPermissionRequest{ToolName: "write_file"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Outcome != "denied" {
t.Errorf("expected denied for write_file, got %q", resp.Outcome)
}
}
func TestHandlePermission_DefaultMode_Approves(t *testing.T) {
// permMode = "" defaults to "approve-all" behaviour (unknown → approve)
tb := &ToolBridge{permMode: "unknown-mode"}
resp, err := tb.handlePermission(RequestPermissionRequest{ToolName: "anything"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Outcome != "approved" {
t.Errorf("expected approved, got %q", resp.Outcome)
}
}
// --- readFile / writeFile ---
func TestReadFile_Success(t *testing.T) {
tb, dir := newTestBridge(t)
f := filepath.Join(dir, "read.txt")
os.WriteFile(f, []byte("hello content"), 0644)
resp, err := tb.readFile(ReadTextFileRequest{Path: "read.txt"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Content != "hello content" {
t.Errorf("expected 'hello content', got %q", resp.Content)
}
}
func TestReadFile_NotFound(t *testing.T) {
tb, _ := newTestBridge(t)
_, err := tb.readFile(ReadTextFileRequest{Path: "missing.txt"})
if err == nil {
t.Error("expected error for missing file")
}
}
func TestReadFile_PathEscape(t *testing.T) {
tb, _ := newTestBridge(t)
_, err := tb.readFile(ReadTextFileRequest{Path: "../../etc/passwd"})
if err == nil {
t.Error("expected path escape to be denied")
}
}
func TestWriteFile_Success(t *testing.T) {
tb, dir := newTestBridge(t)
resp, err := tb.writeFile(WriteTextFileRequest{Path: "out.txt", Content: "written"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp == nil {
t.Fatal("expected non-nil response")
}
got, _ := os.ReadFile(filepath.Join(dir, "out.txt"))
if string(got) != "written" {
t.Errorf("expected 'written', got %q", string(got))
}
}
func TestWriteFile_CreatesSubdirs(t *testing.T) {
tb, dir := newTestBridge(t)
_, err := tb.writeFile(WriteTextFileRequest{Path: "sub/dir/file.txt", Content: "nested"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got, _ := os.ReadFile(filepath.Join(dir, "sub", "dir", "file.txt"))
if string(got) != "nested" {
t.Errorf("expected 'nested', got %q", string(got))
}
}
func TestWriteFile_PathEscape(t *testing.T) {
tb, _ := newTestBridge(t)
_, err := tb.writeFile(WriteTextFileRequest{Path: "../../evil.txt", Content: "x"})
if err == nil {
t.Error("expected path escape to be denied")
}
}
// --- Handle dispatch ---
func TestHandle_FsReadTextFile(t *testing.T) {
tb, dir := newTestBridge(t)
f := filepath.Join(dir, "test.txt")
os.WriteFile(f, []byte("file data"), 0644)
params, _ := json.Marshal(ReadTextFileRequest{Path: "test.txt"})
result, err := tb.Handle(context.Background(), "fs/readTextFile", params)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
resp := result.(*ReadTextFileResponse)
if resp.Content != "file data" {
t.Errorf("expected 'file data', got %q", resp.Content)
}
}
func TestHandle_FsReadTextFile_DenyAll(t *testing.T) {
tb, _ := newTestBridge(t, WithPermMode("deny-all"))
params, _ := json.Marshal(ReadTextFileRequest{Path: "x.txt"})
_, err := tb.Handle(context.Background(), "fs/readTextFile", params)
if err == nil {
t.Error("expected deny-all to reject read")
}
}
func TestHandle_FsWriteTextFile(t *testing.T) {
tb, dir := newTestBridge(t)
params, _ := json.Marshal(WriteTextFileRequest{Path: "w.txt", Content: "hello"})
_, err := tb.Handle(context.Background(), "fs/writeTextFile", params)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got, _ := os.ReadFile(filepath.Join(dir, "w.txt"))
if string(got) != "hello" {
t.Errorf("expected 'hello', got %q", string(got))
}
}
func TestHandle_FsWriteTextFile_DenyAll(t *testing.T) {
tb, _ := newTestBridge(t, WithPermMode("deny-all"))
params, _ := json.Marshal(WriteTextFileRequest{Path: "x.txt", Content: "x"})
_, err := tb.Handle(context.Background(), "fs/writeTextFile", params)
if err == nil {
t.Error("expected deny-all to reject write")
}
}
func TestHandle_FsWriteTextFile_ApproveReads(t *testing.T) {
tb, _ := newTestBridge(t, WithPermMode("approve-reads"))
params, _ := json.Marshal(WriteTextFileRequest{Path: "x.txt", Content: "x"})
_, err := tb.Handle(context.Background(), "fs/writeTextFile", params)
if err == nil {
t.Error("expected approve-reads to reject write")
}
}
func TestHandle_PermissionRequest(t *testing.T) {
tb, _ := newTestBridge(t)
params, _ := json.Marshal(RequestPermissionRequest{ToolName: "bash"})
result, err := tb.Handle(context.Background(), "permission/request", params)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
resp := result.(*RequestPermissionResponse)
if resp.Outcome != "approved" {
t.Errorf("expected approved, got %q", resp.Outcome)
}
}
func TestHandle_UnknownMethod(t *testing.T) {
tb, _ := newTestBridge(t)
_, err := tb.Handle(context.Background(), "unknown/method", json.RawMessage(`{}`))
if err == nil {
t.Error("expected error for unknown method")
}
if !strings.Contains(err.Error(), "unknown method") {
t.Errorf("expected 'unknown method' in error, got %q", err.Error())
}
}
func TestHandle_MalformedParams(t *testing.T) {
tb, _ := newTestBridge(t)
methods := []string{
"fs/readTextFile", "fs/writeTextFile",
"terminal/output", "terminal/release",
"terminal/waitForExit", "terminal/kill",
"permission/request",
}
for _, m := range methods {
t.Run(m, func(t *testing.T) {
_, err := tb.Handle(context.Background(), m, json.RawMessage(`{invalid json`))
if err == nil {
t.Errorf("expected error for malformed params on %q", m)
}
})
}
}
func TestHandle_TerminalCreate_DenyAll(t *testing.T) {
tb, _ := newTestBridge(t, WithPermMode("deny-all"))
params, _ := json.Marshal(CreateTerminalRequest{Command: "ls"})
_, err := tb.Handle(context.Background(), "terminal/create", params)
if err == nil {
t.Error("expected deny-all to reject terminal/create")
}
}
func TestHandle_TerminalCreate_ApproveReads(t *testing.T) {
tb, _ := newTestBridge(t, WithPermMode("approve-reads"))
params, _ := json.Marshal(CreateTerminalRequest{Command: "ls"})
_, err := tb.Handle(context.Background(), "terminal/create", params)
if err == nil {
t.Error("expected approve-reads to reject terminal/create")
}
}
func TestHandle_TerminalOutput_NotFound(t *testing.T) {
tb, _ := newTestBridge(t)
params, _ := json.Marshal(TerminalOutputRequest{TerminalID: "nonexistent"})
_, err := tb.Handle(context.Background(), "terminal/output", params)
if err == nil {
t.Error("expected error for nonexistent terminal")
}
}
func TestHandle_TerminalRelease_NotFound(t *testing.T) {
tb, _ := newTestBridge(t)
params, _ := json.Marshal(ReleaseTerminalRequest{TerminalID: "nonexistent"})
result, err := tb.Handle(context.Background(), "terminal/release", params)
// releaseTerminal returns success even for nonexistent ID
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Error("expected non-nil result")
}
}
func TestHandle_TerminalKill_NotFound(t *testing.T) {
tb, _ := newTestBridge(t)
params, _ := json.Marshal(KillTerminalRequest{TerminalID: "nonexistent"})
_, err := tb.Handle(context.Background(), "terminal/kill", params)
if err == nil {
t.Error("expected error for nonexistent terminal kill")
}
}
func TestHandle_TerminalKill_DenyAll(t *testing.T) {
tb, _ := newTestBridge(t, WithPermMode("deny-all"))
params, _ := json.Marshal(KillTerminalRequest{TerminalID: "t1"})
_, err := tb.Handle(context.Background(), "terminal/kill", params)
if err == nil {
t.Error("expected deny-all to reject terminal/kill")
}
}
func TestHandle_TerminalWaitForExit_NotFound(t *testing.T) {
tb, _ := newTestBridge(t)
params, _ := json.Marshal(WaitForTerminalExitRequest{TerminalID: "nonexistent"})
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
_, err := tb.Handle(ctx, "terminal/waitForExit", params)
if err == nil {
t.Error("expected error for nonexistent terminal waitForExit")
}
}
// --- Close kills all active terminals ---
func TestToolBridge_Close_KillsTerminals(t *testing.T) {
tb, _ := newTestBridge(t)
// Manually insert a fake terminal with a cancel function
cancelled := false
var mu sync.Mutex
fakeTerm := &Terminal{
id: "t1",
exited: make(chan struct{}),
cancel: func() {
mu.Lock()
cancelled = true
mu.Unlock()
},
}
tb.terminals.Store("t1", fakeTerm)
err := tb.Close()
if err != nil {
t.Fatalf("Close error: %v", err)
}
mu.Lock()
wasCancelled := cancelled
mu.Unlock()
if !wasCancelled {
t.Error("expected terminal cancel to be called on Close")
}
}
func TestToolBridge_Close_EmptyTerminals(t *testing.T) {
tb, _ := newTestBridge(t)
err := tb.Close()
if err != nil {
t.Fatalf("Close on empty bridge failed: %v", err)
}
}
+262
View File
@@ -0,0 +1,262 @@
package acp
import (
"encoding/json"
"testing"
)
// roundTrip marshals v to JSON then unmarshals into a new value of same type.
// Returns the JSON bytes and the round-tripped value.
func roundTrip[T any](t *testing.T, v T) ([]byte, T) {
t.Helper()
data, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal error: %v", err)
}
var out T
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
return data, out
}
func TestInitializeRequest_RoundTrip(t *testing.T) {
req := InitializeRequest{
ClientInfo: ClientInfo{Name: "goclaw", Version: "1.0"},
Capabilities: ClientCaps{
Fs: &FsCaps{ReadTextFile: true, WriteTextFile: false},
Terminal: &TerminalCaps{Enabled: true},
},
}
_, got := roundTrip(t, req)
if got.ClientInfo.Name != "goclaw" {
t.Errorf("ClientInfo.Name: got %q", got.ClientInfo.Name)
}
if got.ClientInfo.Version != "1.0" {
t.Errorf("ClientInfo.Version: got %q", got.ClientInfo.Version)
}
if got.Capabilities.Fs == nil || !got.Capabilities.Fs.ReadTextFile {
t.Error("Capabilities.Fs.ReadTextFile should be true")
}
if got.Capabilities.Fs.WriteTextFile {
t.Error("Capabilities.Fs.WriteTextFile should be false")
}
if got.Capabilities.Terminal == nil || !got.Capabilities.Terminal.Enabled {
t.Error("Capabilities.Terminal.Enabled should be true")
}
}
func TestInitializeResponse_RoundTrip(t *testing.T) {
resp := InitializeResponse{
AgentInfo: AgentInfo{Name: "claude-code", Version: "2.0"},
Capabilities: AgentCaps{
LoadSession: true,
PromptCapabilities: &PromptCaps{
Audio: false,
Image: true,
EmbeddedContext: true,
},
SessionCapabilities: &SessionCaps{},
},
}
_, got := roundTrip(t, resp)
if got.AgentInfo.Name != "claude-code" {
t.Errorf("AgentInfo.Name: got %q", got.AgentInfo.Name)
}
if !got.Capabilities.LoadSession {
t.Error("LoadSession should be true")
}
if got.Capabilities.PromptCapabilities == nil {
t.Fatal("PromptCapabilities should not be nil")
}
if !got.Capabilities.PromptCapabilities.Image {
t.Error("PromptCapabilities.Image should be true")
}
}
func TestNewSessionResponse_RoundTrip(t *testing.T) {
resp := NewSessionResponse{SessionID: "sess-abc-123"}
_, got := roundTrip(t, resp)
if got.SessionID != "sess-abc-123" {
t.Errorf("SessionID: got %q", got.SessionID)
}
}
func TestPromptRequest_RoundTrip(t *testing.T) {
req := PromptRequest{
SessionID: "sess-1",
Content: []ContentBlock{
{Type: "text", Text: "hello"},
{Type: "image", Data: "base64data", MimeType: "image/png"},
},
}
_, got := roundTrip(t, req)
if got.SessionID != "sess-1" {
t.Errorf("SessionID: got %q", got.SessionID)
}
if len(got.Content) != 2 {
t.Fatalf("expected 2 content blocks, got %d", len(got.Content))
}
if got.Content[0].Type != "text" || got.Content[0].Text != "hello" {
t.Errorf("content[0]: got type=%q text=%q", got.Content[0].Type, got.Content[0].Text)
}
if got.Content[1].Type != "image" || got.Content[1].Data != "base64data" {
t.Errorf("content[1]: got type=%q data=%q", got.Content[1].Type, got.Content[1].Data)
}
}
func TestPromptResponse_RoundTrip(t *testing.T) {
resp := PromptResponse{StopReason: "endTurn"}
_, got := roundTrip(t, resp)
if got.StopReason != "endTurn" {
t.Errorf("StopReason: got %q", got.StopReason)
}
}
func TestCancelNotification_RoundTrip(t *testing.T) {
n := CancelNotification{SessionID: "sess-xyz"}
_, got := roundTrip(t, n)
if got.SessionID != "sess-xyz" {
t.Errorf("SessionID: got %q", got.SessionID)
}
}
func TestSessionUpdate_RoundTrip(t *testing.T) {
exitCode := 0
su := SessionUpdate{
Kind: "message",
StopReason: "endTurn",
Message: &MessageUpdate{
Role: "assistant",
Content: []ContentBlock{
{Type: "text", Text: "done"},
},
},
ToolCall: &ToolCallUpdate{
ID: "tc-1",
Name: "run_code",
Status: "completed",
Content: []ContentBlock{
{Type: "text", Text: "exit 0"},
},
},
}
_ = exitCode
_, got := roundTrip(t, su)
if got.Kind != "message" {
t.Errorf("Kind: got %q", got.Kind)
}
if got.StopReason != "endTurn" {
t.Errorf("StopReason: got %q", got.StopReason)
}
if got.Message == nil || got.Message.Role != "assistant" {
t.Error("Message.Role should be assistant")
}
if got.ToolCall == nil || got.ToolCall.ID != "tc-1" {
t.Error("ToolCall.ID should be tc-1")
}
}
func TestContentBlock_OmitEmpty(t *testing.T) {
// Text block: data and mimeType should be omitted
cb := ContentBlock{Type: "text", Text: "hello"}
data, err := json.Marshal(cb)
if err != nil {
t.Fatal(err)
}
var m map[string]any
json.Unmarshal(data, &m)
if _, hasData := m["data"]; hasData {
t.Error("data field should be omitted for text block")
}
if _, hasMime := m["mimeType"]; hasMime {
t.Error("mimeType field should be omitted for text block")
}
}
func TestTerminalTypes_RoundTrip(t *testing.T) {
create := CreateTerminalRequest{Command: "bash", Args: []string{"-c", "echo hi"}, Cwd: "/tmp"}
_, gotCreate := roundTrip(t, create)
if gotCreate.Command != "bash" || len(gotCreate.Args) != 2 || gotCreate.Cwd != "/tmp" {
t.Errorf("CreateTerminalRequest round-trip failed: %+v", gotCreate)
}
exitCode := 42
output := TerminalOutputResponse{Output: "hello", ExitStatus: &exitCode}
_, gotOutput := roundTrip(t, output)
if gotOutput.Output != "hello" {
t.Errorf("Output: got %q", gotOutput.Output)
}
if gotOutput.ExitStatus == nil || *gotOutput.ExitStatus != 42 {
t.Errorf("ExitStatus: expected 42, got %v", gotOutput.ExitStatus)
}
waitResp := WaitForTerminalExitResponse{ExitStatus: 1}
_, gotWait := roundTrip(t, waitResp)
if gotWait.ExitStatus != 1 {
t.Errorf("WaitForTerminalExitResponse.ExitStatus: got %d", gotWait.ExitStatus)
}
}
func TestPermissionTypes_RoundTrip(t *testing.T) {
req := RequestPermissionRequest{ToolName: "bash", Description: "run a script"}
_, got := roundTrip(t, req)
if got.ToolName != "bash" || got.Description != "run a script" {
t.Errorf("RequestPermissionRequest round-trip: %+v", got)
}
resp := RequestPermissionResponse{Outcome: "approved"}
_, gotResp := roundTrip(t, resp)
if gotResp.Outcome != "approved" {
t.Errorf("RequestPermissionResponse.Outcome: got %q", gotResp.Outcome)
}
}
func TestFsTypes_RoundTrip(t *testing.T) {
read := ReadTextFileRequest{Path: "/workspace/file.go"}
_, gotRead := roundTrip(t, read)
if gotRead.Path != "/workspace/file.go" {
t.Errorf("ReadTextFileRequest.Path: got %q", gotRead.Path)
}
readResp := ReadTextFileResponse{Content: "package main"}
_, gotReadResp := roundTrip(t, readResp)
if gotReadResp.Content != "package main" {
t.Errorf("ReadTextFileResponse.Content: got %q", gotReadResp.Content)
}
write := WriteTextFileRequest{Path: "/workspace/out.txt", Content: "data"}
_, gotWrite := roundTrip(t, write)
if gotWrite.Path != "/workspace/out.txt" || gotWrite.Content != "data" {
t.Errorf("WriteTextFileRequest round-trip: %+v", gotWrite)
}
}
func TestClientCaps_NilOmitted(t *testing.T) {
caps := ClientCaps{} // both Fs and Terminal nil
data, err := json.Marshal(caps)
if err != nil {
t.Fatal(err)
}
var m map[string]any
json.Unmarshal(data, &m)
if _, hasFs := m["fs"]; hasFs {
t.Error("fs should be omitted when nil")
}
if _, hasTerm := m["terminal"]; hasTerm {
t.Error("terminal should be omitted when nil")
}
}
func TestTerminalOutputResponse_NilExitStatus(t *testing.T) {
resp := TerminalOutputResponse{Output: "running...", ExitStatus: nil}
data, err := json.Marshal(resp)
if err != nil {
t.Fatal(err)
}
var m map[string]any
json.Unmarshal(data, &m)
if _, hasExit := m["exitStatus"]; hasExit {
t.Error("exitStatus should be omitted when nil")
}
}