/** @jest-environment setup-polly-jest/jest-environment-node */ import * as request from 'supertest'; import { INestApplication } from '@nestjs/common'; import { setupPolly } from 'setup-polly-jest'; import * as NodeHttpAdapter from '@pollyjs/adapter-node-http'; import * as FSPersister from '@pollyjs/persister-fs'; import * as path from 'path'; import { resetDB, setupOrganizationAndUser, login, createWorkflowForUser, createWorkflowApplicationVersion, createUser, initTestApp, createUserWorkflowPermissions, closeTestApp, } from 'test-helper'; import { WorkflowBundle } from '../../../../src/entities/workflow_bundle.entity'; import { getDataSourceToken } from '@nestjs/typeorm'; import { DataSource as TypeOrmDataSource } from 'typeorm'; // Helper functions for flexible response assertions const expectSemVer = () => expect.stringMatching(/^\d+\.\d+\.\d+/); const expectISO8601 = () => expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); const expectBundleStatus = () => expect.stringMatching(/^(none|building|ready|failed)$/); // Helper function to wait for bundle completion const waitForBundleReady = async ( bundleRepo: any, appVersionId: string, language: string = 'javascript', timeoutMs: number = 15000 ): Promise => { const startTime = Date.now(); const pollInterval = 200; // Poll every 200ms while (Date.now() - startTime < timeoutMs) { const bundle = await bundleRepo.findOne({ where: { appVersionId, language } }); if (bundle) { if (bundle.status === 'ready') { return bundle; } if (bundle.status === 'failed') { throw new Error(`Bundle generation failed: ${bundle.error}`); } // If status is 'building', continue polling } await new Promise(resolve => setTimeout(resolve, pollInterval)); } throw new Error(`Bundle generation timed out after ${timeoutMs}ms`); }; /** * @group workflows */ describe('WorkflowBundleController', () => { describe('EE (plan: enterprise)', () => { let app: INestApplication; const context = setupPolly({ adapters: [NodeHttpAdapter as any], persister: FSPersister as any, recordFailedRequests: true, recordIfMissing: true, mode: (process.env.POLLY_MODE as any) || 'replay', matchRequestsBy: { method: true, headers: { exclude: ['user-agent', 'accept-encoding', 'connection', 'host', 'cookie'], }, body: true, url: { protocol: true, hostname: true, port: true, pathname: true, query: true, }, }, persisterOptions: { fs: { // Store recordings as __fixtures__/spec-file-name/* recordingsDir: path.resolve( __dirname, `../__fixtures__/${path.basename(__filename).replace(/\.[tj]s$/, '')}` ), }, }, }); beforeAll(async () => { ({ app } = await initTestApp({ edition: 'ee' })); }); beforeEach(async () => { // Configure Polly.js to only pass through localhost calls // External API calls (like NPM registry) will be recorded context.polly.server .any() .filter(req => { const url = new URL(req.url); // Pass through localhost and internal calls without recording return url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname.includes('host.docker.internal'); }) .passthrough(); // All other requests (external APIs) will be handled by Polly's recording mechanism }); afterEach(async () => { await context.polly.stop(); }); describe('GET /api/workflows/packages/:language/search | search packages', () => { it('should search JavaScript packages via unified endpoint', async () => { const { user } = await setupOrganizationAndUser(app, { email: 'admin@tooljet.io', password: 'password', firstName: 'Admin', lastName: 'User', }, { workflowPermissions: { isAllEditable: true, workflowCreate: true } }); const loggedUser = await login(app, user.email); const tokenCookie = loggedUser.tokenCookie; const response = await request(app.getHttpServer()) .get('/api/workflows/packages/javascript/search') .query({ q: 'lodash', limit: 5 }) .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send(); expect(response.statusCode).toBe(200); expect(Array.isArray(response.body)).toBe(true); expect(response.body.length).toBeGreaterThan(0); expect(response.body.length).toBeLessThanOrEqual(5); // Validate exact PackageSearchResult structure expect(response.body).toEqual( expect.arrayContaining([ expect.objectContaining({ name: expect.any(String), version: expectSemVer(), description: expect.any(String), downloads: expect.any(Number), links: expect.any(Object), author: expect.any(String), keywords: expect.arrayContaining([expect.any(String)]), modified: expectISO8601() }) ]) ); // Validate specific search result content expect(response.body[0].name).toContain('lodash'); }); it('should handle scoped packages in search', async () => { const { user } = await setupOrganizationAndUser(app, { email: 'admin@tooljet.io', password: 'password', firstName: 'Admin', lastName: 'User', }, { workflowPermissions: { isAllEditable: true, workflowCreate: true } }); const loggedUser = await login(app, user.email); const tokenCookie = loggedUser.tokenCookie; const response = await request(app.getHttpServer()) .get('/api/workflows/packages/javascript/search') .query({ q: '@types/node', limit: 3 }) .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send(); expect(response.statusCode).toBe(200); expect(Array.isArray(response.body)).toBe(true); expect(response.body.length).toBeLessThanOrEqual(3); // Validate PackageSearchResult structure for scoped packages if (response.body.length > 0) { expect(response.body).toEqual( expect.arrayContaining([ expect.objectContaining({ name: expect.stringMatching(/^@[a-z0-9-~][a-z0-9-._~]*\/[a-z0-9-~][a-z0-9-._~]*$/), // NPM scoped package pattern version: expectSemVer(), description: expect.any(String) }) ]) ); expect(response.body[0].name).toContain('@types'); } }); it('should search Python packages via unified endpoint', async () => { const { user } = await setupOrganizationAndUser(app, { email: 'admin@tooljet.io', password: 'password', firstName: 'Admin', lastName: 'User', }, { workflowPermissions: { isAllEditable: true, workflowCreate: true } }); const loggedUser = await login(app, user.email); const tokenCookie = loggedUser.tokenCookie; const response = await request(app.getHttpServer()) .get('/api/workflows/packages/python/search') .query({ q: 'pandas', limit: 5 }) .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send(); expect(response.statusCode).toBe(200); expect(Array.isArray(response.body)).toBe(true); expect(response.body.length).toBeGreaterThan(0); expect(response.body.length).toBeLessThanOrEqual(5); // Validate PythonPackageSearchResult structure expect(response.body).toEqual( expect.arrayContaining([ expect.objectContaining({ name: expect.any(String), version: expectSemVer(), description: expect.any(String), }) ]) ); // Validate specific search result content expect(response.body[0].name.toLowerCase()).toContain('pandas'); }); it('should require authentication', async () => { const response = await request(app.getHttpServer()) .get('/api/workflows/packages/javascript/search') .query({ q: 'lodash' }) .send(); expect(response.statusCode).toBe(401); // Validate standard NestJS 401 error response structure expect(response.body).toEqual( expect.objectContaining({ statusCode: 401, message: 'Unauthorized' }) ); }); it('should return 403 when user lacks workflow edit permissions', async () => { const { user } = await setupOrganizationAndUser(app, { email: 'readonly@tooljet.io', password: 'password', firstName: 'ReadOnly', lastName: 'User', }, { workflowPermissions: { isAllEditable: false, workflowCreate: false } }); const loggedUser = await login(app, user.email); const tokenCookie = loggedUser.tokenCookie; const response = await request(app.getHttpServer()) .get('/api/workflows/packages/javascript/search') .query({ q: 'lodash', limit: 5 }) .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send(); expect(response.statusCode).toBe(403); expect(response.body).toEqual( expect.objectContaining({ message: 'You do not have permission to access this resource' }) ); }); }); describe('GET /api/workflows/packages/:language/:name | get package metadata', () => { it('should get JavaScript package info via unified endpoint', async () => { const { user } = await setupOrganizationAndUser(app, { email: 'admin@tooljet.io', password: 'password', firstName: 'Admin', lastName: 'User', }, { workflowPermissions: { isAllEditable: true, workflowCreate: true } }); const loggedUser = await login(app, user.email); const tokenCookie = loggedUser.tokenCookie; const response = await request(app.getHttpServer()) .get('/api/workflows/packages/javascript/lodash') .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send(); expect(response.statusCode).toBe(200); expect(response.body).toEqual( expect.objectContaining({ name: 'lodash', version: expect.any(String), description: expect.any(String), versions: expect.any(Array), language: 'javascript', }) ); }); it('should get Python package info via unified endpoint', async () => { const { user } = await setupOrganizationAndUser(app, { email: 'admin@tooljet.io', password: 'password', firstName: 'Admin', lastName: 'User', }, { workflowPermissions: { isAllEditable: true, workflowCreate: true } }); const loggedUser = await login(app, user.email); const tokenCookie = loggedUser.tokenCookie; const response = await request(app.getHttpServer()) .get('/api/workflows/packages/python/requests') .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send(); expect(response.statusCode).toBe(200); expect(response.body).toEqual( expect.objectContaining({ name: 'requests', version: expect.any(String), description: expect.any(String), versions: expect.any(Array), language: 'python', }) ); }); it('should handle 404 for unknown packages', async () => { const { user } = await setupOrganizationAndUser(app, { email: 'admin@tooljet.io', password: 'password', firstName: 'Admin', lastName: 'User', }, { workflowPermissions: { isAllEditable: true, workflowCreate: true } }); const loggedUser = await login(app, user.email); const tokenCookie = loggedUser.tokenCookie; const response = await request(app.getHttpServer()) .get('/api/workflows/packages/python/nonexistent-package-xyz-12345') .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send(); expect(response.statusCode).toBe(404); }); }); describe('GET /api/workflows/packages/:language/:name/versions | list versions', () => { it('should get JavaScript package versions via unified endpoint', async () => { const { user } = await setupOrganizationAndUser(app, { email: 'admin@tooljet.io', password: 'password', firstName: 'Admin', lastName: 'User', }, { workflowPermissions: { isAllEditable: true, workflowCreate: true } }); const loggedUser = await login(app, user.email); const tokenCookie = loggedUser.tokenCookie; const response = await request(app.getHttpServer()) .get('/api/workflows/packages/javascript/lodash/versions') .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send(); expect(response.statusCode).toBe(200); expect(response.body).toEqual( expect.objectContaining({ versions: expect.any(Array), language: 'javascript', }) ); expect(response.body.versions.length).toBeGreaterThan(0); }); it('should get Python package versions via unified endpoint', async () => { const { user } = await setupOrganizationAndUser(app, { email: 'admin@tooljet.io', password: 'password', firstName: 'Admin', lastName: 'User', }, { workflowPermissions: { isAllEditable: true, workflowCreate: true } }); const loggedUser = await login(app, user.email); const tokenCookie = loggedUser.tokenCookie; const response = await request(app.getHttpServer()) .get('/api/workflows/packages/python/numpy/versions') .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send(); expect(response.statusCode).toBe(200); expect(response.body).toEqual( expect.objectContaining({ versions: expect.any(Array), language: 'python', }) ); expect(response.body.versions.length).toBeGreaterThan(0); }); }); describe('GET /api/workflows/:appVersionId/packages/:language | get workflow packages', () => { it('should get empty JavaScript dependencies for new workflow', async () => { const { user } = await setupOrganizationAndUser(app, { email: 'admin@tooljet.io', password: 'password', firstName: 'Admin', lastName: 'User', }, { workflowPermissions: { isAllEditable: true, workflowCreate: true } }); const workflow = await createWorkflowForUser(app, user, 'test-workflow'); const appVersion = await createWorkflowApplicationVersion(app, workflow); const loggedUser = await login(app, user.email); const tokenCookie = loggedUser.tokenCookie; const response = await request(app.getHttpServer()) .get(`/api/workflows/${appVersion.id}/packages/javascript`) .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send(); expect(response.statusCode).toBe(200); // Validate exact GetPackagesResult structure expect(response.body).toEqual( expect.objectContaining({ dependencies: expect.any(Object) }) ); // Verify empty dependencies for new workflow expect(Object.keys(response.body.dependencies)).toHaveLength(0); }); it('should get empty Python dependencies for new workflow', async () => { const { user } = await setupOrganizationAndUser(app, { email: 'admin@tooljet.io', password: 'password', firstName: 'Admin', lastName: 'User', }, { workflowPermissions: { isAllEditable: true, workflowCreate: true } }); const workflow = await createWorkflowForUser(app, user, 'test-workflow-python'); const appVersion = await createWorkflowApplicationVersion(app, workflow); const loggedUser = await login(app, user.email); const tokenCookie = loggedUser.tokenCookie; const response = await request(app.getHttpServer()) .get(`/api/workflows/${appVersion.id}/packages/python`) .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send(); expect(response.statusCode).toBe(200); // Python dependencies are stored as string (requirements.txt format) // Empty workflow returns empty string expect(response.body).toEqual( expect.objectContaining({ dependencies: '' }) ); }); it('should get existing dependencies for workflow with packages', async () => { const { user } = await setupOrganizationAndUser(app, { email: 'admin@tooljet.io', password: 'password', firstName: 'Admin', lastName: 'User', }, { workflowPermissions: { isAllEditable: true, workflowCreate: true } }); const workflow = await createWorkflowForUser(app, user, 'test-workflow-with-deps'); const appVersion = await createWorkflowApplicationVersion(app, workflow); const loggedUser = await login(app, user.email); const tokenCookie = loggedUser.tokenCookie; // First, add some dependencies to the workflow const dependencies = { 'lodash': '4.17.21', 'is-odd': '3.0.1', 'uuid': '9.0.0' }; await request(app.getHttpServer()) .put(`/api/workflows/${appVersion.id}/packages/javascript`) .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send({ dependencies }); // Wait for bundle generation to complete to avoid FK constraint violations on cleanup const defaultDataSource = app.get(getDataSourceToken('default')); const bundleRepo = defaultDataSource.getRepository(WorkflowBundle); await waitForBundleReady(bundleRepo, appVersion.id, 'javascript'); // Re-authenticate to ensure fresh token for GET request const refreshedUser = await login(app, user.email); // Now get the dependencies const response = await request(app.getHttpServer()) .get(`/api/workflows/${appVersion.id}/packages/javascript`) .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', refreshedUser.tokenCookie) .send(); expect(response.statusCode).toBe(200); // Validate exact GetPackagesResult structure expect(response.body).toEqual( expect.objectContaining({ dependencies: expect.any(Object) }) ); // Verify the specific dependencies we set expect(response.body.dependencies).toEqual( expect.objectContaining({ 'lodash': expectSemVer(), 'is-odd': expectSemVer(), 'uuid': expectSemVer() }) ); // Verify we have the expected number of dependencies expect(Object.keys(response.body.dependencies)).toHaveLength(3); }); it('should require authentication', async () => { const response = await request(app.getHttpServer()).get('/api/workflows/fake-id/packages/javascript').send(); expect(response.statusCode).toBe(401); // Validate standard NestJS 401 error response structure expect(response.body).toEqual( expect.objectContaining({ statusCode: 401, message: 'Unauthorized' }) ); }); it('should return 403 when user lacks workflow edit permissions', async () => { // Create admin user who will own the workflow const { user: adminUser, organization } = await setupOrganizationAndUser(app, { email: 'admin@tooljet.io', password: 'password', firstName: 'Admin', lastName: 'User', }, { workflowPermissions: { isAllEditable: true, workflowCreate: true } }); // Create workflow owned by admin const workflow = await createWorkflowForUser(app, adminUser, 'test-workflow'); const appVersion = await createWorkflowApplicationVersion(app, workflow); // Create limited user in the same organization const { user: limitedUser } = await createUser(app, { email: 'readonly@tooljet.io', firstName: 'ReadOnly', lastName: 'User', groups: ['end-user'], organization: organization, }); // Give limited user view-only permissions (no edit access) await createUserWorkflowPermissions(app, limitedUser, adminUser.organizationId, { isAllEditable: false, workflowCreate: false }); // Authenticate as limited user const loggedUser = await login(app, limitedUser.email); const tokenCookie = loggedUser.tokenCookie; const response = await request(app.getHttpServer()) .get(`/api/workflows/${appVersion.id}/packages/javascript`) .set('tj-workspace-id', adminUser.organizationId) .set('Cookie', tokenCookie) .send(); expect(response.statusCode).toBe(403); expect(response.body).toEqual( expect.objectContaining({ message: 'You do not have permission to access this resource' }) ); }); }); describe('PUT /api/workflows/:appVersionId/packages/:language | update packages', () => { it('should update JavaScript workflow packages and verify entity', async () => { const { user } = await setupOrganizationAndUser(app, { email: 'admin@tooljet.io', password: 'password', firstName: 'Admin', lastName: 'User', }, { workflowPermissions: { isAllEditable: true, workflowCreate: true } }); const workflow = await createWorkflowForUser(app, user, 'test-workflow'); const appVersion = await createWorkflowApplicationVersion(app, workflow, { definition: { nodes: [{ id: '1', data: { nodeType: 'start' }, position: { x: 0, y: 0 } }], edges: [], }, }); const loggedUser = await login(app, user.email); const tokenCookie = loggedUser.tokenCookie; const dependencies = { 'is-odd': '3.0.1', lodash: '4.17.21', }; const response = await request(app.getHttpServer()) .put(`/api/workflows/${appVersion.id}/packages/javascript`) .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send({ dependencies }); expect(response.statusCode).toBe(200); expect(response.body).toEqual( expect.objectContaining({ success: true, message: expect.stringContaining('javascript'), bundleStatus: expect.stringMatching(/^(building|ready|failed)$/) }) ); // Verify entity in database const defaultDataSource = app.get(getDataSourceToken('default')); const bundleRepo = defaultDataSource.getRepository(WorkflowBundle); // Wait for bundle generation to complete const bundleEntity = await waitForBundleReady(bundleRepo, appVersion.id, 'javascript'); expect(bundleEntity).toBeDefined(); expect(bundleEntity).toEqual( expect.objectContaining({ id: expect.any(String), appVersionId: appVersion.id, dependencies: JSON.stringify(dependencies), // Stored as JSON string in TEXT column bundleBinary: expect.any(Buffer), bundleSize: expect.any(Number), bundleSha: expect.stringMatching(/^[a-f0-9]{64}$/), generationTimeMs: expect.any(Number), error: null, status: 'ready', createdAt: expect.any(Date), updatedAt: expect.any(Date) }) ); expect(bundleEntity!.bundleSize).toBeGreaterThan(0); expect(bundleEntity!.generationTimeMs).toBeGreaterThan(0); const bundleContent = bundleEntity!.bundleBinary.toString('utf-8'); expect(bundleContent).toContain('is-odd'); expect(bundleContent).toContain('lodash'); }); it('should update Python packages and verify entity', async () => { const { user } = await setupOrganizationAndUser(app, { email: 'admin@tooljet.io', password: 'password', firstName: 'Admin', lastName: 'User', }, { workflowPermissions: { isAllEditable: true, workflowCreate: true } }); const workflow = await createWorkflowForUser(app, user, 'test-workflow-python-update'); const appVersion = await createWorkflowApplicationVersion(app, workflow, { definition: { nodes: [{ id: '1', data: { nodeType: 'start' }, position: { x: 0, y: 0 } }], edges: [], }, }); const loggedUser = await login(app, user.email); const tokenCookie = loggedUser.tokenCookie; // Python dependencies are sent as requirements.txt string format const dependencies = 'requests==2.31.0\ncharset-normalizer==3.3.0'; const response = await request(app.getHttpServer()) .put(`/api/workflows/${appVersion.id}/packages/python`) .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send({ dependencies }); expect(response.statusCode).toBe(200); expect(response.body).toEqual( expect.objectContaining({ success: true, message: expect.stringContaining('python'), bundleStatus: expect.stringMatching(/^(building|ready|failed)$/) }) ); // Verify entity in database const defaultDataSource = app.get(getDataSourceToken('default')); const bundleRepo = defaultDataSource.getRepository(WorkflowBundle); // Wait for bundle generation to complete const bundleEntity = await waitForBundleReady(bundleRepo, appVersion.id, 'python', 60000); expect(bundleEntity).toBeDefined(); expect(bundleEntity).toEqual( expect.objectContaining({ id: expect.any(String), appVersionId: appVersion.id, dependencies: dependencies, // Stored as string (requirements.txt format) language: 'python', bundleBinary: expect.any(Buffer), bundleSize: expect.any(Number), bundleSha: expect.stringMatching(/^[a-f0-9]{64}$/), generationTimeMs: expect.any(Number), error: null, status: 'ready', }) ); expect(bundleEntity!.bundleSize).toBeGreaterThan(0); }, 120000); it('should handle invalid dependencies with proper error', async () => { const { user } = await setupOrganizationAndUser(app, { email: 'admin@tooljet.io', password: 'password', firstName: 'Admin', lastName: 'User', }, { workflowPermissions: { isAllEditable: true, workflowCreate: true } }); const workflow = await createWorkflowForUser(app, user, 'test-workflow'); const appVersion = await createWorkflowApplicationVersion(app, workflow); const loggedUser = await login(app, user.email); const tokenCookie = loggedUser.tokenCookie; const response = await request(app.getHttpServer()) .put(`/api/workflows/${appVersion.id}/packages/javascript`) .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send({ dependencies: { 'nonexistent-package-12345': '1.0.0' } }); expect(response.statusCode).toBe(200); expect(response.body).toHaveProperty('bundleStatus'); // Wait for bundle generation to complete (even if it fails) to avoid FK constraint violations on cleanup // For invalid packages, the bundle may fail quickly or not be created at all const defaultDataSource = app.get(getDataSourceToken('default')); const bundleRepo = defaultDataSource.getRepository(WorkflowBundle); // Poll until bundle status is not 'building' or timeout (bundle may never be created for early failures) const startTime = Date.now(); while (Date.now() - startTime < 10000) { const bundle = await bundleRepo.findOne({ where: { appVersionId: appVersion.id, language: 'javascript' } }); if (!bundle || bundle.status !== 'building') break; await new Promise(resolve => setTimeout(resolve, 200)); } // Additional small delay to ensure any async operations complete await new Promise(resolve => setTimeout(resolve, 500)); }, 60000); it('should require authentication', async () => { const response = await request(app.getHttpServer()) .put('/api/workflows/fake-id/packages/javascript') .send({ dependencies: { lodash: '4.17.21' } }); expect(response.statusCode).toBe(401); // Validate standard NestJS 401 error response structure expect(response.body).toEqual( expect.objectContaining({ statusCode: 401, message: 'Unauthorized' }) ); }); it('should return 403 when user lacks workflow edit permissions', async () => { // Create admin user who will own the workflow const { user: adminUser, organization } = await setupOrganizationAndUser(app, { email: 'admin@tooljet.io', password: 'password', firstName: 'Admin', lastName: 'User', }, { workflowPermissions: { isAllEditable: true, workflowCreate: true } }); // Create workflow owned by admin const workflow = await createWorkflowForUser(app, adminUser, 'test-workflow'); const appVersion = await createWorkflowApplicationVersion(app, workflow); // Create limited user in the same organization const { user: limitedUser } = await createUser(app, { email: 'readonly@tooljet.io', firstName: 'ReadOnly', lastName: 'User', groups: ['end-user'], organization: organization, }); // Give limited user view-only permissions (no edit access) await createUserWorkflowPermissions(app, limitedUser, adminUser.organizationId, { isAllEditable: false, workflowCreate: false }); // Authenticate as limited user const loggedUser = await login(app, limitedUser.email); const tokenCookie = loggedUser.tokenCookie; const response = await request(app.getHttpServer()) .put(`/api/workflows/${appVersion.id}/packages/javascript`) .set('tj-workspace-id', adminUser.organizationId) .set('Cookie', tokenCookie) .send({ dependencies: { lodash: '4.17.21' } }); expect(response.statusCode).toBe(403); expect(response.body).toEqual( expect.objectContaining({ message: 'You do not have permission to access this resource' }) ); }); }); describe('GET /api/workflows/:appVersionId/bundle/:language/status | check bundle status', () => { it('should return JavaScript bundle status with type checking', async () => { const { user } = await setupOrganizationAndUser(app, { email: 'admin@tooljet.io', password: 'password', firstName: 'Admin', lastName: 'User', }, { workflowPermissions: { isAllEditable: true, workflowCreate: true } }); const workflow = await createWorkflowForUser(app, user, 'test-workflow'); const appVersion = await createWorkflowApplicationVersion(app, workflow); const loggedUser = await login(app, user.email); const tokenCookie = loggedUser.tokenCookie; // First, add dependencies to trigger bundle generation const dependencies = { 'lodash': '4.17.21' }; const updateResponse = await request(app.getHttpServer()) .put(`/api/workflows/${appVersion.id}/packages/javascript`) .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send({ dependencies }); expect(updateResponse.statusCode).toBe(200); // Wait a moment for bundle generation to complete await new Promise(resolve => setTimeout(resolve, 1000)); const response = await request(app.getHttpServer()) .get(`/api/workflows/${appVersion.id}/bundle/javascript/status`) .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send(); expect(response.statusCode).toBe(200); // Validate exact BundleStatus structure expect(response.body).toEqual( expect.objectContaining({ status: expectBundleStatus(), language: 'javascript', }) ); }); it('should return Python bundle status', async () => { const { user } = await setupOrganizationAndUser(app, { email: 'admin@tooljet.io', password: 'password', firstName: 'Admin', lastName: 'User', }, { workflowPermissions: { isAllEditable: true, workflowCreate: true } }); const workflow = await createWorkflowForUser(app, user, 'test-workflow-python-status'); const appVersion = await createWorkflowApplicationVersion(app, workflow); const loggedUser = await login(app, user.email); const tokenCookie = loggedUser.tokenCookie; // First, add dependencies to trigger bundle generation const dependencies = { 'requests': '2.31.0' }; await request(app.getHttpServer()) .put(`/api/workflows/${appVersion.id}/packages/python`) .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send({ dependencies }); // Wait for bundle generation await new Promise(resolve => setTimeout(resolve, 2000)); const response = await request(app.getHttpServer()) .get(`/api/workflows/${appVersion.id}/bundle/python/status`) .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send(); expect(response.statusCode).toBe(200); expect(response.body).toEqual( expect.objectContaining({ status: expectBundleStatus(), language: 'python', }) ); }, 60000); it('should return none status for workflow without bundle', async () => { const { user } = await setupOrganizationAndUser(app, { email: 'admin@tooljet.io', password: 'password', firstName: 'Admin', lastName: 'User', }, { workflowPermissions: { isAllEditable: true, workflowCreate: true } }); const workflow = await createWorkflowForUser(app, user, 'test-workflow-no-bundle'); const appVersion = await createWorkflowApplicationVersion(app, workflow); const loggedUser = await login(app, user.email); const tokenCookie = loggedUser.tokenCookie; const response = await request(app.getHttpServer()) .get(`/api/workflows/${appVersion.id}/bundle/python/status`) .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send(); expect(response.statusCode).toBe(200); expect(response.body).toEqual( expect.objectContaining({ status: 'none' }) ); }); it('should require authentication', async () => { const response = await request(app.getHttpServer()) .get('/api/workflows/fake-id/bundle/javascript/status') .send(); expect(response.statusCode).toBe(401); // Validate standard NestJS 401 error response structure expect(response.body).toEqual( expect.objectContaining({ statusCode: 401, message: 'Unauthorized' }) ); }); it('should return 403 when user lacks workflow edit permissions', async () => { // Create admin user who will own the workflow const { user: adminUser, organization } = await setupOrganizationAndUser(app, { email: 'admin@tooljet.io', password: 'password', firstName: 'Admin', lastName: 'User', }, { workflowPermissions: { isAllEditable: true, workflowCreate: true } }); // Create workflow owned by admin const workflow = await createWorkflowForUser(app, adminUser, 'test-workflow'); const appVersion = await createWorkflowApplicationVersion(app, workflow); // Create limited user in the same organization const { user: limitedUser } = await createUser(app, { email: 'readonly@tooljet.io', firstName: 'ReadOnly', lastName: 'User', groups: ['end-user'], organization: organization, }); // Give limited user view-only permissions (no edit access) await createUserWorkflowPermissions(app, limitedUser, adminUser.organizationId, { isAllEditable: false, workflowCreate: false }); // Authenticate as limited user const loggedUser = await login(app, limitedUser.email); const tokenCookie = loggedUser.tokenCookie; const response = await request(app.getHttpServer()) .get(`/api/workflows/${appVersion.id}/bundle/javascript/status`) .set('tj-workspace-id', adminUser.organizationId) .set('Cookie', tokenCookie) .send(); expect(response.statusCode).toBe(403); expect(response.body).toEqual( expect.objectContaining({ message: 'You do not have permission to access this resource' }) ); }); }); describe('POST /api/workflows/:appVersionId/bundle/:language/rebuild | rebuild bundle', () => { it('should rebuild JavaScript bundle and verify entity consistency', async () => { const { user } = await setupOrganizationAndUser(app, { email: 'admin@tooljet.io', password: 'password', firstName: 'Admin', lastName: 'User', }, { workflowPermissions: { isAllEditable: true, workflowCreate: true } }); const workflow = await createWorkflowForUser(app, user, 'test-workflow'); const appVersion = await createWorkflowApplicationVersion(app, workflow); const loggedUser = await login(app, user.email); const tokenCookie = loggedUser.tokenCookie; // First create a bundle const dependencies = { 'is-odd': '3.0.1' }; await request(app.getHttpServer()) .put(`/api/workflows/${appVersion.id}/packages/javascript`) .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send({ dependencies }); // Wait for initial bundle generation const defaultDataSource = app.get(getDataSourceToken('default')); const bundleRepo = defaultDataSource.getRepository(WorkflowBundle); const initialBundle = await waitForBundleReady(bundleRepo, appVersion.id, 'javascript'); // Rebuild the bundle const rebuildResponse = await request(app.getHttpServer()) .post(`/api/workflows/${appVersion.id}/bundle/javascript/rebuild`) .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send(); expect(rebuildResponse.statusCode).toBe(201); // POST operations return 201 Created expect(rebuildResponse.body).toEqual( expect.objectContaining({ success: true, message: expect.stringContaining('javascript'), bundleStatus: 'building' }) ); // Wait for rebuild completion const rebuiltBundle = await waitForBundleReady(bundleRepo, appVersion.id, 'javascript'); expect(rebuiltBundle).toBeDefined(); expect(rebuiltBundle!.bundleSha).toBe(initialBundle!.bundleSha); }); it('should rebuild Python bundle and verify entity consistency', async () => { const { user } = await setupOrganizationAndUser(app, { email: 'admin@tooljet.io', password: 'password', firstName: 'Admin', lastName: 'User', }, { workflowPermissions: { isAllEditable: true, workflowCreate: true } }); const workflow = await createWorkflowForUser(app, user, 'test-workflow-python-rebuild'); const appVersion = await createWorkflowApplicationVersion(app, workflow); const loggedUser = await login(app, user.email); const tokenCookie = loggedUser.tokenCookie; // First create a Python bundle (requirements.txt string format) const dependencies = 'requests==2.31.0'; await request(app.getHttpServer()) .put(`/api/workflows/${appVersion.id}/packages/python`) .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send({ dependencies }); // Wait for initial bundle generation const defaultDataSource = app.get(getDataSourceToken('default')); const bundleRepo = defaultDataSource.getRepository(WorkflowBundle); const initialBundle = await waitForBundleReady(bundleRepo, appVersion.id, 'python', 60000); // Rebuild the bundle const rebuildResponse = await request(app.getHttpServer()) .post(`/api/workflows/${appVersion.id}/bundle/python/rebuild`) .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send(); expect(rebuildResponse.statusCode).toBe(201); expect(rebuildResponse.body).toEqual( expect.objectContaining({ success: true, message: expect.stringContaining('python'), bundleStatus: 'building' }) ); // Wait for rebuild completion const rebuiltBundle = await waitForBundleReady(bundleRepo, appVersion.id, 'python', 60000); expect(rebuiltBundle).toBeDefined(); // Note: Python bundles may have different SHAs on rebuild due to timestamps in wheel files // We verify the bundle was rebuilt successfully with same dependencies instead expect(rebuiltBundle!.dependencies).toEqual(initialBundle!.dependencies); expect(rebuiltBundle!.status).toBe('ready'); expect(rebuiltBundle!.language).toBe('python'); }, 180000); it('should handle rebuild with no existing bundle', async () => { const { user } = await setupOrganizationAndUser(app, { email: 'admin@tooljet.io', password: 'password', firstName: 'Admin', lastName: 'User', }, { workflowPermissions: { isAllEditable: true, workflowCreate: true } }); const workflow = await createWorkflowForUser(app, user, 'test-workflow'); const appVersion = await createWorkflowApplicationVersion(app, workflow); const loggedUser = await login(app, user.email); const tokenCookie = loggedUser.tokenCookie; const response = await request(app.getHttpServer()) .post(`/api/workflows/${appVersion.id}/bundle/javascript/rebuild`) .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send(); expect(response.statusCode).toBe(500); // Implementation throws HttpException with INTERNAL_SERVER_ERROR }); it('should require authentication', async () => { const response = await request(app.getHttpServer()) .post('/api/workflows/fake-id/bundle/javascript/rebuild') .send(); expect(response.statusCode).toBe(401); // Validate standard NestJS 401 error response structure expect(response.body).toEqual( expect.objectContaining({ statusCode: 401, message: 'Unauthorized' }) ); }); it('should return 403 when user lacks workflow edit permissions', async () => { // Create admin user who will own the workflow const { user: adminUser, organization } = await setupOrganizationAndUser(app, { email: 'admin@tooljet.io', password: 'password', firstName: 'Admin', lastName: 'User', }, { workflowPermissions: { isAllEditable: true, workflowCreate: true } }); // Create workflow owned by admin const workflow = await createWorkflowForUser(app, adminUser, 'test-workflow'); const appVersion = await createWorkflowApplicationVersion(app, workflow); // Create limited user in the same organization const { user: limitedUser } = await createUser(app, { email: 'readonly@tooljet.io', firstName: 'ReadOnly', lastName: 'User', groups: ['end-user'], organization: organization, }); // Give limited user view-only permissions (no edit access) await createUserWorkflowPermissions(app, limitedUser, adminUser.organizationId, { isAllEditable: false, workflowCreate: false }); // Authenticate as limited user const loggedUser = await login(app, limitedUser.email); const tokenCookie = loggedUser.tokenCookie; const response = await request(app.getHttpServer()) .post(`/api/workflows/${appVersion.id}/bundle/javascript/rebuild`) .set('tj-workspace-id', adminUser.organizationId) .set('Cookie', tokenCookie) .send(); expect(response.statusCode).toBe(403); expect(response.body).toEqual( expect.objectContaining({ message: 'You do not have permission to access this resource' }) ); }); }); describe('Complete workflow package management flow | end-to-end', () => { it('should handle full JavaScript package management workflow with entity verification', async () => { const { user } = await setupOrganizationAndUser(app, { email: 'admin@tooljet.io', password: 'password', firstName: 'Admin', lastName: 'User', }, { workflowPermissions: { isAllEditable: true, workflowCreate: true } }); const workflow = await createWorkflowForUser(app, user, 'integration-test-workflow'); const appVersion = await createWorkflowApplicationVersion(app, workflow, { definition: { nodes: [ { id: 'start-node', data: { nodeType: 'start', label: 'Start' }, position: { x: 100, y: 100 }, type: 'input', }, ], edges: [], }, }); const loggedUser = await login(app, user.email); const tokenCookie = loggedUser.tokenCookie; // Step 1: Search for packages const searchResponse = await request(app.getHttpServer()) .get('/api/workflows/packages/javascript/search') .query({ q: 'is-odd', limit: 5 }) .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send(); expect(searchResponse.statusCode).toBe(200); expect(Array.isArray(searchResponse.body)).toBe(true); // Step 2: Update workflow packages const dependencies = { 'is-odd': '3.0.1', lodash: '4.17.21' }; const updateResponse = await request(app.getHttpServer()) .put(`/api/workflows/${appVersion.id}/packages/javascript`) .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send({ dependencies }); expect(updateResponse.statusCode).toBe(200); expect(updateResponse.body).toHaveProperty('success', true); // Step 3: Poll bundle status until ready let statusResponse: any; let attempts = 0; const maxAttempts = 10; do { await new Promise((resolve) => setTimeout(resolve, 200)); statusResponse = await request(app.getHttpServer()) .get(`/api/workflows/${appVersion.id}/bundle/javascript/status`) .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send(); attempts++; } while (statusResponse.body.status === 'building' && attempts < maxAttempts); expect(statusResponse.statusCode).toBe(200); expect(['ready']).toContain(statusResponse.body.status); // Step 4: Verify entity in database const defaultDataSource = app.get(getDataSourceToken('default')); const bundleRepo = defaultDataSource.getRepository(WorkflowBundle); const bundleEntity = await waitForBundleReady(bundleRepo, appVersion.id, 'javascript'); expect(bundleEntity).toBeDefined(); expect(bundleEntity!.dependencies).toEqual(JSON.stringify(dependencies)); // Stored as JSON string in TEXT column expect(bundleEntity!.status).toBe('ready'); expect(bundleEntity!.bundleSize).toBeGreaterThan(0); expect(bundleEntity!.bundleSha).toMatch(/^[a-f0-9]{64}$/); expect(bundleEntity!.generationTimeMs).toBeGreaterThan(0); expect(bundleEntity!.bundleBinary).toBeDefined(); const bundleContent = bundleEntity!.bundleBinary.toString('utf-8'); expect(bundleContent).toContain('is-odd'); expect(bundleContent).toContain('lodash'); expect(bundleEntity!.error).toBeNull(); // Step 5: Test rebuild functionality const initialSha = bundleEntity!.bundleSha; const rebuildResponse = await request(app.getHttpServer()) .post(`/api/workflows/${appVersion.id}/bundle/javascript/rebuild`) .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send(); expect(rebuildResponse.statusCode).toBe(201); // POST operations return 201 Created expect(rebuildResponse.body).toHaveProperty('success', true); // Step 6: Verify rebuilt bundle consistency const rebuiltEntity = await waitForBundleReady(bundleRepo, appVersion.id, 'javascript'); expect(rebuiltEntity).toBeDefined(); expect(rebuiltEntity!.bundleSha).toBe(initialSha); // Same dependencies = same SHA expect(rebuiltEntity!.status).toBe('ready'); expect(rebuiltEntity!.dependencies).toEqual(JSON.stringify(dependencies)); // Stored as JSON string in TEXT column }); }); afterAll(async () => { await closeTestApp(app); }, 60_000); }); }); /** * @group workflows */ describe('WorkflowBundleController | CE', () => { let app: INestApplication; const context = setupPolly({ adapters: [NodeHttpAdapter as any], persister: FSPersister as any, recordFailedRequests: true, recordIfMissing: true, mode: (process.env.POLLY_MODE as any) || 'replay', matchRequestsBy: { method: true, headers: { exclude: ['user-agent', 'accept-encoding', 'connection', 'host', 'cookie'], }, body: true, url: { protocol: true, hostname: true, port: true, pathname: true, query: true, }, }, persisterOptions: { fs: { recordingsDir: path.resolve(__dirname, '../__fixtures__/workflow-bundles-ce'), }, }, }); beforeAll(async () => { // Use CE edition without EE mock providers ({ app } = await initTestApp({ edition: 'ce' })); }); beforeEach(async () => { // Configure Polly.js to only pass through localhost calls // External API calls (like NPM registry) will be recorded context.polly.server .any() .filter(req => { const url = new URL(req.url); // Pass through localhost and internal calls without recording return url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname.includes('host.docker.internal'); }) .passthrough(); // All other requests (external APIs) will be handled by Polly's recording mechanism }); afterEach(async () => { await context.polly.stop(); }); afterAll(async () => { await closeTestApp(app); }, 60_000); describe('CE Limitations | all endpoints return enterprise feature errors', () => { it('should return enterprise feature error for package search', async () => { const { user } = await setupOrganizationAndUser(app, { email: 'admin@tooljet.io', password: 'password', firstName: 'Admin', lastName: 'User', }); const loggedUser = await login(app, user.email); const tokenCookie = loggedUser.tokenCookie; const response = await request(app.getHttpServer()) .get('/api/workflows/packages/javascript/search') .query({ q: 'lodash', limit: 5 }) .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send(); expect(response.statusCode).toBe(500); // Validate structured enterprise error response expect(response.body).toEqual( expect.objectContaining({ statusCode: 500, message: 'Enterprise feature: Package management requires ToolJet Enterprise Edition' }) ); }); it('should return enterprise feature error for get packages', async () => { const { user } = await setupOrganizationAndUser(app, { email: 'admin@tooljet.io', password: 'password', firstName: 'Admin', lastName: 'User', }); const workflow = await createWorkflowForUser(app, user, 'Test Workflow'); const appVersion = await createWorkflowApplicationVersion(app, workflow); const loggedUser = await login(app, user.email); const tokenCookie = loggedUser.tokenCookie; const response = await request(app.getHttpServer()) .get(`/api/workflows/${appVersion.id}/packages/javascript`) .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send(); expect(response.statusCode).toBe(500); // Validate structured enterprise error response expect(response.body).toEqual( expect.objectContaining({ statusCode: 500, message: 'Enterprise feature: Package management requires ToolJet Enterprise Edition' }) ); }); it('should return enterprise feature error for update packages', async () => { const { user } = await setupOrganizationAndUser(app, { email: 'admin@tooljet.io', password: 'password', firstName: 'Admin', lastName: 'User', }); const workflow = await createWorkflowForUser(app, user, 'Test Workflow'); const appVersion = await createWorkflowApplicationVersion(app, workflow); const loggedUser = await login(app, user.email); const tokenCookie = loggedUser.tokenCookie; const response = await request(app.getHttpServer()) .put(`/api/workflows/${appVersion.id}/packages/javascript`) .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send({ dependencies: { 'lodash': '4.17.21' } }); expect(response.statusCode).toBe(500); // Validate structured enterprise error response expect(response.body).toEqual( expect.objectContaining({ statusCode: 500, message: 'Enterprise feature: Package management requires ToolJet Enterprise Edition' }) ); }); it('should return enterprise feature error for bundle status', async () => { const { user } = await setupOrganizationAndUser(app, { email: 'admin@tooljet.io', password: 'password', firstName: 'Admin', lastName: 'User', }); const workflow = await createWorkflowForUser(app, user, 'Test Workflow'); const appVersion = await createWorkflowApplicationVersion(app, workflow); const loggedUser = await login(app, user.email); const tokenCookie = loggedUser.tokenCookie; const response = await request(app.getHttpServer()) .get(`/api/workflows/${appVersion.id}/bundle/javascript/status`) .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send(); expect(response.statusCode).toBe(500); // Validate structured enterprise error response expect(response.body).toEqual( expect.objectContaining({ statusCode: 500, message: 'Enterprise feature: Package management requires ToolJet Enterprise Edition' }) ); }); it('should return enterprise feature error for bundle rebuild', async () => { const { user } = await setupOrganizationAndUser(app, { email: 'admin@tooljet.io', password: 'password', firstName: 'Admin', lastName: 'User', }); const workflow = await createWorkflowForUser(app, user, 'Test Workflow'); const appVersion = await createWorkflowApplicationVersion(app, workflow); const loggedUser = await login(app, user.email); const tokenCookie = loggedUser.tokenCookie; const response = await request(app.getHttpServer()) .post(`/api/workflows/${appVersion.id}/bundle/javascript/rebuild`) .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send({}); expect(response.statusCode).toBe(500); // Validate structured enterprise error response expect(response.body).toEqual( expect.objectContaining({ statusCode: 500, message: 'Enterprise feature: Package management requires ToolJet Enterprise Edition' }) ); }); it('should return enterprise feature error for Python package search', async () => { const { user } = await setupOrganizationAndUser(app, { email: 'admin@tooljet.io', password: 'password', firstName: 'Admin', lastName: 'User', }); const loggedUser = await login(app, user.email); const tokenCookie = loggedUser.tokenCookie; const response = await request(app.getHttpServer()) .get('/api/workflows/packages/python/search') .query({ q: 'requests', limit: 5 }) .set('tj-workspace-id', user.defaultOrganizationId) .set('Cookie', tokenCookie) .send(); expect(response.statusCode).toBe(500); // Validate structured enterprise error response expect(response.body).toEqual( expect.objectContaining({ statusCode: 500, message: 'Enterprise feature: Package management requires ToolJet Enterprise Edition' }) ); }); }); });