- 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>
255 lines
9.2 KiB
TypeScript
255 lines
9.2 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import { createGetCommand } from '../../src/commands/get.js';
|
|
import type { GetCommandDeps } from '../../src/commands/get.js';
|
|
|
|
function makeDeps(items: unknown[] = []): GetCommandDeps & { output: string[] } {
|
|
const output: string[] = [];
|
|
return {
|
|
output,
|
|
fetchResource: vi.fn(async () => items),
|
|
log: (...args: string[]) => output.push(args.join(' ')),
|
|
};
|
|
}
|
|
|
|
describe('get command', () => {
|
|
it('lists servers in table format', async () => {
|
|
const deps = makeDeps([
|
|
{ id: 'srv-1', name: 'slack', transport: 'STDIO', packageName: '@slack/mcp', dockerImage: null },
|
|
{ id: 'srv-2', name: 'github', transport: 'SSE', packageName: null, dockerImage: 'ghcr.io/github-mcp' },
|
|
]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'servers']);
|
|
|
|
expect(deps.fetchResource).toHaveBeenCalledWith('servers', undefined);
|
|
expect(deps.output[0]).toContain('NAME');
|
|
expect(deps.output[0]).toContain('TRANSPORT');
|
|
expect(deps.output.join('\n')).toContain('slack');
|
|
expect(deps.output.join('\n')).toContain('github');
|
|
});
|
|
|
|
it('resolves resource aliases', async () => {
|
|
const deps = makeDeps([]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'srv']);
|
|
expect(deps.fetchResource).toHaveBeenCalledWith('servers', undefined);
|
|
});
|
|
|
|
it('passes ID when provided', async () => {
|
|
const deps = makeDeps([{ id: 'srv-1', name: 'slack' }]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'servers', 'srv-1']);
|
|
expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1');
|
|
});
|
|
|
|
it('outputs apply-compatible JSON format', async () => {
|
|
const deps = makeDeps([{ id: 'srv-1', name: 'slack', createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1 }]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'servers', '-o', 'json']);
|
|
|
|
const parsed = JSON.parse(deps.output[0] ?? '');
|
|
// Wrapped in resource key, internal fields stripped
|
|
expect(parsed).toHaveProperty('servers');
|
|
expect(parsed.servers[0].name).toBe('slack');
|
|
expect(parsed.servers[0]).not.toHaveProperty('id');
|
|
expect(parsed.servers[0]).not.toHaveProperty('createdAt');
|
|
expect(parsed.servers[0]).not.toHaveProperty('updatedAt');
|
|
expect(parsed.servers[0]).not.toHaveProperty('version');
|
|
});
|
|
|
|
it('outputs apply-compatible YAML format', async () => {
|
|
const deps = makeDeps([{ id: 'srv-1', name: 'slack', createdAt: '2025-01-01' }]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'servers', '-o', 'yaml']);
|
|
const text = deps.output[0];
|
|
expect(text).toContain('servers:');
|
|
expect(text).toContain('name: slack');
|
|
expect(text).not.toContain('id:');
|
|
expect(text).not.toContain('createdAt:');
|
|
});
|
|
|
|
it('lists instances with correct columns', async () => {
|
|
const deps = makeDeps([
|
|
{ id: 'inst-1', serverId: 'srv-1', server: { name: 'my-grafana' }, status: 'RUNNING', containerId: 'abc123def456', port: 3000 },
|
|
]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'instances']);
|
|
expect(deps.output[0]).toContain('NAME');
|
|
expect(deps.output[0]).toContain('STATUS');
|
|
expect(deps.output.join('\n')).toContain('my-grafana');
|
|
expect(deps.output.join('\n')).toContain('RUNNING');
|
|
});
|
|
|
|
it('shows no results message for empty list', async () => {
|
|
const deps = makeDeps([]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'servers']);
|
|
expect(deps.output[0]).toContain('No servers found');
|
|
});
|
|
|
|
it('lists users with correct columns (no ROLE column)', async () => {
|
|
const deps = makeDeps([
|
|
{ id: 'usr-1', email: 'alice@test.com', name: 'Alice', provider: null },
|
|
{ id: 'usr-2', email: 'bob@test.com', name: null, provider: 'oidc' },
|
|
]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'users']);
|
|
|
|
expect(deps.fetchResource).toHaveBeenCalledWith('users', undefined);
|
|
const text = deps.output.join('\n');
|
|
expect(text).toContain('EMAIL');
|
|
expect(text).toContain('NAME');
|
|
expect(text).not.toContain('ROLE');
|
|
expect(text).toContain('PROVIDER');
|
|
expect(text).toContain('alice@test.com');
|
|
expect(text).toContain('Alice');
|
|
expect(text).toContain('bob@test.com');
|
|
expect(text).toContain('oidc');
|
|
});
|
|
|
|
it('resolves user alias', async () => {
|
|
const deps = makeDeps([]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'user']);
|
|
expect(deps.fetchResource).toHaveBeenCalledWith('users', undefined);
|
|
});
|
|
|
|
it('lists groups with correct columns', async () => {
|
|
const deps = makeDeps([
|
|
{
|
|
id: 'grp-1',
|
|
name: 'dev-team',
|
|
description: 'Developers',
|
|
members: [{ user: { email: 'alice@test.com' } }, { user: { email: 'bob@test.com' } }],
|
|
},
|
|
{ id: 'grp-2', name: 'ops-team', description: 'Operations', members: [] },
|
|
]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'groups']);
|
|
|
|
expect(deps.fetchResource).toHaveBeenCalledWith('groups', undefined);
|
|
const text = deps.output.join('\n');
|
|
expect(text).toContain('NAME');
|
|
expect(text).toContain('MEMBERS');
|
|
expect(text).toContain('DESCRIPTION');
|
|
expect(text).toContain('dev-team');
|
|
expect(text).toContain('2');
|
|
expect(text).toContain('ops-team');
|
|
expect(text).toContain('0');
|
|
});
|
|
|
|
it('resolves group alias', async () => {
|
|
const deps = makeDeps([]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'group']);
|
|
expect(deps.fetchResource).toHaveBeenCalledWith('groups', undefined);
|
|
});
|
|
|
|
it('lists rbac definitions with correct columns', async () => {
|
|
const deps = makeDeps([
|
|
{
|
|
id: 'rbac-1',
|
|
name: 'admins',
|
|
subjects: [{ kind: 'User', name: 'admin@test.com' }],
|
|
roleBindings: [{ role: 'edit', resource: '*' }],
|
|
},
|
|
]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'rbac']);
|
|
|
|
expect(deps.fetchResource).toHaveBeenCalledWith('rbac', undefined);
|
|
const text = deps.output.join('\n');
|
|
expect(text).toContain('NAME');
|
|
expect(text).toContain('SUBJECTS');
|
|
expect(text).toContain('BINDINGS');
|
|
expect(text).toContain('admins');
|
|
expect(text).toContain('User:admin@test.com');
|
|
expect(text).toContain('edit:*');
|
|
});
|
|
|
|
it('resolves rbac-definition alias', async () => {
|
|
const deps = makeDeps([]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'rbac-definition']);
|
|
expect(deps.fetchResource).toHaveBeenCalledWith('rbac', undefined);
|
|
});
|
|
|
|
it('lists projects with new columns', async () => {
|
|
const deps = makeDeps([{
|
|
id: 'proj-1',
|
|
name: 'smart-home',
|
|
description: 'Home automation',
|
|
proxyMode: 'filtered',
|
|
ownerId: 'usr-1',
|
|
servers: [{ server: { name: 'grafana' } }],
|
|
}]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'projects']);
|
|
|
|
const text = deps.output.join('\n');
|
|
expect(text).toContain('MODE');
|
|
expect(text).toContain('SERVERS');
|
|
expect(text).toContain('smart-home');
|
|
expect(text).toContain('filtered');
|
|
expect(text).toContain('1');
|
|
});
|
|
|
|
it('displays mixed resource and operation bindings', async () => {
|
|
const deps = makeDeps([
|
|
{
|
|
id: 'rbac-1',
|
|
name: 'admin-access',
|
|
subjects: [{ kind: 'Group', name: 'admin' }],
|
|
roleBindings: [
|
|
{ role: 'edit', resource: '*' },
|
|
{ role: 'run', action: 'logs' },
|
|
{ role: 'run', action: 'backup' },
|
|
],
|
|
},
|
|
]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'rbac']);
|
|
|
|
const text = deps.output.join('\n');
|
|
expect(text).toContain('edit:*');
|
|
expect(text).toContain('run>logs');
|
|
expect(text).toContain('run>backup');
|
|
});
|
|
|
|
it('displays name-scoped resource bindings', async () => {
|
|
const deps = makeDeps([
|
|
{
|
|
id: 'rbac-1',
|
|
name: 'ha-viewer',
|
|
subjects: [{ kind: 'User', name: 'alice@test.com' }],
|
|
roleBindings: [{ role: 'view', resource: 'servers', name: 'my-ha' }],
|
|
},
|
|
]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'rbac']);
|
|
|
|
const text = deps.output.join('\n');
|
|
expect(text).toContain('view:servers:my-ha');
|
|
});
|
|
|
|
it('shows no results message for empty users list', async () => {
|
|
const deps = makeDeps([]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'users']);
|
|
expect(deps.output[0]).toContain('No users found');
|
|
});
|
|
|
|
it('shows no results message for empty groups list', async () => {
|
|
const deps = makeDeps([]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'groups']);
|
|
expect(deps.output[0]).toContain('No groups found');
|
|
});
|
|
|
|
it('shows no results message for empty rbac list', async () => {
|
|
const deps = makeDeps([]);
|
|
const cmd = createGetCommand(deps);
|
|
await cmd.parseAsync(['node', 'test', 'rbac']);
|
|
expect(deps.output[0]).toContain('No rbac found');
|
|
});
|
|
});
|