mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-17 07:09:53 +02:00
Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
365 lines
13 KiB
Go
365 lines
13 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
composio "github.com/multica-ai/multica/server/internal/integrations/composio"
|
|
"github.com/multica-ai/multica/server/internal/util"
|
|
sdk "github.com/multica-ai/multica/server/pkg/composio"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
const composioTestUserID = "22222222-2222-2222-2222-222222222222"
|
|
|
|
// --- local fakes (handler package can only see the exported interfaces) ---
|
|
|
|
type composioFakeSDK struct {
|
|
createLinkResp *sdk.CreateLinkResponse
|
|
revokeErr error
|
|
// listAuthErr, when set, makes ListAuthConfigs fail. This propagates through
|
|
// Service.authConfigMap → ListToolkits, exercising the handler's 502 path.
|
|
listAuthErr error
|
|
}
|
|
|
|
func (f *composioFakeSDK) CreateLink(_ context.Context, _ sdk.CreateLinkRequest) (*sdk.CreateLinkResponse, error) {
|
|
if f.createLinkResp != nil {
|
|
return f.createLinkResp, nil
|
|
}
|
|
return &sdk.CreateLinkResponse{RedirectURL: "https://composio.example/redirect"}, nil
|
|
}
|
|
|
|
// ListConnectedAccounts echoes the requested id as an account owned by the
|
|
// handler-test user under the notion auth config, so callback ownership
|
|
// verification passes on the happy path.
|
|
func (f *composioFakeSDK) ListConnectedAccounts(_ context.Context, req sdk.ListConnectedAccountsRequest) (*sdk.ListConnectedAccountsResponse, error) {
|
|
id := ""
|
|
if len(req.ConnectedAccountIDs) > 0 {
|
|
id = req.ConnectedAccountIDs[0]
|
|
}
|
|
return &sdk.ListConnectedAccountsResponse{Items: []sdk.ConnectedAccount{{
|
|
ID: id,
|
|
UserID: composioTestUserID,
|
|
AuthConfigID: "ac_notion",
|
|
}}}, nil
|
|
}
|
|
func (f *composioFakeSDK) RevokeConnection(_ context.Context, _ string) error { return f.revokeErr }
|
|
func (f *composioFakeSDK) DeleteConnectedAccount(_ context.Context, _ string) error { return nil }
|
|
|
|
// ListAuthConfigs reports a single enabled notion auth config so BeginConnect
|
|
// resolves notion → ac_notion and the callback's auth-config check matches.
|
|
func (f *composioFakeSDK) ListAuthConfigs(_ context.Context, _ sdk.ListAuthConfigsRequest) (*sdk.ListAuthConfigsResponse, error) {
|
|
if f.listAuthErr != nil {
|
|
return nil, f.listAuthErr
|
|
}
|
|
return &sdk.ListAuthConfigsResponse{Items: []sdk.AuthConfig{{
|
|
ID: "ac_notion",
|
|
Toolkit: sdk.Toolkit{Slug: "notion"},
|
|
Status: "ENABLED",
|
|
}}}, nil
|
|
}
|
|
|
|
func (f *composioFakeSDK) ListToolkits(_ context.Context, _ sdk.ListToolkitsRequest) (*sdk.ListToolkitsResponse, error) {
|
|
return &sdk.ListToolkitsResponse{Items: []sdk.Toolkit{
|
|
{Slug: "notion", Name: "Notion"},
|
|
{Slug: "github", Name: "GitHub"},
|
|
}}, nil
|
|
}
|
|
func (f *composioFakeSDK) CreateSession(_ context.Context, _ sdk.CreateSessionRequest) (*sdk.CreateSessionResponse, error) {
|
|
return &sdk.CreateSessionResponse{}, nil
|
|
}
|
|
func (f *composioFakeSDK) MCPAuthHeaders() map[string]string {
|
|
return map[string]string{"x-api-key": "k"}
|
|
}
|
|
|
|
type composioFakeStore struct {
|
|
rows []db.UserComposioConnection
|
|
nextID byte
|
|
}
|
|
|
|
func (s *composioFakeStore) UpsertUserComposioConnection(_ context.Context, arg db.UpsertUserComposioConnectionParams) (db.UserComposioConnection, error) {
|
|
for i := range s.rows {
|
|
if s.rows[i].UserID.Bytes == arg.UserID.Bytes && s.rows[i].ConnectedAccountID == arg.ConnectedAccountID {
|
|
s.rows[i].Status = "active"
|
|
return s.rows[i], nil
|
|
}
|
|
}
|
|
s.nextID++
|
|
var b [16]byte
|
|
b[15] = s.nextID
|
|
row := db.UserComposioConnection{
|
|
ID: pgtype.UUID{Bytes: b, Valid: true},
|
|
UserID: arg.UserID,
|
|
ToolkitSlug: arg.ToolkitSlug,
|
|
AuthConfigID: arg.AuthConfigID,
|
|
ConnectedAccountID: arg.ConnectedAccountID,
|
|
ComposioUserID: arg.ComposioUserID,
|
|
Status: "active",
|
|
ConnectedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
|
}
|
|
s.rows = append(s.rows, row)
|
|
return row, nil
|
|
}
|
|
func (s *composioFakeStore) ListActiveUserComposioConnections(_ context.Context, userID pgtype.UUID) ([]db.UserComposioConnection, error) {
|
|
out := []db.UserComposioConnection{}
|
|
for _, r := range s.rows {
|
|
if r.UserID.Bytes == userID.Bytes && r.Status == "active" {
|
|
out = append(out, r)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
func (s *composioFakeStore) GetUserComposioConnection(_ context.Context, arg db.GetUserComposioConnectionParams) (db.UserComposioConnection, error) {
|
|
for _, r := range s.rows {
|
|
if r.ID.Bytes == arg.ID.Bytes && r.UserID.Bytes == arg.UserID.Bytes {
|
|
return r, nil
|
|
}
|
|
}
|
|
return db.UserComposioConnection{}, pgx.ErrNoRows
|
|
}
|
|
func (s *composioFakeStore) MarkUserComposioConnectionRevoked(_ context.Context, arg db.MarkUserComposioConnectionRevokedParams) error {
|
|
for i := range s.rows {
|
|
if s.rows[i].ID.Bytes == arg.ID.Bytes && s.rows[i].UserID.Bytes == arg.UserID.Bytes {
|
|
s.rows[i].Status = "revoked"
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func newComposioTestHandler(t *testing.T, sdkFake composio.SDK, store composio.Store) *Handler {
|
|
t.Helper()
|
|
svc, err := composio.NewService(sdkFake, store, composio.Config{
|
|
StateSecret: []byte("handler-test-secret"),
|
|
CallbackBaseURL: "https://multica.ai",
|
|
FrontendBaseURL: "https://multica.ai",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewService: %v", err)
|
|
}
|
|
h := &Handler{Composio: svc}
|
|
withComposioMCPAppsFlag(t, h, true)
|
|
return h
|
|
}
|
|
|
|
func composioReq(method, target, body string) *http.Request {
|
|
var r *http.Request
|
|
if body != "" {
|
|
r = httptest.NewRequest(method, target, strings.NewReader(body))
|
|
} else {
|
|
r = httptest.NewRequest(method, target, nil)
|
|
}
|
|
r.Header.Set("X-User-ID", composioTestUserID)
|
|
return r
|
|
}
|
|
|
|
// --- tests ---
|
|
|
|
func TestComposio_ServiceUnavailableWhenNil(t *testing.T) {
|
|
h := &Handler{}
|
|
for _, hf := range []http.HandlerFunc{
|
|
h.ComposioConnectInit, h.ComposioCallback, h.ListComposioConnections, h.DeleteComposioConnection,
|
|
} {
|
|
w := httptest.NewRecorder()
|
|
hf(w, composioReq(http.MethodGet, "/", ""))
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Errorf("expected 503 when Composio nil, got %d", w.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestComposio_ServiceUnavailableWhenFlagDisabled(t *testing.T) {
|
|
h := newComposioTestHandler(t, &composioFakeSDK{}, &composioFakeStore{})
|
|
withComposioMCPAppsFlag(t, h, false)
|
|
|
|
w := httptest.NewRecorder()
|
|
h.ListComposioToolkits(w, composioReq(http.MethodGet, "/toolkits", ""))
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("expected 503 when feature flag disabled, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestComposio_ConnectInit(t *testing.T) {
|
|
h := newComposioTestHandler(t, &composioFakeSDK{}, &composioFakeStore{})
|
|
|
|
// success
|
|
w := httptest.NewRecorder()
|
|
h.ComposioConnectInit(w, composioReq(http.MethodPost, "/", `{"toolkit_slug":"notion"}`))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
var resp ComposioConnectInitResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if resp.RedirectURL == "" {
|
|
t.Error("expected redirect_url")
|
|
}
|
|
|
|
// unsupported toolkit → 400
|
|
w = httptest.NewRecorder()
|
|
h.ComposioConnectInit(w, composioReq(http.MethodPost, "/", `{"toolkit_slug":"github"}`))
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("unsupported toolkit: expected 400, got %d", w.Code)
|
|
}
|
|
|
|
// missing slug → 400
|
|
w = httptest.NewRecorder()
|
|
h.ComposioConnectInit(w, composioReq(http.MethodPost, "/", `{}`))
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("missing slug: expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestComposio_ListToolkits(t *testing.T) {
|
|
h := newComposioTestHandler(t, &composioFakeSDK{}, &composioFakeStore{})
|
|
|
|
w := httptest.NewRecorder()
|
|
h.ListComposioToolkits(w, composioReq(http.MethodGet, "/toolkits", ""))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
var toolkits []ComposioToolkitResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &toolkits); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
// Only notion has an enabled auth config; github is filtered out server-side
|
|
// (MUL-4009), so a single connectable toolkit comes back.
|
|
if len(toolkits) != 1 {
|
|
t.Fatalf("expected 1 toolkit, got %d (%s)", len(toolkits), w.Body.String())
|
|
}
|
|
if toolkits[0].Slug != "notion" || !toolkits[0].Connectable {
|
|
t.Errorf("toolkit = %+v, want connectable notion", toolkits[0])
|
|
}
|
|
for _, tk := range toolkits {
|
|
if tk.Slug == "github" {
|
|
t.Error("github has no auth config and must be filtered out")
|
|
}
|
|
if tk.Logo == "" {
|
|
t.Errorf("toolkit %q missing logo", tk.Slug)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestComposio_ListToolkits_ResolverErrorIs502 pins the key behavior of this
|
|
// PR (MUL-4009): when the service can't resolve which toolkits are connectable
|
|
// (auth-config lookup fails), ListComposioToolkits must return 502 rather than
|
|
// silently degrading to an empty catalog. A regression back to a soft empty
|
|
// list would render as a misleading "no apps configured" state.
|
|
func TestComposio_ListToolkits_ResolverErrorIs502(t *testing.T) {
|
|
h := newComposioTestHandler(t, &composioFakeSDK{listAuthErr: errors.New("auth_configs upstream blip")}, &composioFakeStore{})
|
|
|
|
w := httptest.NewRecorder()
|
|
h.ListComposioToolkits(w, composioReq(http.MethodGet, "/toolkits", ""))
|
|
if w.Code != http.StatusBadGateway {
|
|
t.Fatalf("expected 502 on resolver error, got %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestComposio_CallbackRedirects(t *testing.T) {
|
|
store := &composioFakeStore{}
|
|
|
|
// Mint a valid signed state by driving BeginConnect through a capturing
|
|
// SDK, then replay it through the real callback handler.
|
|
capturing := &composioCapturingSDK{}
|
|
h2 := newComposioTestHandler(t, capturing, store)
|
|
bw := httptest.NewRecorder()
|
|
h2.ComposioConnectInit(bw, composioReq(http.MethodPost, "/", `{"toolkit_slug":"notion"}`))
|
|
state := capturing.stateFromCallback()
|
|
if state == "" {
|
|
t.Fatal("could not capture signed state")
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
h2.ComposioCallback(w, composioReq(http.MethodGet, "/callback?state="+state+"&status=success&connected_account_id=ca_1", ""))
|
|
if w.Code != http.StatusFound {
|
|
t.Fatalf("expected 302, got %d", w.Code)
|
|
}
|
|
if loc := w.Header().Get("Location"); !strings.Contains(loc, "connected=notion") {
|
|
t.Errorf("success location = %q", loc)
|
|
}
|
|
|
|
// failure path: bad state → error redirect
|
|
w = httptest.NewRecorder()
|
|
h2.ComposioCallback(w, composioReq(http.MethodGet, "/callback?state=bad&status=success&connected_account_id=ca_1", ""))
|
|
if w.Code != http.StatusFound {
|
|
t.Fatalf("expected 302 on bad state, got %d", w.Code)
|
|
}
|
|
if loc := w.Header().Get("Location"); !strings.Contains(loc, "error=composio_connect_failed") {
|
|
t.Errorf("failure location = %q", loc)
|
|
}
|
|
}
|
|
|
|
func TestComposio_ListAndDelete(t *testing.T) {
|
|
store := &composioFakeStore{}
|
|
userUUID, _ := util.ParseUUID(composioTestUserID)
|
|
row, _ := store.UpsertUserComposioConnection(context.Background(), db.UpsertUserComposioConnectionParams{
|
|
UserID: userUUID,
|
|
ToolkitSlug: "notion",
|
|
AuthConfigID: "ac_notion",
|
|
ConnectedAccountID: "ca_list",
|
|
ComposioUserID: composioTestUserID,
|
|
})
|
|
h := newComposioTestHandler(t, &composioFakeSDK{}, store)
|
|
|
|
// list
|
|
w := httptest.NewRecorder()
|
|
h.ListComposioConnections(w, composioReq(http.MethodGet, "/connections", ""))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("list: expected 200, got %d", w.Code)
|
|
}
|
|
var conns []ComposioConnectionResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &conns); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(conns) != 1 || conns[0].ToolkitSlug != "notion" {
|
|
t.Fatalf("conns = %+v", conns)
|
|
}
|
|
|
|
// delete (owner) → 204, routed through chi so {id} resolves
|
|
r := chi.NewRouter()
|
|
r.Delete("/api/integrations/composio/connections/{id}", h.DeleteComposioConnection)
|
|
delReq := composioReq(http.MethodDelete, "/api/integrations/composio/connections/"+util.UUIDToString(row.ID), "")
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, delReq)
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("delete: expected 204, got %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
|
|
// delete unknown id → 404
|
|
missing := "33333333-3333-3333-3333-333333333333"
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, composioReq(http.MethodDelete, "/api/integrations/composio/connections/"+missing, ""))
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("delete missing: expected 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// composioCapturingSDK records the callback URL so a test can replay the signed
|
|
// state through the real callback handler.
|
|
type composioCapturingSDK struct {
|
|
composioFakeSDK
|
|
lastCallbackURL string
|
|
}
|
|
|
|
func (f *composioCapturingSDK) CreateLink(_ context.Context, req sdk.CreateLinkRequest) (*sdk.CreateLinkResponse, error) {
|
|
f.lastCallbackURL = req.CallbackURL
|
|
return &sdk.CreateLinkResponse{RedirectURL: "https://composio.example/redirect"}, nil
|
|
}
|
|
|
|
func (f *composioCapturingSDK) stateFromCallback() string {
|
|
idx := strings.Index(f.lastCallbackURL, "state=")
|
|
if idx < 0 {
|
|
return ""
|
|
}
|
|
return f.lastCallbackURL[idx+len("state="):]
|
|
}
|