mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 12:13:51 +00:00
374 lines
12 KiB
Go
374 lines
12 KiB
Go
package bitrix24
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// capturedImbotCall is one recorded imbot.v2.Chat.Message.send request, as
|
|
// observed by the fake imbot server in newOAuthInviteTestChannel.
|
|
type capturedImbotCall struct {
|
|
DialogID string
|
|
Message string
|
|
Keyboard []map[string]any // decoded from the JSON-string fields[keyboard] param
|
|
}
|
|
|
|
// fakeImbotServer records every imbot.v2.Chat.Message.send call it receives
|
|
// and always answers with a minimal success envelope.
|
|
type fakeImbotServer struct {
|
|
mu sync.Mutex
|
|
calls []capturedImbotCall
|
|
// failDialogIDs marks dialog ids whose call should return a Bitrix error
|
|
// instead of success — used to simulate the DM-send-fails-falls-back case.
|
|
failDialogIDs map[string]bool
|
|
}
|
|
|
|
func newFakeImbotServer() *fakeImbotServer {
|
|
return &fakeImbotServer{failDialogIDs: map[string]bool{}}
|
|
}
|
|
|
|
func (f *fakeImbotServer) handler() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
_ = r.ParseForm()
|
|
dialogID := r.Form.Get("dialogId")
|
|
|
|
call := capturedImbotCall{
|
|
DialogID: dialogID,
|
|
Message: r.Form.Get("fields[message]"),
|
|
}
|
|
if kb := r.Form.Get("fields[keyboard]"); kb != "" {
|
|
_ = json.Unmarshal([]byte(kb), &call.Keyboard)
|
|
}
|
|
f.mu.Lock()
|
|
f.calls = append(f.calls, call)
|
|
fail := f.failDialogIDs[dialogID]
|
|
f.mu.Unlock()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if fail {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_, _ = w.Write([]byte(`{"error":"ACCESS_DENIED","error_description":"cannot message this user"}`))
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`{"result":123}`))
|
|
}
|
|
}
|
|
|
|
func (f *fakeImbotServer) snapshot() []capturedImbotCall {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
out := make([]capturedImbotCall, len(f.calls))
|
|
copy(out, f.calls)
|
|
return out
|
|
}
|
|
|
|
// newOAuthInviteTestChannel builds a Channel where a webhook event with no
|
|
// auth block will fall all the way through provisionIfMissing into
|
|
// sendOAuthInvite, and captures whatever imbot.v2.Chat.Message.send calls
|
|
// that triggers via imbotSrv.
|
|
func newOAuthInviteTestChannel(t *testing.T, imbotSrv *httptest.Server) (*Channel, *bus.MessageBus) {
|
|
t.Helper()
|
|
resetWebhookRouterForTest()
|
|
t.Cleanup(resetWebhookRouterForTest)
|
|
|
|
mcpStore := newFakeMCPStore()
|
|
serverID := uuid.New()
|
|
mcpStore.serversByName["bitrix-mcp"] = &store.MCPServerData{
|
|
BaseModel: store.BaseModel{ID: serverID},
|
|
Name: "bitrix-mcp",
|
|
}
|
|
// MCP auto-onboard is never expected to be hit in these tests (no auth
|
|
// in the event → brand-new-user branch short-circuits before it).
|
|
mcpSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Error("auto-onboard must not be called when the event carries no auth block")
|
|
}))
|
|
t.Cleanup(mcpSrv.Close)
|
|
|
|
fs := newFakeStore()
|
|
mb := bus.New()
|
|
fn := FactoryWithPortalStoreAndMCP(fs, mcpStore, testOAuthEncKey)
|
|
cfgJSON := `{"portal":"p","bot_code":"c","bot_name":"n","bot_type":"B","dm_policy":"open","group_policy":"open","require_mention":false,"mcp_server_name":"bitrix-mcp","mcp_base_url":"` + mcpSrv.URL + `"}`
|
|
ch, err := fn("b1", nil, json.RawMessage(cfgJSON), mb, nil)
|
|
if err != nil {
|
|
t.Fatalf("factory: %v", err)
|
|
}
|
|
bc := ch.(*Channel)
|
|
bc.SetTenantID(store.GenNewID())
|
|
|
|
bc.startMu.Lock()
|
|
bc.botID = 42
|
|
bc.startMu.Unlock()
|
|
|
|
if err := bc.initMCPProvisioner(context.Background()); err != nil {
|
|
t.Fatalf("initMCPProvisioner: %v", err)
|
|
}
|
|
|
|
// Portal with BOTH a captured public_url (BuildUserAuthorizeURL) AND a
|
|
// pre-seeded, non-expired access token (so Client().Call reaches
|
|
// imbotSrv directly, no OAuth refresh round-trip needed).
|
|
portalFS := newFakeStore()
|
|
portal := newTestPortal(t, httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Error("no OAuth token call expected — access token is pre-seeded fresh")
|
|
})), portalFS, bc.TenantID(), "p", store.BitrixPortalState{
|
|
PublicURL: "https://goclaw.example.com",
|
|
AccessToken: "bot-access-tok",
|
|
RefreshToken: "bot-refresh-tok",
|
|
ExpiresAt: time.Now().Add(time.Hour),
|
|
})
|
|
|
|
bc.startMu.Lock()
|
|
bc.portal = portal
|
|
bc.client = NewClient("portal.bitrix24.com", &http.Client{
|
|
Transport: &rewriteRT{target: imbotSrv.URL, base: http.DefaultTransport},
|
|
})
|
|
bc.client.SetPortal(portal)
|
|
bc.startMu.Unlock()
|
|
|
|
return bc, mb
|
|
}
|
|
|
|
func TestHandleMessage_OAuthInvite_GroupOrigin_SendsDMAndHint(t *testing.T) {
|
|
imbot := newFakeImbotServer()
|
|
srv := httptest.NewServer(imbot.handler())
|
|
defer srv.Close()
|
|
|
|
bc, mb := newOAuthInviteTestChannel(t, srv)
|
|
|
|
bc.DispatchEvent(context.Background(), &Event{
|
|
Type: EventMessageAdd,
|
|
Params: EventParams{
|
|
FromUserID: "1058",
|
|
DialogID: "chat777",
|
|
ChatID: "777",
|
|
MessageID: "m-1",
|
|
MessageType: "chat", // group
|
|
Message: "hello bot",
|
|
},
|
|
})
|
|
|
|
if _, ok := drainOne(mb, 200*time.Millisecond); ok {
|
|
t.Error("must not publish to agent bus while user has no MCP access")
|
|
}
|
|
|
|
calls := imbot.snapshot()
|
|
if len(calls) != 2 {
|
|
t.Fatalf("expected 2 imbot calls (DM + group hint), got %d: %+v", len(calls), calls)
|
|
}
|
|
|
|
dm := calls[0]
|
|
if dm.DialogID != "1058" {
|
|
t.Errorf("DM dialogId = %q, want the plain user id 1058", dm.DialogID)
|
|
}
|
|
if len(dm.Keyboard) != 1 {
|
|
t.Fatalf("DM keyboard should have exactly 1 button, got %d", len(dm.Keyboard))
|
|
}
|
|
link, _ := dm.Keyboard[0]["LINK"].(string)
|
|
if !strings.Contains(link, "/oauth/authorize/") {
|
|
t.Errorf("keyboard button LINK = %q, want an authorize URL", link)
|
|
}
|
|
if strings.Contains(dm.Message, link) {
|
|
t.Error("DM message text must NOT contain the raw URL (must live only in the keyboard button)")
|
|
}
|
|
|
|
hint := calls[1]
|
|
if hint.DialogID != "chat777" {
|
|
t.Errorf("group hint dialogId = %q, want original chatID chat777", hint.DialogID)
|
|
}
|
|
if strings.Contains(hint.Message, "http") {
|
|
t.Error("group hint must not leak the authorize URL")
|
|
}
|
|
}
|
|
|
|
func TestHandleMessage_OAuthInvite_DMOrigin_NoHint(t *testing.T) {
|
|
imbot := newFakeImbotServer()
|
|
srv := httptest.NewServer(imbot.handler())
|
|
defer srv.Close()
|
|
|
|
bc, _ := newOAuthInviteTestChannel(t, srv)
|
|
|
|
bc.DispatchEvent(context.Background(), &Event{
|
|
Type: EventMessageAdd,
|
|
Params: EventParams{
|
|
FromUserID: "1058",
|
|
DialogID: "1058", // DM: dialog IS the user id
|
|
MessageID: "m-1",
|
|
MessageType: "private",
|
|
Message: "hello bot",
|
|
},
|
|
})
|
|
|
|
calls := imbot.snapshot()
|
|
if len(calls) != 1 {
|
|
t.Fatalf("expected exactly 1 imbot call (DM only, no hint), got %d: %+v", len(calls), calls)
|
|
}
|
|
if calls[0].DialogID != "1058" {
|
|
t.Errorf("dialogId = %q, want 1058", calls[0].DialogID)
|
|
}
|
|
}
|
|
|
|
func TestHandleMessage_OAuthInvite_DMSendFails_FallsBackToChat(t *testing.T) {
|
|
imbot := newFakeImbotServer()
|
|
imbot.failDialogIDs["1058"] = true // DM to the user fails
|
|
srv := httptest.NewServer(imbot.handler())
|
|
defer srv.Close()
|
|
|
|
bc, _ := newOAuthInviteTestChannel(t, srv)
|
|
|
|
bc.DispatchEvent(context.Background(), &Event{
|
|
Type: EventMessageAdd,
|
|
Params: EventParams{
|
|
FromUserID: "1058",
|
|
DialogID: "chat777",
|
|
ChatID: "777",
|
|
MessageID: "m-1",
|
|
MessageType: "chat",
|
|
Message: "hello bot",
|
|
},
|
|
})
|
|
|
|
calls := imbot.snapshot()
|
|
// Expect: failed DM attempt (dialogId=1058) + fallback into chat777
|
|
// carrying the SAME message+keyboard (not the group-hint text).
|
|
if len(calls) != 2 {
|
|
t.Fatalf("expected 2 calls (failed DM + fallback), got %d: %+v", len(calls), calls)
|
|
}
|
|
if calls[0].DialogID != "1058" {
|
|
t.Errorf("first call dialogId = %q, want 1058 (the attempted DM)", calls[0].DialogID)
|
|
}
|
|
fallback := calls[1]
|
|
if fallback.DialogID != "chat777" {
|
|
t.Errorf("fallback dialogId = %q, want chat777", fallback.DialogID)
|
|
}
|
|
if len(fallback.Keyboard) != 1 {
|
|
t.Fatalf("fallback must carry the SAME keyboard button, got %d buttons", len(fallback.Keyboard))
|
|
}
|
|
if fallback.Message != oauthInviteMessage {
|
|
t.Errorf("fallback message = %q, want the full invite hint (not the group-only hint text)", fallback.Message)
|
|
}
|
|
}
|
|
|
|
// TestHandleMessage_OAuthInvite_TotalFailure_ReleasesDebounce covers the
|
|
// double-failure case (DM AND the chatID fallback both error) — the user got
|
|
// NOTHING delivered, so the debounce slot taken for this attempt must be
|
|
// released immediately rather than blocking a retry for the full 5-minute TTL.
|
|
func TestHandleMessage_OAuthInvite_TotalFailure_ReleasesDebounce(t *testing.T) {
|
|
imbot := newFakeImbotServer()
|
|
imbot.failDialogIDs["1058"] = true // DM fails
|
|
imbot.failDialogIDs["chat777"] = true // fallback also fails
|
|
srv := httptest.NewServer(imbot.handler())
|
|
defer srv.Close()
|
|
|
|
bc, _ := newOAuthInviteTestChannel(t, srv)
|
|
|
|
bc.DispatchEvent(context.Background(), &Event{
|
|
Type: EventMessageAdd,
|
|
Params: EventParams{
|
|
FromUserID: "1058",
|
|
DialogID: "chat777",
|
|
ChatID: "777",
|
|
MessageID: "m-1",
|
|
MessageType: "chat",
|
|
Message: "hello bot",
|
|
},
|
|
})
|
|
|
|
calls := imbot.snapshot()
|
|
if len(calls) != 2 {
|
|
t.Fatalf("expected 2 attempted calls (DM + fallback, both failing), got %d: %+v", len(calls), calls)
|
|
}
|
|
|
|
// The debounce slot must have been released — a second trigger right
|
|
// after must be allowed to attempt delivery again immediately.
|
|
if !bc.tryAcquireOAuthInviteNotify("1058") {
|
|
t.Fatal("debounce slot should have been released after total delivery failure, but a retry was blocked")
|
|
}
|
|
}
|
|
|
|
// TestOAuthInviteDebounce_IndependentOfNotifyDebounce exercises
|
|
// tryAcquireOAuthInviteNotify directly (unit-level, not through the full
|
|
// DispatchEvent pipeline — the outer 60s auto-onboard debounce in
|
|
// provisionIfMissing would otherwise swallow a second same-second webhook
|
|
// retry before ever reaching this code, making the two debounces
|
|
// impossible to tell apart end-to-end). Verifies: (1) a second acquire for
|
|
// the same user within the TTL is rejected, (2) marking notifyDebounce
|
|
// (the OTHER notice type's map) does not affect oauthInviteDebounce at all.
|
|
func TestOAuthInviteDebounce_IndependentOfNotifyDebounce(t *testing.T) {
|
|
imbot := newFakeImbotServer()
|
|
srv := httptest.NewServer(imbot.handler())
|
|
defer srv.Close()
|
|
bc, _ := newOAuthInviteTestChannel(t, srv)
|
|
|
|
if !bc.tryAcquireOAuthInviteNotify("1058") {
|
|
t.Fatal("first acquire should succeed")
|
|
}
|
|
if bc.tryAcquireOAuthInviteNotify("1058") {
|
|
t.Fatal("second acquire within TTL should be debounced")
|
|
}
|
|
|
|
// A DIFFERENT user must not be affected by the first user's debounce.
|
|
if !bc.tryAcquireOAuthInviteNotify("999") {
|
|
t.Fatal("a different user must not be debounced by user 1058's entry")
|
|
}
|
|
|
|
// Marking the OTHER notice type's debounce map must not influence this one.
|
|
bc.notifyMu.Lock()
|
|
if bc.notifyDebounce == nil {
|
|
bc.notifyDebounce = make(map[string]time.Time)
|
|
}
|
|
bc.notifyDebounce["777"] = time.Now()
|
|
bc.notifyMu.Unlock()
|
|
if !bc.tryAcquireOAuthInviteNotify("777") {
|
|
t.Fatal("oauthInviteDebounce must be independent of notifyDebounce — user 777 was only debounced in the OTHER map")
|
|
}
|
|
}
|
|
|
|
// TestOAuthInviteDebounce_SweepsExpiredEntries verifies tryAcquireOAuthInviteNotify
|
|
// evicts stale entries instead of letting the map grow unbounded for the life
|
|
// of the process (code-review finding: the map must not repeat the never-evicted
|
|
// pattern of mcpDebounce/notifyDebounce).
|
|
func TestOAuthInviteDebounce_SweepsExpiredEntries(t *testing.T) {
|
|
imbot := newFakeImbotServer()
|
|
srv := httptest.NewServer(imbot.handler())
|
|
defer srv.Close()
|
|
bc, _ := newOAuthInviteTestChannel(t, srv)
|
|
|
|
// Seed an already-expired entry directly (can't sleep 5 real minutes in a test).
|
|
bc.oauthInviteMu.Lock()
|
|
bc.oauthInviteDebounce = map[string]time.Time{
|
|
"stale-user": time.Now().Add(-mcpUserNotifyDebounceTTL - time.Second),
|
|
}
|
|
bc.oauthInviteMu.Unlock()
|
|
|
|
// Any call sweeps expired entries before doing its own check-and-set.
|
|
if !bc.tryAcquireOAuthInviteNotify("fresh-user") {
|
|
t.Fatal("acquire for a new user should succeed")
|
|
}
|
|
|
|
bc.oauthInviteMu.Lock()
|
|
_, staleStillPresent := bc.oauthInviteDebounce["stale-user"]
|
|
_, freshPresent := bc.oauthInviteDebounce["fresh-user"]
|
|
mapSize := len(bc.oauthInviteDebounce)
|
|
bc.oauthInviteMu.Unlock()
|
|
|
|
if staleStillPresent {
|
|
t.Error("expired entry 'stale-user' should have been swept, but is still present")
|
|
}
|
|
if !freshPresent {
|
|
t.Error("'fresh-user' should be present after its own acquire")
|
|
}
|
|
if mapSize != 1 {
|
|
t.Errorf("map should contain exactly 1 entry (fresh-user) after sweep, got %d", mapSize)
|
|
}
|
|
}
|