proxyMode "direct" was a security hole (leaked secrets as plaintext env vars in .mcp.json) and bypassed all mcplocal features (gating, audit, RBAC, content pipeline, namespacing). Removed from schema, API, CLI, and all tests. Old configs with proxyMode are accepted but silently stripped via Zod .transform() for backward compatibility. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
293 lines
10 KiB
TypeScript
293 lines
10 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { ProjectService } from '../src/services/project.service.js';
|
|
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
|
|
import type { IProjectRepository, ProjectWithRelations } from '../src/repositories/project.repository.js';
|
|
import type { IMcpServerRepository } from '../src/repositories/interfaces.js';
|
|
import type { McpServer } from '@prisma/client';
|
|
|
|
function makeProject(overrides: Partial<ProjectWithRelations> = {}): ProjectWithRelations {
|
|
return {
|
|
id: 'proj-1',
|
|
name: 'test-project',
|
|
description: '',
|
|
ownerId: 'user-1',
|
|
proxyModel: '',
|
|
gated: true,
|
|
llmProvider: null,
|
|
llmModel: null,
|
|
version: 1,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
servers: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeServer(overrides: Partial<McpServer> = {}): McpServer {
|
|
return {
|
|
id: 'srv-1',
|
|
name: 'test-server',
|
|
description: '',
|
|
packageName: '@mcp/test',
|
|
dockerImage: null,
|
|
transport: 'STDIO',
|
|
repositoryUrl: null,
|
|
externalUrl: null,
|
|
command: null,
|
|
containerPort: null,
|
|
replicas: 1,
|
|
env: [],
|
|
healthCheck: null,
|
|
version: 1,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
templateName: null,
|
|
templateVersion: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function mockProjectRepo(): IProjectRepository {
|
|
return {
|
|
findAll: vi.fn(async () => []),
|
|
findById: vi.fn(async () => null),
|
|
findByName: vi.fn(async () => null),
|
|
create: vi.fn(async (data) => makeProject({
|
|
name: data.name,
|
|
description: data.description,
|
|
ownerId: data.ownerId,
|
|
llmProvider: data.llmProvider ?? null,
|
|
llmModel: data.llmModel ?? null,
|
|
})),
|
|
update: vi.fn(async (_id, data) => makeProject({ ...data as Partial<ProjectWithRelations> })),
|
|
delete: vi.fn(async () => {}),
|
|
setServers: vi.fn(async () => {}),
|
|
addServer: vi.fn(async () => {}),
|
|
removeServer: vi.fn(async () => {}),
|
|
};
|
|
}
|
|
|
|
function mockServerRepo(): IMcpServerRepository {
|
|
return {
|
|
findAll: vi.fn(async () => []),
|
|
findById: vi.fn(async () => null),
|
|
findByName: vi.fn(async () => null),
|
|
create: vi.fn(async () => makeServer()),
|
|
update: vi.fn(async () => makeServer()),
|
|
delete: vi.fn(async () => {}),
|
|
};
|
|
}
|
|
|
|
describe('ProjectService', () => {
|
|
let projectRepo: ReturnType<typeof mockProjectRepo>;
|
|
let serverRepo: ReturnType<typeof mockServerRepo>;
|
|
let service: ProjectService;
|
|
|
|
beforeEach(() => {
|
|
projectRepo = mockProjectRepo();
|
|
serverRepo = mockServerRepo();
|
|
service = new ProjectService(projectRepo, serverRepo);
|
|
});
|
|
|
|
describe('create', () => {
|
|
it('creates a basic project', async () => {
|
|
// After create, getById is called to re-fetch with relations
|
|
const created = makeProject({ name: 'my-project', ownerId: 'user-1' });
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(created);
|
|
|
|
const result = await service.create({ name: 'my-project' }, 'user-1');
|
|
expect(result.name).toBe('my-project');
|
|
expect(result.ownerId).toBe('user-1');
|
|
expect(projectRepo.create).toHaveBeenCalled();
|
|
});
|
|
|
|
it('throws ConflictError when name exists', async () => {
|
|
vi.mocked(projectRepo.findByName).mockResolvedValue(makeProject());
|
|
await expect(service.create({ name: 'taken' }, 'u1')).rejects.toThrow(ConflictError);
|
|
});
|
|
|
|
it('validates input', async () => {
|
|
await expect(service.create({ name: '' }, 'u1')).rejects.toThrow();
|
|
});
|
|
|
|
it('creates project with servers (resolves names)', async () => {
|
|
const srv1 = makeServer({ id: 'srv-1', name: 'github' });
|
|
const srv2 = makeServer({ id: 'srv-2', name: 'slack' });
|
|
vi.mocked(serverRepo.findByName).mockImplementation(async (name) => {
|
|
if (name === 'github') return srv1;
|
|
if (name === 'slack') return srv2;
|
|
return null;
|
|
});
|
|
|
|
const created = makeProject({ id: 'proj-new' });
|
|
vi.mocked(projectRepo.create).mockResolvedValue(created);
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({
|
|
id: 'proj-new',
|
|
servers: [
|
|
{ id: 'ps-1', server: { id: 'srv-1', name: 'github' } },
|
|
{ id: 'ps-2', server: { id: 'srv-2', name: 'slack' } },
|
|
],
|
|
}));
|
|
|
|
const result = await service.create({ name: 'my-project', servers: ['github', 'slack'] }, 'user-1');
|
|
expect(projectRepo.setServers).toHaveBeenCalledWith('proj-new', ['srv-1', 'srv-2']);
|
|
expect(result.servers).toHaveLength(2);
|
|
});
|
|
|
|
it('throws NotFoundError when server name resolution fails', async () => {
|
|
vi.mocked(serverRepo.findByName).mockResolvedValue(null);
|
|
|
|
await expect(
|
|
service.create({ name: 'my-project', servers: ['nonexistent'] }, 'user-1'),
|
|
).rejects.toThrow(NotFoundError);
|
|
});
|
|
|
|
});
|
|
|
|
describe('getById', () => {
|
|
it('throws NotFoundError when not found', async () => {
|
|
await expect(service.getById('missing')).rejects.toThrow(NotFoundError);
|
|
});
|
|
|
|
it('returns project when found', async () => {
|
|
const proj = makeProject({ id: 'found' });
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(proj);
|
|
const result = await service.getById('found');
|
|
expect(result.id).toBe('found');
|
|
});
|
|
});
|
|
|
|
describe('resolveAndGet', () => {
|
|
it('finds by ID first', async () => {
|
|
const proj = makeProject({ id: 'proj-id' });
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(proj);
|
|
const result = await service.resolveAndGet('proj-id');
|
|
expect(result.id).toBe('proj-id');
|
|
});
|
|
|
|
it('falls back to name when ID not found', async () => {
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(null);
|
|
const proj = makeProject({ name: 'my-name' });
|
|
vi.mocked(projectRepo.findByName).mockResolvedValue(proj);
|
|
const result = await service.resolveAndGet('my-name');
|
|
expect(result.name).toBe('my-name');
|
|
});
|
|
|
|
it('throws NotFoundError when neither ID nor name found', async () => {
|
|
await expect(service.resolveAndGet('nothing')).rejects.toThrow(NotFoundError);
|
|
});
|
|
});
|
|
|
|
describe('update', () => {
|
|
it('updates servers (full replacement)', async () => {
|
|
const existing = makeProject({ id: 'proj-1' });
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(existing);
|
|
|
|
const srv = makeServer({ id: 'srv-new', name: 'new-srv' });
|
|
vi.mocked(serverRepo.findByName).mockResolvedValue(srv);
|
|
|
|
await service.update('proj-1', { servers: ['new-srv'] });
|
|
expect(projectRepo.setServers).toHaveBeenCalledWith('proj-1', ['srv-new']);
|
|
});
|
|
|
|
it('updates llmProvider', async () => {
|
|
const existing = makeProject({ id: 'proj-1' });
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(existing);
|
|
|
|
await service.update('proj-1', { llmProvider: 'anthropic' });
|
|
expect(projectRepo.update).toHaveBeenCalledWith('proj-1', {
|
|
llmProvider: 'anthropic',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('delete', () => {
|
|
it('deletes project', async () => {
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
|
await service.delete('p1');
|
|
expect(projectRepo.delete).toHaveBeenCalledWith('p1');
|
|
});
|
|
|
|
it('throws NotFoundError when project does not exist', async () => {
|
|
await expect(service.delete('missing')).rejects.toThrow(NotFoundError);
|
|
});
|
|
});
|
|
|
|
describe('addServer', () => {
|
|
it('attaches a server by name', async () => {
|
|
const project = makeProject({ id: 'proj-1' });
|
|
const srv = makeServer({ id: 'srv-1', name: 'my-ha' });
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(project);
|
|
vi.mocked(serverRepo.findByName).mockResolvedValue(srv);
|
|
|
|
await service.addServer('proj-1', 'my-ha');
|
|
expect(projectRepo.addServer).toHaveBeenCalledWith('proj-1', 'srv-1');
|
|
});
|
|
|
|
it('throws NotFoundError when project not found', async () => {
|
|
await expect(service.addServer('missing', 'my-ha')).rejects.toThrow(NotFoundError);
|
|
});
|
|
|
|
it('throws NotFoundError when server not found', async () => {
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'proj-1' }));
|
|
vi.mocked(serverRepo.findByName).mockResolvedValue(null);
|
|
|
|
await expect(service.addServer('proj-1', 'nonexistent')).rejects.toThrow(NotFoundError);
|
|
});
|
|
});
|
|
|
|
describe('removeServer', () => {
|
|
it('detaches a server by name', async () => {
|
|
const project = makeProject({ id: 'proj-1' });
|
|
const srv = makeServer({ id: 'srv-1', name: 'my-ha' });
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(project);
|
|
vi.mocked(serverRepo.findByName).mockResolvedValue(srv);
|
|
|
|
await service.removeServer('proj-1', 'my-ha');
|
|
expect(projectRepo.removeServer).toHaveBeenCalledWith('proj-1', 'srv-1');
|
|
});
|
|
|
|
it('throws NotFoundError when project not found', async () => {
|
|
await expect(service.removeServer('missing', 'my-ha')).rejects.toThrow(NotFoundError);
|
|
});
|
|
|
|
it('throws NotFoundError when server not found', async () => {
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'proj-1' }));
|
|
vi.mocked(serverRepo.findByName).mockResolvedValue(null);
|
|
|
|
await expect(service.removeServer('proj-1', 'nonexistent')).rejects.toThrow(NotFoundError);
|
|
});
|
|
});
|
|
|
|
describe('generateMcpConfig', () => {
|
|
it('generates single mcplocal proxy entry', async () => {
|
|
const project = makeProject({
|
|
id: 'proj-1',
|
|
name: 'my-proj',
|
|
servers: [{ id: 'ps-1', server: { id: 'srv-1', name: 'github' } }],
|
|
});
|
|
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(project);
|
|
|
|
const config = await service.generateMcpConfig('proj-1');
|
|
expect(Object.keys(config.mcpServers)).toHaveLength(1);
|
|
expect(config.mcpServers['my-proj']?.url).toBe('http://localhost:3100/api/v1/mcp/proxy/project/my-proj');
|
|
});
|
|
|
|
it('resolves by name for mcp-config', async () => {
|
|
const project = makeProject({
|
|
id: 'proj-1',
|
|
name: 'my-proj',
|
|
servers: [],
|
|
});
|
|
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(null);
|
|
vi.mocked(projectRepo.findByName).mockResolvedValue(project);
|
|
|
|
const config = await service.generateMcpConfig('my-proj');
|
|
expect(Object.keys(config.mcpServers)).toHaveLength(1);
|
|
expect(config.mcpServers['my-proj']?.url).toBe('http://localhost:3100/api/v1/mcp/proxy/project/my-proj');
|
|
});
|
|
});
|
|
});
|