feat: remove ProjectMember, add expose RBAC role, attach/detach-server commands
- Remove ProjectMember model entirely (RBAC manages project access) - Add 'expose' RBAC role for /mcp-config endpoint access (edit implies expose) - Rename CLI flags: --llm-provider → --proxy-mode-llm-provider, --llm-model → --proxy-mode-llm-model - Add attach-server / detach-server CLI commands (mcpctl --project NAME attach-server SERVER) - Add POST/DELETE /api/v1/projects/:id/servers endpoints for server attach/detach - Remove members from backup/restore, apply, get, describe - Prisma migration to drop ProjectMember table Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,6 @@ 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, ISecretRepository } from '../src/repositories/interfaces.js';
|
||||
import type { IUserRepository } from '../src/repositories/user.repository.js';
|
||||
import type { McpServer } from '@prisma/client';
|
||||
|
||||
function makeProject(overrides: Partial<ProjectWithRelations> = {}): ProjectWithRelations {
|
||||
@@ -19,7 +18,6 @@ function makeProject(overrides: Partial<ProjectWithRelations> = {}): ProjectWith
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
servers: [],
|
||||
members: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -64,7 +62,8 @@ function mockProjectRepo(): IProjectRepository {
|
||||
update: vi.fn(async (_id, data) => makeProject({ ...data as Partial<ProjectWithRelations> })),
|
||||
delete: vi.fn(async () => {}),
|
||||
setServers: vi.fn(async () => {}),
|
||||
setMembers: vi.fn(async () => {}),
|
||||
addServer: vi.fn(async () => {}),
|
||||
removeServer: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,33 +89,17 @@ function mockSecretRepo(): ISecretRepository {
|
||||
};
|
||||
}
|
||||
|
||||
function mockUserRepo(): IUserRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByEmail: vi.fn(async () => null),
|
||||
create: vi.fn(async () => ({
|
||||
id: 'u-1', email: 'test@example.com', name: null, role: 'user',
|
||||
provider: null, externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
})),
|
||||
delete: vi.fn(async () => {}),
|
||||
count: vi.fn(async () => 0),
|
||||
};
|
||||
}
|
||||
|
||||
describe('ProjectService', () => {
|
||||
let projectRepo: ReturnType<typeof mockProjectRepo>;
|
||||
let serverRepo: ReturnType<typeof mockServerRepo>;
|
||||
let secretRepo: ReturnType<typeof mockSecretRepo>;
|
||||
let userRepo: ReturnType<typeof mockUserRepo>;
|
||||
let service: ProjectService;
|
||||
|
||||
beforeEach(() => {
|
||||
projectRepo = mockProjectRepo();
|
||||
serverRepo = mockServerRepo();
|
||||
secretRepo = mockSecretRepo();
|
||||
userRepo = mockUserRepo();
|
||||
service = new ProjectService(projectRepo, serverRepo, secretRepo, userRepo);
|
||||
service = new ProjectService(projectRepo, serverRepo, secretRepo);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
@@ -164,32 +147,6 @@ describe('ProjectService', () => {
|
||||
expect(result.servers).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('creates project with members (resolves emails)', async () => {
|
||||
vi.mocked(userRepo.findByEmail).mockImplementation(async (email) => {
|
||||
if (email === 'alice@test.com') {
|
||||
return { id: 'u-alice', email: 'alice@test.com', name: 'Alice', role: 'user', provider: null, externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date() };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const created = makeProject({ id: 'proj-new' });
|
||||
vi.mocked(projectRepo.create).mockResolvedValue(created);
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({
|
||||
id: 'proj-new',
|
||||
members: [
|
||||
{ id: 'pm-1', user: { id: 'u-alice', email: 'alice@test.com', name: 'Alice' } },
|
||||
],
|
||||
}));
|
||||
|
||||
const result = await service.create({
|
||||
name: 'my-project',
|
||||
members: ['alice@test.com'],
|
||||
}, 'user-1');
|
||||
|
||||
expect(projectRepo.setMembers).toHaveBeenCalledWith('proj-new', ['u-alice']);
|
||||
expect(result.members).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('creates project with proxyMode and llmProvider', async () => {
|
||||
const created = makeProject({ id: 'proj-filtered', proxyMode: 'filtered', llmProvider: 'openai' });
|
||||
vi.mocked(projectRepo.create).mockResolvedValue(created);
|
||||
@@ -219,16 +176,6 @@ describe('ProjectService', () => {
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('throws NotFoundError when member email resolution fails', async () => {
|
||||
vi.mocked(userRepo.findByEmail).mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.create({
|
||||
name: 'my-project',
|
||||
members: ['nobody@test.com'],
|
||||
}, 'user-1'),
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
@@ -277,19 +224,6 @@ describe('ProjectService', () => {
|
||||
expect(projectRepo.setServers).toHaveBeenCalledWith('proj-1', ['srv-new']);
|
||||
});
|
||||
|
||||
it('updates members (full replacement)', async () => {
|
||||
const existing = makeProject({ id: 'proj-1' });
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(existing);
|
||||
|
||||
vi.mocked(userRepo.findByEmail).mockResolvedValue({
|
||||
id: 'u-bob', email: 'bob@test.com', name: 'Bob', role: 'user',
|
||||
provider: null, externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await service.update('proj-1', { members: ['bob@test.com'] });
|
||||
expect(projectRepo.setMembers).toHaveBeenCalledWith('proj-1', ['u-bob']);
|
||||
});
|
||||
|
||||
it('updates proxyMode', async () => {
|
||||
const existing = makeProject({ id: 'proj-1' });
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(existing);
|
||||
@@ -314,6 +248,52 @@ describe('ProjectService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 direct mode config with STDIO servers', async () => {
|
||||
const srv = makeServer({ id: 'srv-1', name: 'github', packageName: '@mcp/github', transport: 'STDIO' });
|
||||
|
||||
Reference in New Issue
Block a user