diff --git a/internal/channels/feishu/bot_parse_test.go b/internal/channels/feishu/bot_parse_test.go new file mode 100644 index 00000000..745f3ebc --- /dev/null +++ b/internal/channels/feishu/bot_parse_test.go @@ -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) + } +} diff --git a/internal/channels/feishu/bot_policy_test.go b/internal/channels/feishu/bot_policy_test.go new file mode 100644 index 00000000..d661c0a1 --- /dev/null +++ b/internal/channels/feishu/bot_policy_test.go @@ -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) + } +} diff --git a/internal/channels/feishu/channel_media_send_test.go b/internal/channels/feishu/channel_media_send_test.go new file mode 100644 index 00000000..59ea464a --- /dev/null +++ b/internal/channels/feishu/channel_media_send_test.go @@ -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) + } +} diff --git a/internal/channels/feishu/coverage_boost_test.go b/internal/channels/feishu/coverage_boost_test.go new file mode 100644 index 00000000..2168396d --- /dev/null +++ b/internal/channels/feishu/coverage_boost_test.go @@ -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") + } +} diff --git a/internal/channels/feishu/factory_test.go b/internal/channels/feishu/factory_test.go new file mode 100644 index 00000000..33768aa1 --- /dev/null +++ b/internal/channels/feishu/factory_test.go @@ -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") + } +} diff --git a/internal/channels/feishu/feishu_helpers_test.go b/internal/channels/feishu/feishu_helpers_test.go new file mode 100644 index 00000000..b4c2194e --- /dev/null +++ b/internal/channels/feishu/feishu_helpers_test.go @@ -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, "") { + t.Errorf("missing first mention in: %q", got) + } + if !strings.Contains(got, "") { + 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") + } +} diff --git a/internal/channels/feishu/feishu_lifecycle_test.go b/internal/channels/feishu/feishu_lifecycle_test.go new file mode 100644 index 00000000..aead8639 --- /dev/null +++ b/internal/channels/feishu/feishu_lifecycle_test.go @@ -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 +} diff --git a/internal/channels/feishu/larkclient_contact_test.go b/internal/channels/feishu/larkclient_contact_test.go new file mode 100644 index 00000000..0214a31d --- /dev/null +++ b/internal/channels/feishu/larkclient_contact_test.go @@ -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") + } +} diff --git a/internal/channels/feishu/larkclient_http_test.go b/internal/channels/feishu/larkclient_http_test.go new file mode 100644 index 00000000..ddc2273f --- /dev/null +++ b/internal/channels/feishu/larkclient_http_test.go @@ -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") + } +} diff --git a/internal/channels/feishu/larkclient_messaging_extra_test.go b/internal/channels/feishu/larkclient_messaging_extra_test.go new file mode 100644 index 00000000..2e2771a6 --- /dev/null +++ b/internal/channels/feishu/larkclient_messaging_extra_test.go @@ -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") + } +} diff --git a/internal/channels/feishu/larkevents_test.go b/internal/channels/feishu/larkevents_test.go new file mode 100644 index 00000000..52dc144c --- /dev/null +++ b/internal/channels/feishu/larkevents_test.go @@ -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. +} diff --git a/internal/channels/feishu/larkws_proto_test.go b/internal/channels/feishu/larkws_proto_test.go new file mode 100644 index 00000000..34ad0268 --- /dev/null +++ b/internal/channels/feishu/larkws_proto_test.go @@ -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"]) + } +} diff --git a/internal/channels/feishu/larkws_test.go b/internal/channels/feishu/larkws_test.go new file mode 100644 index 00000000..cc692959 --- /dev/null +++ b/internal/channels/feishu/larkws_test.go @@ -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) + } +} diff --git a/internal/channels/feishu/media_helpers_test.go b/internal/channels/feishu/media_helpers_test.go new file mode 100644 index 00000000..c774e8f7 --- /dev/null +++ b/internal/channels/feishu/media_helpers_test.go @@ -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) + } + }) + } +} diff --git a/internal/channels/feishu/media_inbound_test.go b/internal/channels/feishu/media_inbound_test.go new file mode 100644 index 00000000..b8e34425 --- /dev/null +++ b/internal/channels/feishu/media_inbound_test.go @@ -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) + } +} diff --git a/internal/providers/acp/helpers_test.go b/internal/providers/acp/helpers_test.go new file mode 100644 index 00000000..41d73da8 --- /dev/null +++ b/internal/providers/acp/helpers_test.go @@ -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) +} diff --git a/internal/providers/acp/jsonrpc_test.go b/internal/providers/acp/jsonrpc_test.go new file mode 100644 index 00000000..98fb3580 --- /dev/null +++ b/internal/providers/acp/jsonrpc_test.go @@ -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() +} diff --git a/internal/providers/acp/process_test.go b/internal/providers/acp/process_test.go new file mode 100644 index 00000000..c98a32bb --- /dev/null +++ b/internal/providers/acp/process_test.go @@ -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") + } +} diff --git a/internal/providers/acp/session_test.go b/internal/providers/acp/session_test.go new file mode 100644 index 00000000..8ba8f41f --- /dev/null +++ b/internal/providers/acp/session_test.go @@ -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"}) +} diff --git a/internal/providers/acp/terminal_test.go b/internal/providers/acp/terminal_test.go new file mode 100644 index 00000000..61378863 --- /dev/null +++ b/internal/providers/acp/terminal_test.go @@ -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") + } +} diff --git a/internal/providers/acp/tool_bridge_test.go b/internal/providers/acp/tool_bridge_test.go new file mode 100644 index 00000000..7f54f74b --- /dev/null +++ b/internal/providers/acp/tool_bridge_test.go @@ -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) + } +} diff --git a/internal/providers/acp/types_test.go b/internal/providers/acp/types_test.go new file mode 100644 index 00000000..7ec2543c --- /dev/null +++ b/internal/providers/acp/types_test.go @@ -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") + } +}