import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { writeFileSync, readFileSync, mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { createConfigCommand } from '../../src/commands/config.js'; import type { ApiClient } from '../../src/api-client.js'; import { saveCredentials, loadCredentials } from '../../src/auth/index.js'; function mockClient(): ApiClient { return { get: vi.fn(async () => ({ mcpServers: { 'slack--default': { command: 'npx', args: ['-y', '@anthropic/slack-mcp'], env: { WORKSPACE: 'test' } }, 'github--default': { command: 'npx', args: ['-y', '@anthropic/github-mcp'] }, }, })), post: vi.fn(async () => ({ token: 'impersonated-tok', user: { email: 'other@test.com' } })), put: vi.fn(async () => ({})), delete: vi.fn(async () => {}), } as unknown as ApiClient; } describe('config claude-generate', () => { let client: ReturnType; let output: string[]; let tmpDir: string; const log = (...args: string[]) => output.push(args.join(' ')); beforeEach(() => { client = mockClient(); output = []; tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-claude-')); }); afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); it('generates .mcp.json from project config', async () => { const outPath = join(tmpDir, '.mcp.json'); const cmd = createConfigCommand( { configDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log }, ); await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '-o', outPath], { from: 'user' }); expect(client.get).toHaveBeenCalledWith('/api/v1/projects/proj-1/mcp-config'); const written = JSON.parse(readFileSync(outPath, 'utf-8')); expect(written.mcpServers['slack--default']).toBeDefined(); expect(output.join('\n')).toContain('2 server(s)'); }); it('prints to stdout with --stdout', async () => { const cmd = createConfigCommand( { configDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log }, ); await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '--stdout'], { from: 'user' }); expect(output[0]).toContain('mcpServers'); }); it('merges with existing .mcp.json', async () => { const outPath = join(tmpDir, '.mcp.json'); writeFileSync(outPath, JSON.stringify({ mcpServers: { 'existing--server': { command: 'echo', args: [] } }, })); const cmd = createConfigCommand( { configDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log }, ); await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '-o', outPath, '--merge'], { from: 'user' }); const written = JSON.parse(readFileSync(outPath, 'utf-8')); expect(written.mcpServers['existing--server']).toBeDefined(); expect(written.mcpServers['slack--default']).toBeDefined(); expect(output.join('\n')).toContain('3 server(s)'); }); }); describe('config impersonate', () => { let client: ReturnType; let output: string[]; let tmpDir: string; const log = (...args: string[]) => output.push(args.join(' ')); beforeEach(() => { client = mockClient(); output = []; tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-impersonate-')); }); afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); it('impersonates a user and saves backup', async () => { saveCredentials({ token: 'admin-tok', mcpdUrl: 'http://localhost:3100', user: 'admin@test.com' }, { configDir: tmpDir }); const cmd = createConfigCommand( { configDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log }, ); await cmd.parseAsync(['impersonate', 'other@test.com'], { from: 'user' }); expect(client.post).toHaveBeenCalledWith('/api/v1/auth/impersonate', { email: 'other@test.com' }); expect(output.join('\n')).toContain('Impersonating other@test.com'); const creds = loadCredentials({ configDir: tmpDir }); expect(creds!.user).toBe('other@test.com'); expect(creds!.token).toBe('impersonated-tok'); // Backup exists const backup = JSON.parse(readFileSync(join(tmpDir, 'credentials-backup'), 'utf-8')); expect(backup.user).toBe('admin@test.com'); }); it('quits impersonation and restores backup', async () => { // Set up current (impersonated) credentials saveCredentials({ token: 'impersonated-tok', mcpdUrl: 'http://localhost:3100', user: 'other@test.com' }, { configDir: tmpDir }); // Set up backup (original) credentials writeFileSync(join(tmpDir, 'credentials-backup'), JSON.stringify({ token: 'admin-tok', mcpdUrl: 'http://localhost:3100', user: 'admin@test.com', })); const cmd = createConfigCommand( { configDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log }, ); await cmd.parseAsync(['impersonate', '--quit'], { from: 'user' }); expect(output.join('\n')).toContain('Returned to admin@test.com'); const creds = loadCredentials({ configDir: tmpDir }); expect(creds!.user).toBe('admin@test.com'); expect(creds!.token).toBe('admin-tok'); }); it('errors when not logged in', async () => { const cmd = createConfigCommand( { configDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log }, ); await cmd.parseAsync(['impersonate', 'other@test.com'], { from: 'user' }); expect(output.join('\n')).toContain('Not logged in'); }); it('errors when quitting with no backup', async () => { const cmd = createConfigCommand( { configDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log }, ); await cmd.parseAsync(['impersonate', '--quit'], { from: 'user' }); expect(output.join('\n')).toContain('No impersonation session to quit'); }); });