mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-03-23 19:03:07 +00:00
The test 'should switch to nodejs user when running as root' was failing because: - Alpine Linux's ps command shows numeric UIDs (1) instead of usernames (nodejs) - Parsing ps output is unreliable across different environments Fixed by: - Using 'id -u' to check the numeric UID directly (expects 1001 for nodejs user) - Adding functional test to verify write permissions to /app directory - This approach is environment-agnostic and more reliable than parsing ps output The test now properly verifies that the container switches from root to nodejs user.
503 lines
18 KiB
TypeScript
503 lines
18 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
|
|
import { execSync } from 'child_process';
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
import os from 'os';
|
|
import { exec, waitForHealthy, isRunningInHttpMode, getProcessEnv } from './test-helpers';
|
|
|
|
// Skip tests if not in CI or if Docker is not available
|
|
const SKIP_DOCKER_TESTS = process.env.CI !== 'true' && !process.env.RUN_DOCKER_TESTS;
|
|
const describeDocker = SKIP_DOCKER_TESTS ? describe.skip : describe;
|
|
|
|
// Helper to check if Docker is available
|
|
async function isDockerAvailable(): Promise<boolean> {
|
|
try {
|
|
await exec('docker --version');
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Helper to generate unique container names
|
|
function generateContainerName(suffix: string): string {
|
|
return `n8n-mcp-entrypoint-test-${Date.now()}-${suffix}`;
|
|
}
|
|
|
|
// Helper to clean up containers
|
|
async function cleanupContainer(containerName: string) {
|
|
try {
|
|
await exec(`docker stop ${containerName}`);
|
|
await exec(`docker rm ${containerName}`);
|
|
} catch {
|
|
// Ignore errors - container might not exist
|
|
}
|
|
}
|
|
|
|
// Helper to run container with timeout
|
|
async function runContainerWithTimeout(
|
|
containerName: string,
|
|
dockerCmd: string,
|
|
timeoutMs: number = 5000
|
|
): Promise<{ stdout: string; stderr: string }> {
|
|
return new Promise(async (resolve, reject) => {
|
|
const timeout = setTimeout(async () => {
|
|
try {
|
|
await exec(`docker stop ${containerName}`);
|
|
} catch {}
|
|
reject(new Error(`Container timeout after ${timeoutMs}ms`));
|
|
}, timeoutMs);
|
|
|
|
try {
|
|
const result = await exec(dockerCmd);
|
|
clearTimeout(timeout);
|
|
resolve(result);
|
|
} catch (error) {
|
|
clearTimeout(timeout);
|
|
reject(error);
|
|
}
|
|
});
|
|
}
|
|
|
|
describeDocker('Docker Entrypoint Script', () => {
|
|
let tempDir: string;
|
|
let dockerAvailable: boolean;
|
|
const imageName = 'n8n-mcp-test:latest';
|
|
const containers: string[] = [];
|
|
|
|
beforeAll(async () => {
|
|
dockerAvailable = await isDockerAvailable();
|
|
if (!dockerAvailable) {
|
|
console.warn('Docker not available, skipping Docker entrypoint tests');
|
|
return;
|
|
}
|
|
|
|
// Check if image exists
|
|
let imageExists = false;
|
|
try {
|
|
await exec(`docker image inspect ${imageName}`);
|
|
imageExists = true;
|
|
} catch {
|
|
imageExists = false;
|
|
}
|
|
|
|
// Build test image if in CI or if explicitly requested or if image doesn't exist
|
|
if (!imageExists || process.env.CI === 'true' || process.env.BUILD_DOCKER_TEST_IMAGE === 'true') {
|
|
const projectRoot = path.resolve(__dirname, '../../../');
|
|
console.log('Building Docker image for tests...');
|
|
try {
|
|
execSync(`docker build -t ${imageName} .`, {
|
|
cwd: projectRoot,
|
|
stdio: 'inherit'
|
|
});
|
|
console.log('Docker image built successfully');
|
|
} catch (error) {
|
|
console.error('Failed to build Docker image:', error);
|
|
throw new Error('Docker image build failed - tests cannot continue');
|
|
}
|
|
} else {
|
|
console.log(`Using existing Docker image: ${imageName}`);
|
|
}
|
|
}, 60000); // Increase timeout to 60s for Docker build
|
|
|
|
beforeEach(() => {
|
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docker-entrypoint-test-'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
// Clean up containers with error tracking
|
|
const cleanupErrors: string[] = [];
|
|
for (const container of containers) {
|
|
try {
|
|
await cleanupContainer(container);
|
|
} catch (error) {
|
|
cleanupErrors.push(`Failed to cleanup ${container}: ${error}`);
|
|
}
|
|
}
|
|
|
|
if (cleanupErrors.length > 0) {
|
|
console.warn('Container cleanup errors:', cleanupErrors);
|
|
}
|
|
|
|
containers.length = 0;
|
|
|
|
// Clean up temp directory
|
|
if (fs.existsSync(tempDir)) {
|
|
fs.rmSync(tempDir, { recursive: true });
|
|
}
|
|
}, 20000); // Increase timeout for cleanup
|
|
|
|
describe('MCP Mode handling', () => {
|
|
it('should default to stdio mode when MCP_MODE is not set', async () => {
|
|
if (!dockerAvailable) return;
|
|
|
|
const containerName = generateContainerName('default-mode');
|
|
containers.push(containerName);
|
|
|
|
// Check that stdio mode is used by default
|
|
const { stdout } = await exec(
|
|
`docker run --name ${containerName} ${imageName} sh -c "env | grep -E '^MCP_MODE=' || echo 'MCP_MODE not set (defaults to stdio)'"`
|
|
);
|
|
|
|
// Should either show MCP_MODE=stdio or indicate it's not set (which means stdio by default)
|
|
expect(stdout.trim()).toMatch(/MCP_MODE=stdio|MCP_MODE not set/);
|
|
});
|
|
|
|
it('should respect MCP_MODE=http environment variable', async () => {
|
|
if (!dockerAvailable) return;
|
|
|
|
const containerName = generateContainerName('http-mode');
|
|
containers.push(containerName);
|
|
|
|
// Run in HTTP mode
|
|
const { stdout } = await exec(
|
|
`docker run --name ${containerName} -e MCP_MODE=http -e AUTH_TOKEN=test ${imageName} sh -c "env | grep MCP_MODE"`
|
|
);
|
|
|
|
expect(stdout.trim()).toBe('MCP_MODE=http');
|
|
});
|
|
});
|
|
|
|
describe('n8n-mcp serve command', () => {
|
|
it('should transform "n8n-mcp serve" to HTTP mode', async () => {
|
|
if (!dockerAvailable) return;
|
|
|
|
const containerName = generateContainerName('serve-transform');
|
|
containers.push(containerName);
|
|
|
|
// Test that "n8n-mcp serve" command triggers HTTP mode
|
|
// The entrypoint checks if the first two args are "n8n-mcp" and "serve"
|
|
try {
|
|
// Start container with n8n-mcp serve command
|
|
await exec(`docker run -d --name ${containerName} -e AUTH_TOKEN=test -p 13000:3000 ${imageName} n8n-mcp serve`);
|
|
|
|
// Give it a moment to start
|
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
|
|
// Check if the server is running in HTTP mode by checking the process
|
|
const { stdout: psOutput } = await exec(`docker exec ${containerName} ps aux | grep node | grep -v grep || echo "No node process"`);
|
|
|
|
// The process should be running with HTTP mode
|
|
expect(psOutput).toContain('node');
|
|
expect(psOutput).toContain('/app/dist/mcp/index.js');
|
|
|
|
// Check that the server is actually running in HTTP mode
|
|
// We can verify this by checking if the HTTP server is listening
|
|
const { stdout: curlOutput } = await exec(
|
|
`docker exec ${containerName} sh -c "curl -s http://localhost:3000/health || echo 'Server not responding'"`
|
|
);
|
|
|
|
// If running in HTTP mode, the health endpoint should respond
|
|
expect(curlOutput).toContain('ok');
|
|
} catch (error) {
|
|
console.error('Test error:', error);
|
|
throw error;
|
|
}
|
|
}, 15000); // Increase timeout for container startup
|
|
|
|
it('should preserve arguments after "n8n-mcp serve"', async () => {
|
|
if (!dockerAvailable) return;
|
|
|
|
const containerName = generateContainerName('serve-args-preserve');
|
|
containers.push(containerName);
|
|
|
|
// Start container with serve command and custom port
|
|
// Note: --port is not in the whitelist in the n8n-mcp wrapper, so we'll use allowed args
|
|
await exec(`docker run -d --name ${containerName} -e AUTH_TOKEN=test -p 8080:3000 ${imageName} n8n-mcp serve --verbose`);
|
|
|
|
// Give it a moment to start
|
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
|
|
// Check that the server started with the verbose flag
|
|
// We can check the process args to verify
|
|
const { stdout } = await exec(`docker exec ${containerName} ps aux | grep node | grep -v grep || echo "Process not found"`);
|
|
|
|
// Should contain the verbose flag
|
|
expect(stdout).toContain('--verbose');
|
|
}, 10000);
|
|
});
|
|
|
|
describe('Database path configuration', () => {
|
|
it('should use default database path when NODE_DB_PATH is not set', async () => {
|
|
if (!dockerAvailable) return;
|
|
|
|
const containerName = generateContainerName('default-db-path');
|
|
containers.push(containerName);
|
|
|
|
const { stdout } = await exec(
|
|
`docker run --name ${containerName} ${imageName} sh -c "ls -la /app/data/nodes.db 2>&1 || echo 'Database not found'"`
|
|
);
|
|
|
|
// Should either find the database or be trying to create it at default path
|
|
expect(stdout).toMatch(/nodes\.db|Database not found/);
|
|
});
|
|
|
|
it('should respect NODE_DB_PATH environment variable', async () => {
|
|
if (!dockerAvailable) return;
|
|
|
|
const containerName = generateContainerName('custom-db-path');
|
|
containers.push(containerName);
|
|
|
|
// Use a path that the nodejs user can create
|
|
// We need to check the environment inside the running process, not the initial shell
|
|
await exec(
|
|
`docker run -d --name ${containerName} -e NODE_DB_PATH=/tmp/custom/test.db -e AUTH_TOKEN=test ${imageName}`
|
|
);
|
|
|
|
// Give it more time to start and stabilize
|
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
|
|
// Check the actual process environment using the helper function
|
|
const nodeDbPath = await getProcessEnv(containerName, 'NODE_DB_PATH');
|
|
|
|
expect(nodeDbPath).toBe('/tmp/custom/test.db');
|
|
}, 15000);
|
|
|
|
it('should validate NODE_DB_PATH format', async () => {
|
|
if (!dockerAvailable) return;
|
|
|
|
const containerName = generateContainerName('invalid-db-path');
|
|
containers.push(containerName);
|
|
|
|
// Try with invalid path (not ending with .db)
|
|
try {
|
|
await exec(
|
|
`docker run --name ${containerName} -e NODE_DB_PATH=/custom/invalid-path ${imageName} echo "Should not reach here"`
|
|
);
|
|
expect.fail('Container should have exited with error');
|
|
} catch (error: any) {
|
|
expect(error.stderr).toContain('ERROR: NODE_DB_PATH must end with .db');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Permission handling', () => {
|
|
it('should fix permissions when running as root', async () => {
|
|
if (!dockerAvailable) return;
|
|
|
|
const containerName = generateContainerName('root-permissions');
|
|
containers.push(containerName);
|
|
|
|
// Run as root and let the container initialize
|
|
await exec(
|
|
`docker run -d --name ${containerName} --user root ${imageName}`
|
|
);
|
|
|
|
// Give entrypoint time to fix permissions
|
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
|
|
// Check directory ownership
|
|
const { stdout } = await exec(
|
|
`docker exec ${containerName} ls -ld /app/data | awk '{print $3}'`
|
|
);
|
|
|
|
// Directory should be owned by nodejs user after entrypoint runs
|
|
expect(stdout.trim()).toBe('nodejs');
|
|
});
|
|
|
|
it('should switch to nodejs user when running as root', async () => {
|
|
if (!dockerAvailable) return;
|
|
|
|
const containerName = generateContainerName('user-switch');
|
|
containers.push(containerName);
|
|
|
|
// Run as root but the entrypoint should switch to nodejs user
|
|
await exec(`docker run -d --name ${containerName} --user root ${imageName}`);
|
|
|
|
// Give it time to start and for the user switch to complete
|
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
|
|
// Method 1: Check what user docker exec runs as
|
|
// When the entrypoint switches to nodejs user, docker exec should also run as that user
|
|
const { stdout: idOutput } = await exec(
|
|
`docker exec ${containerName} id -u`
|
|
);
|
|
|
|
// The nodejs user has UID 1001
|
|
expect(idOutput.trim()).toBe('1001');
|
|
|
|
// Method 2: Verify the effective user can write to nodejs-owned directories
|
|
// This proves we're actually running as nodejs, not just reporting it
|
|
const { stdout: writeTest } = await exec(
|
|
`docker exec ${containerName} sh -c "touch /app/test-write && echo success || echo failed"`
|
|
);
|
|
|
|
expect(writeTest.trim()).toBe('success');
|
|
|
|
// Clean up test file
|
|
await exec(`docker exec ${containerName} rm -f /app/test-write`);
|
|
}, 15000);
|
|
});
|
|
|
|
describe('Auth token validation', () => {
|
|
it('should require AUTH_TOKEN in HTTP mode', async () => {
|
|
if (!dockerAvailable) return;
|
|
|
|
const containerName = generateContainerName('auth-required');
|
|
containers.push(containerName);
|
|
|
|
try {
|
|
await exec(
|
|
`docker run --name ${containerName} -e MCP_MODE=http ${imageName} echo "Should fail"`
|
|
);
|
|
expect.fail('Should have failed without AUTH_TOKEN');
|
|
} catch (error: any) {
|
|
expect(error.stderr).toContain('AUTH_TOKEN or AUTH_TOKEN_FILE is required for HTTP mode');
|
|
}
|
|
});
|
|
|
|
it('should accept AUTH_TOKEN_FILE', async () => {
|
|
if (!dockerAvailable) return;
|
|
|
|
const containerName = generateContainerName('auth-file');
|
|
containers.push(containerName);
|
|
|
|
// Create auth token file
|
|
const tokenFile = path.join(tempDir, 'auth-token');
|
|
fs.writeFileSync(tokenFile, 'secret-token-from-file');
|
|
|
|
const { stdout } = await exec(
|
|
`docker run --name ${containerName} -e MCP_MODE=http -e AUTH_TOKEN_FILE=/auth/token -v "${tokenFile}:/auth/token:ro" ${imageName} sh -c "echo 'Started successfully'"`
|
|
);
|
|
|
|
expect(stdout.trim()).toBe('Started successfully');
|
|
});
|
|
|
|
it('should validate AUTH_TOKEN_FILE exists', async () => {
|
|
if (!dockerAvailable) return;
|
|
|
|
const containerName = generateContainerName('auth-file-missing');
|
|
containers.push(containerName);
|
|
|
|
try {
|
|
await exec(
|
|
`docker run --name ${containerName} -e MCP_MODE=http -e AUTH_TOKEN_FILE=/non/existent/file ${imageName} echo "Should fail"`
|
|
);
|
|
expect.fail('Should have failed with missing AUTH_TOKEN_FILE');
|
|
} catch (error: any) {
|
|
expect(error.stderr).toContain('AUTH_TOKEN_FILE specified but file not found');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Signal handling and process management', () => {
|
|
it('should use exec to ensure proper signal propagation', async () => {
|
|
if (!dockerAvailable) return;
|
|
|
|
const containerName = generateContainerName('signal-handling');
|
|
containers.push(containerName);
|
|
|
|
// Start container in background
|
|
await exec(
|
|
`docker run -d --name ${containerName} ${imageName}`
|
|
);
|
|
|
|
// Give it more time to fully start
|
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
|
|
// Check the main process - Alpine ps has different syntax
|
|
const { stdout } = await exec(
|
|
`docker exec ${containerName} sh -c "ps | grep -E '^ *1 ' | awk '{print \\$1}'"`
|
|
);
|
|
|
|
expect(stdout.trim()).toBe('1');
|
|
}, 15000); // Increase timeout for this test
|
|
});
|
|
|
|
describe('Logging behavior', () => {
|
|
it('should suppress logs in stdio mode', async () => {
|
|
if (!dockerAvailable) return;
|
|
|
|
const containerName = generateContainerName('stdio-quiet');
|
|
containers.push(containerName);
|
|
|
|
// Run in stdio mode and check for clean output
|
|
const { stdout, stderr } = await exec(
|
|
`docker run --name ${containerName} -e MCP_MODE=stdio ${imageName} sh -c "sleep 0.1 && echo 'STDIO_TEST' && exit 0"`
|
|
);
|
|
|
|
// In stdio mode, initialization logs should be suppressed
|
|
expect(stderr).not.toContain('Creating database directory');
|
|
expect(stderr).not.toContain('Database not found');
|
|
});
|
|
|
|
it('should show logs in HTTP mode', async () => {
|
|
if (!dockerAvailable) return;
|
|
|
|
const containerName = generateContainerName('http-logs');
|
|
containers.push(containerName);
|
|
|
|
// Create a fresh database directory to trigger initialization logs
|
|
const dbDir = path.join(tempDir, 'data');
|
|
fs.mkdirSync(dbDir);
|
|
|
|
const { stdout, stderr } = await exec(
|
|
`docker run --name ${containerName} -e MCP_MODE=http -e AUTH_TOKEN=test -v "${dbDir}:/app/data" ${imageName} sh -c "echo 'HTTP_TEST' && exit 0"`
|
|
);
|
|
|
|
// In HTTP mode, logs should be visible
|
|
const output = stdout + stderr;
|
|
expect(output).toContain('HTTP_TEST');
|
|
});
|
|
});
|
|
|
|
describe('Config file integration', () => {
|
|
it('should load config before validation checks', async () => {
|
|
if (!dockerAvailable) return;
|
|
|
|
const containerName = generateContainerName('config-order');
|
|
containers.push(containerName);
|
|
|
|
// Create config that sets required AUTH_TOKEN
|
|
const configPath = path.join(tempDir, 'config.json');
|
|
const config = {
|
|
mcp_mode: 'http',
|
|
auth_token: 'token-from-config'
|
|
};
|
|
fs.writeFileSync(configPath, JSON.stringify(config));
|
|
|
|
// Should start successfully with AUTH_TOKEN from config
|
|
const { stdout } = await exec(
|
|
`docker run --name ${containerName} -v "${configPath}:/app/config.json:ro" ${imageName} sh -c "echo 'Started with config' && env | grep AUTH_TOKEN"`
|
|
);
|
|
|
|
expect(stdout).toContain('Started with config');
|
|
expect(stdout).toContain('AUTH_TOKEN=token-from-config');
|
|
});
|
|
});
|
|
|
|
describe('Database initialization with file locking', () => {
|
|
it('should prevent race conditions during database initialization', async () => {
|
|
if (!dockerAvailable) return;
|
|
|
|
// This test simulates multiple containers trying to initialize the database simultaneously
|
|
const containerPrefix = 'db-race';
|
|
const numContainers = 3;
|
|
const containerNames = Array.from({ length: numContainers }, (_, i) =>
|
|
generateContainerName(`${containerPrefix}-${i}`)
|
|
);
|
|
containers.push(...containerNames);
|
|
|
|
// Shared volume for database
|
|
const dbDir = path.join(tempDir, 'shared-data');
|
|
fs.mkdirSync(dbDir);
|
|
|
|
// Start all containers simultaneously
|
|
const promises = containerNames.map(name =>
|
|
exec(
|
|
`docker run --name ${name} -v "${dbDir}:/app/data" ${imageName} sh -c "ls -la /app/data/nodes.db && echo 'Container ${name} completed'"`
|
|
).catch(error => ({ stdout: '', stderr: error.stderr || error.message }))
|
|
);
|
|
|
|
const results = await Promise.all(promises);
|
|
|
|
// All containers should complete successfully
|
|
const successCount = results.filter(r => r.stdout.includes('completed')).length;
|
|
expect(successCount).toBeGreaterThan(0);
|
|
|
|
// Database should exist and be valid
|
|
const dbPath = path.join(dbDir, 'nodes.db');
|
|
expect(fs.existsSync(dbPath)).toBe(true);
|
|
});
|
|
});
|
|
}); |