UNPKG

@miralium/mcp-security-report

Version:

MCP server for managing application security audit findings and reports

471 lines (384 loc) 16.8 kB
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import { spawn, ChildProcess } from 'child_process'; import { mkdir, rm, writeFile, access } from 'fs/promises'; import { join } from 'path'; import { StorageManager } from '../src/server/storage/index.js'; import { CliHandlers } from '../src/cli/handlers.js'; describe('Instance Locking Tests', () => { let testDir: string; let cliPath: string; let tmpBaseDir: string; let testCounter = 0; let nextPort = 4000; // Start at a higher port to avoid conflicts beforeAll(async () => { tmpBaseDir = join(process.cwd(), '.tmp'); await mkdir(tmpBaseDir, { recursive: true }); cliPath = join(process.cwd(), 'dist', 'cli.js'); }); afterAll(async () => { if (tmpBaseDir) { await rm(tmpBaseDir, { recursive: true, force: true }); } }); beforeEach(async () => { testCounter++; testDir = join(tmpBaseDir, `instance-lock-test-${testCounter}`); await mkdir(testDir, { recursive: true }); // Create .mcp-projects.json to make this a valid MCP directory await writeFile(join(testDir, '.mcp-projects.json'), JSON.stringify({ projects: {}, lastActive: undefined }, null, 2)); nextPort += 10; // Increment port range for each test to avoid conflicts }); afterEach(async () => { // Clean up any remaining processes and files await rm(testDir, { recursive: true, force: true }); }); describe('StorageManager Instance Locking', () => { it('should prevent multiple StorageManager instances in same directory', async () => { const storage1 = new StorageManager(testDir); await storage1.acquireInstanceLock(); const storage2 = new StorageManager(testDir); await expect(storage2.acquireInstanceLock()).rejects.toThrow( /Another MCP Security Report instance is already running/ ); await expect(storage2.acquireInstanceLock()).rejects.toThrow( /Running multiple instances in the same directory can lead to data corruption/ ); await expect(storage2.acquireInstanceLock()).rejects.toThrow( /you can manually remove the lock file/ ); await storage1.releaseInstanceLock(); }); it('should allow sequential StorageManager instances after lock release', async () => { const storage1 = new StorageManager(testDir); await storage1.acquireInstanceLock(); await storage1.releaseInstanceLock(); const storage2 = new StorageManager(testDir); await expect(storage2.acquireInstanceLock()).resolves.toBeUndefined(); await storage2.releaseInstanceLock(); }); it('should create lock file at expected location', async () => { const storage = new StorageManager(testDir); await storage.acquireInstanceLock(); const lockPath = join(testDir, '.mcp-instance.lock'); // Lock file should exist (but may be a directory or symlink used by proper-lockfile) try { await access(lockPath); // If access succeeds, lock file exists } catch (error) { // If access fails, check if the lock is actually held by trying to acquire another const storage2 = new StorageManager(testDir); await expect(storage2.acquireInstanceLock()).rejects.toThrow(); } await storage.releaseInstanceLock(); }); it('should handle multiple acquire calls on same instance gracefully', async () => { const storage = new StorageManager(testDir); await storage.acquireInstanceLock(); // Second acquire on same instance should not fail (idempotent) // But proper-lockfile might throw if called twice, so let's test differently const storage2 = new StorageManager(testDir); await expect(storage2.acquireInstanceLock()).rejects.toThrow(); await storage.releaseInstanceLock(); }); }); describe('CLI Handler Instance Locking', () => { it('should prevent multiple CLI handlers in same directory', async () => { const handlers1 = new CliHandlers(testDir); await handlers1.acquireInstanceLock(); const handlers2 = new CliHandlers(testDir); await expect(handlers2.acquireInstanceLock()).rejects.toThrow( /Another MCP Security Report instance is already running/ ); await handlers1.cleanup(); }); it('should automatically acquire lock when using CLI operations', async () => { const handlers1 = new CliHandlers(testDir); // Acquire lock first await handlers1.acquireInstanceLock(); const handlers2 = new CliHandlers(testDir); // Second handler should fail to acquire lock await expect(handlers2.acquireInstanceLock()).rejects.toThrow(/Another MCP Security Report instance/); await handlers1.cleanup(); }); }); describe('Server Process Instance Locking', () => { it('should prevent multiple server processes in same directory', async () => { const serverPort1 = nextPort++; const serverPort2 = nextPort++; // Start first server const server1 = spawn('node', [cliPath, 'serve', '--project-dir', testDir, '--port', `${serverPort1}`], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, NODE_ENV: 'test' }, }); // Wait for first server to start await new Promise<void>((resolve, reject) => { const timeout = setTimeout(() => { server1.kill(); reject(new Error('First server startup timeout')); }, 15000); // Increased timeout server1.stdout.on('data', (data: Buffer) => { if (data.toString().includes('MCP server listening on')) { clearTimeout(timeout); resolve(); } }); server1.stderr.on('data', (data: Buffer) => { const errorText = data.toString(); console.error('Server 1 stderr:', errorText); if (errorText.includes('Another MCP Security Report instance') || errorText.includes('validation')) { clearTimeout(timeout); reject(new Error('Server 1 failed to start due to existing lock or validation error')); } }); }); // Try to start second server in same directory const server2 = spawn('node', [cliPath, 'serve', '--project-dir', testDir, '--port', `${serverPort2}`], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, NODE_ENV: 'test' }, }); // Second server should fail with lock error const server2Error = await new Promise<string>((resolve) => { let errorOutput = ''; server2.stderr.on('data', (data: Buffer) => { errorOutput += data.toString(); }); server2.on('exit', () => { resolve(errorOutput); }); // Give it time to fail setTimeout(() => { if (server2.exitCode === null) { server2.kill(); } resolve(errorOutput); }, 8000); }); expect(server2Error).toContain('Another MCP Security Report instance is already running'); expect(server2Error).toContain('Running multiple instances in the same directory can lead to data corruption'); expect(server2Error).toContain('manually remove the lock file'); // Clean up server1.kill(); server2.kill(); }, 25000); // Longer test timeout it('should prevent CLI operations while server is running', async () => { const serverPort = nextPort++; // Start server const server = spawn('node', [cliPath, 'serve', '--project-dir', testDir, '--port', `${serverPort}`], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, NODE_ENV: 'test' }, }); // Wait for server to start await new Promise<void>((resolve, reject) => { const timeout = setTimeout(() => { server.kill(); reject(new Error('Server startup timeout')); }, 15000); server.stdout.on('data', (data: Buffer) => { if (data.toString().includes('MCP server listening on')) { clearTimeout(timeout); resolve(); } }); server.stderr.on('data', (data: Buffer) => { const errorText = data.toString(); console.error('Server stderr:', errorText); if (errorText.includes('Another MCP Security Report instance')) { clearTimeout(timeout); reject(new Error('Server failed to start due to existing lock')); } }); }); // Try CLI operation - use list instead of create to avoid parameter issues const cliProcess = spawn('node', [cliPath, 'project', 'list', '--project-dir', testDir], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, NODE_ENV: 'test' }, }); const cliResult = await new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve) => { let stdout = ''; let stderr = ''; cliProcess.stdout.on('data', (data: Buffer) => { stdout += data.toString(); }); cliProcess.stderr.on('data', (data: Buffer) => { stderr += data.toString(); }); cliProcess.on('exit', (code) => { resolve({ stdout, stderr, exitCode: code || 0 }); }); // Give CLI command time to run and fail setTimeout(() => { if (cliProcess.exitCode === null) { cliProcess.kill(); } resolve({ stdout, stderr, exitCode: -1 }); }, 5000); }); expect(cliResult.exitCode).not.toBe(0); expect(cliResult.stderr).toContain('Another MCP Security Report instance is already running'); // Clean up server.kill(); cliProcess.kill(); }, 25000); // Longer test timeout }); describe('Lock File Error Messages', () => { it('should provide helpful error message with lock file path', async () => { const storage1 = new StorageManager(testDir); await storage1.acquireInstanceLock(); const storage2 = new StorageManager(testDir); try { await storage2.acquireInstanceLock(); expect.fail('Should have thrown an error'); } catch (error) { const errorMessage = (error as Error).message; expect(errorMessage).toContain(`Another MCP Security Report instance is already running in ${testDir}`); expect(errorMessage).toContain('Running multiple instances in the same directory can lead to data corruption'); expect(errorMessage).toContain('If you are certain no other instance is running, you can manually remove the lock file:'); expect(errorMessage).toContain(`rm -r "${join(testDir, '.mcp-instance.lock')}"`); } await storage1.releaseInstanceLock(); }); }); describe('Lock Cleanup on Process Exit', () => { it('should clean up lock file when process exits normally', async () => { const lockPath = join(testDir, '.mcp-instance.lock'); // Start a CLI process that creates a project (acquires lock) const cliProcess = spawn('node', [cliPath, 'project', 'create', 'test-project', '--project-dir', testDir], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, NODE_ENV: 'test' }, }); await new Promise<void>((resolve) => { cliProcess.on('exit', () => { resolve(); }); }); // Lock file should be cleaned up await expect(access(lockPath)).rejects.toThrow(); }); it('should allow new instances after previous process exits', async () => { // Run first CLI command const cliProcess1 = spawn('node', [cliPath, 'project', 'create', 'test-project-1', '--project-dir', testDir], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, NODE_ENV: 'test' }, }); await new Promise<void>((resolve) => { cliProcess1.on('exit', () => { resolve(); }); }); // Run second CLI command (should work after first process exits) const cliProcess2 = spawn('node', [cliPath, 'project', 'create', 'test-project-2', '--project-dir', testDir], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, NODE_ENV: 'test' }, }); const cliOutput = await new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve) => { let stdout = ''; let stderr = ''; cliProcess2.stdout.on('data', (data: Buffer) => { stdout += data.toString(); }); cliProcess2.stderr.on('data', (data: Buffer) => { stderr += data.toString(); }); cliProcess2.on('exit', (code) => { resolve({ stdout, stderr, exitCode: code || 0 }); }); }); expect(cliOutput.exitCode).toBe(0); expect(cliOutput.stderr).not.toContain('Another MCP Security Report instance is already running'); }); }); describe('Concurrent Access Scenarios', () => { it('should handle rapid sequential access attempts', async () => { const results: Array<{ success: boolean; error?: string }> = []; // Try to create multiple handlers rapidly const attempts = 5; for (let i = 0; i < attempts; i++) { try { const handlers = new CliHandlers(testDir); await handlers.acquireInstanceLock(); await handlers.cleanup(); results.push({ success: true }); } catch (error) { results.push({ success: false, error: (error as Error).message }); } } // At least one should succeed, others should fail with lock error const successCount = results.filter(r => r.success).length; const lockErrors = results.filter(r => !r.success && r.error?.includes('Another MCP Security Report instance')).length; expect(successCount).toBeGreaterThan(0); expect(successCount + lockErrors).toBe(attempts); }); it('should properly handle mixed server and CLI access', async () => { // Start server first const serverPort = nextPort++; const server = spawn('node', [cliPath, 'serve', '--project-dir', testDir, '--port', `${serverPort}`], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, NODE_ENV: 'test' }, }); // Wait for server to start and acquire lock await new Promise<void>((resolve, reject) => { const timeout = setTimeout(() => { server.kill(); reject(new Error('Server startup timeout')); }, 15000); server.stdout.on('data', (data: Buffer) => { if (data.toString().includes('MCP server listening on')) { clearTimeout(timeout); resolve(); } }); server.stderr.on('data', (data: Buffer) => { const errorText = data.toString(); console.error('Mixed test server stderr:', errorText); if (errorText.includes('Another MCP Security Report instance')) { clearTimeout(timeout); reject(new Error('Server failed to start due to existing lock')); } }); }); // Try multiple CLI operations const cliAttempts = 3; const cliResults: Array<{ success: boolean; error?: string }> = []; for (let i = 0; i < cliAttempts; i++) { const cliProcess = spawn('node', [cliPath, 'project', 'list', '--project-dir', testDir], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, NODE_ENV: 'test' }, }); const result = await new Promise<{ success: boolean; error?: string }>((resolve) => { let stderr = ''; cliProcess.stderr.on('data', (data: Buffer) => { stderr += data.toString(); }); cliProcess.on('exit', (code) => { if (code === 0) { resolve({ success: true }); } else { resolve({ success: false, error: stderr }); } }); // Add timeout for CLI operations setTimeout(() => { if (cliProcess.exitCode === null) { cliProcess.kill(); } resolve({ success: false, error: stderr || 'CLI operation timeout' }); }, 5000); }); cliResults.push(result); } // All CLI operations should fail with lock error const allFailed = cliResults.every(r => !r.success); const allHaveLockError = cliResults.every(r => r.error?.includes('Another MCP Security Report instance')); expect(allFailed).toBe(true); expect(allHaveLockError).toBe(true); // Clean up server.kill(); }, 30000); // Longer test timeout }); });