evOo-local/packages/adapters/src/composio-connector.test.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

753 lines
25 KiB
TypeScript

import type { AdapterContext, ConnectorEvent, ConnectorTool } from "@rakazo/adapter-kit";
import { createLogger, createTestSink, installLogger } from "@rakazo/logging";
import { describe, expect, it, vi } from "vitest";
import { composioToolkitDirectory } from "./composio-catalog-cache.js";
import {
asConnectorTools,
ComposioConnector,
CompositeConnector,
collectLogIds,
collectPages,
executeSessionKey,
filterCatalog,
isComposioEnabled,
isNoAuthToolkitError,
mergeConnectedPlugins,
needsLivePluginSync,
planLiveConnectionSync,
sanitizeComposioError,
} from "./composio-connector.js";
import { DestinationEmulator } from "./destination-emulator.js";
const composioSdkState = vi.hoisted(() => ({
created: [] as Array<{ userId: string; config: Record<string, unknown> }>,
directoryFails: false,
executions: [] as Array<{ tool: string; args: Record<string, unknown> }>,
connectedAccounts: {
list: async (_query?: Record<string, unknown>) => ({ items: [] as Array<{ id: string }> }),
waitForConnection: async (id: string, _timeout?: number) => ({ id: `resolved-${id}` }),
get: async (id: string) => ({ id, userId: "user-1" }),
delete: async (_id: string) => undefined,
},
sessions: new Map<
string,
{
sessionId: string;
toolkits?: () => Promise<{
items: Array<{
slug: string;
name: string;
logo: string | null;
isNoAuth: boolean;
connection?: { isActive?: boolean; connectedAccount?: { id: string } };
}>;
cursor?: string;
}>;
tools?: () => Promise<unknown[]>;
execute?: (
tool: string,
args: Record<string, unknown>,
) => Promise<{
data: Record<string, unknown>;
error: null;
logId: string;
}>;
}
>(),
}));
vi.mock("@composio/core", () => ({
Composio: class {
readonly sessions = {
use: async (sessionId: string) => {
const session = composioSdkState.sessions.get(sessionId);
if (!session) throw new Error(`unknown session ${sessionId}`);
return session;
},
};
readonly connectedAccounts = {
list: (query?: Record<string, unknown>) => composioSdkState.connectedAccounts.list(query),
waitForConnection: (id: string, timeout?: number) =>
composioSdkState.connectedAccounts.waitForConnection(id, timeout),
get: (id: string) => composioSdkState.connectedAccounts.get(id),
delete: (id: string) => composioSdkState.connectedAccounts.delete(id),
};
async create(userId: string, config: Record<string, unknown>) {
composioSdkState.created.push({ userId, config });
const toolkits = Array.isArray(config.toolkits) ? config.toolkits : [];
if (toolkits.length === 0) {
const session = {
sessionId: "catalog-session",
toolkits: async () => {
if (composioSdkState.directoryFails) throw new Error("directory unavailable");
return {
items: [
{
slug: "GITHUB",
name: "GitHub",
logo: null,
isNoAuth: false,
connection: { isActive: true, connectedAccount: { id: "ca-github" } },
},
],
};
},
};
composioSdkState.sessions.set(session.sessionId, session);
return session;
}
const scopedToCanonicalGithub = toolkits.includes("GITHUB");
const session = {
sessionId: scopedToCanonicalGithub ? "github-session" : "unscoped-session",
tools: async () =>
scopedToCanonicalGithub
? [
{
type: "function",
function: {
name: "GITHUB_GET_REPOS",
description: "List GitHub repositories",
parameters: { type: "object", properties: {} },
},
},
]
: [],
execute: async (tool: string, args: Record<string, unknown>) => {
composioSdkState.executions.push({ tool, args });
return { data: { ok: true }, error: null, logId: "log-github" };
},
};
composioSdkState.sessions.set(session.sessionId, session);
return session;
}
},
}));
describe("composio tool mapping", () => {
it("maps OpenAI-style session tools and raw slugs", () => {
const tools = asConnectorTools([
{
type: "function",
function: {
name: "COMPOSIO_SEARCH_TOOLS",
description: "Search tools",
parameters: { type: "object", properties: { query: { type: "string" } } },
},
},
{
slug: "HACKERNEWS_GET_USER",
description: "Look up a public HN profile",
inputParameters: { type: "object", properties: { username: { type: "string" } } },
},
]);
expect(tools.map((tool) => tool.name)).toEqual([
"COMPOSIO_SEARCH_TOOLS",
"HACKERNEWS_GET_USER",
]);
expect(tools[1]?.inputSchema).toMatchObject({ properties: { username: { type: "string" } } });
});
it("retains provider route metadata independently of the tool name", async () => {
const destination = new DestinationEmulator();
const events: ConnectorEvent[] = [];
const composio = {
describe: () => ({ ...destination.describe(), id: "composio" }),
discoverTools: async () => [
{
name: "destination.write",
description: "shadow",
inputSchema: {},
route: { connectorId: "composio", toolName: "destination.write" },
} satisfies ConnectorTool,
],
execute: async function* () {
yield { type: "result", data: { provider: "composio" } } as ConnectorEvent;
},
} as never;
const connector = new CompositeConnector(destination, [composio]);
const context = { userId: "u" } as AdapterContext;
for await (const event of connector.execute(
{
tool: "destination.write",
args: {},
executionId: "x",
route: { connectorId: "composio", toolName: "destination.write" },
},
context,
))
events.push(event);
expect(events).toEqual([{ type: "result", data: { provider: "composio" } }]);
});
it("logs sanitized connector discovery failures", async () => {
const destination = new DestinationEmulator();
const failing = {
describe: () => ({ ...destination.describe(), id: "failing" }),
discoverTools: async () => {
throw new Error("denied ak_secretvaluehere");
},
execute: async function* () {},
} as never;
const connector = new CompositeConnector(destination, [failing]);
const sink = createTestSink();
installLogger(createLogger({ service: "rakazo-api", sinks: [sink] }));
try {
await expect(connector.discoverTools({ userId: "u" } as AdapterContext)).resolves.toEqual([
expect.objectContaining({ name: "destination.write" }),
]);
expect(sink.events[0]).toMatchObject({
message: "connector discovery failed",
"connector.id": "failing",
});
const logged = JSON.stringify(sink.events);
expect(logged).toContain("[redacted]");
expect(logged).not.toContain("ak_secretvaluehere");
} finally {
installLogger(createLogger({ service: "rakazo-api", level: "off", sinks: [] }));
}
});
it("redacts project keys from errors", () => {
expect(sanitizeComposioError("denied ak_secretvaluehere")).toContain("[redacted]");
expect(sanitizeComposioError("denied ak_secretvaluehere")).not.toContain("ak_secret");
expect(sanitizeComposioError("COMPOSIO_API_KEY=ak_shouldnotleak")).not.toContain(
"ak_shouldnotleak",
);
});
it("paginates until the cursor ends", async () => {
const pages = [
{ items: ["gmail", "github"], cursor: "page-2" },
{ items: ["slack"], cursor: undefined },
];
const items = await collectPages(async (cursor) => {
if (!cursor) return pages[0]!;
return pages[1]!;
});
expect(items).toEqual(["gmail", "github", "slack"]);
});
it("treats Composio no-auth toolkit errors as in-app connect", () => {
expect(
isNoAuthToolkitError(
new Error(
'400 {"error":{"message":"Toolkit hackernews does not require authentication.","slug":"ToolRouterV2_ToolkitsIsNoAuth"}}',
),
),
).toBe(true);
expect(isNoAuthToolkitError(new Error("redirect required"))).toBe(false);
});
it("collects nested Composio log ids", () => {
expect(
collectLogIds({
logId: "",
data: { results: [{ log_id: "log_abc123", slug: "HACKERNEWS_GET_USER" }] },
}),
).toEqual(["log_abc123"]);
});
it("keys execute sessions by sorted unique toolkits", () => {
expect(executeSessionKey(["hackernews", "gmail", "hackernews"])).toBe("gmail,hackernews");
expect(executeSessionKey(["github", "GITHUB"])).toBe("github");
expect(executeSessionKey([])).toBe("");
expect(executeSessionKey(["GMAIL"], { GMAIL: ["ca-work", "ca-personal", "ca-work"] })).toBe(
"GMAIL|GMAIL:ca-personal,ca-work",
);
});
it("pins every connected account into multi-account execute sessions", async () => {
composioSdkState.created.length = 0;
composioSdkState.sessions.clear();
composioToolkitDirectory.invalidate();
const connector = new ComposioConnector();
await connector.discoverTools({
operationId: "composio-multi-account",
traceId: "composio-multi-account",
spaceId: "workspace",
userId: "user-1",
signal: new AbortController().signal,
connectedConnections: [
{
id: "connection-personal",
connectorId: "composio",
externalId: "github",
displayName: "Personal",
providerRef: "ca-personal",
},
{
id: "connection-work",
connectorId: "composio",
externalId: "GITHUB",
displayName: "Work",
providerRef: "ca-work",
},
{
id: "connection-personal-dup",
connectorId: "composio",
externalId: "GitHub",
displayName: "Personal again",
providerRef: "ca-personal",
},
{
id: "connection-noauth",
connectorId: "composio",
externalId: "GITHUB",
displayName: "Legacy",
providerRef: "github",
},
],
});
expect(composioSdkState.created.at(-1)).toEqual({
userId: "user-1",
config: {
manageConnections: false,
sandbox: { enable: false },
toolkits: ["GITHUB"],
connectedAccounts: { GITHUB: ["ca-personal", "ca-work"] },
multiAccount: {
enable: true,
maxAccountsPerToolkit: 10,
requireExplicitSelection: true,
},
},
});
});
it("pins a single concrete account without enabling multi-account mode", async () => {
composioSdkState.created.length = 0;
composioSdkState.sessions.clear();
composioToolkitDirectory.invalidate();
const connector = new ComposioConnector();
await connector.discoverTools({
operationId: "composio-single-account",
traceId: "composio-single-account",
spaceId: "workspace",
userId: "user-1",
signal: new AbortController().signal,
connectedConnections: [
{
id: "connection-work",
connectorId: "composio",
externalId: "gmail",
displayName: "Work",
providerRef: "ca-work",
},
],
});
expect(composioSdkState.created.at(-1)).toEqual({
userId: "user-1",
config: {
manageConnections: false,
sandbox: { enable: false },
toolkits: ["GMAIL"],
connectedAccounts: { GMAIL: ["ca-work"] },
},
});
});
it("omits connectedAccounts and multiAccount for legacy slug-only refs", async () => {
composioSdkState.created.length = 0;
composioSdkState.sessions.clear();
composioToolkitDirectory.invalidate();
const connector = new ComposioConnector();
await connector.discoverTools({
operationId: "composio-legacy-only",
traceId: "composio-legacy-only",
spaceId: "workspace",
userId: "user-1",
signal: new AbortController().signal,
connectedConnections: [
{
id: "connection-legacy",
connectorId: "composio",
externalId: "GITHUB",
displayName: "Legacy",
providerRef: "github",
},
{
id: "connection-hackernews",
connectorId: "composio",
externalId: "hackernews",
displayName: "Hacker News",
providerRef: "HACKERNEWS",
},
],
});
expect(composioSdkState.created.at(-1)).toEqual({
userId: "user-1",
config: {
manageConnections: false,
sandbox: { enable: false },
toolkits: ["GITHUB", "HACKERNEWS"],
},
});
});
it("uses catalog-canonical toolkit slugs without preloading every tool", async () => {
composioSdkState.created.length = 0;
composioSdkState.executions.length = 0;
composioSdkState.sessions.clear();
composioToolkitDirectory.invalidate();
const connector = new ComposioConnector();
const context: AdapterContext = {
operationId: "composio-canonical-slug",
traceId: "composio-canonical-slug",
spaceId: "workspace",
userId: "user-1",
signal: new AbortController().signal,
connectedConnections: [
{
id: "connection-github",
connectorId: "composio",
externalId: "github",
displayName: "GitHub",
},
],
};
await expect(connector.discoverTools(context)).resolves.toContainEqual(
expect.objectContaining({ name: "GITHUB_GET_REPOS" }),
);
expect(
composioSdkState.created.map(({ userId, config }) => ({
userId,
toolkits: config.toolkits,
sessionPreset: config.sessionPreset,
})),
).toEqual([
{ userId: "__rakazo_catalog__", toolkits: undefined, sessionPreset: undefined },
{ userId: "user-1", toolkits: ["GITHUB"], sessionPreset: undefined },
]);
const events: ConnectorEvent[] = [];
for await (const event of connector.execute(
{
tool: "GITHUB_GET_REPOS",
args: { owner: "composio" },
executionId: "composio-canonical-execution",
},
context,
)) {
events.push(event);
}
expect(events).toContainEqual(expect.objectContaining({ type: "result" }));
expect(composioSdkState.executions).toEqual([
{ tool: "GITHUB_GET_REPOS", args: { owner: "composio" } },
]);
await expect(connector.connectionReady(context, "github")).resolves.toBe(true);
await expect(connector.connectedAccountId("user-1", "github")).resolves.toBe("ca-github");
});
it("resolves connection-request ids to connected-account ids and skips sibling refs", async () => {
composioSdkState.created.length = 0;
composioSdkState.sessions.clear();
composioToolkitDirectory.invalidate();
composioSdkState.connectedAccounts.list = async () => ({
items: [{ id: "ca-personal" }, { id: "ca-work" }],
});
composioSdkState.connectedAccounts.waitForConnection = async (id: string) => {
if (id === "req-work") return { id: "ca-work" };
if (id === "req-missing") throw new Error("timeout");
return { id: `resolved-${id}` };
};
const connector = new ComposioConnector();
await expect(
connector.resolveConnectedAccountId("user-1", "gmail", "req-work", ["ca-personal"]),
).resolves.toBe("ca-work");
await expect(
connector.resolveConnectedAccountId("user-1", "gmail", "gmail", ["ca-personal"]),
).resolves.toBe("ca-work");
await expect(
connector.resolveConnectedAccountId("user-1", "gmail", "req-missing", [
"ca-personal",
"ca-work",
]),
).resolves.toBeUndefined();
});
it("cancels pending authorization requests by request id without waiting", async () => {
const deleted: string[] = [];
const waited: string[] = [];
composioSdkState.connectedAccounts.waitForConnection = async (id: string) => {
waited.push(id);
throw new Error(`should not wait for ${id}`);
};
composioSdkState.connectedAccounts.delete = async (id: string) => {
deleted.push(id);
};
const connector = new ComposioConnector();
await connector.cancelAuthorizationRequest("req-pending-1", {
operationId: "test",
traceId: "test",
spaceId: "workspace",
userId: "user-1",
signal: new AbortController().signal,
});
expect(waited).toEqual([]);
expect(deleted).toEqual(["req-pending-1"]);
});
it.each([{ status: 404 }, { statusCode: 404 }])(
"ignores missing authorization requests during cancellation (%o)",
async (notFound) => {
composioSdkState.connectedAccounts.delete = async () => {
throw Object.assign(new Error("not found"), notFound);
};
const connector = new ComposioConnector();
await expect(
connector.cancelAuthorizationRequest("req-missing", {
operationId: "test",
traceId: "test",
spaceId: "workspace",
userId: "user-1",
signal: new AbortController().signal,
}),
).resolves.toBeUndefined();
},
);
it("surfaces authorization cancellation failures for router fallback", async () => {
composioSdkState.connectedAccounts.delete = async () => {
throw Object.assign(new Error("service unavailable"), { status: 503 });
};
const connector = new ComposioConnector();
await expect(
connector.cancelAuthorizationRequest("req-pending-1", {
operationId: "test",
traceId: "test",
spaceId: "workspace",
userId: "user-1",
signal: new AbortController().signal,
}),
).rejects.toThrow("service unavailable");
});
it("resolves authorization-request ids before deleting on revoke", async () => {
const deleted: string[] = [];
const waited: string[] = [];
composioSdkState.connectedAccounts.list = async () => ({ items: [] });
composioSdkState.connectedAccounts.waitForConnection = async (id: string) => {
waited.push(id);
return { id: `ca-from-${id}` };
};
composioSdkState.connectedAccounts.delete = async (id: string) => {
deleted.push(id);
};
const connector = new ComposioConnector();
await connector.revoke("req-oauth-1", {
operationId: "test",
traceId: "test",
spaceId: "workspace",
userId: "user-1",
signal: new AbortController().signal,
});
expect(waited).toEqual(["req-oauth-1"]);
expect(deleted).toEqual(["ca-from-req-oauth-1"]);
});
it("uses Composio slug casing when the toolkit directory is unavailable", async () => {
composioSdkState.created.length = 0;
composioSdkState.sessions.clear();
composioSdkState.directoryFails = true;
composioToolkitDirectory.invalidate();
try {
const connector = new ComposioConnector();
const context = {
operationId: "composio-directory-fallback",
traceId: "composio-directory-fallback",
spaceId: "workspace",
userId: "user-1",
signal: new AbortController().signal,
connectedConnections: [
{
id: "connection-github",
connectorId: "composio",
externalId: "github",
displayName: "GitHub",
},
],
} satisfies AdapterContext;
await expect(connector.discoverTools(context)).resolves.toContainEqual(
expect.objectContaining({ name: "GITHUB_GET_REPOS" }),
);
expect(composioSdkState.created.at(-1)?.config.toolkits).toEqual(["GITHUB"]);
} finally {
composioSdkState.directoryFails = false;
composioToolkitDirectory.invalidate();
}
});
it("merges live Composio slugs onto pending DB plugin rows", () => {
const merged = mergeConnectedPlugins(
[
{ provider: "github", displayName: "GitHub", status: "connected" },
{ provider: "gmail", displayName: "Gmail", status: "pending" },
{ provider: "linear", displayName: "Linear", status: "revoked" },
],
["gmail", "github", "notion"],
);
expect(merged).toEqual([
{ provider: "github", displayName: "GitHub" },
{ provider: "gmail", displayName: "Gmail" },
]);
});
it("reconciles Composio slugs without case sensitivity", () => {
expect(
mergeConnectedPlugins(
[{ provider: "github", displayName: "GitHub", status: "pending" }],
["GITHUB"],
),
).toEqual([{ provider: "github", displayName: "GitHub" }]);
expect(
planLiveConnectionSync(
[{ id: "row-gh", provider: "github", status: "pending", displayName: "GitHub" }],
["GITHUB"],
),
).toEqual({ connectIds: ["row-gh"], revokeIds: [] });
});
it("only fetches live Composio slugs when a Rakazo row is still pending or errored", () => {
expect(needsLivePluginSync([{ status: "connected" }, { status: "revoked" }])).toBe(false);
expect(needsLivePluginSync([{ status: "pending" }])).toBe(true);
expect(needsLivePluginSync([{ status: "error" }])).toBe(true);
});
it("keeps DB-connected plugins when live Composio listing is empty", () => {
expect(
mergeConnectedPlugins(
[{ provider: "github", displayName: "GitHub", status: "connected" }],
[],
),
).toEqual([{ provider: "github", displayName: "GitHub" }]);
});
it("plans DB sync when Composio is connected but Rakazo is still pending", () => {
expect(
planLiveConnectionSync(
[
{ id: "row-gmail", provider: "gmail", status: "pending", displayName: "Gmail" },
{ id: "row-gh", provider: "github", status: "connected", displayName: "GitHub" },
],
["gmail", "slack"],
),
).toEqual({
connectIds: ["row-gmail"],
revokeIds: [],
});
});
it("does not create connection rows for live slugs that have no workspace row", () => {
expect(
planLiveConnectionSync(
[{ id: "row-gh", provider: "github", status: "connected", displayName: "GitHub" }],
["github", "slack"],
),
).toEqual({ connectIds: [], revokeIds: [] });
});
it("reconnects existing error or revoked rows instead of inserting duplicates", () => {
expect(
planLiveConnectionSync(
[
{ id: "row-err", provider: "gmail", status: "error", displayName: "Gmail" },
{ id: "row-old", provider: "slack", status: "revoked", displayName: "Slack" },
{ id: "row-gh", provider: "github", status: "connected", displayName: "GitHub" },
],
["gmail", "slack", "github", "slack"],
),
).toEqual({
connectIds: ["row-err", "row-old"],
revokeIds: [],
});
});
it("revokes abandoned pending or error rows after a successful live listing", () => {
expect(
planLiveConnectionSync(
[
{ id: "row-gmail", provider: "gmail", status: "pending", displayName: "Gmail" },
{ id: "row-dup", provider: "gmail", status: "pending", displayName: "Gmail" },
{ id: "row-err", provider: "slack", status: "error", displayName: "Slack" },
{ id: "row-gh", provider: "github", status: "connected", displayName: "GitHub" },
],
["gmail"],
),
).toEqual({
connectIds: ["row-gmail"],
revokeIds: ["row-dup", "row-err"],
});
});
it("clears failed additional-account error rows while keeping in-flight pendings", () => {
expect(
planLiveConnectionSync(
[
{ id: "row-gmail", provider: "gmail", status: "connected", displayName: "Personal" },
{ id: "row-work", provider: "gmail", status: "pending", displayName: "Work" },
{ id: "row-fail", provider: "gmail", status: "error", displayName: "Gmail" },
{ id: "row-err", provider: "slack", status: "error", displayName: "Slack" },
],
["gmail"],
),
).toEqual({
connectIds: [],
revokeIds: ["row-fail", "row-err"],
});
});
it("keeps an in-flight additional account when the provider is already connected", () => {
expect(
planLiveConnectionSync(
[
{ id: "row-gmail", provider: "gmail", status: "connected", displayName: "Personal" },
{ id: "row-work", provider: "gmail", status: "pending", displayName: "Work" },
{ id: "row-err", provider: "slack", status: "error", displayName: "Slack" },
],
["gmail"],
),
).toEqual({
connectIds: [],
revokeIds: ["row-err"],
});
});
it("filters the catalog by name or slug", () => {
const items = [
{ slug: "github", name: "GitHub", logo: null, connected: false, noAuth: false },
{ slug: "hackernews", name: "Hacker News", logo: null, connected: false, noAuth: true },
];
expect(filterCatalog(items, "hacker").map((item) => item.slug)).toEqual(["hackernews"]);
});
});
describe("Composio during pnpm test", () => {
it("does not construct a live Platform client under Vitest", () => {
expect(process.env.VITEST).toBeTruthy();
expect(isComposioEnabled("ck_must_not_call_live")).toBe(false);
});
it.each(["0", "false"])("does not treat VITEST=%s as an active test runner", (value) => {
vi.stubEnv("VITEST", value);
expect(isComposioEnabled("ck_configured")).toBe(true);
vi.unstubAllEnvs();
});
});