mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-23 18:17:44 +02:00
Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
731 lines
27 KiB
Go
731 lines
27 KiB
Go
package composio
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"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"
|
|
)
|
|
|
|
// ---- fakes ---------------------------------------------------------------
|
|
|
|
type fakeSDK struct {
|
|
createLinkResp *sdk.CreateLinkResponse
|
|
createLinkErr error
|
|
lastCreateLink sdk.CreateLinkRequest
|
|
revoked []string
|
|
revokeErr error
|
|
deleted []string
|
|
deleteErr error
|
|
createSessResp *sdk.CreateSessionResponse
|
|
createSessErr error
|
|
lastSessReq sdk.CreateSessionRequest
|
|
createSessCalls int
|
|
// account-ownership verification (CompleteCallback). By default
|
|
// ListConnectedAccounts echoes the requested id with acctUserID /
|
|
// acctAuthConfigID so success-path tests can opt in to a matching account;
|
|
// acctMissing returns no items, listAccountsErr forces a transport error.
|
|
acctUserID string
|
|
acctAuthConfigID string
|
|
acctNestedAuthConfigID string
|
|
acctMissing bool
|
|
listAccountsErr error
|
|
lastListAccounts sdk.ListConnectedAccountsRequest
|
|
// auth-config resolution (BeginConnect / ListToolkits connectable flag).
|
|
// authConfigs nil => a default single notion→ac_notion ENABLED config so
|
|
// existing connect tests keep resolving; set explicitly to override.
|
|
authConfigs []sdk.AuthConfig
|
|
authConfigsSet bool
|
|
listAuthErr error
|
|
// toolkit catalog (ListToolkits).
|
|
toolkits []sdk.Toolkit
|
|
listToolkitsErr error
|
|
}
|
|
|
|
func (f *fakeSDK) CreateLink(_ context.Context, req sdk.CreateLinkRequest) (*sdk.CreateLinkResponse, error) {
|
|
f.lastCreateLink = req
|
|
if f.createLinkErr != nil {
|
|
return nil, f.createLinkErr
|
|
}
|
|
if f.createLinkResp != nil {
|
|
return f.createLinkResp, nil
|
|
}
|
|
return &sdk.CreateLinkResponse{RedirectURL: "https://composio.example/redirect", ConnectedAccountID: "ca_pending"}, nil
|
|
}
|
|
|
|
func (f *fakeSDK) ListConnectedAccounts(_ context.Context, req sdk.ListConnectedAccountsRequest) (*sdk.ListConnectedAccountsResponse, error) {
|
|
f.lastListAccounts = req
|
|
if f.listAccountsErr != nil {
|
|
return nil, f.listAccountsErr
|
|
}
|
|
if f.acctMissing {
|
|
return &sdk.ListConnectedAccountsResponse{}, nil
|
|
}
|
|
id := ""
|
|
if len(req.ConnectedAccountIDs) > 0 {
|
|
id = req.ConnectedAccountIDs[0]
|
|
}
|
|
return &sdk.ListConnectedAccountsResponse{Items: []sdk.ConnectedAccount{{
|
|
ID: id,
|
|
UserID: f.acctUserID,
|
|
AuthConfigID: f.acctAuthConfigID,
|
|
AuthConfig: sdk.AuthConfigRef{ID: f.acctNestedAuthConfigID},
|
|
}}}, nil
|
|
}
|
|
|
|
func (f *fakeSDK) ListAuthConfigs(_ context.Context, _ sdk.ListAuthConfigsRequest) (*sdk.ListAuthConfigsResponse, error) {
|
|
if f.listAuthErr != nil {
|
|
return nil, f.listAuthErr
|
|
}
|
|
items := f.authConfigs
|
|
if !f.authConfigsSet && items == nil {
|
|
items = []sdk.AuthConfig{{
|
|
ID: "ac_notion",
|
|
Toolkit: sdk.Toolkit{Slug: "notion"},
|
|
Status: "ENABLED",
|
|
IsComposioManaged: true,
|
|
}}
|
|
}
|
|
return &sdk.ListAuthConfigsResponse{Items: items}, nil
|
|
}
|
|
|
|
func (f *fakeSDK) ListToolkits(_ context.Context, _ sdk.ListToolkitsRequest) (*sdk.ListToolkitsResponse, error) {
|
|
if f.listToolkitsErr != nil {
|
|
return nil, f.listToolkitsErr
|
|
}
|
|
return &sdk.ListToolkitsResponse{Items: f.toolkits}, nil
|
|
}
|
|
|
|
func (f *fakeSDK) RevokeConnection(_ context.Context, id string) error {
|
|
f.revoked = append(f.revoked, id)
|
|
return f.revokeErr
|
|
}
|
|
|
|
func (f *fakeSDK) DeleteConnectedAccount(_ context.Context, id string) error {
|
|
f.deleted = append(f.deleted, id)
|
|
return f.deleteErr
|
|
}
|
|
|
|
func (f *fakeSDK) CreateSession(_ context.Context, req sdk.CreateSessionRequest) (*sdk.CreateSessionResponse, error) {
|
|
f.createSessCalls++
|
|
f.lastSessReq = req
|
|
if f.createSessErr != nil {
|
|
return nil, f.createSessErr
|
|
}
|
|
if f.createSessResp != nil {
|
|
return f.createSessResp, nil
|
|
}
|
|
return &sdk.CreateSessionResponse{MCP: sdk.MCPDescriptor{URL: "https://mcp.example/session"}}, nil
|
|
}
|
|
|
|
func (f *fakeSDK) MCPAuthHeaders() map[string]string {
|
|
return map[string]string{"x-api-key": "secret"}
|
|
}
|
|
|
|
// fakeStore is an in-memory implementation of Store with the same
|
|
// (user_id, connected_account_id) uniqueness as the real table.
|
|
type fakeStore struct {
|
|
rows []db.UserComposioConnection
|
|
nextID byte
|
|
}
|
|
|
|
func newFakeStore() *fakeStore { return &fakeStore{nextID: 1} }
|
|
|
|
func (s *fakeStore) UpsertUserComposioConnection(_ context.Context, arg db.UpsertUserComposioConnectionParams) (db.UserComposioConnection, error) {
|
|
for i := range s.rows {
|
|
if uuidEqual(s.rows[i].UserID, arg.UserID) && s.rows[i].ConnectedAccountID == arg.ConnectedAccountID {
|
|
s.rows[i].ToolkitSlug = arg.ToolkitSlug
|
|
s.rows[i].AuthConfigID = arg.AuthConfigID
|
|
s.rows[i].ComposioUserID = arg.ComposioUserID
|
|
s.rows[i].Status = "active"
|
|
s.rows[i].UpdatedAt = pgtype.Timestamptz{Time: time.Now(), Valid: true}
|
|
return s.rows[i], nil
|
|
}
|
|
}
|
|
row := db.UserComposioConnection{
|
|
ID: mintUUID(s.nextID),
|
|
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.nextID++
|
|
s.rows = append(s.rows, row)
|
|
return row, nil
|
|
}
|
|
|
|
func (s *fakeStore) ListActiveUserComposioConnections(_ context.Context, userID pgtype.UUID) ([]db.UserComposioConnection, error) {
|
|
out := []db.UserComposioConnection{}
|
|
for _, r := range s.rows {
|
|
if uuidEqual(r.UserID, userID) && r.Status == "active" {
|
|
out = append(out, r)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *fakeStore) GetUserComposioConnection(_ context.Context, arg db.GetUserComposioConnectionParams) (db.UserComposioConnection, error) {
|
|
for _, r := range s.rows {
|
|
if uuidEqual(r.ID, arg.ID) && uuidEqual(r.UserID, arg.UserID) {
|
|
return r, nil
|
|
}
|
|
}
|
|
return db.UserComposioConnection{}, pgx.ErrNoRows
|
|
}
|
|
|
|
func (s *fakeStore) MarkUserComposioConnectionRevoked(_ context.Context, arg db.MarkUserComposioConnectionRevokedParams) error {
|
|
for i := range s.rows {
|
|
if uuidEqual(s.rows[i].ID, arg.ID) && uuidEqual(s.rows[i].UserID, arg.UserID) {
|
|
s.rows[i].Status = "revoked"
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func uuidEqual(a, b pgtype.UUID) bool { return a.Valid && b.Valid && a.Bytes == b.Bytes }
|
|
|
|
func mintUUID(n byte) pgtype.UUID {
|
|
var b [16]byte
|
|
b[15] = n
|
|
return pgtype.UUID{Bytes: b, Valid: true}
|
|
}
|
|
|
|
func newTestService(t *testing.T, client SDK, store Store) *Service {
|
|
t.Helper()
|
|
svc, err := NewService(client, store, Config{
|
|
StateSecret: testSecret,
|
|
CallbackBaseURL: "https://multica.ai",
|
|
FrontendBaseURL: "https://multica.ai",
|
|
Now: func() time.Time { return time.Unix(1_700_000_000, 0) },
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewService: %v", err)
|
|
}
|
|
return svc
|
|
}
|
|
|
|
// ---- tests ---------------------------------------------------------------
|
|
|
|
func TestNewService_Validation(t *testing.T) {
|
|
t.Parallel()
|
|
if _, err := NewService(nil, newFakeStore(), Config{StateSecret: testSecret, CallbackBaseURL: "x"}); err == nil {
|
|
t.Error("expected error for nil client")
|
|
}
|
|
if _, err := NewService(&fakeSDK{}, nil, Config{StateSecret: testSecret, CallbackBaseURL: "x"}); err == nil {
|
|
t.Error("expected error for nil store")
|
|
}
|
|
if _, err := NewService(&fakeSDK{}, newFakeStore(), Config{CallbackBaseURL: "x"}); err == nil {
|
|
t.Error("expected error for empty secret")
|
|
}
|
|
if _, err := NewService(&fakeSDK{}, newFakeStore(), Config{StateSecret: testSecret}); err == nil {
|
|
t.Error("expected error for empty callback base")
|
|
}
|
|
}
|
|
|
|
func TestBeginConnect_MappingAndState(t *testing.T) {
|
|
t.Parallel()
|
|
sdkFake := &fakeSDK{}
|
|
svc := newTestService(t, sdkFake, newFakeStore())
|
|
userID := mintUUID(7)
|
|
|
|
redirect, err := svc.BeginConnect(context.Background(), userID, "Notion")
|
|
if err != nil {
|
|
t.Fatalf("BeginConnect: %v", err)
|
|
}
|
|
if redirect != "https://composio.example/redirect" {
|
|
t.Errorf("redirect = %q", redirect)
|
|
}
|
|
// toolkit → auth_config mapping
|
|
if sdkFake.lastCreateLink.AuthConfigID != "ac_notion" {
|
|
t.Errorf("auth config = %q", sdkFake.lastCreateLink.AuthConfigID)
|
|
}
|
|
// composio_user_id == multica user id
|
|
if sdkFake.lastCreateLink.UserID != util.UUIDToString(userID) {
|
|
t.Errorf("composio user id = %q, want %q", sdkFake.lastCreateLink.UserID, util.UUIDToString(userID))
|
|
}
|
|
// callback URL carries the signed state and points at our callback path
|
|
cb := sdkFake.lastCreateLink.CallbackURL
|
|
if !strings.HasPrefix(cb, "https://multica.ai"+callbackPath+"?state=") {
|
|
t.Fatalf("callback url = %q", cb)
|
|
}
|
|
u, _ := url.Parse(cb)
|
|
state := u.Query().Get("state")
|
|
claims, err := verifyState(testSecret, state, time.Unix(1_700_000_000, 0))
|
|
if err != nil {
|
|
t.Fatalf("state did not verify: %v", err)
|
|
}
|
|
if claims.ToolkitSlug != "notion" || claims.UserID != util.UUIDToString(userID) {
|
|
t.Errorf("claims = %+v", claims)
|
|
}
|
|
// The resolved auth_config_id is signed into the state so the callback can
|
|
// verify the returned account against it exactly (no fail-open re-resolve).
|
|
if claims.AuthConfigID != "ac_notion" {
|
|
t.Errorf("state auth config = %q, want ac_notion", claims.AuthConfigID)
|
|
}
|
|
}
|
|
|
|
func TestBeginConnect_UnsupportedToolkit(t *testing.T) {
|
|
t.Parallel()
|
|
svc := newTestService(t, &fakeSDK{}, newFakeStore())
|
|
if _, err := svc.BeginConnect(context.Background(), mintUUID(1), "github"); !errors.Is(err, ErrToolkitNotSupported) {
|
|
t.Fatalf("expected ErrToolkitNotSupported, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestBeginConnect_UnsupportedWhenNoAuthConfig: with the project reporting no
|
|
// enabled auth configs at all, even notion is not connectable.
|
|
func TestBeginConnect_UnsupportedWhenNoAuthConfig(t *testing.T) {
|
|
t.Parallel()
|
|
sdkFake := &fakeSDK{authConfigsSet: true, authConfigs: []sdk.AuthConfig{}}
|
|
svc := newTestService(t, sdkFake, newFakeStore())
|
|
if _, err := svc.BeginConnect(context.Background(), mintUUID(1), "notion"); !errors.Is(err, ErrToolkitNotSupported) {
|
|
t.Fatalf("expected ErrToolkitNotSupported with no auth configs, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestBeginConnect_PrefersCustomAuthConfig: when a toolkit has both a
|
|
// Composio-managed and a custom (white-label) auth config, the custom one wins.
|
|
func TestBeginConnect_PrefersCustomAuthConfig(t *testing.T) {
|
|
t.Parallel()
|
|
sdkFake := &fakeSDK{authConfigsSet: true, authConfigs: []sdk.AuthConfig{
|
|
{ID: "ac_managed", Toolkit: sdk.Toolkit{Slug: "notion"}, Status: "ENABLED", IsComposioManaged: true},
|
|
{ID: "ac_custom", Toolkit: sdk.Toolkit{Slug: "notion"}, Status: "ENABLED", IsComposioManaged: false},
|
|
}}
|
|
svc := newTestService(t, sdkFake, newFakeStore())
|
|
if _, err := svc.BeginConnect(context.Background(), mintUUID(1), "notion"); err != nil {
|
|
t.Fatalf("BeginConnect: %v", err)
|
|
}
|
|
if sdkFake.lastCreateLink.AuthConfigID != "ac_custom" {
|
|
t.Errorf("auth config = %q, want ac_custom (custom preferred over managed)", sdkFake.lastCreateLink.AuthConfigID)
|
|
}
|
|
}
|
|
|
|
// TestListToolkits_FiltersToConnectable: only toolkits with an enabled auth
|
|
// config are returned (MUL-4009); the rest are dropped from the catalog, and
|
|
// every surfaced entry is Connectable by construction.
|
|
func TestListToolkits_FiltersToConnectable(t *testing.T) {
|
|
t.Parallel()
|
|
sdkFake := &fakeSDK{
|
|
authConfigsSet: true,
|
|
authConfigs: []sdk.AuthConfig{
|
|
{ID: "ac_notion", Toolkit: sdk.Toolkit{Slug: "notion"}, Status: "ENABLED"},
|
|
{ID: "ac_slack", Toolkit: sdk.Toolkit{Slug: "slack"}, Status: "ENABLED"},
|
|
},
|
|
toolkits: []sdk.Toolkit{
|
|
{Slug: "github", Name: "GitHub", LogoURL: "https://logo/gh", Categories: []string{"dev"}},
|
|
{Slug: "notion", Name: "Notion", LogoURL: "https://logo/notion", Categories: []string{"productivity"}},
|
|
{Slug: "slack", Name: "Slack"},
|
|
},
|
|
}
|
|
svc := newTestService(t, sdkFake, newFakeStore())
|
|
tks, err := svc.ListToolkits(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ListToolkits: %v", err)
|
|
}
|
|
// notion and slack have enabled auth configs; github is filtered out.
|
|
if len(tks) != 2 {
|
|
t.Fatalf("expected 2 connectable toolkits, got %d: %+v", len(tks), tks)
|
|
}
|
|
bySlug := make(map[string]ToolkitView, len(tks))
|
|
for _, tk := range tks {
|
|
if !tk.Connectable {
|
|
t.Errorf("surfaced toolkit %q must be connectable", tk.Slug)
|
|
}
|
|
if tk.Slug == "github" {
|
|
t.Errorf("non-connectable toolkit %q should have been filtered out", tk.Slug)
|
|
}
|
|
bySlug[tk.Slug] = tk
|
|
}
|
|
notion, ok := bySlug["notion"]
|
|
if !ok {
|
|
t.Fatalf("expected notion in results: %+v", tks)
|
|
}
|
|
if notion.Name != "Notion" || notion.LogoURL != "https://logo/notion" || notion.Category != "productivity" {
|
|
t.Errorf("notion fields not mapped: %+v", notion)
|
|
}
|
|
// slack carried no upstream logo, so the composio default logo URL is derived.
|
|
if slack, ok := bySlug["slack"]; !ok || slack.LogoURL != "https://logos.composio.dev/api/slack" {
|
|
t.Errorf("slack default logo = %+v", bySlug["slack"])
|
|
}
|
|
}
|
|
|
|
// TestListToolkits_ResolverErrorReturnsError: an /auth_configs failure is
|
|
// surfaced as an error rather than silently degrading to an empty catalog
|
|
// (MUL-4009). With filtering in place, masking the error would render as a
|
|
// misleading "no apps configured" empty state, so the handler must be able to
|
|
// return a 502 instead.
|
|
func TestListToolkits_ResolverErrorReturnsError(t *testing.T) {
|
|
t.Parallel()
|
|
sdkFake := &fakeSDK{
|
|
listAuthErr: errors.New("upstream blip"),
|
|
toolkits: []sdk.Toolkit{{Slug: "notion", Name: "Notion"}},
|
|
}
|
|
svc := newTestService(t, sdkFake, newFakeStore())
|
|
tks, err := svc.ListToolkits(context.Background())
|
|
if err == nil {
|
|
t.Fatalf("ListToolkits should fail on auth-config resolver error, got %+v", tks)
|
|
}
|
|
if tks != nil {
|
|
t.Errorf("expected nil toolkits on error, got %+v", tks)
|
|
}
|
|
}
|
|
|
|
func TestCompleteCallback_SuccessAndIdempotent(t *testing.T) {
|
|
t.Parallel()
|
|
store := newFakeStore()
|
|
userID := mintUUID(3)
|
|
// The account Composio reports for ca_123 belongs to this user under the
|
|
// notion auth config, so ownership verification passes.
|
|
sdkFake := &fakeSDK{acctUserID: util.UUIDToString(userID), acctAuthConfigID: "ac_notion"}
|
|
svc := newTestService(t, sdkFake, store)
|
|
state, _ := signState(testSecret, stateClaims{
|
|
UserID: util.UUIDToString(userID),
|
|
ToolkitSlug: "notion",
|
|
AuthConfigID: "ac_notion",
|
|
Exp: time.Unix(1_700_000_000, 0).Add(time.Minute).Unix(),
|
|
})
|
|
|
|
slug, err := svc.CompleteCallback(context.Background(), state, "success", "ca_123")
|
|
if err != nil {
|
|
t.Fatalf("CompleteCallback: %v", err)
|
|
}
|
|
if slug != "notion" {
|
|
t.Errorf("slug = %q", slug)
|
|
}
|
|
// Duplicate callback (same connected account) must not create a 2nd row.
|
|
if _, err := svc.CompleteCallback(context.Background(), state, "success", "ca_123"); err != nil {
|
|
t.Fatalf("second CompleteCallback: %v", err)
|
|
}
|
|
if len(store.rows) != 1 {
|
|
t.Fatalf("expected 1 row after duplicate callback, got %d", len(store.rows))
|
|
}
|
|
row := store.rows[0]
|
|
if row.ComposioUserID != util.UUIDToString(userID) {
|
|
t.Errorf("composio_user_id invariant broken: %q", row.ComposioUserID)
|
|
}
|
|
if row.AuthConfigID != "ac_notion" || row.ToolkitSlug != "notion" || row.Status != "active" {
|
|
t.Errorf("row = %+v", row)
|
|
}
|
|
}
|
|
|
|
func TestCompleteCallback_AcceptsNestedAuthConfig(t *testing.T) {
|
|
t.Parallel()
|
|
store := newFakeStore()
|
|
userID := mintUUID(30)
|
|
// Composio v3.1 returns connected-account auth config under auth_config.id,
|
|
// not always as a top-level auth_config_id.
|
|
sdkFake := &fakeSDK{acctUserID: util.UUIDToString(userID), acctNestedAuthConfigID: "ac_notion"}
|
|
svc := newTestService(t, sdkFake, store)
|
|
state, _ := signState(testSecret, stateClaims{
|
|
UserID: util.UUIDToString(userID),
|
|
ToolkitSlug: "notion",
|
|
AuthConfigID: "ac_notion",
|
|
Exp: time.Unix(1_700_000_000, 0).Add(time.Minute).Unix(),
|
|
})
|
|
|
|
if _, err := svc.CompleteCallback(context.Background(), state, "success", "ca_nested"); err != nil {
|
|
t.Fatalf("CompleteCallback: %v", err)
|
|
}
|
|
if len(store.rows) != 1 {
|
|
t.Fatalf("expected 1 row, got %d", len(store.rows))
|
|
}
|
|
}
|
|
|
|
func TestCompleteCallback_NonSuccessNoRow(t *testing.T) {
|
|
t.Parallel()
|
|
store := newFakeStore()
|
|
svc := newTestService(t, &fakeSDK{}, store)
|
|
state, _ := signState(testSecret, stateClaims{
|
|
UserID: util.UUIDToString(mintUUID(4)),
|
|
ToolkitSlug: "notion",
|
|
Exp: time.Unix(1_700_000_000, 0).Add(time.Minute).Unix(),
|
|
})
|
|
slug, err := svc.CompleteCallback(context.Background(), state, "failed", "ca_x")
|
|
if !errors.Is(err, ErrConnectNotSuccessful) {
|
|
t.Fatalf("expected ErrConnectNotSuccessful, got %v", err)
|
|
}
|
|
if slug != "notion" {
|
|
t.Errorf("slug = %q (should still be returned for redirect)", slug)
|
|
}
|
|
if len(store.rows) != 0 {
|
|
t.Fatalf("expected no row written on non-success, got %d", len(store.rows))
|
|
}
|
|
}
|
|
|
|
func TestCompleteCallback_BadState(t *testing.T) {
|
|
t.Parallel()
|
|
svc := newTestService(t, &fakeSDK{}, newFakeStore())
|
|
if _, err := svc.CompleteCallback(context.Background(), "garbage", "success", "ca_1"); err == nil {
|
|
t.Fatal("expected error for malformed state")
|
|
}
|
|
}
|
|
|
|
// TestCompleteCallback_TamperedAccountRejected covers the PR 4608 blocker:
|
|
// a valid, un-expired state paired with a connected_account_id that Composio
|
|
// reports as belonging to a DIFFERENT user must be rejected, and no row written.
|
|
func TestCompleteCallback_TamperedAccountRejected(t *testing.T) {
|
|
t.Parallel()
|
|
store := newFakeStore()
|
|
userID := mintUUID(20)
|
|
// Composio says ca_evil belongs to someone else, not our state's user.
|
|
sdkFake := &fakeSDK{acctUserID: util.UUIDToString(mintUUID(99)), acctAuthConfigID: "ac_notion"}
|
|
svc := newTestService(t, sdkFake, store)
|
|
state, _ := signState(testSecret, stateClaims{
|
|
UserID: util.UUIDToString(userID),
|
|
ToolkitSlug: "notion",
|
|
AuthConfigID: "ac_notion",
|
|
Exp: time.Unix(1_700_000_000, 0).Add(time.Minute).Unix(),
|
|
})
|
|
if _, err := svc.CompleteCallback(context.Background(), state, "success", "ca_evil"); !errors.Is(err, ErrAccountVerification) {
|
|
t.Fatalf("expected ErrAccountVerification for foreign account, got %v", err)
|
|
}
|
|
if len(store.rows) != 0 {
|
|
t.Fatalf("no row should be written when ownership fails, got %d", len(store.rows))
|
|
}
|
|
}
|
|
|
|
// TestCompleteCallback_WrongAuthConfigRejected is the cross-toolkit proof: the
|
|
// account belongs to the right user but was created under a DIFFERENT toolkit's
|
|
// auth config (e.g. the user pasting their slack account id into a notion
|
|
// callback). The state-signed auth_config_id must not match, so it is rejected.
|
|
func TestCompleteCallback_WrongAuthConfigRejected(t *testing.T) {
|
|
t.Parallel()
|
|
store := newFakeStore()
|
|
userID := mintUUID(21)
|
|
// Account is owned by the user but lives under ac_other (another toolkit).
|
|
sdkFake := &fakeSDK{acctUserID: util.UUIDToString(userID), acctAuthConfigID: "ac_other"}
|
|
svc := newTestService(t, sdkFake, store)
|
|
state, _ := signState(testSecret, stateClaims{
|
|
UserID: util.UUIDToString(userID),
|
|
ToolkitSlug: "notion",
|
|
AuthConfigID: "ac_notion",
|
|
Exp: time.Unix(1_700_000_000, 0).Add(time.Minute).Unix(),
|
|
})
|
|
if _, err := svc.CompleteCallback(context.Background(), state, "success", "ca_x"); !errors.Is(err, ErrAccountVerification) {
|
|
t.Fatalf("expected ErrAccountVerification for wrong auth config, got %v", err)
|
|
}
|
|
if len(store.rows) != 0 {
|
|
t.Fatalf("no row should be written, got %d", len(store.rows))
|
|
}
|
|
}
|
|
|
|
// TestCompleteCallback_MissingAuthConfigFailsClosed is the regression for the
|
|
// re-review blocker: a state with no signed auth_config_id (the old fail-open
|
|
// path) plus an account owned by the user must STILL be rejected — the empty
|
|
// expected auth config now fails closed instead of skipping the check.
|
|
func TestCompleteCallback_MissingAuthConfigFailsClosed(t *testing.T) {
|
|
t.Parallel()
|
|
store := newFakeStore()
|
|
userID := mintUUID(25)
|
|
// Account genuinely belongs to the user — only the missing auth-config
|
|
// binding should trip the rejection.
|
|
sdkFake := &fakeSDK{acctUserID: util.UUIDToString(userID), acctAuthConfigID: "ac_notion"}
|
|
svc := newTestService(t, sdkFake, store)
|
|
state, _ := signState(testSecret, stateClaims{
|
|
UserID: util.UUIDToString(userID),
|
|
ToolkitSlug: "notion",
|
|
// AuthConfigID deliberately omitted (empty) — must fail closed.
|
|
Exp: time.Unix(1_700_000_000, 0).Add(time.Minute).Unix(),
|
|
})
|
|
if _, err := svc.CompleteCallback(context.Background(), state, "success", "ca_owned"); !errors.Is(err, ErrAccountVerification) {
|
|
t.Fatalf("expected ErrAccountVerification when state carries no auth config, got %v", err)
|
|
}
|
|
if len(store.rows) != 0 {
|
|
t.Fatalf("no row should be written, got %d", len(store.rows))
|
|
}
|
|
}
|
|
|
|
// TestCompleteCallback_UnknownAccountRejected ensures an account id Composio
|
|
// does not know about fails closed rather than being mirrored verbatim.
|
|
func TestCompleteCallback_UnknownAccountRejected(t *testing.T) {
|
|
t.Parallel()
|
|
store := newFakeStore()
|
|
userID := mintUUID(22)
|
|
sdkFake := &fakeSDK{acctMissing: true}
|
|
svc := newTestService(t, sdkFake, store)
|
|
state, _ := signState(testSecret, stateClaims{
|
|
UserID: util.UUIDToString(userID),
|
|
ToolkitSlug: "notion",
|
|
Exp: time.Unix(1_700_000_000, 0).Add(time.Minute).Unix(),
|
|
})
|
|
if _, err := svc.CompleteCallback(context.Background(), state, "success", "ca_ghost"); !errors.Is(err, ErrAccountVerification) {
|
|
t.Fatalf("expected ErrAccountVerification for unknown account, got %v", err)
|
|
}
|
|
if len(store.rows) != 0 {
|
|
t.Fatalf("no row should be written, got %d", len(store.rows))
|
|
}
|
|
}
|
|
|
|
func TestListConnections(t *testing.T) {
|
|
t.Parallel()
|
|
store := newFakeStore()
|
|
svc := newTestService(t, &fakeSDK{}, store)
|
|
userID := mintUUID(5)
|
|
seedActive(store, userID, "notion", "ca_a")
|
|
|
|
conns, err := svc.ListConnections(context.Background(), userID)
|
|
if err != nil {
|
|
t.Fatalf("ListConnections: %v", err)
|
|
}
|
|
if len(conns) != 1 || conns[0].ToolkitSlug != "notion" || conns[0].Status != "active" {
|
|
t.Fatalf("conns = %+v", conns)
|
|
}
|
|
}
|
|
|
|
func TestDisconnect_OwnerRevokeIdempotentAndFilter(t *testing.T) {
|
|
t.Parallel()
|
|
store := newFakeStore()
|
|
sdkFake := &fakeSDK{}
|
|
svc := newTestService(t, sdkFake, store)
|
|
userID := mintUUID(6)
|
|
row := seedActive(store, userID, "notion", "ca_z")
|
|
|
|
if err := svc.Disconnect(context.Background(), userID, row.ID); err != nil {
|
|
t.Fatalf("Disconnect: %v", err)
|
|
}
|
|
if len(sdkFake.revoked) != 1 || sdkFake.revoked[0] != "ca_z" {
|
|
t.Errorf("revoked = %v", sdkFake.revoked)
|
|
}
|
|
// Local row should now be filtered out of the active list.
|
|
conns, _ := svc.ListConnections(context.Background(), userID)
|
|
if len(conns) != 0 {
|
|
t.Errorf("expected 0 active after disconnect, got %d", len(conns))
|
|
}
|
|
// Second disconnect is idempotent (row still owned, marks revoked again).
|
|
if err := svc.Disconnect(context.Background(), userID, row.ID); err != nil {
|
|
t.Fatalf("idempotent Disconnect: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestDisconnect_RevokedRowNoOp covers the PR 4608 blocker: once a row is
|
|
// locally revoked, a second DELETE must be a pure no-op and must NOT call
|
|
// upstream again — otherwise a non-404 upstream error on the repeat would be
|
|
// surfaced as a 502 and break idempotency.
|
|
func TestDisconnect_RevokedRowNoOp(t *testing.T) {
|
|
t.Parallel()
|
|
store := newFakeStore()
|
|
sdkFake := &fakeSDK{}
|
|
svc := newTestService(t, sdkFake, store)
|
|
userID := mintUUID(30)
|
|
row := seedActive(store, userID, "notion", "ca_noop")
|
|
|
|
// First disconnect revokes upstream and marks the row revoked.
|
|
if err := svc.Disconnect(context.Background(), userID, row.ID); err != nil {
|
|
t.Fatalf("first Disconnect: %v", err)
|
|
}
|
|
if len(sdkFake.revoked) != 1 {
|
|
t.Fatalf("expected 1 upstream revoke, got %d", len(sdkFake.revoked))
|
|
}
|
|
|
|
// Now make the upstream fail with a NON-404 error. A correct no-op must not
|
|
// touch upstream, so this error must never surface.
|
|
sdkFake.revokeErr = &sdk.APIError{HTTPStatus: http.StatusInternalServerError}
|
|
sdkFake.deleteErr = &sdk.APIError{HTTPStatus: http.StatusInternalServerError}
|
|
if err := svc.Disconnect(context.Background(), userID, row.ID); err != nil {
|
|
t.Fatalf("second Disconnect on already-revoked row should be a no-op, got %v", err)
|
|
}
|
|
if len(sdkFake.revoked) != 1 {
|
|
t.Errorf("second disconnect must not call upstream revoke again, revoked=%v", sdkFake.revoked)
|
|
}
|
|
}
|
|
|
|
func TestDisconnect_UpstreamNotFoundIsIdempotent(t *testing.T) {
|
|
t.Parallel()
|
|
store := newFakeStore()
|
|
sdkFake := &fakeSDK{revokeErr: &sdk.APIError{HTTPStatus: http.StatusNotFound}}
|
|
svc := newTestService(t, sdkFake, store)
|
|
userID := mintUUID(8)
|
|
row := seedActive(store, userID, "notion", "ca_404")
|
|
|
|
if err := svc.Disconnect(context.Background(), userID, row.ID); err != nil {
|
|
t.Fatalf("Disconnect should treat upstream 404 as success, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDisconnect_NotOwner(t *testing.T) {
|
|
t.Parallel()
|
|
store := newFakeStore()
|
|
svc := newTestService(t, &fakeSDK{}, store)
|
|
owner := mintUUID(9)
|
|
row := seedActive(store, owner, "notion", "ca_o")
|
|
attacker := mintUUID(10)
|
|
if err := svc.Disconnect(context.Background(), attacker, row.ID); !errors.Is(err, ErrConnectionNotFound) {
|
|
t.Fatalf("expected ErrConnectionNotFound for non-owner, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCreateMCPSession_NoOpWhenEmpty(t *testing.T) {
|
|
t.Parallel()
|
|
sdkFake := &fakeSDK{}
|
|
svc := newTestService(t, sdkFake, newFakeStore())
|
|
sess, err := svc.CreateMCPSession(context.Background(), mintUUID(11))
|
|
if err != nil {
|
|
t.Fatalf("CreateMCPSession: %v", err)
|
|
}
|
|
if sess != nil {
|
|
t.Fatalf("expected nil session when no connections, got %+v", sess)
|
|
}
|
|
if sdkFake.createSessCalls != 0 {
|
|
t.Errorf("CreateSession should not be called when there are no connections")
|
|
}
|
|
}
|
|
|
|
func TestCreateMCPSession_PinsConnectedAccounts(t *testing.T) {
|
|
t.Parallel()
|
|
store := newFakeStore()
|
|
sdkFake := &fakeSDK{}
|
|
svc := newTestService(t, sdkFake, store)
|
|
userID := mintUUID(12)
|
|
seedActive(store, userID, "notion", "ca_pin")
|
|
|
|
sess, err := svc.CreateMCPSession(context.Background(), userID)
|
|
if err != nil {
|
|
t.Fatalf("CreateMCPSession: %v", err)
|
|
}
|
|
if sess == nil || sess.URL != "https://mcp.example/session" {
|
|
t.Fatalf("session = %+v", sess)
|
|
}
|
|
if sess.Headers["x-api-key"] != "secret" {
|
|
t.Errorf("headers = %+v", sess.Headers)
|
|
}
|
|
if sdkFake.lastSessReq.UserID != util.UUIDToString(userID) {
|
|
t.Errorf("session user id = %q", sdkFake.lastSessReq.UserID)
|
|
}
|
|
assertPinnedAccount(t, sdkFake.lastSessReq, "notion", "ca_pin")
|
|
}
|
|
|
|
func TestCallbackRedirect(t *testing.T) {
|
|
t.Parallel()
|
|
svc := newTestService(t, &fakeSDK{}, newFakeStore())
|
|
if got := svc.CallbackRedirect("notion", true); got != "https://multica.ai/settings?tab=integrations&connected=notion" {
|
|
t.Errorf("success redirect = %q", got)
|
|
}
|
|
if got := svc.CallbackRedirect("notion", false); got != "https://multica.ai/settings?tab=integrations&error=composio_connect_failed" {
|
|
t.Errorf("failure redirect = %q", got)
|
|
}
|
|
}
|
|
|
|
// seedActive inserts an active connection through the store and returns the row.
|
|
func seedActive(store *fakeStore, userID pgtype.UUID, slug, caID string) db.UserComposioConnection {
|
|
row, _ := store.UpsertUserComposioConnection(context.Background(), db.UpsertUserComposioConnectionParams{
|
|
UserID: userID,
|
|
ToolkitSlug: slug,
|
|
AuthConfigID: "ac_notion",
|
|
ConnectedAccountID: caID,
|
|
ComposioUserID: util.UUIDToString(userID),
|
|
})
|
|
return row
|
|
}
|