mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 20:14:50 +00:00
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>
123 lines
4.4 KiB
Go
123 lines
4.4 KiB
Go
package bitrix24
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/internal/systemmessages"
|
|
)
|
|
|
|
// fakeBitrixPairingStore implements store.PairingStore for pairing-reply tests.
|
|
type fakeBitrixPairingStore struct {
|
|
requested bool
|
|
requestedFor string
|
|
}
|
|
|
|
func (s *fakeBitrixPairingStore) RequestPairing(_ context.Context, senderID, _, _, _ string, _ map[string]string) (string, error) {
|
|
s.requested = true
|
|
s.requestedFor = senderID
|
|
return "code123", nil
|
|
}
|
|
func (s *fakeBitrixPairingStore) ApprovePairing(context.Context, string, string) (*store.PairedDeviceData, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *fakeBitrixPairingStore) DenyPairing(context.Context, string) error { return nil }
|
|
func (s *fakeBitrixPairingStore) RevokePairing(context.Context, string, string) error { return nil }
|
|
func (s *fakeBitrixPairingStore) SetPairingPermanent(context.Context, string, string, bool) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *fakeBitrixPairingStore) IsPaired(context.Context, string, string) (bool, error) {
|
|
return false, nil
|
|
}
|
|
func (s *fakeBitrixPairingStore) ListPending(context.Context) []store.PairingRequestData { return nil }
|
|
func (s *fakeBitrixPairingStore) ListPaired(context.Context) []store.PairedDeviceData { return nil }
|
|
func (s *fakeBitrixPairingStore) MigrateGroupChatID(context.Context, string, string, string) error {
|
|
return nil
|
|
}
|
|
|
|
// TestSendPairingReply_SendsCodeViaV2 verifies an unpaired sender gets a pairing
|
|
// code sent back through the v2 chat API (imbot.v2.Chat.Message.send), not the
|
|
// old v1 imbot.message.add, and not silence.
|
|
func TestSendPairingReply_SendsCodeViaV2(t *testing.T) {
|
|
var calls int32
|
|
var gotPath, gotMessage string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddInt32(&calls, 1)
|
|
gotPath = r.URL.Path
|
|
_ = r.ParseForm()
|
|
gotMessage = r.Form.Get("fields[message]")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"result": map[string]any{"id": 999}})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
bc := newChannelWithBoundPortal(t, srv)
|
|
bc.SetSystemMessages(systemmessages.NewResolver(nil))
|
|
ps := &fakeBitrixPairingStore{}
|
|
bc.SetPairingService(ps)
|
|
|
|
bc.sendPairingReply(context.Background(), "42", "chat100", "direct")
|
|
|
|
if atomic.LoadInt32(&calls) == 0 {
|
|
t.Fatal("expected a REST send for the pairing reply, got none (bot stayed silent)")
|
|
}
|
|
if !strings.HasSuffix(gotPath, "/rest/imbot.v2.Chat.Message.send.json") {
|
|
t.Errorf("pairing reply used wrong endpoint %q; want v2 imbot.v2.Chat.Message.send", gotPath)
|
|
}
|
|
if !ps.requested {
|
|
t.Error("expected RequestPairing to be called to generate a code")
|
|
}
|
|
if !strings.Contains(gotMessage, "code123") {
|
|
t.Errorf("pairing message should contain the code; got %q", gotMessage)
|
|
}
|
|
}
|
|
|
|
// TestSendPairingReply_NoService_NoOp: without a pairing service the call is a
|
|
// safe no-op (no send).
|
|
func TestSendPairingReply_NoService_NoOp(t *testing.T) {
|
|
var calls int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddInt32(&calls, 1)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"result": map[string]any{"id": 1}})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
bc := newChannelWithBoundPortal(t, srv) // no SetPairingService → PairingService() nil
|
|
bc.sendPairingReply(context.Background(), "42", "chat100", "direct")
|
|
|
|
if got := atomic.LoadInt32(&calls); got != 0 {
|
|
t.Errorf("expected no send without a pairing service, got %d", got)
|
|
}
|
|
}
|
|
|
|
// TestSendPairingReply_Debounced: a second call within the debounce window does
|
|
// not fire a second send.
|
|
func TestSendPairingReply_Debounced(t *testing.T) {
|
|
var calls int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddInt32(&calls, 1)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"result": map[string]any{"id": 1}})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
bc := newChannelWithBoundPortal(t, srv)
|
|
bc.SetSystemMessages(systemmessages.NewResolver(nil))
|
|
bc.SetPairingService(&fakeBitrixPairingStore{})
|
|
|
|
bc.sendPairingReply(context.Background(), "42", "chat100", "direct")
|
|
bc.sendPairingReply(context.Background(), "42", "chat100", "direct") // debounced
|
|
|
|
if got := atomic.LoadInt32(&calls); got != 1 {
|
|
t.Errorf("expected 1 send (second debounced), got %d", got)
|
|
}
|
|
}
|