Files
yatulandClaude Opus 5 5379cc1163 feat(pairing): let an operator make a device pairing permanent
Every approved pairing expires 30 days after approval (pairedDeviceTTL),
and nothing lets an operator change that: owners of a personal setup have
to re-pair their own Telegram account and browser every month. The schema
already treats a NULL paired_devices.expires_at as "never expires" (IsPaired
and the prune both check for it), only no code path ever writes NULL.

- store: SetPairingPermanent(senderID, channel, permanent) on PairingStore,
  PG and SQLite. permanent=true clears expires_at, false restarts the
  default TTL from now. An already expired pairing is not revived.
  PairedDeviceData gains expires_at (Unix ms, null = never), returned by
  ApprovePairing and ListPaired.
- gateway: device.pair.approve accepts `permanent`; new admin RPC
  device.pair.update {senderId, channel, permanent} for existing pairings,
  classified in permissions/policy.go next to the other pairing methods.
- mcp: pairing approve tool accepts `permanent`.
- web UI (Nodes): "Never expires" switch in the approve dialog, an
  Expires column, and a Make permanent / Set expiry action per device.
  ConfirmDialog takes optional children for the switch.

The default stays 30 days; permanence is an explicit operator choice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 12:12:23 +04:00

538 lines
16 KiB
Go

package feishu
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// --- 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")
}
}
func TestHandleMessageEvent_GroupContextFallsBackToSenderIDWhenNameLookupFails(t *testing.T) {
requireMention := true
msgBus := bus.New()
defer msgBus.Close()
ch, err := New(config.FeishuConfig{
AppID: "app",
AppSecret: "secret",
GroupPolicy: "open",
RequireMention: &requireMention,
}, msgBus, nil, nil, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
ch.SetName("feishu-test")
ch.botOpenID = "ou_target_bot"
srv := newSimpleMockServer(t, `{"code":50000,"msg":"name lookup failed"}`)
ch.client = NewLarkClient("app", "secret", srv.URL)
first := feishuGroupTextEvent(t, "om_context", "ou_duc", "oc_group", "Send AGENTS.md again", nil)
ch.handleMessageEvent(context.Background(), first)
assertNoFeishuInbound(t, msgBus)
entries := ch.GroupHistory().GetEntries("oc_group")
if len(entries) != 1 {
t.Fatalf("history entries = %d, want 1", len(entries))
}
if entries[0].Sender != "ou_duc" {
t.Fatalf("history sender = %q, want sender ID fallback", entries[0].Sender)
}
second := feishuGroupTextEvent(t, "om_mention", "ou_duc", "oc_group", "@_user_1", []EventMention{
feishuMention("@_user_1", "ou_target_bot", "Techlead"),
})
ch.handleMessageEvent(context.Background(), second)
msg := assertFeishuInbound(t, msgBus)
for _, want := range []string{
"[From: ou_duc]",
"[empty message]",
"Send AGENTS.md again",
} {
if !strings.Contains(msg.Content, want) {
t.Errorf("content missing %q:\n%s", want, msg.Content)
}
}
if got := ch.GroupHistory().GetEntries("oc_group"); len(got) != 0 {
t.Fatalf("history entries after publish = %d, want 0", len(got))
}
}
func TestHandleMessageEvent_GroupContextPreservesResolvedSenderName(t *testing.T) {
requireMention := true
msgBus := bus.New()
defer msgBus.Close()
ch, err := New(config.FeishuConfig{
AppID: "app",
AppSecret: "secret",
GroupPolicy: "open",
RequireMention: &requireMention,
}, msgBus, nil, nil, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
ch.SetName("feishu-test")
ch.botOpenID = "ou_target_bot"
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"user":{"name":"Duc Nguyen"}}}`)
ch.client = NewLarkClient("app", "secret", srv.URL)
event := feishuGroupTextEvent(t, "om_named", "ou_duc", "oc_group", "@_user_1 hello", []EventMention{
feishuMention("@_user_1", "ou_target_bot", "Techlead"),
})
ch.handleMessageEvent(context.Background(), event)
msg := assertFeishuInbound(t, msgBus)
if msg.Content != "[From: Duc Nguyen]\nhello" {
t.Fatalf("content = %q, want resolved sender name", msg.Content)
}
if msg.Metadata["sender_name"] != "Duc Nguyen" {
t.Fatalf("sender_name metadata = %q", msg.Metadata["sender_name"])
}
}
func TestHandleMessageEvent_P2PContextRemainsNameBased(t *testing.T) {
msgBus := bus.New()
defer msgBus.Close()
ch, err := New(config.FeishuConfig{AppID: "app", AppSecret: "secret", DMPolicy: "open"}, msgBus, nil, nil, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
ch.SetName("feishu-test")
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"user":{"name":"Duc Nguyen"}}}`)
ch.client = NewLarkClient("app", "secret", srv.URL)
event := feishuGroupTextEvent(t, "om_p2p", "ou_duc", "oc_p2p", "hello", nil)
event.Event.Message.ChatType = "p2p"
ch.handleMessageEvent(context.Background(), event)
msg := assertFeishuInbound(t, msgBus)
if msg.Content != "[From: Duc Nguyen]\nhello" {
t.Fatalf("content = %q, want unchanged p2p name context", msg.Content)
}
}
func TestHandleMessageEvent_GroupContextDoesNotEmitEmptySenderLabel(t *testing.T) {
requireMention := true
msgBus := bus.New()
defer msgBus.Close()
ch, err := New(config.FeishuConfig{
AppID: "app",
AppSecret: "secret",
GroupPolicy: "open",
RequireMention: &requireMention,
}, msgBus, nil, nil, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
ch.SetName("feishu-test")
ch.botOpenID = "ou_target_bot"
event := feishuGroupTextEvent(t, "om_no_sender", "", "oc_group", "@_user_1", []EventMention{
feishuMention("@_user_1", "ou_target_bot", "Techlead"),
})
ch.handleMessageEvent(context.Background(), event)
msg := assertFeishuInbound(t, msgBus)
if msg.Content != "[empty message]" {
t.Fatalf("content = %q, want no empty sender annotation", msg.Content)
}
}
type feishuPolicyPairingStore struct {
requests int
lastSenderID string
lastChannel string
lastChatID string
paired map[string]bool
}
func (s *feishuPolicyPairingStore) RequestPairing(_ context.Context, senderID, channel, chatID, _ string, _ map[string]string) (string, error) {
s.requests++
s.lastSenderID = senderID
s.lastChannel = channel
s.lastChatID = chatID
return "PAIR1234", nil
}
func (s *feishuPolicyPairingStore) SetPairingPermanent(context.Context, string, string, bool) error {
return nil
}
func (s *feishuPolicyPairingStore) IsPaired(_ context.Context, senderID, channel string) (bool, error) {
return s.paired[senderID+"|"+channel], nil
}
func (s *feishuPolicyPairingStore) ApprovePairing(context.Context, string, string) (*store.PairedDeviceData, error) {
return nil, errors.New("not implemented")
}
func (s *feishuPolicyPairingStore) DenyPairing(context.Context, string) error { return nil }
func (s *feishuPolicyPairingStore) RevokePairing(context.Context, string, string) error {
return nil
}
func (s *feishuPolicyPairingStore) ListPending(context.Context) []store.PairingRequestData {
return nil
}
func (s *feishuPolicyPairingStore) ListPaired(context.Context) []store.PairedDeviceData {
return nil
}
func (s *feishuPolicyPairingStore) MigrateGroupChatID(context.Context, string, string, string) error {
return nil
}
func TestHandleMessageEvent_GroupPairingRequiresTargetBotMention(t *testing.T) {
requireMention := true
ps := &feishuPolicyPairingStore{paired: map[string]bool{}}
msgBus := bus.New()
defer msgBus.Close()
ch, err := New(config.FeishuConfig{
AppID: "app",
AppSecret: "secret",
GroupPolicy: "pairing",
RequireMention: &requireMention,
}, msgBus, ps, nil, nil)
if err != nil {
t.Fatalf("New feishu channel: %v", err)
}
ch.SetName("feishu-test")
ch.botOpenID = "ou_target_bot"
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"message_id":"om_sent"}}`)
ch.client = NewLarkClient("app", "secret", srv.URL)
event := feishuGroupTextEvent(t, "om_non_target", "ou_alice", "oc_group", "@_user_1 help", []EventMention{
feishuMention("@_user_1", "ou_other_bot", "OtherBot"),
})
ch.handleMessageEvent(context.Background(), event)
if ps.requests != 0 {
t.Fatalf("pairing requests = %d, want 0 for non-target mention", ps.requests)
}
assertNoFeishuInbound(t, msgBus)
}
func TestHandleMessageEvent_GroupSkipsExplicitOtherMentionEvenWhenRequireMentionDisabled(t *testing.T) {
requireMention := false
ps := &feishuPolicyPairingStore{paired: map[string]bool{}}
msgBus := bus.New()
defer msgBus.Close()
ch, err := New(config.FeishuConfig{
AppID: "app",
AppSecret: "secret",
GroupPolicy: "pairing",
RequireMention: &requireMention,
}, msgBus, ps, nil, nil)
if err != nil {
t.Fatalf("New feishu channel: %v", err)
}
ch.SetName("lark-cppai-pm")
ch.botOpenID = "ou_0b6fac6e84cf8c773a0c2768147799e2"
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"message_id":"om_sent"}}`)
ch.client = NewLarkClient("app", "secret", srv.URL)
event := feishuGroupTextEvent(t, "om_other_bot_target", "ou_alice", "oc_group", "@_user_1 help", []EventMention{
feishuMention("@_user_1", "ou_3ef6f00429b40780fb7ffc4a972fd835", "itsddvnm"),
})
ch.handleMessageEvent(context.Background(), event)
if ps.requests != 0 {
t.Fatalf("pairing requests = %d, want 0 when explicit mention targets another bot", ps.requests)
}
assertNoFeishuInbound(t, msgBus)
}
func TestHandleMessageEvent_GroupPairingRequestsOnlyWhenTargetMentioned(t *testing.T) {
requireMention := true
ps := &feishuPolicyPairingStore{paired: map[string]bool{}}
msgBus := bus.New()
defer msgBus.Close()
ch, err := New(config.FeishuConfig{
AppID: "app",
AppSecret: "secret",
GroupPolicy: "pairing",
RequireMention: &requireMention,
}, msgBus, ps, nil, nil)
if err != nil {
t.Fatalf("New feishu channel: %v", err)
}
ch.SetName("feishu-test")
ch.botOpenID = "ou_target_bot"
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"message_id":"om_sent"}}`)
ch.client = NewLarkClient("app", "secret", srv.URL)
event := feishuGroupTextEvent(t, "om_target", "ou_alice", "oc_group", "@_user_1 help", []EventMention{
feishuMention("@_user_1", "ou_target_bot", "TargetBot"),
})
ch.handleMessageEvent(context.Background(), event)
if ps.requests != 1 {
t.Fatalf("pairing requests = %d, want 1", ps.requests)
}
if ps.lastSenderID != "group:oc_group" {
t.Fatalf("pairing senderID = %q, want group:oc_group", ps.lastSenderID)
}
if ps.lastChannel != "feishu-test" || ps.lastChatID != "oc_group" {
t.Fatalf("unexpected pairing request channel/chat: %s/%s", ps.lastChannel, ps.lastChatID)
}
assertNoFeishuInbound(t, msgBus)
}
func TestHandleMessageEvent_GroupPairingAllowsAlreadyPairedTargetMention(t *testing.T) {
requireMention := true
ps := &feishuPolicyPairingStore{
paired: map[string]bool{"group:oc_group|feishu-test": true},
}
msgBus := bus.New()
defer msgBus.Close()
ch, err := New(config.FeishuConfig{
AppID: "app",
AppSecret: "secret",
GroupPolicy: "pairing",
RequireMention: &requireMention,
}, msgBus, ps, nil, nil)
if err != nil {
t.Fatalf("New feishu channel: %v", err)
}
ch.SetName("feishu-test")
ch.botOpenID = "ou_target_bot"
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"name":"Alice"}}`)
ch.client = NewLarkClient("app", "secret", srv.URL)
event := feishuGroupTextEvent(t, "om_paired_target", "ou_alice", "oc_group", "@_user_1 help", []EventMention{
feishuMention("@_user_1", "ou_target_bot", "TargetBot"),
})
ch.handleMessageEvent(context.Background(), event)
if ps.requests != 0 {
t.Fatalf("pairing requests = %d, want 0 for already paired group", ps.requests)
}
msg := assertFeishuInbound(t, msgBus)
if msg.Channel != "feishu-test" || msg.ChatID != "oc_group" || msg.PeerKind != "group" {
t.Fatalf("unexpected inbound route: channel=%q chat=%q peer=%q", msg.Channel, msg.ChatID, msg.PeerKind)
}
}
func feishuGroupTextEvent(t *testing.T, messageID, senderID, chatID, text string, mentions []EventMention) *MessageEvent {
t.Helper()
content, err := json.Marshal(map[string]string{"text": text})
if err != nil {
t.Fatalf("marshal text content: %v", err)
}
ev := &MessageEvent{}
ev.Event.Message.MessageID = messageID
ev.Event.Message.ChatID = chatID
ev.Event.Message.ChatType = "group"
ev.Event.Message.MessageType = "text"
ev.Event.Message.Content = string(content)
ev.Event.Message.Mentions = mentions
ev.Event.Sender.SenderID.OpenID = senderID
return ev
}
func feishuMention(key, openID, name string) EventMention {
return EventMention{
Key: key,
ID: struct {
OpenID string `json:"open_id"`
UserID string `json:"user_id"`
UnionID string `json:"union_id"`
}{OpenID: openID},
Name: name,
}
}
func assertFeishuInbound(t *testing.T, msgBus *bus.MessageBus) bus.InboundMessage {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
msg, ok := msgBus.ConsumeInbound(ctx)
if !ok {
t.Fatal("expected inbound message")
}
return msg
}
func assertNoFeishuInbound(t *testing.T, msgBus *bus.MessageBus) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
if msg, ok := msgBus.ConsumeInbound(ctx); ok {
t.Fatalf("unexpected inbound message: %+v", msg)
}
}
// --- 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)
}
}