Files
multica/server/pkg/composio/webhook_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

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