mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 14:00:09 +02:00
* 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>
255 lines
7.8 KiB
Go
255 lines
7.8 KiB
Go
package composio_test
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/multica-ai/multica/server/pkg/composio"
|
|
)
|
|
|
|
// helper: produce a valid signature for the given inputs.
|
|
func sign(secret, id, ts, body string) string {
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
mac.Write([]byte(id + "." + ts + "." + body))
|
|
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
func TestVerifyWebhook_Success(t *testing.T) {
|
|
secret := "shh"
|
|
body := `{"id":"evt_1","type":"composio.connected_account.expired"}`
|
|
id := "msg_abc"
|
|
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
|
sig := sign(secret, id, ts, body)
|
|
|
|
err := composio.VerifyWebhook(secret, composio.WebhookHeaders{
|
|
ID: id, Timestamp: ts, Signature: "v1," + sig,
|
|
}, []byte(body), composio.VerifyOptions{})
|
|
if err != nil {
|
|
t.Fatalf("VerifyWebhook: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifyWebhook_AcceptsBareSignature(t *testing.T) {
|
|
secret := "shh"
|
|
body := `{}`
|
|
id := "msg_b"
|
|
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
|
sig := sign(secret, id, ts, body)
|
|
// No version prefix: just the raw base64
|
|
err := composio.VerifyWebhook(secret, composio.WebhookHeaders{
|
|
ID: id, Timestamp: ts, Signature: sig,
|
|
}, []byte(body), composio.VerifyOptions{})
|
|
if err != nil {
|
|
t.Fatalf("VerifyWebhook bare: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifyWebhook_AcceptsMultipleVersions(t *testing.T) {
|
|
secret := "shh"
|
|
body := `{}`
|
|
id := "msg_c"
|
|
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
|
good := sign(secret, id, ts, body)
|
|
bad := "AAAA" + good[4:]
|
|
// One bad sig, one good sig — verify should still pass.
|
|
hdr := "v2," + bad + " v1," + good
|
|
err := composio.VerifyWebhook(secret, composio.WebhookHeaders{
|
|
ID: id, Timestamp: ts, Signature: hdr,
|
|
}, []byte(body), composio.VerifyOptions{})
|
|
if err != nil {
|
|
t.Fatalf("VerifyWebhook multi: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifyWebhook_RejectsTamperedBody(t *testing.T) {
|
|
secret := "shh"
|
|
body := `{"data":"original"}`
|
|
id := "msg_d"
|
|
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
|
sig := sign(secret, id, ts, body)
|
|
|
|
err := composio.VerifyWebhook(secret, composio.WebhookHeaders{
|
|
ID: id, Timestamp: ts, Signature: "v1," + sig,
|
|
}, []byte(`{"data":"tampered"}`), composio.VerifyOptions{})
|
|
if !errors.Is(err, composio.ErrInvalidWebhookSignature) {
|
|
t.Fatalf("expected ErrInvalidWebhookSignature, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifyWebhook_RejectsStaleTimestamp(t *testing.T) {
|
|
secret := "shh"
|
|
body := `{}`
|
|
id := "msg_e"
|
|
old := time.Now().Add(-10 * time.Minute).Unix()
|
|
ts := strconv.FormatInt(old, 10)
|
|
sig := sign(secret, id, ts, body)
|
|
err := composio.VerifyWebhook(secret, composio.WebhookHeaders{
|
|
ID: id, Timestamp: ts, Signature: "v1," + sig,
|
|
}, []byte(body), composio.VerifyOptions{Tolerance: 5 * time.Minute})
|
|
if !errors.Is(err, composio.ErrWebhookTimestampStale) {
|
|
t.Fatalf("expected ErrWebhookTimestampStale, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifyWebhook_NegativeToleranceDisablesCheck(t *testing.T) {
|
|
secret := "shh"
|
|
body := `{}`
|
|
id := "msg_f"
|
|
ts := "1" // ancient
|
|
sig := sign(secret, id, ts, body)
|
|
err := composio.VerifyWebhook(secret, composio.WebhookHeaders{
|
|
ID: id, Timestamp: ts, Signature: "v1," + sig,
|
|
}, []byte(body), composio.VerifyOptions{Tolerance: -1})
|
|
if err != nil {
|
|
t.Fatalf("VerifyWebhook negative tolerance: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifyWebhook_HonorsCustomNow(t *testing.T) {
|
|
secret := "shh"
|
|
body := `{}`
|
|
id := "msg_g"
|
|
ts := "1700000000"
|
|
sig := sign(secret, id, ts, body)
|
|
err := composio.VerifyWebhook(secret, composio.WebhookHeaders{
|
|
ID: id, Timestamp: ts, Signature: "v1," + sig,
|
|
}, []byte(body), composio.VerifyOptions{
|
|
Tolerance: 5 * time.Second,
|
|
Now: func() time.Time { return time.Unix(1700000003, 0) },
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected fresh timestamp, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifyWebhook_MissingHeaders(t *testing.T) {
|
|
err := composio.VerifyWebhook("shh", composio.WebhookHeaders{}, []byte(`{}`), composio.VerifyOptions{})
|
|
if !errors.Is(err, composio.ErrMissingWebhookHeaders) {
|
|
t.Fatalf("expected ErrMissingWebhookHeaders, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifyWebhook_EmptySecret(t *testing.T) {
|
|
err := composio.VerifyWebhook("", composio.WebhookHeaders{
|
|
ID: "x", Timestamp: "1", Signature: "v1,xyz",
|
|
}, []byte(`{}`), composio.VerifyOptions{})
|
|
if !errors.Is(err, composio.ErrWebhookSecretMissing) {
|
|
t.Fatalf("expected ErrWebhookSecretMissing, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifyWebhook_AcceptsRFC3339Timestamp(t *testing.T) {
|
|
secret := "shh"
|
|
body := `{}`
|
|
id := "msg_h"
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
sig := sign(secret, id, now, body)
|
|
err := composio.VerifyWebhook(secret, composio.WebhookHeaders{
|
|
ID: id, Timestamp: now, Signature: "v1," + sig,
|
|
}, []byte(body), composio.VerifyOptions{})
|
|
if err != nil {
|
|
t.Fatalf("VerifyWebhook rfc3339: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifyHTTPRequest_HappyPath(t *testing.T) {
|
|
secret := "shh"
|
|
body := `{"x":1}`
|
|
id := "msg_req"
|
|
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
|
sig := sign(secret, id, ts, body)
|
|
|
|
r := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader([]byte(body)))
|
|
r.Header.Set(composio.HeaderWebhookID, id)
|
|
r.Header.Set(composio.HeaderWebhookTimestamp, ts)
|
|
r.Header.Set(composio.HeaderWebhookSignature, "v1,"+sig)
|
|
|
|
got, err := composio.VerifyHTTPRequest(secret, r, composio.VerifyOptions{})
|
|
if err != nil {
|
|
t.Fatalf("VerifyHTTPRequest: %v", err)
|
|
}
|
|
if string(got) != body {
|
|
t.Errorf("body roundtrip mismatch: %q vs %q", got, body)
|
|
}
|
|
}
|
|
|
|
func TestVerifyHTTPRequest_ReturnsBodyOnFailure(t *testing.T) {
|
|
body := `{"x":1}`
|
|
r := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader([]byte(body)))
|
|
r.Header.Set(composio.HeaderWebhookID, "id")
|
|
r.Header.Set(composio.HeaderWebhookTimestamp, strconv.FormatInt(time.Now().Unix(), 10))
|
|
r.Header.Set(composio.HeaderWebhookSignature, "v1,deadbeef")
|
|
|
|
got, err := composio.VerifyHTTPRequest("shh", r, composio.VerifyOptions{})
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if string(got) != body {
|
|
t.Errorf("expected body returned for logging, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestVerifyHTTPRequest_NilBody(t *testing.T) {
|
|
r := &http.Request{}
|
|
_, err := composio.VerifyHTTPRequest("shh", r, composio.VerifyOptions{})
|
|
if err == nil {
|
|
t.Fatal("expected error for nil body")
|
|
}
|
|
}
|
|
|
|
// Sanity check: io.ReadAll still gets the same body bytes via our helper.
|
|
func TestVerifyHTTPRequest_BodyReadFully(t *testing.T) {
|
|
body := "{}"
|
|
r := httptest.NewRequest(http.MethodPost, "/", io.NopCloser(bytes.NewReader([]byte(body))))
|
|
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
|
r.Header.Set(composio.HeaderWebhookID, "id")
|
|
r.Header.Set(composio.HeaderWebhookTimestamp, ts)
|
|
r.Header.Set(composio.HeaderWebhookSignature, "v1,"+sign("shh", "id", ts, body))
|
|
got, err := composio.VerifyHTTPRequest("shh", r, composio.VerifyOptions{})
|
|
if err != nil {
|
|
t.Fatalf("VerifyHTTPRequest: %v", err)
|
|
}
|
|
if string(got) != body {
|
|
t.Errorf("body = %q, want %q", got, body)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Event envelope
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestParseEvent_V3Envelope(t *testing.T) {
|
|
raw := []byte(`{
|
|
"id": "evt_1",
|
|
"type": "composio.connected_account.expired",
|
|
"metadata": {"project_id":"pr_a","user_id":"u_1"},
|
|
"data": {"id":"ca_1","status":"EXPIRED"},
|
|
"timestamp": "2026-02-06T12:00:00Z"
|
|
}`)
|
|
ev, err := composio.ParseEvent(raw)
|
|
if err != nil {
|
|
t.Fatalf("ParseEvent: %v", err)
|
|
}
|
|
if ev.ID != "evt_1" || ev.Type != "composio.connected_account.expired" {
|
|
t.Errorf("unexpected envelope: %+v", ev)
|
|
}
|
|
if !bytes.Contains(ev.Data, []byte(`"EXPIRED"`)) {
|
|
t.Errorf("data lost: %s", ev.Data)
|
|
}
|
|
}
|
|
|
|
func TestParseEvent_RejectsGarbage(t *testing.T) {
|
|
if _, err := composio.ParseEvent([]byte(`not-json`)); err == nil {
|
|
t.Error("expected error for non-JSON body")
|
|
}
|
|
}
|