mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
285 lines
10 KiB
TypeScript
285 lines
10 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
|
|
import {
|
|
configureComposioConfigStore,
|
|
deleteComposioAuthConfigId,
|
|
readComposioConfig,
|
|
readPublicComposioConfig,
|
|
setComposioAuthConfigId,
|
|
writeComposioConfig,
|
|
} from '../src/connectors/composio-config.js';
|
|
import { composioConnectorProvider, getStaticComposioCatalogDefinitions } from '../src/connectors/composio.js';
|
|
import type { ConnectorCatalogDefinition } from '../src/connectors/catalog.js';
|
|
|
|
async function useTempComposioStore(): Promise<string> {
|
|
const dir = await mkdtemp(path.join(tmpdir(), 'od-composio-config-'));
|
|
configureComposioConfigStore(dir);
|
|
composioConnectorProvider.clearDiscoveryCache();
|
|
return dir;
|
|
}
|
|
|
|
function composioJson(body: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
function composioDefinition(id = 'github'): ConnectorCatalogDefinition {
|
|
return {
|
|
id,
|
|
name: id,
|
|
provider: 'composio',
|
|
category: 'code',
|
|
authentication: 'composio',
|
|
tools: [],
|
|
allowedToolNames: [],
|
|
};
|
|
}
|
|
|
|
describe('composio config', () => {
|
|
it('stores Composio settings in the configured data directory', async () => {
|
|
const dir = await useTempComposioStore();
|
|
|
|
const publicConfig = writeComposioConfig({
|
|
apiKey: 'cmp_secret_1234',
|
|
});
|
|
|
|
expect(publicConfig).toEqual({
|
|
configured: true,
|
|
apiKeyTail: '1234',
|
|
});
|
|
expect(readComposioConfig()).toMatchObject({ apiKey: 'cmp_secret_1234', authConfigIds: {} });
|
|
await expect(readFile(path.join(dir, 'connectors', 'composio-config.json'), 'utf8')).resolves.toContain('cmp_secret_1234');
|
|
});
|
|
|
|
it('preserves and updates persisted auth config ids', async () => {
|
|
await useTempComposioStore();
|
|
writeComposioConfig({ apiKey: 'stored_secret', authConfigIds: { github: 'ac_github' } });
|
|
|
|
writeComposioConfig({});
|
|
setComposioAuthConfigId('slack', 'ac_slack');
|
|
deleteComposioAuthConfigId('github');
|
|
|
|
expect(readComposioConfig()).toEqual({
|
|
apiKey: 'stored_secret',
|
|
authConfigIds: { slack: 'ac_slack' },
|
|
});
|
|
});
|
|
|
|
it('does not read Composio credentials from environment variables', async () => {
|
|
await useTempComposioStore();
|
|
const originalApiKey = process.env.COMPOSIO_API_KEY;
|
|
try {
|
|
process.env.COMPOSIO_API_KEY = 'env_secret';
|
|
|
|
expect(readPublicComposioConfig()).toMatchObject({ configured: false, apiKeyTail: '' });
|
|
expect(composioConnectorProvider.isConfigured(composioDefinition())).toBe(false);
|
|
|
|
writeComposioConfig({ apiKey: 'stored_secret' });
|
|
expect(readPublicComposioConfig()).toMatchObject({ configured: true, apiKeyTail: 'cret' });
|
|
} finally {
|
|
if (originalApiKey === undefined) delete process.env.COMPOSIO_API_KEY;
|
|
else process.env.COMPOSIO_API_KEY = originalApiKey;
|
|
}
|
|
});
|
|
|
|
it('can clear the stored API key through settings', async () => {
|
|
await useTempComposioStore();
|
|
writeComposioConfig({ apiKey: 'stored_secret' });
|
|
|
|
const publicConfig = writeComposioConfig({ apiKey: '' });
|
|
|
|
expect(publicConfig.configured).toBe(false);
|
|
expect(composioConnectorProvider.isConfigured(composioDefinition())).toBe(false);
|
|
expect(readComposioConfig()).toEqual({ apiKey: '', authConfigIds: {} });
|
|
});
|
|
|
|
it('clears stored auth config ids when the API key changes', async () => {
|
|
await useTempComposioStore();
|
|
writeComposioConfig({ apiKey: 'stored_secret', authConfigIds: { github: 'ac_github' } });
|
|
|
|
const publicConfig = writeComposioConfig({ apiKey: 'new_secret' });
|
|
|
|
expect(publicConfig).toEqual({ configured: true, apiKeyTail: 'cret' });
|
|
expect(readComposioConfig()).toEqual({ apiKey: 'new_secret', authConfigIds: {} });
|
|
});
|
|
|
|
it('ignores stale unsupported persisted technical fields', async () => {
|
|
await useTempComposioStore();
|
|
writeComposioConfig({ apiKey: 'stored_secret' });
|
|
|
|
const publicConfig = writeComposioConfig({ apiKey: '', baseUrl: '', userId: '', timeoutMs: null });
|
|
|
|
expect(publicConfig).toEqual({ configured: false, apiKeyTail: '' });
|
|
expect(readComposioConfig()).toEqual({ apiKey: '', authConfigIds: {} });
|
|
});
|
|
|
|
it('loads persisted Composio catalog cache into fast definitions', async () => {
|
|
const dir = await useTempComposioStore();
|
|
await mkdir(path.join(dir, 'connectors'), { recursive: true });
|
|
await writeFile(path.join(dir, 'connectors', 'composio-catalog-cache.json'), JSON.stringify({
|
|
schemaVersion: 1,
|
|
provider: 'composio',
|
|
fetchedAt: '2026-05-07T00:00:00.000Z',
|
|
definitions: [
|
|
{
|
|
id: 'slack',
|
|
name: 'Slack',
|
|
provider: 'composio',
|
|
category: 'Communication',
|
|
providerConnectorId: 'SLACK',
|
|
authentication: 'composio',
|
|
toolCount: 48,
|
|
tools: [
|
|
{
|
|
name: 'slack.slack_list_channels',
|
|
title: 'List channels',
|
|
description: 'List Slack channels',
|
|
safety: { sideEffect: 'read', approval: 'auto', reason: 'read-only' },
|
|
refreshEligible: true,
|
|
curation: { useCases: ['personal_daily_digest'], reason: 'Digest source' },
|
|
requiredScopes: ['read'],
|
|
providerToolId: 'SLACK_LIST_CHANNELS',
|
|
},
|
|
],
|
|
allowedToolNames: ['slack.slack_list_channels'],
|
|
minimumApproval: 'auto',
|
|
},
|
|
],
|
|
}, null, 2));
|
|
|
|
composioConnectorProvider.configureCatalogCache(dir);
|
|
|
|
expect(composioConnectorProvider.getFastDefinitions().find((definition) => definition.id === 'slack')).toMatchObject({
|
|
id: 'slack',
|
|
toolCount: 48,
|
|
tools: [expect.objectContaining({
|
|
name: 'slack.slack_list_channels',
|
|
curation: expect.objectContaining({ useCases: ['personal_daily_digest'] }),
|
|
})],
|
|
});
|
|
});
|
|
|
|
it('falls back to the static catalog when the persisted cache is empty', async () => {
|
|
const dir = await useTempComposioStore();
|
|
await mkdir(path.join(dir, 'connectors'), { recursive: true });
|
|
await writeFile(path.join(dir, 'connectors', 'composio-catalog-cache.json'), JSON.stringify({
|
|
schemaVersion: 1,
|
|
provider: 'composio',
|
|
fetchedAt: '2026-05-07T00:00:00.000Z',
|
|
definitions: [],
|
|
}, null, 2));
|
|
|
|
composioConnectorProvider.configureCatalogCache(dir);
|
|
|
|
expect(composioConnectorProvider.getFastDefinitions()).toEqual(getStaticComposioCatalogDefinitions());
|
|
});
|
|
|
|
it('does not hydrate persisted catalog cache before the runtime data directory is configured', async () => {
|
|
const defaultCacheDir = path.join(process.cwd(), '.od', 'connectors');
|
|
const defaultCachePath = path.join(defaultCacheDir, 'composio-catalog-cache.json');
|
|
const dir = await useTempComposioStore();
|
|
await mkdir(defaultCacheDir, { recursive: true });
|
|
await writeFile(defaultCachePath, JSON.stringify({
|
|
schemaVersion: 1,
|
|
provider: 'composio',
|
|
fetchedAt: '2026-05-07T00:00:00.000Z',
|
|
definitions: [composioDefinition('wrong-tenant')],
|
|
}, null, 2));
|
|
|
|
try {
|
|
vi.resetModules();
|
|
const composioModule = await import('../src/connectors/composio.js');
|
|
|
|
expect(composioModule.composioConnectorProvider.getFastDefinitions().find((definition) => definition.id === 'wrong-tenant')).toBeUndefined();
|
|
|
|
await mkdir(path.join(dir, 'connectors'), { recursive: true });
|
|
await writeFile(path.join(dir, 'connectors', 'composio-catalog-cache.json'), JSON.stringify({
|
|
schemaVersion: 1,
|
|
provider: 'composio',
|
|
fetchedAt: '2026-05-07T00:00:00.000Z',
|
|
definitions: [composioDefinition('right-tenant')],
|
|
}, null, 2));
|
|
|
|
composioModule.composioConnectorProvider.configureCatalogCache(dir);
|
|
|
|
expect(composioModule.composioConnectorProvider.getFastDefinitions().find((definition) => definition.id === 'right-tenant')).toMatchObject({
|
|
id: 'right-tenant',
|
|
});
|
|
} finally {
|
|
await rm(defaultCachePath, { force: true });
|
|
}
|
|
});
|
|
|
|
it('treats the current Notion search action as read-only despite broad response-size wording', async () => {
|
|
await useTempComposioStore();
|
|
writeComposioConfig({ apiKey: 'cmp_test' });
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = vi.fn(async (input: Parameters<typeof fetch>[0]) => {
|
|
const parsed = new URL(input.toString(), 'https://backend.composio.dev');
|
|
if (parsed.pathname === '/api/v3/auth_configs') {
|
|
return composioJson({
|
|
items: [
|
|
{ id: 'ac_notion', status: 'ENABLED', toolkit: { slug: 'notion' } },
|
|
],
|
|
});
|
|
}
|
|
if (parsed.pathname === '/api/v3.1/toolkits') {
|
|
return composioJson({
|
|
items: [
|
|
{ slug: 'notion', name: 'Notion', categories: [{ name: 'Productivity' }] },
|
|
],
|
|
});
|
|
}
|
|
if (
|
|
parsed.pathname === '/api/v3.1/tools'
|
|
&& parsed.searchParams.get('toolkit_slug') === 'notion'
|
|
) {
|
|
return composioJson({
|
|
items: [
|
|
{
|
|
slug: 'NOTION_SEARCH_NOTION_PAGE',
|
|
name: 'Search Notion pages and databases',
|
|
description:
|
|
'Searches Notion pages and databases by title. Database pages can create large responses for databases with many properties.',
|
|
toolkit: { slug: 'notion' },
|
|
input_parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
query: { type: 'string' },
|
|
page_size: { type: 'integer', minimum: 1, maximum: 100 },
|
|
},
|
|
additionalProperties: false,
|
|
},
|
|
tags: [],
|
|
},
|
|
],
|
|
total_items: 1,
|
|
});
|
|
}
|
|
return composioJson({ items: [] });
|
|
}) as typeof fetch;
|
|
|
|
try {
|
|
const definition = await composioConnectorProvider.getPreviewDefinition('notion', {
|
|
toolsLimit: 1000,
|
|
});
|
|
const tool = definition?.tools.find(
|
|
(candidate) => candidate.name === 'notion.notion_search_notion_page',
|
|
);
|
|
|
|
expect(definition?.allowedToolNames).toContain('notion.notion_search_notion_page');
|
|
expect(tool).toMatchObject({
|
|
refreshEligible: true,
|
|
safety: { sideEffect: 'read', approval: 'auto' },
|
|
curation: expect.objectContaining({ useCases: ['personal_daily_digest'] }),
|
|
});
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
});
|