maestro/src/bridge/tools-api.test.ts
2026-06-03 05:08:00 +00:00

230 lines
8.2 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express, { type RequestHandler } from 'express';
import request from 'supertest';
import {
mountToolsApi,
_resetToolCatalogCacheForTests,
type McpCatalogDeps,
type ToolCatalogEntry,
} from './tools-api.js';
import { setSshSubsystem, type SshSubsystem } from '../engine/tools/ssh.js';
/**
* Build an express app exposing /api/tools. `user` controls what
* (req as any).user is set to before the catalog handler runs.
*/
function makeApp(opts?: {
user?: { id?: string; role?: string } | null;
authActive?: boolean;
mcp?: McpCatalogDeps | null;
}): express.Application {
const app = express();
app.use(express.json());
if (opts?.user !== null) {
const u = opts?.user ?? { id: 'u1', role: 'user' };
app.use((req, _res, next) => {
(req as unknown as { user: typeof u }).user = u;
// supertest doesn't have passport; fake isAuthenticated.
(req as unknown as { isAuthenticated: () => boolean }).isAuthenticated = () => true;
next();
});
}
const requireAuth: RequestHandler = (req, res, next) => {
if ((req as unknown as { user?: { id?: string } }).user?.id) {
next();
} else {
res.status(401).json({ error: 'Unauthorized' });
}
};
mountToolsApi(app, {
authActive: opts?.authActive ?? false,
requireAuth,
mcp: opts?.mcp ?? null,
});
return app;
}
describe('GET /api/tools (runtime catalog)', () => {
beforeEach(() => {
_resetToolCatalogCacheForTests();
setSshSubsystem(null);
});
afterEach(() => {
setSshSubsystem(null);
_resetToolCatalogCacheForTests();
});
it('returns ToolCatalogResponse with builtin tools', async () => {
const res = await request(makeApp()).get('/api/tools');
expect(res.status).toBe(200);
expect(Array.isArray(res.body.tools)).toBe(true);
const names = (res.body.tools as ToolCatalogEntry[]).map((t) => t.name);
// Core builtin tools must be present.
expect(names).toContain('Read');
expect(names).toContain('Write');
expect(names).toContain('Bash');
});
it('tags core tools with source=builtin and category=core', async () => {
const res = await request(makeApp()).get('/api/tools');
const read = (res.body.tools as ToolCatalogEntry[]).find((t) => t.name === 'Read');
expect(read).toBeDefined();
expect(read!.source).toBe('builtin');
expect(read!.category).toBe('core');
expect(read!.scope).toBe('piece');
expect(read!.available).toBe(true);
});
it('includes meta tools tagged source=meta scope=global', async () => {
const res = await request(makeApp()).get('/api/tools');
const tools = res.body.tools as ToolCatalogEntry[];
const readDoc = tools.find((t) => t.name === 'ReadToolDoc');
expect(readDoc).toBeDefined();
expect(readDoc!.source).toBe('meta');
expect(readDoc!.scope).toBe('global');
expect(readDoc!.available).toBe(true);
const brainstorm = tools.find((t) => t.name === 'Brainstorm');
expect(brainstorm?.source).toBe('meta');
expect(brainstorm?.scope).toBe('global');
});
it('marks ssh tools available=false with reason when SSH subsystem is not initialised', async () => {
setSshSubsystem(null);
const res = await request(makeApp()).get('/api/tools');
const ssh = (res.body.tools as ToolCatalogEntry[]).find((t) => t.name === 'SshExec');
// SSH module may not exist in some lean builds; only assert when present.
if (ssh) {
expect(ssh.category).toBe('ssh');
expect(ssh.available).toBe(false);
expect(ssh.reason).toMatch(/SSH subsystem not initialised/);
expect(ssh.scope).toBe('piece');
}
});
it('marks ssh tools available=true when subsystem is initialised', async () => {
setSshSubsystem({} as SshSubsystem);
const res = await request(makeApp()).get('/api/tools');
const ssh = (res.body.tools as ToolCatalogEntry[]).find((t) => t.name === 'SshExec');
if (ssh) {
expect(ssh.available).toBe(true);
expect(ssh.reason).toBeUndefined();
}
});
it('includes MCP tools for authenticated user', async () => {
const mcp: McpCatalogDeps = {
registry: {
listEnabledForUser: () => [
{ id: 'canva', name: 'Canva', enabled: true },
],
},
tokenManager: {
hasToken: () => true,
},
toolCache: {
getAllForServers: () => [
{ serverId: 'canva', toolName: 'createDesign' },
{ serverId: 'canva', toolName: 'listDesigns' },
],
},
};
const res = await request(makeApp({ mcp })).get('/api/tools');
const tools = res.body.tools as ToolCatalogEntry[];
const create = tools.find((t) => t.name === 'mcp__canva__createDesign');
expect(create).toBeDefined();
expect(create!.source).toBe('mcp');
expect(create!.category).toBe('mcp:canva');
expect(create!.serverId).toBe('canva');
expect(create!.scope).toBe('user');
expect(create!.available).toBe(true);
});
it('marks MCP tools unavailable with reason when user is not connected (offline)', async () => {
const mcp: McpCatalogDeps = {
registry: {
listEnabledForUser: () => [{ id: 'gh', name: 'GitHub', enabled: true }],
},
tokenManager: {
hasToken: () => false, // user not connected
},
toolCache: {
getAllForServers: () => [{ serverId: 'gh', toolName: 'listIssues' }],
},
};
const res = await request(makeApp({ mcp })).get('/api/tools');
const tool = (res.body.tools as ToolCatalogEntry[]).find(
(t) => t.name === 'mcp__gh__listIssues',
);
expect(tool).toBeDefined();
expect(tool!.available).toBe(false);
expect(tool!.reason).toMatch(/offline/);
});
it('omits MCP tools when caller has no user id (unauthenticated)', async () => {
const mcp: McpCatalogDeps = {
registry: {
listEnabledForUser: () => [{ id: 'canva', name: 'Canva', enabled: true }],
},
tokenManager: { hasToken: () => true },
toolCache: {
getAllForServers: () => [{ serverId: 'canva', toolName: 'createDesign' }],
},
};
const res = await request(makeApp({ user: null, mcp })).get('/api/tools');
expect(res.status).toBe(200);
const tools = res.body.tools as ToolCatalogEntry[];
expect(tools.some((t) => t.source === 'mcp')).toBe(false);
});
it('returns 401 when authActive=true and caller is not authenticated', async () => {
const res = await request(makeApp({ user: null, authActive: true })).get('/api/tools');
expect(res.status).toBe(401);
});
it('?legacy=1 returns flat array of tool names', async () => {
const res = await request(makeApp()).get('/api/tools?legacy=1');
expect(res.status).toBe(200);
expect(Array.isArray(res.body.tools)).toBe(true);
// Must be string[], NOT objects.
for (const t of res.body.tools as unknown[]) {
expect(typeof t).toBe('string');
}
expect(res.body.tools).toContain('Read');
expect(res.body.tools).toContain('ReadToolDoc');
});
it('?legacy=1 includes per-user MCP names when authenticated', async () => {
const mcp: McpCatalogDeps = {
registry: {
listEnabledForUser: () => [{ id: 'canva', name: 'Canva', enabled: true }],
},
tokenManager: { hasToken: () => true },
toolCache: {
getAllForServers: () => [{ serverId: 'canva', toolName: 'createDesign' }],
},
};
const res = await request(makeApp({ mcp })).get('/api/tools?legacy=1');
expect(res.body.tools).toContain('mcp__canva__createDesign');
});
it('surfaces a placeholder entry when MCP server has no cached tools', async () => {
const mcp: McpCatalogDeps = {
registry: {
listEnabledForUser: () => [{ id: 'fresh', name: 'Fresh', enabled: true }],
},
tokenManager: { hasToken: () => false },
toolCache: { getAllForServers: () => [] },
};
const res = await request(makeApp({ mcp })).get('/api/tools');
const placeholder = (res.body.tools as ToolCatalogEntry[]).find(
(t) => t.serverId === 'fresh',
);
expect(placeholder).toBeDefined();
expect(placeholder!.available).toBe(false);
expect(placeholder!.reason).toMatch(/offline|no cached tools/);
expect(placeholder!.scope).toBe('user');
});
});