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; 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; 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 proxyMode: filtered 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', proxyMode: 'filtered', 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 }); }); });