mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
Preserve governed Board CLI authority and strict verification fixtures on the Muse stack.
Keep approval/cancel ownership, current membership/key authority and durable first-change audit atomic. Bound Retry-After scheduling within the original challenge; never automatically resubmit consent. Retain descriptor ownership until identity-based cleanup and close it on success and error. Keep strict Git-origin and synchronized provider-entry fixture assertions.
Related public work: LukeAbell's existing PR #13919 proposes the same production descriptor-lifetime fix. This independently measured local integration retains the original tests and adds real-descriptor coverage. Do not treat this local integration as ownership of that contribution.
Carry actual merged UI source 896f3c7f3b through a normal forward merge, preserving canonical workspace recovery, Muse authority and existing migration bytes. Current-head CI and substantive review remain merge gates. Complete qualification of the final immutable canonical source and actual provider acceptance remain separate.
Refs #16482, #13919.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
345 lines
27 KiB
TypeScript
345 lines
27 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { eq } from "drizzle-orm";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { agents, companies, companyMemberships, connectionGrants, createDb, issues, toolApplications, toolCatalogEntries, toolConnections, toolConnectionAppSnapshots } from "@paperclipai/db";
|
|
import { composioAppSetupSchema } from "@paperclipai/shared";
|
|
import { composioAppAccounts, composioAppSetupResult } from "../services/composio-app-setup.js";
|
|
import { toolAccessService } from "../services/tool-access.js";
|
|
import { toolAccessPolicyService } from "../services/tool-access-policy.js";
|
|
import { toolAccessRoutes } from "../routes/tool-access.js";
|
|
import { errorHandler } from "../middleware/error-handler.js";
|
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
|
|
describe("Composio app authorization evidence", () => {
|
|
it("completes app setup without an agent selection", () => {
|
|
expect(composioAppSetupSchema.parse({ action: "complete" })).toEqual({ action: "complete" });
|
|
expect(composioAppSetupSchema.safeParse({ action: "complete", agentId: randomUUID() }).success).toBe(false);
|
|
});
|
|
it("keeps only the requested app's account identities and never treats missing or malformed evidence as an empty list", () => {
|
|
const response = { results: { gmail: { accounts: [{ id: "gmail", status: "ACTIVE" }] }, circleback_mcp: { accounts: [{ id: "ca_1", alias: "Work", status: "active", is_default: true, access_token: "secret" }] } } };
|
|
expect(composioAppAccounts(response, "circleback_mcp")).toEqual([{ id: "ca_1", alias: "Work", status: "ACTIVE", isDefault: true }]);
|
|
expect(composioAppAccounts(response, "missing")).toBeUndefined();
|
|
expect(composioAppAccounts({ circleback_mcp: { accounts: [] } }, "circleback_mcp")).toEqual([]);
|
|
expect(composioAppAccounts({ circleback_mcp: { accounts: [{ status: "active" }] } }, "circleback_mcp")).toBeUndefined();
|
|
expect(() => composioAppAccounts({ circleback_mcp: { error: "expired", accounts: [] } }, "circleback_mcp")).toThrow("could not check");
|
|
});
|
|
it("accepts a hosted link and only verifies the requested toolkit", () => {
|
|
expect(composioAppSetupResult({ results: { circleback_mcp: { redirect_url: "https://connect.composio.dev/link/test" } } }, "circleback_mcp"))
|
|
.toEqual({ status: "authorization_required", authorizationUrl: "https://connect.composio.dev/link/test" });
|
|
expect(composioAppSetupResult({ results: { gmail: { status: "ACTIVE" }, circleback_mcp: { accounts: [{ status: "INITIATED" }] } } }, "circleback_mcp"))
|
|
.toEqual({ status: "not_connected" });
|
|
expect(composioAppSetupResult({ result: { content: [{ type: "text", text: JSON.stringify({ results: { circleback_mcp: { accounts: [{ status: "ACTIVE" }] } } }) }] } }, "circleback_mcp"))
|
|
.toEqual({ status: "connected" });
|
|
});
|
|
it("rejects provider failures and untrusted handoff URLs", () => {
|
|
expect(() => composioAppSetupResult({ isError: true }, "circleback_mcp")).toThrow("could not configure");
|
|
expect(composioAppSetupResult({ redirect_url: "https://untrusted.example/link/test" }, "circleback_mcp")).toEqual({ status: "not_connected" });
|
|
});
|
|
it("recognizes the lowercase account status returned by the live Composio gateway", () => {
|
|
const response = { result: { content: [{ type: "text", text: JSON.stringify({
|
|
data: { results: { circleback_mcp: { toolkit: "circleback_mcp", status: "active", accounts: [{ status: "active", is_default: true }] } } },
|
|
error: null, successful: true,
|
|
}) }], isError: false } };
|
|
expect(composioAppSetupResult(response, "circleback_mcp")).toEqual({ status: "connected" });
|
|
expect(composioAppSetupResult(response, "gmail")).toEqual({ status: "not_connected" });
|
|
});
|
|
});
|
|
|
|
const support = await getEmbeddedPostgresTestSupport();
|
|
(support.supported ? describe : describe.skip)("direct Composio setup", () => {
|
|
let db: ReturnType<typeof createDb>;
|
|
let cleanup: (() => Promise<void>) | undefined;
|
|
beforeAll(async () => {
|
|
const database = await startEmbeddedPostgresTestDatabase("paperclip-composio-setup-");
|
|
db = createDb(database.connectionString);
|
|
cleanup = database.cleanup;
|
|
}, 20_000);
|
|
afterAll(async () => { await cleanup?.(); });
|
|
|
|
async function fixture(syncToolkits: string[] = ["circleback_mcp", "notion", "hubspot"]) {
|
|
const [company] = await db.insert(companies).values({ name: "Composio direct setup", issuePrefix: randomUUID().slice(0, 6).toUpperCase() }).returning();
|
|
const userId = randomUUID();
|
|
await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user", principalId: userId, membershipRole: "admin", status: "active" });
|
|
const [agent, otherAgent] = await db.insert(agents).values(["Default agent", "Other agent"].map(name => ({ companyId: company.id, name, role: "general" as const, status: "idle" as const, adapterType: "codex_local" }))).returning();
|
|
const [app] = await db.insert(toolApplications).values({ companyId: company.id, applicationKey: randomUUID(), name: "Composio", type: "mcp_http", metadata: { sourceTemplateKey: "composio" } }).returning();
|
|
const [connection] = await db.insert(toolConnections).values({ companyId: company.id, applicationId: app.id, uid: randomUUID(), name: "Composio account", transport: "mcp_remote", authKind: "none", credentialPolicy: "shared", status: "active", enabled: true, config: { sourceTemplateKey: "composio", url: "https://connect.composio.dev/mcp" } }).returning();
|
|
await db.insert(connectionGrants).values({ companyId: company.id, connectionId: connection.id, kind: "organization", isDefault: true, status: "active" });
|
|
const catalog = await db.insert(toolCatalogEntries).values([
|
|
["COMPOSIO_SEARCH_TOOLS", "read"], ["COMPOSIO_GET_TOOL_SCHEMAS", "read"], ["COMPOSIO_MULTI_EXECUTE_TOOL", "destructive"], ["COMPOSIO_MANAGE_CONNECTIONS", "destructive"],
|
|
].map(([toolName, riskLevel]) => ({ companyId: company.id, connectionId: connection.id, name: toolName, toolName, versionHash: "v1", status: "active" as const, riskLevel: riskLevel as "read" | "destructive" }))).returning();
|
|
const accountId = randomUUID();
|
|
let accounts = [{ id: accountId, alias: "Work", status: "initiated", is_default: true, access_token: "never-store-this-token" }];
|
|
let onList = async () => {};
|
|
let listResult: unknown = undefined;
|
|
let failedToolkit: string | undefined;
|
|
let mutationStatus = 200;
|
|
let applyMutation = true;
|
|
const operations: string[] = [];
|
|
const batches: { name: string; action: string; account_id?: string; alias?: string }[][] = [];
|
|
const access = toolAccessService(db, { composioAppToolkits: syncToolkits, remoteHttpRequest: async (_url, init) => {
|
|
const request = JSON.parse(String(init.body));
|
|
const toolkits = request.params.arguments.toolkits;
|
|
expect(request.params.name).toBe("COMPOSIO_MANAGE_CONNECTIONS");
|
|
batches.push(toolkits);
|
|
const results: Record<string, unknown> = {};
|
|
for (const toolkit of toolkits) {
|
|
operations.push(toolkit.action);
|
|
if (toolkit.action === "list") await onList();
|
|
else if (mutationStatus !== 200) return new Response("unconfirmed", { status: mutationStatus });
|
|
if (applyMutation && toolkit.name === "circleback_mcp") {
|
|
if (toolkit.action === "rename") accounts = accounts.map(account => account.id === toolkit.account_id ? { ...account, alias: toolkit.alias } : account);
|
|
if (toolkit.action === "remove") accounts = accounts.filter(account => account.id !== toolkit.account_id);
|
|
}
|
|
results[toolkit.name] = toolkit.name === failedToolkit ? { toolkit: toolkit.name, accounts: [{ status: "ACTIVE" }] }
|
|
: toolkit.action === "add" ? { redirect_url: "https://connect.composio.dev/link/test" }
|
|
: listResult ?? { toolkit: toolkit.name, accounts: toolkit.name === "circleback_mcp" ? accounts : [] };
|
|
}
|
|
return Response.json({ jsonrpc: "2.0", id: request.id, result: { structuredContent: { data: { results } } } });
|
|
} });
|
|
return { company, agent, otherAgent, connection, catalog, access, operations, batches, accountId, actor: { actorType: "user" as const, actorId: userId },
|
|
activate: () => { accounts = accounts.map(account => ({ ...account, status: "active" })); },
|
|
disconnect: () => { accounts = []; },
|
|
setListResult: (result: unknown) => { listResult = result; },
|
|
failToolkit: (toolkit: string) => { failedToolkit = toolkit; },
|
|
setMutationStatus: (status: number) => { mutationStatus = status; },
|
|
refuseMutation: () => { applyMutation = false; },
|
|
onList: (callback: () => Promise<void>) => { onList = callback; } };
|
|
|
|
}
|
|
|
|
async function configureAccess(f: Awaited<ReturnType<typeof fixture>>, agentId?: string) {
|
|
await f.access.putConnectionInstalls(f.connection.id, { installs: agentId
|
|
? [{ targetType: "agent", targetId: agentId }] : [{ targetType: "company", targetId: f.company.id }] }, f.actor);
|
|
await f.access.finishGalleryAppConnection(f.company.id, f.connection.id, {
|
|
enabledCatalogEntryIds: f.catalog.map(tool => tool.id),
|
|
askFirstCatalogEntryIds: f.catalog.filter(tool => ["COMPOSIO_MULTI_EXECUTE_TOOL", "COMPOSIO_MANAGE_CONNECTIONS"].includes(tool.toolName)).map(tool => tool.id),
|
|
access: agentId ? { agentIds: [agentId] } : "all_agents",
|
|
}, f.actor);
|
|
}
|
|
|
|
it("generates and verifies app sign-in without a task or new access grants, preserving company-wide gateway defaults", async () => {
|
|
const f = await fixture();
|
|
await configureAccess(f);
|
|
const grants = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, f.connection.id));
|
|
const installs = await f.access.listConnectionInstalls(f.connection.id, f.company.id);
|
|
const before = (await f.access.getEffectiveProfilesForAgent(f.company.id, f.agent.id)).allowedTools;
|
|
expect(await f.access.setupComposioApp(f.connection.id, "circleback_mcp", { action: "start" }, f.actor)).toMatchObject({ status: "authorization_required" });
|
|
expect(f.operations).toEqual(["list", "add"]);
|
|
expect(await db.select().from(issues).where(eq(issues.companyId, f.company.id))).toHaveLength(0);
|
|
await expect(f.access.setupComposioApp(f.connection.id, "circleback_mcp", { action: "complete" }, f.actor)).rejects.toThrow("Finish connecting");
|
|
expect((await f.access.getEffectiveProfilesForAgent(f.company.id, f.agent.id)).allowedTools).toEqual(before);
|
|
f.activate();
|
|
await f.access.setupComposioApp(f.connection.id, "circleback_mcp", { action: "complete" }, f.actor);
|
|
expect((await f.access.getEffectiveProfilesForAgent(f.company.id, f.agent.id)).allowedTools).toEqual(before);
|
|
expect((await f.access.getEffectiveProfilesForAgent(f.company.id, f.otherAgent.id)).allowedTools.map(t => t.toolName).sort()).toEqual(f.catalog.map(t => t.toolName).sort());
|
|
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, f.connection.id))).toEqual(grants);
|
|
expect(await f.access.listConnectionInstalls(f.connection.id, f.company.id)).toEqual(installs);
|
|
const tool = f.catalog.find(t => t.toolName === "COMPOSIO_MULTI_EXECUTE_TOOL")!;
|
|
const decision = await toolAccessPolicyService(db).decide({ companyId: f.company.id, actor: { actorType: "agent", actorId: f.agent.id, agentId: f.agent.id }, request: { connectionId: f.connection.id, catalogEntryId: tool.id, toolName: tool.toolName, arguments: {} } });
|
|
expect(decision.decision).toBe("require_approval");
|
|
expect(f.operations.filter(action => action === "add")).toHaveLength(1);
|
|
});
|
|
it("does not create another link for an active account and rejects unknown toolkits", async () => {
|
|
const f = await fixture();
|
|
f.activate();
|
|
expect(await f.access.setupComposioApp(f.connection.id, "circleback_mcp", { action: "start" }, f.actor)).toEqual({ status: "connected" });
|
|
expect(f.operations).toEqual(["list"]);
|
|
await expect(f.access.setupComposioApp(f.connection.id, "invented", { action: "start" }, f.actor)).rejects.toThrow("not in the catalog");
|
|
});
|
|
it("keeps meta-tool execution behind approval even if its catalog risk is read", async () => {
|
|
const f = await fixture();
|
|
const tool = f.catalog.find(t => t.toolName === "COMPOSIO_MULTI_EXECUTE_TOOL")!;
|
|
await db.update(toolCatalogEntries).set({ riskLevel: "read" }).where(eq(toolCatalogEntries.id, tool.id));
|
|
await configureAccess(f);
|
|
f.activate();
|
|
await f.access.setupComposioApp(f.connection.id, "circleback_mcp", { action: "complete" }, f.actor);
|
|
const decision = await toolAccessPolicyService(db).decide({ companyId: f.company.id, actor: { actorType: "agent", actorId: f.agent.id, agentId: f.agent.id }, request: { connectionId: f.connection.id, catalogEntryId: tool.id, toolName: tool.toolName, arguments: {} } });
|
|
expect(decision.decision).toBe("require_approval");
|
|
});
|
|
it("does not complete app setup if the gateway is disabled while checking Composio", async () => {
|
|
const f = await fixture();
|
|
f.activate();
|
|
f.onList(async () => { await db.update(toolConnections).set({ enabled: false }).where(eq(toolConnections.id, f.connection.id)); });
|
|
await expect(f.access.setupComposioApp(f.connection.id, "circleback_mcp", { action: "complete" }, f.actor)).rejects.toThrow("no longer active");
|
|
expect((await f.access.getEffectiveProfilesForAgent(f.company.id, f.agent.id)).allowedTools).toHaveLength(0);
|
|
});
|
|
it("preserves a saved gateway's restricted access when another app is connected", async () => {
|
|
const f = await fixture();
|
|
await configureAccess(f, f.agent.id);
|
|
const before = (await f.access.getEffectiveProfilesForAgent(f.company.id, f.agent.id)).allowedTools;
|
|
f.activate();
|
|
await f.access.setupComposioApp(f.connection.id, "circleback_mcp", { action: "complete" }, f.actor);
|
|
expect((await f.access.getEffectiveProfilesForAgent(f.company.id, f.agent.id)).allowedTools).toEqual(before);
|
|
expect((await f.access.getEffectiveProfilesForAgent(f.company.id, f.otherAgent.id)).allowedTools).toHaveLength(0);
|
|
});
|
|
it("rejects agents, non-managers, and other companies before calling Composio", async () => {
|
|
const f = await fixture();
|
|
const userId = randomUUID();
|
|
await db.insert(companyMemberships).values({ companyId: f.company.id, principalType: "user", principalId: userId, membershipRole: "member", status: "active" });
|
|
let providerCalls = 0;
|
|
const member: Express.Request["actor"] = { type: "board", userId, source: "session", isInstanceAdmin: false, companyIds: [f.company.id], memberships: [{ companyId: f.company.id, membershipRole: "member", status: "active" }] };
|
|
const actors: [Express.Request["actor"], number][] = [
|
|
[member, 403],
|
|
[{ ...member, companyIds: [randomUUID()], memberships: [] }, 404],
|
|
[{ type: "agent", agentId: f.agent.id, companyId: f.company.id, source: "agent_key" }, 403],
|
|
];
|
|
for (const [actor, status] of actors) {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => { req.actor = actor; next(); });
|
|
app.use("/api", toolAccessRoutes(db, { remoteHttpRequest: async () => { providerCalls++; throw new Error("Must not reach provider"); } }));
|
|
app.use(errorHandler);
|
|
await request(app).post(`/api/tool-connections/${f.connection.id}/composio/apps/circleback_mcp/setup`).send({ action: "start" }).expect(status);
|
|
await request(app).get(`/api/tool-connections/${f.connection.id}/composio/apps`).expect(status);
|
|
await request(app).post(`/api/tool-connections/${f.connection.id}/composio/apps/sync`).send({ force: true }).expect(status);
|
|
await request(app).post(`/api/tool-connections/${f.connection.id}/composio/apps/refresh`).send({ toolkits: ["circleback_mcp"] }).expect(status);
|
|
await request(app).post(`/api/tool-connections/${f.connection.id}/composio/apps/circleback_mcp/accounts`).send({ action: "remove", accountId: f.accountId }).expect(status);
|
|
}
|
|
expect(providerCalls).toBe(0);
|
|
});
|
|
it("persists safe observations per user and gateway, and refreshes known apps without creating accounts", async () => {
|
|
const f = await fixture();
|
|
f.activate();
|
|
const result = await f.access.refreshComposioApps(f.connection.id, ["circleback_mcp", "hubspot"], f.actor);
|
|
expect(result.apps.find(app => app.toolkit === "circleback_mcp")).toMatchObject({ connectionId: f.connection.id, status: "connected", accounts: [{ id: f.accountId, alias: "Work", status: "ACTIVE" }] });
|
|
expect(JSON.stringify(await db.select().from(toolConnectionAppSnapshots).where(eq(toolConnectionAppSnapshots.connectionId, f.connection.id)))).not.toContain("never-store-this-token");
|
|
expect((await f.access.listComposioApps(f.connection.id, { ...f.actor, actorId: randomUUID() })).apps).toEqual([]);
|
|
f.operations.length = 0;
|
|
f.disconnect();
|
|
const refreshed = await f.access.refreshComposioApps(f.connection.id, [], f.actor);
|
|
expect(f.operations).toEqual(["list"]);
|
|
expect(refreshed.apps.find(app => app.toolkit === "circleback_mcp")).toMatchObject({ status: "not_connected", accounts: [] });
|
|
expect((await f.access.getEffectiveProfilesForAgent(f.company.id, f.agent.id)).allowedTools).toEqual([]);
|
|
});
|
|
it("rechecks apps from earlier direct setup and hides observations after a gateway identity changes", async () => {
|
|
const f = await fixture();
|
|
f.activate();
|
|
await f.access.setupComposioApp(f.connection.id, "circleback_mcp", { action: "start" }, f.actor);
|
|
await db.delete(toolConnectionAppSnapshots).where(eq(toolConnectionAppSnapshots.connectionId, f.connection.id));
|
|
expect((await f.access.refreshComposioApps(f.connection.id, [], f.actor)).apps).toHaveLength(1);
|
|
await db.update(toolConnections).set({ config: { ...f.connection.config, url: "https://connect.composio.dev/other-mcp" } }).where(eq(toolConnections.id, f.connection.id));
|
|
expect((await f.access.listComposioApps(f.connection.id, f.actor)).apps).toEqual([]);
|
|
});
|
|
async function finishSync(f: Awaited<ReturnType<typeof fixture>>) {
|
|
await vi.waitFor(async () => expect((await f.access.listComposioApps(f.connection.id, f.actor)).sync.status).not.toBe("syncing"), { timeout: 10_000 });
|
|
return f.access.listComposioApps(f.connection.id, f.actor);
|
|
}
|
|
|
|
it("discovers already connected apps across the catalog without a visible-page filter or access changes", async () => {
|
|
const f = await fixture(); f.activate();
|
|
const grants = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, f.connection.id));
|
|
await f.access.syncComposioApps(f.connection.id, false, f.actor);
|
|
const result = await finishSync(f);
|
|
expect(result.sync).toMatchObject({ status: "ready", checked: 3, total: 3, failed: 0, coverage: "supported_catalog" });
|
|
expect(result.apps.find(app => app.toolkit === "circleback_mcp")).toMatchObject({ status: "connected", errorAt: null });
|
|
expect(f.batches.flat().map(tool => tool.name)).toEqual(["circleback_mcp", "notion", "hubspot"]);
|
|
expect(f.operations.every(action => action === "list")).toBe(true);
|
|
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, f.connection.id))).toEqual(grants);
|
|
expect(await db.select().from(issues).where(eq(issues.companyId, f.company.id))).toHaveLength(0);
|
|
await f.access.syncComposioApps(f.connection.id, false, f.actor);
|
|
expect(f.operations).toHaveLength(3); // Fresh inventory does not start another scan.
|
|
f.disconnect();
|
|
await f.access.syncComposioApps(f.connection.id, true, f.actor);
|
|
expect((await finishSync(f)).apps.find(app => app.toolkit === "circleback_mcp")).toMatchObject({ status: "not_connected", accounts: [] });
|
|
});
|
|
|
|
it("shares a lease between concurrent syncs and bounds provider batches", async () => {
|
|
const { COMPOSIO_APP_TOOLKITS } = await import("@paperclipai/shared/aggregator-app-catalog");
|
|
const names = ["circleback_mcp", ...COMPOSIO_APP_TOOLKITS.filter(name => name !== "circleback_mcp").slice(0, 35)];
|
|
const f = await fixture(names); f.activate();
|
|
let release!: () => void;
|
|
const held = new Promise<void>(resolve => { release = resolve; });
|
|
let acknowledgeProviderEntry!: () => void;
|
|
const providerEntered = new Promise<void>(resolve => { acknowledgeProviderEntry = resolve; });
|
|
f.onList(() => { acknowledgeProviderEntry(); return held; });
|
|
try {
|
|
await Promise.all([f.access.syncComposioApps(f.connection.id, true, f.actor), f.access.syncComposioApps(f.connection.id, true, f.actor)]);
|
|
await providerEntered;
|
|
expect(f.batches).toHaveLength(1);
|
|
release();
|
|
const result = await finishSync(f);
|
|
expect(result.sync).toMatchObject({ status: "ready", total: 36, checked: 36 });
|
|
expect(f.batches.map(batch => batch.length)).toEqual([32, 4]);
|
|
expect(new Set(f.batches.flat().map(tool => tool.name)).size).toBe(36);
|
|
} finally {
|
|
release();
|
|
}
|
|
});
|
|
|
|
it("keeps failed account observations unverified and restores them after a successful sync", async () => {
|
|
const f = await fixture(); f.activate();
|
|
await f.access.syncComposioApps(f.connection.id, true, f.actor); await finishSync(f);
|
|
f.setListResult({ accounts: [{ status: "ACTIVE" }] });
|
|
await f.access.syncComposioApps(f.connection.id, true, f.actor);
|
|
const failed = await finishSync(f);
|
|
expect(failed.sync.status).toBe("error");
|
|
expect(failed.apps.find(app => app.toolkit === "circleback_mcp")).toMatchObject({ status: "connected", accounts: [{ id: f.accountId }] });
|
|
expect(failed.apps.find(app => app.toolkit === "circleback_mcp")?.errorAt).toBeTruthy();
|
|
f.setListResult(undefined);
|
|
await f.access.syncComposioApps(f.connection.id, true, f.actor);
|
|
expect((await finishSync(f)).apps.find(app => app.toolkit === "circleback_mcp")?.errorAt).toBeNull();
|
|
});
|
|
|
|
it("reports an incomplete sync when a toolkit without prior accounts fails", async () => {
|
|
const f = await fixture(); f.activate(); f.failToolkit("hubspot");
|
|
await f.access.syncComposioApps(f.connection.id, true, f.actor);
|
|
const result = await finishSync(f);
|
|
expect(result.sync).toMatchObject({ status: "error", checked: 3, total: 3, failed: 1 });
|
|
expect(result.sync.error).toBeTruthy();
|
|
expect(result.apps.find(app => app.toolkit === "circleback_mcp")?.accounts[0].id).toBe(f.accountId);
|
|
expect(result.apps.find(app => app.toolkit === "hubspot")).toBeUndefined();
|
|
});
|
|
|
|
it("rejects observations when credentials change while a provider request is in flight", async () => {
|
|
const f = await fixture(); f.activate();
|
|
f.onList(async () => { await db.update(toolConnections).set({ config: { ...f.connection.config, url: "https://connect.composio.dev/replaced" } }).where(eq(toolConnections.id, f.connection.id)); });
|
|
await f.access.syncComposioApps(f.connection.id, true, f.actor);
|
|
await vi.waitFor(() => expect(f.operations.length).toBeGreaterThan(0));
|
|
await vi.waitFor(async () => expect((await f.access.listComposioApps(f.connection.id, f.actor)).sync.status).toBe("idle"));
|
|
expect((await f.access.listComposioApps(f.connection.id, f.actor)).apps).toEqual([]);
|
|
});
|
|
|
|
it("renames and removes only a verified account in the selected toolkit, and confirms both changes", async () => {
|
|
const f = await fixture(); f.activate();
|
|
const grants = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, f.connection.id));
|
|
await expect(f.access.manageComposioAppAccount(f.connection.id, "hubspot", { action: "remove", accountId: f.accountId }, f.actor)).rejects.toThrow("no longer available");
|
|
expect(f.operations).toEqual(["list"]);
|
|
const renamed = await f.access.manageComposioAppAccount(f.connection.id, "circleback_mcp", { action: "rename", accountId: f.accountId, alias: "Meetings" }, f.actor);
|
|
expect(renamed.apps.find(app => app.toolkit === "circleback_mcp")?.accounts[0].alias).toBe("Meetings");
|
|
expect(f.batches.some(batch => batch.some(op => op.action === "rename" && op.account_id === f.accountId && op.alias === "Meetings"))).toBe(true);
|
|
const removed = await f.access.manageComposioAppAccount(f.connection.id, "circleback_mcp", { action: "remove", accountId: f.accountId }, f.actor);
|
|
expect(removed.apps.find(app => app.toolkit === "circleback_mcp")).toMatchObject({ status: "not_connected", accounts: [] });
|
|
expect((await db.select().from(toolConnections).where(eq(toolConnections.id, f.connection.id)))[0]).toMatchObject({ status: "active", enabled: true });
|
|
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, f.connection.id))).toEqual(grants);
|
|
});
|
|
it("adds another account without a task or an agent grant, even when an account is already active", async () => {
|
|
const f = await fixture(); f.activate();
|
|
const result = await f.access.manageComposioAppAccount(f.connection.id, "circleback_mcp", { action: "add" }, f.actor);
|
|
expect(result.authorizationUrl).toBe("https://connect.composio.dev/link/test");
|
|
expect(f.operations).toEqual(["add"]);
|
|
expect(await db.select().from(issues).where(eq(issues.companyId, f.company.id))).toHaveLength(0);
|
|
expect((await f.access.getEffectiveProfilesForAgent(f.company.id, f.agent.id)).allowedTools).toEqual([]);
|
|
});
|
|
it("retains previous observations when a list result is incomplete or fails", async () => {
|
|
const f = await fixture(); f.activate();
|
|
await f.access.refreshComposioApps(f.connection.id, ["circleback_mcp"], f.actor);
|
|
f.setListResult({ toolkit: "circleback_mcp", accounts: [{ status: "ACTIVE" }] });
|
|
await expect(f.access.refreshComposioApps(f.connection.id, ["circleback_mcp"], f.actor)).rejects.toThrow("complete account list");
|
|
expect((await f.access.listComposioApps(f.connection.id, f.actor)).apps[0].accounts[0].id).toBe(f.accountId);
|
|
f.setListResult({ toolkit: "circleback_mcp", error: "expired", accounts: [] });
|
|
await expect(f.access.refreshComposioApps(f.connection.id, ["circleback_mcp"], f.actor)).rejects.toThrow("could not check");
|
|
expect((await f.access.listComposioApps(f.connection.id, f.actor)).apps[0].status).toBe("connected");
|
|
});
|
|
it("never retries an uncertain mutation or reports an unconfirmed rename as success", async () => {
|
|
const f = await fixture(); f.activate();
|
|
f.setMutationStatus(400);
|
|
await expect(f.access.manageComposioAppAccount(f.connection.id, "circleback_mcp", { action: "remove", accountId: f.accountId }, f.actor)).rejects.toThrow("has not confirmed");
|
|
expect(f.operations.filter(op => op === "remove")).toHaveLength(1);
|
|
f.setMutationStatus(200); f.refuseMutation();
|
|
await expect(f.access.manageComposioAppAccount(f.connection.id, "circleback_mcp", { action: "rename", accountId: f.accountId, alias: "Meetings" }, f.actor)).rejects.toThrow("has not confirmed");
|
|
expect((await f.access.listComposioApps(f.connection.id, f.actor)).apps[0].accounts[0].alias).toBe("Work");
|
|
});
|
|
});
|