Files
multica/server/pkg/composio/client_test.go
Multica Eve 8d0ea04fb0 feat(composio): add standalone Go SDK client (MVP) (#4603)
* feat(composio): add standalone Go SDK client (MVP)

Adds server/pkg/composio — a self-contained Go SDK for the Composio v3.1
REST API. Built on go-resty/resty v2; zero coupling to other Multica
packages so it can be vendored or extracted later without surgery.

MVP surface (just the endpoints Stage 2 needs):

- POST /connected_accounts/link        Client.CreateLink
- POST /tool_router/session            Client.CreateSession
- GET  /connected_accounts             Client.ListConnectedAccounts
- POST /connected_accounts/{id}/revoke Client.RevokeConnection
- DELETE /connected_accounts/{id}      Client.DeleteConnectedAccount
                                       (404 -> nil, idempotent)
- GET  /toolkits                       Client.ListToolkits
- GET  /toolkits/{slug}                Client.GetToolkit
- POST /tools/execute/{slug}           Client.ExecuteTool
- Webhook HMAC-SHA256 verification     composio.VerifyWebhook /
                                       VerifyHTTPRequest + ParseEvent

Other notes:

- Auth via x-api-key header (Composio v3.1 contract).
- Typed *APIError envelope with IsNotFound / IsUnauthorized /
  IsRateLimited helpers; falls back to raw body when upstream returns
  non-JSON.
- Webhook signature accepts the official "v1,<sig>" format and any
  comma-separated multi-version list; 300s replay tolerance by default,
  honors an injectable clock for tests; RFC3339 timestamps tolerated.
- README.md documents all public APIs and design choices.

Tests:

- All exercise httptest.NewServer - no real Composio calls.
- 36 tests covering happy paths, validation, 404 idempotence, error
  decoding, signature verify (good / tampered / stale / multi-version /
  bare / RFC3339 / missing headers / empty secret).
- go test ./pkg/composio/... -cover -> 82.2%, exceeds the >=80% bar.

Follow-ups (separate PRs):

- server/internal/integrations/composio - DB schema, REST handlers,
  registration_service (CSRF), dispatch hook (MUL-3720 remainder).
- Pagination iterators, retry middleware, proxy execute, triggers.

Refs: MUL-3720, MUL-3715
Co-authored-by: multica-agent <github@multica.ai>

* fix(composio): align SDK with v3.1 wire contract (PR #4603 review)

Addresses GPT-Boy's review on PR #4603:

Must-fix
- ListConnectedAccountsRequest: switch UserID/AuthConfigID singular fields
  to plural slices (UserIDs, AuthConfigIDs, ToolkitSlugs, ConnectedAccountIDs,
  Statuses). The Composio v3.1 spec ships these as `*_ids` array params;
  our singular form silently dropped the user-filter on the wire. Also
  surfaces order_by / order_direction / account_type from the same spec.
- ExecuteToolRequest: rename ToolkitVersions -> Version with json tag
  `version` (the actual v3.1 body field). Marks AllowTracing as
  deprecated per the spec.

Nits
- ListToolkitsRequest.SortBy comment: `popular | alphabetical` -> the
  real enum `usage | alphabetically`.
- Client constructor: when Options.HTTPClient is provided, use
  resty.NewWithClient(hc) so the caller's Transport, Jar, CheckRedirect,
  and Timeout all carry through — the prior code only forwarded
  Transport + Timeout despite the field comment promising the full
  *http.Client.

Tests
- TestListConnectedAccounts_QueryString now asserts plural query keys
  (user_ids, auth_config_ids, connected_account_ids, statuses) and
  explicitly guards that the legacy singular keys do not leak.
- TestExecuteTool_Success decodes the body as a raw map and asserts the
  wire key is `version` (not `toolkit_versions`).
- New TestExecuteToolRequest_VersionSerialization locks the json tag.
- New TestNewClient_HonorsInjectedHTTPClient drives a request through a
  recordingTransport and asserts the inbound *http.Client actually
  handled it.

Verification
- go test ./pkg/composio/... -cover -> 85.1% (38 tests; was 82.2% / 36).
- go vet, go build, gofmt -l all clean.

Refs: PR #4603 review, MUL-3720
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 15:07:47 +08:00

568 lines
18 KiB
Go

package composio_test
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/multica-ai/multica/server/pkg/composio"
)
// newTestServer wires up a httptest.Server with the provided handler and
// returns a composio.Client pointed at it.
func newTestServer(t *testing.T, h http.HandlerFunc) (*composio.Client, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
c, err := composio.NewClient(composio.Options{
APIKey: "test-key",
BaseURL: srv.URL,
Timeout: 5 * time.Second,
})
if err != nil {
t.Fatalf("NewClient: %v", err)
}
return c, srv
}
func readJSON(t *testing.T, r *http.Request, out any) {
t.Helper()
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if err := json.Unmarshal(body, out); err != nil {
t.Fatalf("unmarshal body %q: %v", string(body), err)
}
}
func writeJSON(t *testing.T, w http.ResponseWriter, status int, v any) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
t.Fatalf("write json: %v", err)
}
}
// ---------------------------------------------------------------------------
// Client construction
// ---------------------------------------------------------------------------
func TestNewClient_Defaults(t *testing.T) {
c, err := composio.NewClient(composio.Options{APIKey: "k"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := c.BaseURL(); got != composio.DefaultBaseURL {
t.Errorf("BaseURL = %q, want %q", got, composio.DefaultBaseURL)
}
}
func TestNewClient_RequiresAPIKey(t *testing.T) {
_, err := composio.NewClient(composio.Options{})
if err == nil {
t.Fatal("expected error when APIKey is empty")
}
}
func TestNewClient_TrimsTrailingSlash(t *testing.T) {
c, err := composio.NewClient(composio.Options{APIKey: "k", BaseURL: "https://x.example.com/"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got, want := c.BaseURL(), "https://x.example.com"; got != want {
t.Errorf("BaseURL = %q, want %q", got, want)
}
}
// recordingTransport observes whether it actually handled a request.
type recordingTransport struct {
mu sync.Mutex
calls int
last *http.Request
status int
}
func (rt *recordingTransport) RoundTrip(r *http.Request) (*http.Response, error) {
rt.mu.Lock()
rt.calls++
rt.last = r
rt.mu.Unlock()
body := io.NopCloser(strings.NewReader(`{"items":[]}`))
return &http.Response{
StatusCode: rt.statusOr(200),
Body: body,
Header: http.Header{"Content-Type": []string{"application/json"}},
Request: r,
}, nil
}
func (rt *recordingTransport) statusOr(d int) int {
if rt.status == 0 {
return d
}
return rt.status
}
// TestNewClient_HonorsInjectedHTTPClient asserts that when Options.HTTPClient
// is non-nil the SDK actually routes requests through *that* client — full
// fidelity, not just transport+timeout. GPT-Boy's PR review against #4603
// caught the partial behavior; this test locks the fix in.
func TestNewClient_HonorsInjectedHTTPClient(t *testing.T) {
rt := &recordingTransport{}
hc := &http.Client{Transport: rt}
c, err := composio.NewClient(composio.Options{
APIKey: "k",
BaseURL: "https://api.example.invalid",
HTTPClient: hc,
})
if err != nil {
t.Fatalf("NewClient: %v", err)
}
if _, err := c.ListToolkits(context.Background(), composio.ListToolkitsRequest{}); err != nil {
t.Fatalf("ListToolkits: %v", err)
}
rt.mu.Lock()
defer rt.mu.Unlock()
if rt.calls != 1 {
t.Fatalf("expected 1 call through injected transport, got %d", rt.calls)
}
if rt.last == nil || rt.last.URL.Host != "api.example.invalid" {
t.Errorf("request did not flow through injected client: %+v", rt.last)
}
if got := rt.last.Header.Get("x-api-key"); got != "k" {
t.Errorf("api key header lost in injected client: %q", got)
}
}
// ---------------------------------------------------------------------------
// Connect Link
// ---------------------------------------------------------------------------
func TestCreateLink_Success(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/connected_accounts/link" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if got := r.Header.Get("x-api-key"); got != "test-key" {
t.Errorf("missing api key header, got %q", got)
}
var body composio.CreateLinkRequest
readJSON(t, r, &body)
if body.AuthConfigID != "ac_abc" || body.UserID != "u_1" {
t.Errorf("unexpected body: %+v", body)
}
writeJSON(t, w, http.StatusCreated, map[string]any{
"link_token": "ltok_xyz",
"redirect_url": "https://connect.composio.dev/ln_xyz",
"expires_at": "2026-12-31T00:00:00Z",
"connected_account_id": "ca_pending",
})
})
resp, err := c.CreateLink(context.Background(), composio.CreateLinkRequest{
AuthConfigID: "ac_abc",
UserID: "u_1",
CallbackURL: "https://example.com/cb",
})
if err != nil {
t.Fatalf("CreateLink: %v", err)
}
if resp.RedirectURL == "" || resp.LinkToken != "ltok_xyz" || resp.ConnectedAccountID != "ca_pending" {
t.Errorf("unexpected response: %+v", resp)
}
}
func TestCreateLink_ValidatesInputs(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("server should not be hit when inputs are invalid")
})
if _, err := c.CreateLink(context.Background(), composio.CreateLinkRequest{UserID: "u"}); err == nil {
t.Error("expected error when AuthConfigID is empty")
}
if _, err := c.CreateLink(context.Background(), composio.CreateLinkRequest{AuthConfigID: "ac"}); err == nil {
t.Error("expected error when UserID is empty")
}
}
func TestCreateLink_APIError(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, http.StatusBadRequest, map[string]any{
"error": map[string]any{
"message": "bad input",
"code": 400,
"slug": "INVALID_INPUT",
"request_id": "req_1",
},
})
})
_, err := c.CreateLink(context.Background(), composio.CreateLinkRequest{
AuthConfigID: "ac", UserID: "u",
})
if err == nil {
t.Fatal("expected error")
}
var apiErr *composio.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected *APIError, got %T: %v", err, err)
}
if apiErr.HTTPStatus != http.StatusBadRequest || apiErr.Slug != "INVALID_INPUT" || apiErr.Message != "bad input" {
t.Errorf("unexpected APIError: %+v", apiErr)
}
}
// ---------------------------------------------------------------------------
// Connected accounts list / revoke / delete
// ---------------------------------------------------------------------------
func TestListConnectedAccounts_QueryString(t *testing.T) {
var seen *http.Request
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
seen = r
writeJSON(t, w, http.StatusOK, map[string]any{
"items": []map[string]any{
{"id": "ca_1", "user_id": "u_1", "status": "ACTIVE",
"toolkit": map[string]any{"slug": "notion"}},
},
"next_cursor": "cur_2",
})
})
resp, err := c.ListConnectedAccounts(context.Background(), composio.ListConnectedAccountsRequest{
UserIDs: []string{"u_1", "u_2"},
ToolkitSlugs: []string{"notion", "slack"},
AuthConfigIDs: []string{"ac_a"},
ConnectedAccountIDs: []string{"ca_x"},
Statuses: []string{"ACTIVE"},
OrderBy: "updated_at",
OrderDirection: "desc",
AccountType: "PRIVATE",
Limit: 25,
})
if err != nil {
t.Fatalf("ListConnectedAccounts: %v", err)
}
if len(resp.Items) != 1 || resp.Items[0].Toolkit.Slug != "notion" || resp.NextCursor != "cur_2" {
t.Errorf("unexpected response: %+v", resp)
}
q := seen.URL.Query()
// Per Composio v3.1 these are plural array params.
if got := q["user_ids"]; len(got) != 2 || got[0] != "u_1" || got[1] != "u_2" {
t.Errorf("user_ids = %v", got)
}
if got := q["toolkit_slugs"]; len(got) != 2 || got[0] != "notion" || got[1] != "slack" {
t.Errorf("toolkit_slugs = %v", got)
}
if got := q["auth_config_ids"]; len(got) != 1 || got[0] != "ac_a" {
t.Errorf("auth_config_ids = %v", got)
}
if got := q["connected_account_ids"]; len(got) != 1 || got[0] != "ca_x" {
t.Errorf("connected_account_ids = %v", got)
}
if got := q["statuses"]; len(got) != 1 || got[0] != "ACTIVE" {
t.Errorf("statuses = %v", got)
}
// Singular legacy keys must NOT appear — guard against regression.
if q.Has("user_id") || q.Has("auth_config_id") {
t.Errorf("legacy singular query keys leaked: %s", seen.URL.RawQuery)
}
if q.Get("order_by") != "updated_at" {
t.Errorf("order_by = %q", q.Get("order_by"))
}
if q.Get("order_direction") != "desc" {
t.Errorf("order_direction = %q", q.Get("order_direction"))
}
if q.Get("account_type") != "PRIVATE" {
t.Errorf("account_type = %q", q.Get("account_type"))
}
if q.Get("limit") != "25" {
t.Errorf("limit = %q", q.Get("limit"))
}
}
func TestRevokeConnection_Success(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/connected_accounts/ca_42/revoke" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(http.StatusNoContent)
})
if err := c.RevokeConnection(context.Background(), "ca_42"); err != nil {
t.Errorf("RevokeConnection: %v", err)
}
}
func TestRevokeConnection_RequiresID(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("server should not be hit")
})
if err := c.RevokeConnection(context.Background(), ""); err == nil {
t.Error("expected error for empty id")
}
}
func TestDeleteConnectedAccount_IdempotentOn404(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
t.Errorf("method = %s", r.Method)
}
writeJSON(t, w, http.StatusNotFound, map[string]any{
"error": map[string]any{"message": "not found", "status": 404, "slug": "NOT_FOUND"},
})
})
if err := c.DeleteConnectedAccount(context.Background(), "ca_gone"); err != nil {
t.Errorf("expected nil on 404, got %v", err)
}
}
func TestDeleteConnectedAccount_PropagatesOtherErrors(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, http.StatusInternalServerError, map[string]any{
"error": map[string]any{"message": "boom", "status": 500, "slug": "INTERNAL"},
})
})
err := c.DeleteConnectedAccount(context.Background(), "ca_1")
if err == nil {
t.Fatal("expected error")
}
var apiErr *composio.APIError
if !errors.As(err, &apiErr) || apiErr.HTTPStatus != http.StatusInternalServerError {
t.Errorf("unexpected error: %v", err)
}
}
// ---------------------------------------------------------------------------
// Sessions
// ---------------------------------------------------------------------------
func TestCreateSession_Success(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/tool_router/session" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var body composio.CreateSessionRequest
readJSON(t, r, &body)
if body.UserID != "u_1" {
t.Errorf("user_id = %q", body.UserID)
}
if body.ManageConnections == nil || body.ManageConnections.CallbackURL != "https://cb" {
t.Errorf("manage_connections = %+v", body.ManageConnections)
}
writeJSON(t, w, http.StatusCreated, map[string]any{
"session_id": "trs_1",
"mcp": map[string]any{"type": "http", "url": "https://mcp.example/trs_1"},
})
})
enable := true
resp, err := c.CreateSession(context.Background(), composio.CreateSessionRequest{
UserID: "u_1",
ManageConnections: &composio.ManageConnections{
Enable: &enable,
CallbackURL: "https://cb",
},
})
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
if resp.MCP.URL == "" || resp.SessionID != "trs_1" {
t.Errorf("unexpected response: %+v", resp)
}
hdr := c.MCPAuthHeaders()
if hdr["x-api-key"] != "test-key" {
t.Errorf("MCPAuthHeaders = %v", hdr)
}
}
func TestCreateSession_RequiresUserID(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("server should not be hit")
})
if _, err := c.CreateSession(context.Background(), composio.CreateSessionRequest{}); err == nil {
t.Error("expected error for empty UserID")
}
}
// ---------------------------------------------------------------------------
// Toolkits / Tools
// ---------------------------------------------------------------------------
func TestListToolkits_Success(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/toolkits" || r.URL.Query().Get("category") != "productivity" {
t.Errorf("unexpected request: %s ?%s", r.URL.Path, r.URL.RawQuery)
}
writeJSON(t, w, http.StatusOK, map[string]any{
"items": []map[string]any{
{"slug": "notion", "name": "Notion"},
{"slug": "slack", "name": "Slack"},
},
})
})
resp, err := c.ListToolkits(context.Background(), composio.ListToolkitsRequest{Category: "productivity"})
if err != nil {
t.Fatalf("ListToolkits: %v", err)
}
if len(resp.Items) != 2 || resp.Items[0].Slug != "notion" {
t.Errorf("unexpected response: %+v", resp)
}
}
func TestGetToolkit_Success(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/toolkits/notion" {
t.Errorf("path = %s", r.URL.Path)
}
writeJSON(t, w, http.StatusOK, map[string]any{"slug": "notion", "name": "Notion"})
})
tk, err := c.GetToolkit(context.Background(), "notion")
if err != nil {
t.Fatalf("GetToolkit: %v", err)
}
if tk.Slug != "notion" {
t.Errorf("slug = %q", tk.Slug)
}
}
func TestGetToolkit_RequiresSlug(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("server should not be hit")
})
if _, err := c.GetToolkit(context.Background(), ""); err == nil {
t.Error("expected error for empty slug")
}
}
func TestExecuteTool_Success(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/tools/execute/GITHUB_CREATE_ISSUE" {
t.Errorf("path = %s", r.URL.Path)
}
// Decode into a raw map first so we can assert wire keys directly.
raw, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
var wire map[string]any
if err := json.Unmarshal(raw, &wire); err != nil {
t.Fatalf("unmarshal wire: %v", err)
}
if wire["user_id"] != "u_1" {
t.Errorf("user_id = %v", wire["user_id"])
}
// The spec field is `version`, not `toolkit_versions`.
if wire["version"] != "latest" {
t.Errorf("version = %v (want %q)", wire["version"], "latest")
}
if _, leaked := wire["toolkit_versions"]; leaked {
t.Errorf("legacy toolkit_versions field leaked to wire: %s", raw)
}
args, _ := wire["arguments"].(map[string]any)
if args["title"] != "hi" {
t.Errorf("arguments.title = %v", args["title"])
}
writeJSON(t, w, http.StatusOK, map[string]any{
"successful": true,
"data": map[string]any{"issue_number": float64(42)},
"log_id": "log_1",
})
})
resp, err := c.ExecuteTool(context.Background(), "GITHUB_CREATE_ISSUE", composio.ExecuteToolRequest{
UserID: "u_1",
Arguments: map[string]any{"title": "hi"},
Version: "latest",
})
if err != nil {
t.Fatalf("ExecuteTool: %v", err)
}
if !resp.Successful || resp.Data["issue_number"].(float64) != 42 || resp.LogID != "log_1" {
t.Errorf("unexpected response: %+v", resp)
}
}
// TestExecuteToolRequest_VersionSerialization locks in the json tag for the
// Version field — GPT-Boy's review against PR #4603 caught that the field
// used to serialize as `toolkit_versions`, which is not a v3.1 wire key.
func TestExecuteToolRequest_VersionSerialization(t *testing.T) {
req := composio.ExecuteToolRequest{
UserID: "u_1",
Version: "20251027_00",
}
b, err := json.Marshal(req)
if err != nil {
t.Fatalf("marshal: %v", err)
}
got := string(b)
if !strings.Contains(got, `"version":"20251027_00"`) {
t.Errorf("version not serialized as `version`: %s", got)
}
if strings.Contains(got, "toolkit_versions") {
t.Errorf("legacy toolkit_versions key leaked: %s", got)
}
// Zero-value Version must omit the field entirely (omitempty).
bEmpty, _ := json.Marshal(composio.ExecuteToolRequest{UserID: "u_1"})
if strings.Contains(string(bEmpty), "version") {
t.Errorf("empty Version should omit, got: %s", bEmpty)
}
}
func TestExecuteTool_ValidatesInputs(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("server should not be hit")
})
if _, err := c.ExecuteTool(context.Background(), "", composio.ExecuteToolRequest{UserID: "u"}); err == nil {
t.Error("expected error for empty tool slug")
}
if _, err := c.ExecuteTool(context.Background(), "X", composio.ExecuteToolRequest{}); err == nil {
t.Error("expected error when neither UserID nor ConnectedAccountID is set")
}
}
// ---------------------------------------------------------------------------
// Error parsing
// ---------------------------------------------------------------------------
func TestAPIError_FallbackOnNonJSONBody(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte("<html>upstream down</html>"))
})
_, err := c.ListToolkits(context.Background(), composio.ListToolkitsRequest{})
if err == nil {
t.Fatal("expected error")
}
var apiErr *composio.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected *APIError, got %T: %v", err, err)
}
if apiErr.HTTPStatus != http.StatusBadGateway {
t.Errorf("status = %d", apiErr.HTTPStatus)
}
if !strings.Contains(string(apiErr.RawBody), "upstream down") {
t.Errorf("raw body lost: %q", apiErr.RawBody)
}
}
func TestAPIError_HelperPredicates(t *testing.T) {
e := &composio.APIError{HTTPStatus: http.StatusNotFound}
if !e.IsNotFound() {
t.Error("IsNotFound() = false")
}
e2 := &composio.APIError{HTTPStatus: http.StatusUnauthorized}
if !e2.IsUnauthorized() {
t.Error("IsUnauthorized() = false")
}
e3 := &composio.APIError{HTTPStatus: http.StatusTooManyRequests}
if !e3.IsRateLimited() {
t.Error("IsRateLimited() = false")
}
}