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>
349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
import Fastify from 'fastify';
|
|
import type { FastifyInstance } from 'fastify';
|
|
import { registerProjectRoutes } from '../src/routes/projects.js';
|
|
import { ProjectService } from '../src/services/project.service.js';
|
|
import { errorHandler } from '../src/middleware/error-handler.js';
|
|
import type { IProjectRepository, ProjectWithRelations } from '../src/repositories/project.repository.js';
|
|
import type { IMcpServerRepository } from '../src/repositories/interfaces.js';
|
|
|
|
let app: FastifyInstance;
|
|
|
|
function makeProject(overrides: Partial<ProjectWithRelations> = {}): ProjectWithRelations {
|
|
return {
|
|
id: 'proj-1',
|
|
name: 'test-project',
|
|
description: '',
|
|
ownerId: 'user-1',
|
|
prompt: '',
|
|
proxyModel: '',
|
|
gated: true,
|
|
llmProvider: null,
|
|
llmModel: null,
|
|
serverOverrides: null,
|
|
version: 1,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
servers: [],
|
|
...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,
|
|
})),
|
|
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 () => ({} as never)),
|
|
update: vi.fn(async () => ({} as never)),
|
|
delete: vi.fn(async () => {}),
|
|
};
|
|
}
|
|
|
|
afterEach(async () => {
|
|
if (app) await app.close();
|
|
});
|
|
|
|
function createApp(projectRepo: IProjectRepository, serverRepo?: IMcpServerRepository) {
|
|
app = Fastify({ logger: false });
|
|
app.setErrorHandler(errorHandler);
|
|
const service = new ProjectService(projectRepo, serverRepo ?? mockServerRepo());
|
|
registerProjectRoutes(app, service);
|
|
return app.ready();
|
|
}
|
|
|
|
describe('Project Routes', () => {
|
|
describe('GET /api/v1/projects', () => {
|
|
it('returns project list', async () => {
|
|
const repo = mockProjectRepo();
|
|
vi.mocked(repo.findAll).mockResolvedValue([
|
|
makeProject({ id: 'p1', name: 'alpha', ownerId: 'user-1' }),
|
|
makeProject({ id: 'p2', name: 'beta', ownerId: 'user-2' }),
|
|
]);
|
|
await createApp(repo);
|
|
|
|
const res = await app.inject({ method: 'GET', url: '/api/v1/projects' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json<Array<{ name: string }>>();
|
|
expect(body).toHaveLength(2);
|
|
});
|
|
|
|
it('lists all projects without ownerId filtering', async () => {
|
|
// This is the bug fix: the route must call list() without ownerId
|
|
// so that RBAC (preSerialization) handles access filtering, not the DB query.
|
|
const repo = mockProjectRepo();
|
|
vi.mocked(repo.findAll).mockResolvedValue([makeProject()]);
|
|
await createApp(repo);
|
|
|
|
await app.inject({ method: 'GET', url: '/api/v1/projects' });
|
|
// findAll must be called with NO arguments (undefined ownerId)
|
|
expect(repo.findAll).toHaveBeenCalledWith(undefined);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/v1/projects/:id', () => {
|
|
it('returns 404 when not found', async () => {
|
|
const repo = mockProjectRepo();
|
|
await createApp(repo);
|
|
const res = await app.inject({ method: 'GET', url: '/api/v1/projects/missing' });
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it('returns project when found by ID', async () => {
|
|
const repo = mockProjectRepo();
|
|
vi.mocked(repo.findById).mockResolvedValue(makeProject({ id: 'p1', name: 'my-proj' }));
|
|
await createApp(repo);
|
|
const res = await app.inject({ method: 'GET', url: '/api/v1/projects/p1' });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json<{ name: string }>().name).toBe('my-proj');
|
|
});
|
|
|
|
it('resolves by name when ID not found', async () => {
|
|
const repo = mockProjectRepo();
|
|
vi.mocked(repo.findByName).mockResolvedValue(makeProject({ name: 'my-proj' }));
|
|
await createApp(repo);
|
|
const res = await app.inject({ method: 'GET', url: '/api/v1/projects/my-proj' });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json<{ name: string }>().name).toBe('my-proj');
|
|
});
|
|
});
|
|
|
|
describe('POST /api/v1/projects', () => {
|
|
it('creates a project and returns 201', async () => {
|
|
const repo = mockProjectRepo();
|
|
vi.mocked(repo.findById).mockResolvedValue(makeProject({ name: 'new-proj' }));
|
|
await createApp(repo);
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/projects',
|
|
payload: { name: 'new-proj' },
|
|
});
|
|
expect(res.statusCode).toBe(201);
|
|
});
|
|
|
|
it('creates a project with proxyModel', async () => {
|
|
const repo = mockProjectRepo();
|
|
vi.mocked(repo.findById).mockResolvedValue(makeProject({ name: 'pm-proj', proxyModel: 'subindex' }));
|
|
await createApp(repo);
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/projects',
|
|
payload: { name: 'pm-proj', proxyModel: 'subindex' },
|
|
});
|
|
expect(res.statusCode).toBe(201);
|
|
expect(repo.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({ proxyModel: 'subindex' }),
|
|
);
|
|
});
|
|
|
|
it('returns 400 for invalid input', async () => {
|
|
const repo = mockProjectRepo();
|
|
await createApp(repo);
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/projects',
|
|
payload: { name: '' },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it('returns 409 when name already exists', async () => {
|
|
const repo = mockProjectRepo();
|
|
vi.mocked(repo.findByName).mockResolvedValue(makeProject());
|
|
await createApp(repo);
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/projects',
|
|
payload: { name: 'taken' },
|
|
});
|
|
expect(res.statusCode).toBe(409);
|
|
});
|
|
});
|
|
|
|
describe('PUT /api/v1/projects/:id', () => {
|
|
it('updates a project', async () => {
|
|
const repo = mockProjectRepo();
|
|
vi.mocked(repo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
|
await createApp(repo);
|
|
const res = await app.inject({
|
|
method: 'PUT',
|
|
url: '/api/v1/projects/p1',
|
|
payload: { description: 'Updated' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
|
|
it('updates proxyModel on a project', async () => {
|
|
const repo = mockProjectRepo();
|
|
vi.mocked(repo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
|
await createApp(repo);
|
|
const res = await app.inject({
|
|
method: 'PUT',
|
|
url: '/api/v1/projects/p1',
|
|
payload: { proxyModel: 'subindex' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(repo.update).toHaveBeenCalledWith('p1', expect.objectContaining({ proxyModel: 'subindex' }));
|
|
});
|
|
|
|
it('returns 404 when not found', async () => {
|
|
const repo = mockProjectRepo();
|
|
await createApp(repo);
|
|
const res = await app.inject({
|
|
method: 'PUT',
|
|
url: '/api/v1/projects/missing',
|
|
payload: { description: 'x' },
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe('DELETE /api/v1/projects/:id', () => {
|
|
it('deletes a project and returns 204', async () => {
|
|
const repo = mockProjectRepo();
|
|
vi.mocked(repo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
|
await createApp(repo);
|
|
const res = await app.inject({ method: 'DELETE', url: '/api/v1/projects/p1' });
|
|
expect(res.statusCode).toBe(204);
|
|
});
|
|
|
|
it('returns 404 when not found', async () => {
|
|
const repo = mockProjectRepo();
|
|
await createApp(repo);
|
|
const res = await app.inject({ method: 'DELETE', url: '/api/v1/projects/missing' });
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/v1/projects/:id/servers (attach)', () => {
|
|
it('attaches a server to a project', async () => {
|
|
const projectRepo = mockProjectRepo();
|
|
const serverRepo = mockServerRepo();
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
|
vi.mocked(serverRepo.findByName).mockResolvedValue({ id: 'srv-1', name: 'my-ha' } as never);
|
|
await createApp(projectRepo, serverRepo);
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/projects/p1/servers',
|
|
payload: { server: 'my-ha' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(projectRepo.addServer).toHaveBeenCalledWith('p1', 'srv-1');
|
|
});
|
|
|
|
it('returns 400 when server field is missing', async () => {
|
|
const repo = mockProjectRepo();
|
|
vi.mocked(repo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
|
await createApp(repo);
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/projects/p1/servers',
|
|
payload: {},
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it('returns 404 when server not found', async () => {
|
|
const projectRepo = mockProjectRepo();
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
|
await createApp(projectRepo);
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/projects/p1/servers',
|
|
payload: { server: 'nonexistent' },
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe('DELETE /api/v1/projects/:id/servers/:serverName (detach)', () => {
|
|
it('detaches a server from a project', async () => {
|
|
const projectRepo = mockProjectRepo();
|
|
const serverRepo = mockServerRepo();
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
|
vi.mocked(serverRepo.findByName).mockResolvedValue({ id: 'srv-1', name: 'my-ha' } as never);
|
|
await createApp(projectRepo, serverRepo);
|
|
|
|
const res = await app.inject({ method: 'DELETE', url: '/api/v1/projects/p1/servers/my-ha' });
|
|
expect(res.statusCode).toBe(204);
|
|
expect(projectRepo.removeServer).toHaveBeenCalledWith('p1', 'srv-1');
|
|
});
|
|
|
|
it('returns 404 when server not found', async () => {
|
|
const projectRepo = mockProjectRepo();
|
|
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
|
await createApp(projectRepo);
|
|
|
|
const res = await app.inject({ method: 'DELETE', url: '/api/v1/projects/p1/servers/nonexistent' });
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe('serverOverrides', () => {
|
|
it('accepts serverOverrides in project create', async () => {
|
|
const repo = mockProjectRepo();
|
|
vi.mocked(repo.findById).mockResolvedValue(
|
|
makeProject({ name: 'override-proj', serverOverrides: { ha: { proxyModel: 'ha-special' } } }),
|
|
);
|
|
await createApp(repo);
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/projects',
|
|
payload: { name: 'override-proj', serverOverrides: { ha: { proxyModel: 'ha-special' } } },
|
|
});
|
|
expect(res.statusCode).toBe(201);
|
|
expect(repo.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({ serverOverrides: { ha: { proxyModel: 'ha-special' } } }),
|
|
);
|
|
});
|
|
|
|
it('accepts serverOverrides in project update', async () => {
|
|
const repo = mockProjectRepo();
|
|
vi.mocked(repo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
|
await createApp(repo);
|
|
const res = await app.inject({
|
|
method: 'PUT',
|
|
url: '/api/v1/projects/p1',
|
|
payload: { serverOverrides: { ha: { proxyModel: 'ha-special' } } },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(repo.update).toHaveBeenCalledWith('p1', expect.objectContaining({
|
|
serverOverrides: { ha: { proxyModel: 'ha-special' } },
|
|
}));
|
|
});
|
|
|
|
it('returns serverOverrides in project GET', async () => {
|
|
const repo = mockProjectRepo();
|
|
vi.mocked(repo.findById).mockResolvedValue(
|
|
makeProject({ id: 'p1', name: 'ha-proj', serverOverrides: { ha: { proxyModel: 'ha-special' } } }),
|
|
);
|
|
await createApp(repo);
|
|
const res = await app.inject({ method: 'GET', url: '/api/v1/projects/p1' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json<{ serverOverrides: unknown }>();
|
|
expect(body.serverOverrides).toEqual({ ha: { proxyModel: 'ha-special' } });
|
|
});
|
|
});
|
|
});
|