mirror of
https://github.com/ToolJet/ToolJet
synced 2026-04-21 21:47:17 +00:00
* feat: add fe support for python wf * feat(workflows): merge Python workflow frontend support - Update submodule references for frontend/ee and server/ee - Fix E2E test for REST API query (use jsonplaceholder instead of reqres.in) - Rename test files to use kebab-case (workflow-executions.e2e-spec.ts) - Update Python executor service interface and implementation - Fix Python bundle generation tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Akshay Sasidharan <akshaysasidharan93@gmail.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
342 lines
12 KiB
TypeScript
342 lines
12 KiB
TypeScript
import { Test, TestingModule } from '@nestjs/testing';
|
|
import { PythonExecutorService } from '../../ee/workflows/services/python-executor.service';
|
|
import { PythonExecutorService as BasePythonExecutorService } from '../../src/modules/workflows/services/python-executor.service';
|
|
import { SecurityModeDetectorService } from '../../ee/workflows/services/security-mode-detector.service';
|
|
import { SandboxMode } from '../../src/modules/workflows/interfaces/IPythonExecutorService';
|
|
import { Logger } from 'nestjs-pino';
|
|
|
|
/**
|
|
* @group workflows
|
|
*/
|
|
describe('PythonExecutorService', () => {
|
|
describe('Community Edition', () => {
|
|
let ceService: BasePythonExecutorService;
|
|
|
|
beforeEach(async () => {
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [BasePythonExecutorService],
|
|
}).compile();
|
|
|
|
ceService = module.get<BasePythonExecutorService>(BasePythonExecutorService);
|
|
});
|
|
|
|
it('should throw error for execute() in CE', async () => {
|
|
await expect(ceService.execute('print("hello")', {}, null, 10000)).rejects.toThrow(
|
|
'Python execution is not available in Community Edition'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Enterprise Edition', () => {
|
|
let service: PythonExecutorService;
|
|
let securityModeDetector: SecurityModeDetectorService;
|
|
let sandboxMode: SandboxMode;
|
|
|
|
beforeAll(async () => {
|
|
const mockLogger = {
|
|
log: jest.fn(),
|
|
warn: jest.fn(),
|
|
error: jest.fn(),
|
|
};
|
|
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
PythonExecutorService,
|
|
SecurityModeDetectorService,
|
|
{
|
|
provide: Logger,
|
|
useValue: mockLogger,
|
|
},
|
|
],
|
|
}).compile();
|
|
|
|
service = module.get<PythonExecutorService>(PythonExecutorService);
|
|
securityModeDetector = module.get<SecurityModeDetectorService>(SecurityModeDetectorService);
|
|
|
|
await securityModeDetector.onModuleInit();
|
|
sandboxMode = securityModeDetector.getMode();
|
|
});
|
|
|
|
describe('sandbox mode detection', () => {
|
|
it('should detect sandbox mode based on environment', () => {
|
|
const mode = securityModeDetector.getMode();
|
|
expect([SandboxMode.ENABLED, SandboxMode.BYPASSED]).toContain(mode);
|
|
});
|
|
});
|
|
|
|
describe('.wrapUserCode', () => {
|
|
const stateFile = '/tmp/test/state.json';
|
|
const outputFile = '/tmp/test/output.json';
|
|
|
|
it('should wrap code with state injection', () => {
|
|
const wrappedCode = (service as any).wrapUserCode('result = x + 1', stateFile, outputFile);
|
|
|
|
expect(wrappedCode).toContain('_state = json.load');
|
|
expect(wrappedCode).toContain('globals().update(_state)');
|
|
expect(wrappedCode).toContain('result = x + 1');
|
|
});
|
|
|
|
it('should preserve builtin references', () => {
|
|
const wrappedCode = (service as any).wrapUserCode('result = open', stateFile, outputFile);
|
|
|
|
expect(wrappedCode).toContain('_open, _compile, _eval, _exec, _json_dump = open, compile, eval, exec, json.dump');
|
|
});
|
|
|
|
it('should use provided file paths', () => {
|
|
const wrappedCode = (service as any).wrapUserCode('result = 42', stateFile, outputFile);
|
|
|
|
expect(wrappedCode).toContain("'data': result");
|
|
expect(wrappedCode).toContain(stateFile);
|
|
expect(wrappedCode).toContain(outputFile);
|
|
});
|
|
|
|
it('should escape single quotes in user code', () => {
|
|
const wrappedCode = (service as any).wrapUserCode("result = 'hello'", stateFile, outputFile);
|
|
|
|
expect(wrappedCode).toContain("\\'hello\\'");
|
|
});
|
|
});
|
|
|
|
describe('.execute', () => {
|
|
it('should execute simple code: result = 42', async () => {
|
|
const result = await service.execute('result = 42', {}, null, 10000);
|
|
|
|
expect(result.status).toBe('ok');
|
|
expect(result.data).toBe(42);
|
|
expect(result.executionTimeMs).toBeGreaterThan(0);
|
|
}, 15000);
|
|
|
|
it('should inject state variables', async () => {
|
|
const result = await service.execute('result = x + y', { x: 10, y: 5 }, null, 10000);
|
|
|
|
expect(result.status).toBe('ok');
|
|
expect(result.data).toBe(15);
|
|
}, 15000);
|
|
|
|
it('should handle string operations', async () => {
|
|
const result = await service.execute(
|
|
'result = greeting + " " + name',
|
|
{ greeting: 'Hello', name: 'World' },
|
|
null,
|
|
10000
|
|
);
|
|
|
|
expect(result.status).toBe('ok');
|
|
expect(result.data).toBe('Hello World');
|
|
}, 15000);
|
|
|
|
it('should return dict as object', async () => {
|
|
const result = await service.execute('result = {"key": "value", "number": 123}', {}, null, 10000);
|
|
|
|
expect(result.status).toBe('ok');
|
|
expect(result.data).toEqual({ key: 'value', number: 123 });
|
|
}, 15000);
|
|
|
|
it('should return list as array', async () => {
|
|
const result = await service.execute('result = [1, 2, 3, "four"]', {}, null, 10000);
|
|
|
|
expect(result.status).toBe('ok');
|
|
expect(result.data).toEqual([1, 2, 3, 'four']);
|
|
}, 15000);
|
|
|
|
it('should handle None as null', async () => {
|
|
const result = await service.execute('result = None', {}, null, 10000);
|
|
|
|
expect(result.status).toBe('ok');
|
|
expect(result.data).toBeNull();
|
|
}, 15000);
|
|
|
|
it('should handle boolean values', async () => {
|
|
const result = await service.execute('result = x > 5', { x: 10 }, null, 10000);
|
|
|
|
expect(result.status).toBe('ok');
|
|
expect(result.data).toBe(true);
|
|
}, 15000);
|
|
|
|
it('should handle complex nested structures', async () => {
|
|
const result = await service.execute(
|
|
'result = {"users": [{"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}]}',
|
|
{},
|
|
null,
|
|
10000
|
|
);
|
|
|
|
expect(result.status).toBe('ok');
|
|
expect(result.data).toEqual({
|
|
users: [
|
|
{ name: 'Alice', age: 30 },
|
|
{ name: 'Bob', age: 25 },
|
|
],
|
|
});
|
|
}, 15000);
|
|
|
|
it('should handle state that overwrites Python builtins', async () => {
|
|
// State contains "open" and "json" which would overwrite builtins
|
|
// This tests that we correctly save _open and _json_dump before state injection
|
|
const result = await service.execute('result = x + 1', { x: 10, open: 'overwritten', json: 'also overwritten' }, null, 10000);
|
|
|
|
// Should still work because we saved _open/_json_dump before globals().update(state)
|
|
expect(result.status).toBe('ok');
|
|
expect(result.data).toBe(11);
|
|
}, 15000);
|
|
|
|
it('should handle empty code string', async () => {
|
|
const result = await service.execute('', {}, null, 10000);
|
|
|
|
// Empty code should either succeed with undefined result or error gracefully
|
|
expect(['ok', 'error']).toContain(result.status);
|
|
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0);
|
|
}, 15000);
|
|
|
|
it('should handle code with only whitespace', async () => {
|
|
const result = await service.execute(' \n\t\n ', {}, null, 10000);
|
|
|
|
expect(['ok', 'error']).toContain(result.status);
|
|
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0);
|
|
}, 15000);
|
|
});
|
|
|
|
describe('error handling', () => {
|
|
it('should handle syntax errors', async () => {
|
|
const result = await service.execute('def invalid(', {}, null, 10000);
|
|
|
|
expect(result.status).toBe('error');
|
|
expect(result.error).toBeDefined();
|
|
expect(result.trace).toContain('SyntaxError');
|
|
}, 15000);
|
|
|
|
it('should handle runtime errors with traceback', async () => {
|
|
const result = await service.execute('result = undefined_variable', {}, null, 10000);
|
|
|
|
expect(result.status).toBe('error');
|
|
expect(result.error).toContain('is not defined');
|
|
expect(result.trace).toContain('NameError');
|
|
}, 15000);
|
|
|
|
it('should handle division by zero', async () => {
|
|
const result = await service.execute('result = 10 / 0', {}, null, 10000);
|
|
|
|
expect(result.status).toBe('error');
|
|
expect(result.error).toContain('division by zero');
|
|
expect(result.trace).toContain('ZeroDivisionError');
|
|
}, 15000);
|
|
|
|
it('should handle type errors', async () => {
|
|
const result = await service.execute('result = "string" + 123', {}, null, 10000);
|
|
|
|
expect(result.status).toBe('error');
|
|
expect(result.error).toContain('concatenate');
|
|
expect(result.trace).toContain('TypeError');
|
|
}, 15000);
|
|
|
|
it('should handle execution timeout', async () => {
|
|
// Code that runs longer than the timeout (100ms)
|
|
const result = await service.execute(
|
|
'import time; time.sleep(5); result = "done"',
|
|
{},
|
|
null,
|
|
100 // Very short timeout
|
|
);
|
|
|
|
expect(result.status).toBe('error');
|
|
expect(result.error).toBeDefined();
|
|
// Timeout errors typically mention timeout or killed
|
|
expect(result.error.toLowerCase()).toMatch(/timeout|killed|timed out/);
|
|
}, 15000);
|
|
});
|
|
|
|
describe('Python built-in functions', () => {
|
|
it('should support len()', async () => {
|
|
const result = await service.execute('result = len(items)', { items: [1, 2, 3, 4, 5] }, null, 10000);
|
|
|
|
expect(result.status).toBe('ok');
|
|
expect(result.data).toBe(5);
|
|
}, 15000);
|
|
|
|
it('should support range() and list()', async () => {
|
|
const result = await service.execute('result = list(range(5))', {}, null, 10000);
|
|
|
|
expect(result.status).toBe('ok');
|
|
expect(result.data).toEqual([0, 1, 2, 3, 4]);
|
|
}, 15000);
|
|
|
|
it('should support list comprehension', async () => {
|
|
const result = await service.execute('result = [x * 2 for x in numbers]', { numbers: [1, 2, 3] }, null, 10000);
|
|
|
|
expect(result.status).toBe('ok');
|
|
expect(result.data).toEqual([2, 4, 6]);
|
|
}, 15000);
|
|
|
|
it('should support filter with lambda', async () => {
|
|
const result = await service.execute(
|
|
'result = list(filter(lambda x: x > 2, numbers))',
|
|
{ numbers: [1, 2, 3, 4, 5] },
|
|
null,
|
|
10000
|
|
);
|
|
|
|
expect(result.status).toBe('ok');
|
|
expect(result.data).toEqual([3, 4, 5]);
|
|
}, 15000);
|
|
});
|
|
|
|
describe('.execute - sandbox ENABLED mode (nsjail)', () => {
|
|
// Helper function to skip tests at runtime if sandbox is not enabled
|
|
// Note: Jest doesn't have Jasmine's pending() - tests return early instead
|
|
const skipIfNoNsjail = () => {
|
|
if (sandboxMode !== SandboxMode.ENABLED) {
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
it('should execute code in nsjail sandbox', async () => {
|
|
if (skipIfNoNsjail()) return;
|
|
|
|
const result = await service.execute('result = 42', {}, null, 10000);
|
|
|
|
expect(result.status).toBe('ok');
|
|
expect(result.data).toBe(42);
|
|
}, 15000);
|
|
|
|
it('should use isolated filesystem (sandbox /etc/passwd)', async () => {
|
|
if (skipIfNoNsjail()) return;
|
|
|
|
// The sandbox provides a fake /etc/passwd with minimal content
|
|
// This verifies the sandbox mounts are working
|
|
const result = await service.execute(
|
|
`
|
|
try:
|
|
with open('/etc/passwd', 'r') as f:
|
|
content = f.read()
|
|
# Sandbox /etc/passwd is small (< 100 bytes), host version is large (> 1000 bytes)
|
|
result = 'sandbox' if len(content) < 200 else 'host'
|
|
except FileNotFoundError:
|
|
result = 'sandbox' # No access is also sandboxed
|
|
`.trim(),
|
|
{},
|
|
null,
|
|
10000
|
|
);
|
|
|
|
expect(result.status).toBe('ok');
|
|
expect(result.data).toBe('sandbox');
|
|
}, 15000);
|
|
|
|
it('should prevent access to host application files', async () => {
|
|
if (skipIfNoNsjail()) return;
|
|
|
|
// The sandbox should not allow access to /app directory
|
|
const result = await service.execute(
|
|
'import os; result = os.path.exists("/app")',
|
|
{},
|
|
null,
|
|
10000
|
|
);
|
|
|
|
expect(result.status).toBe('ok');
|
|
expect(result.data).toBe(false);
|
|
}, 15000);
|
|
});
|
|
});
|
|
});
|