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>
504 lines
14 KiB
TypeScript
504 lines
14 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { writeFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { createApplyCommand } from '../../src/commands/apply.js';
|
|
import type { ApiClient } from '../../src/api-client.js';
|
|
|
|
function mockClient(): ApiClient {
|
|
return {
|
|
get: vi.fn(async () => []),
|
|
post: vi.fn(async () => ({ id: 'new-id', name: 'test' })),
|
|
put: vi.fn(async () => ({ id: 'existing-id', name: 'test' })),
|
|
delete: vi.fn(async () => {}),
|
|
} as unknown as ApiClient;
|
|
}
|
|
|
|
describe('apply command', () => {
|
|
let client: ReturnType<typeof mockClient>;
|
|
let output: string[];
|
|
let tmpDir: string;
|
|
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
|
|
|
|
beforeEach(() => {
|
|
client = mockClient();
|
|
output = [];
|
|
tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-test-'));
|
|
});
|
|
|
|
it('applies servers from YAML file', async () => {
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
writeFileSync(configPath, `
|
|
servers:
|
|
- name: slack
|
|
description: Slack MCP server
|
|
transport: STDIO
|
|
packageName: "@anthropic/slack-mcp"
|
|
`);
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({ name: 'slack' }));
|
|
expect(output.join('\n')).toContain('Created server: slack');
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('applies servers from JSON file', async () => {
|
|
const configPath = join(tmpDir, 'config.json');
|
|
writeFileSync(configPath, JSON.stringify({
|
|
servers: [{ name: 'github', transport: 'STDIO' }],
|
|
}));
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({ name: 'github' }));
|
|
expect(output.join('\n')).toContain('Created server: github');
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('updates existing servers', async () => {
|
|
vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'slack' }]);
|
|
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
writeFileSync(configPath, `
|
|
servers:
|
|
- name: slack
|
|
description: Updated description
|
|
transport: STDIO
|
|
`);
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
expect(client.put).toHaveBeenCalledWith('/api/v1/servers/srv-1', expect.objectContaining({ name: 'slack' }));
|
|
expect(output.join('\n')).toContain('Updated server: slack');
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('supports dry-run mode', async () => {
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
writeFileSync(configPath, `
|
|
servers:
|
|
- name: test
|
|
transport: STDIO
|
|
`);
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
await cmd.parseAsync([configPath, '--dry-run'], { from: 'user' });
|
|
|
|
expect(client.post).not.toHaveBeenCalled();
|
|
expect(output.join('\n')).toContain('Dry run');
|
|
expect(output.join('\n')).toContain('1 server(s)');
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('applies secrets', async () => {
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
writeFileSync(configPath, `
|
|
secrets:
|
|
- name: ha-creds
|
|
data:
|
|
TOKEN: abc123
|
|
URL: https://ha.local
|
|
`);
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
expect(client.post).toHaveBeenCalledWith('/api/v1/secrets', expect.objectContaining({
|
|
name: 'ha-creds',
|
|
data: { TOKEN: 'abc123', URL: 'https://ha.local' },
|
|
}));
|
|
expect(output.join('\n')).toContain('Created secret: ha-creds');
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('updates existing secrets', async () => {
|
|
vi.mocked(client.get).mockImplementation(async (url: string) => {
|
|
if (url === '/api/v1/secrets') return [{ id: 'sec-1', name: 'ha-creds' }];
|
|
return [];
|
|
});
|
|
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
writeFileSync(configPath, `
|
|
secrets:
|
|
- name: ha-creds
|
|
data:
|
|
TOKEN: new-token
|
|
`);
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
expect(client.put).toHaveBeenCalledWith('/api/v1/secrets/sec-1', { data: { TOKEN: 'new-token' } });
|
|
expect(output.join('\n')).toContain('Updated secret: ha-creds');
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('applies projects', async () => {
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
writeFileSync(configPath, `
|
|
projects:
|
|
- name: my-project
|
|
description: A test project
|
|
`);
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({ name: 'my-project' }));
|
|
expect(output.join('\n')).toContain('Created project: my-project');
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('applies users (no role field)', async () => {
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
writeFileSync(configPath, `
|
|
users:
|
|
- email: alice@test.com
|
|
password: password123
|
|
name: Alice
|
|
`);
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
const callBody = vi.mocked(client.post).mock.calls[0]![1] as Record<string, unknown>;
|
|
expect(callBody).toEqual(expect.objectContaining({
|
|
email: 'alice@test.com',
|
|
password: 'password123',
|
|
name: 'Alice',
|
|
}));
|
|
expect(callBody).not.toHaveProperty('role');
|
|
expect(output.join('\n')).toContain('Created user: alice@test.com');
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('updates existing users matched by email', async () => {
|
|
vi.mocked(client.get).mockImplementation(async (url: string) => {
|
|
if (url === '/api/v1/users') return [{ id: 'usr-1', email: 'alice@test.com' }];
|
|
return [];
|
|
});
|
|
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
writeFileSync(configPath, `
|
|
users:
|
|
- email: alice@test.com
|
|
password: newpassword
|
|
name: Alice Updated
|
|
`);
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
expect(client.put).toHaveBeenCalledWith('/api/v1/users/usr-1', expect.objectContaining({
|
|
email: 'alice@test.com',
|
|
name: 'Alice Updated',
|
|
}));
|
|
expect(output.join('\n')).toContain('Updated user: alice@test.com');
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('applies groups', async () => {
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
writeFileSync(configPath, `
|
|
groups:
|
|
- name: dev-team
|
|
description: Development team
|
|
members:
|
|
- alice@test.com
|
|
- bob@test.com
|
|
`);
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
expect(client.post).toHaveBeenCalledWith('/api/v1/groups', expect.objectContaining({
|
|
name: 'dev-team',
|
|
description: 'Development team',
|
|
members: ['alice@test.com', 'bob@test.com'],
|
|
}));
|
|
expect(output.join('\n')).toContain('Created group: dev-team');
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('updates existing groups', async () => {
|
|
vi.mocked(client.get).mockImplementation(async (url: string) => {
|
|
if (url === '/api/v1/groups') return [{ id: 'grp-1', name: 'dev-team' }];
|
|
return [];
|
|
});
|
|
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
writeFileSync(configPath, `
|
|
groups:
|
|
- name: dev-team
|
|
description: Updated devs
|
|
members:
|
|
- new@test.com
|
|
`);
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
expect(client.put).toHaveBeenCalledWith('/api/v1/groups/grp-1', expect.objectContaining({
|
|
name: 'dev-team',
|
|
description: 'Updated devs',
|
|
}));
|
|
expect(output.join('\n')).toContain('Updated group: dev-team');
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('applies rbacBindings', async () => {
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
writeFileSync(configPath, `
|
|
rbac:
|
|
- name: developers
|
|
subjects:
|
|
- kind: User
|
|
name: alice@test.com
|
|
- kind: Group
|
|
name: dev-team
|
|
roleBindings:
|
|
- role: edit
|
|
resource: servers
|
|
- role: view
|
|
resource: instances
|
|
`);
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', expect.objectContaining({
|
|
name: 'developers',
|
|
subjects: [
|
|
{ kind: 'User', name: 'alice@test.com' },
|
|
{ kind: 'Group', name: 'dev-team' },
|
|
],
|
|
roleBindings: [
|
|
{ role: 'edit', resource: 'servers' },
|
|
{ role: 'view', resource: 'instances' },
|
|
],
|
|
}));
|
|
expect(output.join('\n')).toContain('Created rbacBinding: developers');
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('updates existing rbacBindings', async () => {
|
|
vi.mocked(client.get).mockImplementation(async (url: string) => {
|
|
if (url === '/api/v1/rbac') return [{ id: 'rbac-1', name: 'developers' }];
|
|
return [];
|
|
});
|
|
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
writeFileSync(configPath, `
|
|
rbacBindings:
|
|
- name: developers
|
|
subjects:
|
|
- kind: User
|
|
name: new@test.com
|
|
roleBindings:
|
|
- role: edit
|
|
resource: "*"
|
|
`);
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
expect(client.put).toHaveBeenCalledWith('/api/v1/rbac/rbac-1', expect.objectContaining({
|
|
name: 'developers',
|
|
}));
|
|
expect(output.join('\n')).toContain('Updated rbacBinding: developers');
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('applies projects with servers', async () => {
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
writeFileSync(configPath, `
|
|
projects:
|
|
- name: smart-home
|
|
description: Home automation
|
|
llmProvider: gemini-cli
|
|
llmModel: gemini-2.0-flash
|
|
servers:
|
|
- my-grafana
|
|
- my-ha
|
|
`);
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({
|
|
name: 'smart-home',
|
|
llmProvider: 'gemini-cli',
|
|
llmModel: 'gemini-2.0-flash',
|
|
servers: ['my-grafana', 'my-ha'],
|
|
}));
|
|
expect(output.join('\n')).toContain('Created project: smart-home');
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('dry-run shows all new resource types', async () => {
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
writeFileSync(configPath, `
|
|
secrets:
|
|
- name: creds
|
|
data:
|
|
TOKEN: abc
|
|
users:
|
|
- email: alice@test.com
|
|
password: password123
|
|
groups:
|
|
- name: dev-team
|
|
members: []
|
|
projects:
|
|
- name: my-proj
|
|
description: A project
|
|
rbacBindings:
|
|
- name: admins
|
|
subjects:
|
|
- kind: User
|
|
name: admin@test.com
|
|
roleBindings:
|
|
- role: edit
|
|
resource: "*"
|
|
`);
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
await cmd.parseAsync([configPath, '--dry-run'], { from: 'user' });
|
|
|
|
expect(client.post).not.toHaveBeenCalled();
|
|
const text = output.join('\n');
|
|
expect(text).toContain('Dry run');
|
|
expect(text).toContain('1 secret(s)');
|
|
expect(text).toContain('1 user(s)');
|
|
expect(text).toContain('1 group(s)');
|
|
expect(text).toContain('1 project(s)');
|
|
expect(text).toContain('1 rbacBinding(s)');
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('applies resources in correct order', async () => {
|
|
const callOrder: string[] = [];
|
|
vi.mocked(client.post).mockImplementation(async (url: string) => {
|
|
callOrder.push(url);
|
|
return { id: 'new-id', name: 'test' };
|
|
});
|
|
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
writeFileSync(configPath, `
|
|
rbacBindings:
|
|
- name: admins
|
|
subjects:
|
|
- kind: User
|
|
name: admin@test.com
|
|
roleBindings:
|
|
- role: edit
|
|
resource: "*"
|
|
users:
|
|
- email: admin@test.com
|
|
password: password123
|
|
secrets:
|
|
- name: creds
|
|
data:
|
|
KEY: val
|
|
groups:
|
|
- name: dev-team
|
|
servers:
|
|
- name: my-server
|
|
transport: STDIO
|
|
projects:
|
|
- name: my-proj
|
|
`);
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
// Apply order: secrets → servers → users → groups → projects → templates → rbacBindings
|
|
expect(callOrder[0]).toBe('/api/v1/secrets');
|
|
expect(callOrder[1]).toBe('/api/v1/servers');
|
|
expect(callOrder[2]).toBe('/api/v1/users');
|
|
expect(callOrder[3]).toBe('/api/v1/groups');
|
|
expect(callOrder[4]).toBe('/api/v1/projects');
|
|
expect(callOrder[5]).toBe('/api/v1/rbac');
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('applies rbac with operation bindings', async () => {
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
writeFileSync(configPath, `
|
|
rbac:
|
|
- name: ops-team
|
|
subjects:
|
|
- kind: Group
|
|
name: ops
|
|
roleBindings:
|
|
- role: edit
|
|
resource: servers
|
|
- role: run
|
|
action: backup
|
|
- role: run
|
|
action: logs
|
|
`);
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', expect.objectContaining({
|
|
name: 'ops-team',
|
|
roleBindings: [
|
|
{ role: 'edit', resource: 'servers' },
|
|
{ role: 'run', action: 'backup' },
|
|
{ role: 'run', action: 'logs' },
|
|
],
|
|
}));
|
|
expect(output.join('\n')).toContain('Created rbacBinding: ops-team');
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('applies rbac with name-scoped resource binding', async () => {
|
|
const configPath = join(tmpDir, 'config.yaml');
|
|
writeFileSync(configPath, `
|
|
rbac:
|
|
- name: ha-viewer
|
|
subjects:
|
|
- kind: User
|
|
name: alice@test.com
|
|
roleBindings:
|
|
- role: view
|
|
resource: servers
|
|
name: my-ha
|
|
`);
|
|
|
|
const cmd = createApplyCommand({ client, log });
|
|
await cmd.parseAsync([configPath], { from: 'user' });
|
|
|
|
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', expect.objectContaining({
|
|
name: 'ha-viewer',
|
|
roleBindings: [
|
|
{ role: 'view', resource: 'servers', name: 'my-ha' },
|
|
],
|
|
}));
|
|
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
});
|