Files
SYNITY 641d4f8756 feat(bitrix24): ship per-user OAuth re-authorization flow (#1417)
feat(bitrix24): ship per-user OAuth re-authorization flow (#1417)
2026-07-10 10:56:45 +07:00

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)
}
}