paperclip/server/src/services/composio.test.ts

199 lines
7.0 KiB
TypeScript

import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { afterEach, describe, expect, it } from "vitest";
import { ComposioApiError, createComposioClient } from "./composio.js";
type Fixture = {
baseUrl: string;
requests: Array<{ method: string; url: string; apiKey: string | undefined }>;
close(): Promise<void>;
};
async function startFixture(
handle: (request: IncomingMessage, response: ServerResponse) => void,
): Promise<Fixture> {
const requests: Fixture["requests"] = [];
const server = createServer((request, response) => {
requests.push({
method: request.method ?? "GET",
url: request.url ?? "/",
apiKey: Array.isArray(request.headers["x-api-key"])
? request.headers["x-api-key"]?.[0]
: request.headers["x-api-key"],
});
handle(request, response);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Fixture did not bind a TCP port");
return {
baseUrl: `http://127.0.0.1:${address.port}/api/v3.1`,
requests,
close: () => new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve())),
};
}
const fixtures: Fixture[] = [];
afterEach(async () => {
await Promise.all(fixtures.splice(0).map((fixture) => fixture.close()));
});
describe("Composio REST client", () => {
it("serializes every supported REST operation", async () => {
const requests: Array<{ url: string; method: string; body: unknown }> = [];
const fetchMock = (async (input: string | URL | Request, init?: RequestInit) => {
requests.push({
url: String(input),
method: init?.method ?? "GET",
body: init?.body ? JSON.parse(String(init.body)) : undefined,
});
return new Response(JSON.stringify({ items: [], session_id: "session-1", mcp: { url: "https://mcp.test" } }), {
status: 200,
headers: { "content-type": "application/json" },
});
}) as typeof fetch;
const client = createComposioClient({
apiKey: "ak_fixture",
baseUrl: "https://composio.test/api/v3.1",
fetch: fetchMock,
});
await client.listAuthConfigs({
cursor: "auth-page",
limit: 25,
toolkitSlugs: ["github", "slack"],
showDisabled: true,
});
await client.createConnectLink({
authConfigId: "ac_1",
userId: "user-1",
alias: "primary",
callbackUrl: "https://paperclip.test/callback",
});
await client.listConnectedAccounts({
cursor: "account-page",
limit: 10,
toolkitSlugs: ["github", "slack"],
statuses: ["ACTIVE", "EXPIRED"],
userIds: ["user-1", "user-2"],
authConfigIds: ["ac_1", "ac_2"],
});
await client.deleteConnectedAccount("ca/with spaces");
await client.createSession("user-1", {
mcp: true,
toolkits: ["github"],
tools: { github: { enable: ["GITHUB_LIST_REPOS"] } },
authConfigs: { github: "ac_1" },
connectedAccounts: { github: ["ca_1"] },
});
await client.resumeSession("session/with spaces", { mcp: true });
expect(requests).toEqual([
{
url: "https://composio.test/api/v3.1/auth_configs?cursor=auth-page&limit=25&show_disabled=true&toolkit_slug=github%2Cslack",
method: "GET",
body: undefined,
},
{
url: "https://composio.test/api/v3.1/connected_accounts/link",
method: "POST",
body: {
auth_config_id: "ac_1",
user_id: "user-1",
alias: "primary",
callback_url: "https://paperclip.test/callback",
},
},
{
url: "https://composio.test/api/v3.1/connected_accounts?cursor=account-page&limit=10&toolkit_slugs=github&toolkit_slugs=slack&statuses=ACTIVE&statuses=EXPIRED&user_ids=user-1&user_ids=user-2&auth_config_ids=ac_1&auth_config_ids=ac_2",
method: "GET",
body: undefined,
},
{
url: "https://composio.test/api/v3.1/connected_accounts/ca%2Fwith%20spaces",
method: "DELETE",
body: undefined,
},
{
url: "https://composio.test/api/v3.1/tool_router/session",
method: "POST",
body: {
user_id: "user-1",
mcp: true,
toolkits: { enabled: ["github"] },
tools: { github: { enable: ["GITHUB_LIST_REPOS"] } },
auth_configs: { github: "ac_1" },
connected_accounts: { github: ["ca_1"] },
},
},
{
url: "https://composio.test/api/v3.1/tool_router/session/session%2Fwith%20spaces/attach",
method: "POST",
body: {},
},
]);
});
it("validates an API key with the cheap toolkit-list call", async () => {
const fixture = await startFixture((_request, response) => {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ items: [] }));
});
fixtures.push(fixture);
const client = createComposioClient({ apiKey: "ak_fixture", baseUrl: fixture.baseUrl });
await expect(client.validateApiKey()).resolves.toBeUndefined();
expect(fixture.requests).toEqual([{
method: "GET",
url: "/api/v3.1/toolkits?limit=1",
apiKey: "ak_fixture",
}]);
});
it("rejects an invalid key without reflecting the provider response body", async () => {
const fixture = await startFixture((_request, response) => {
response.writeHead(401, { "content-type": "application/json" });
response.end(JSON.stringify({ message: "provider-controlled secret detail" }));
});
fixtures.push(fixture);
const client = createComposioClient({ apiKey: "bad_fixture", baseUrl: fixture.baseUrl });
const error = await client.validateApiKey().catch((caught) => caught);
expect(error).toBeInstanceOf(ComposioApiError);
expect(error).toMatchObject({ status: 401, message: "Composio rejected the API key." });
expect(String(error)).not.toContain("provider-controlled");
});
it("lists typed toolkits from the project behind the key", async () => {
const fixture = await startFixture((_request, response) => {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({
items: [{
slug: "github",
name: "GitHub",
auth_schemes: ["oauth2"],
meta: { tools_count: 42, logo: "https://example.test/github.png" },
}],
next_cursor: "next-page",
}));
});
fixtures.push(fixture);
const client = createComposioClient({ apiKey: "ak_fixture", baseUrl: fixture.baseUrl });
const result = await client.listToolkits({ limit: 10, cursor: "page-1" });
expect(result).toEqual({
items: [{
slug: "github",
name: "GitHub",
auth_schemes: ["oauth2"],
meta: { tools_count: 42, logo: "https://example.test/github.png" },
}],
next_cursor: "next-page",
});
expect(fixture.requests[0]?.url).toBe("/api/v3.1/toolkits?cursor=page-1&limit=10");
});
});