ToolJet/server/test/modules/external-apis/e2e/modules.spec.ts
Shantanu Mane 0fb732600a
Feature: Import/Export Modules External API (#16300)
* feat(external-apis): add LIST_MODULES, EXPORT_MODULE, IMPORT_MODULE feature keys, DTOs, and ability grants

* feat(external-apis): add CE stub modules controller and register in module

* feat(external-apis): add end-to-end tests for ExternalApisModulesController

* refactor(external-apis): simplify exportModule method signature in ExternalApisModulesController

* feat(external-apis): enhance tests for ExternalApisModulesController with additional cases for non-existent UUIDs

* feat(external-apis): update exportModule method to include exportTjdb parameter and enhance tests for its functionality

* feat(external-apis): enhance tooljet_database import schema validation and add module import/export helpers

* feat(external-apis): add tests for ExternalApisModulesController in starter and CE plans with appropriate status checks

* feat(external-apis): add tests for module and app import endpoints to validate JSON rejection
2026-05-08 21:16:38 +05:30

545 lines
22 KiB
TypeScript

/**
* @group platform
*/
import * as request from 'supertest';
import { INestApplication } from '@nestjs/common';
import {
createUser,
initTestApp,
closeTestApp,
createApplication,
createApplicationVersion,
} from 'test-helper';
import { APP_TYPES } from '@modules/apps/constants';
jest.setTimeout(120_000);
// Token is read from .env.test at runtime by ConfigService — read after env is loaded.
const getExtAuth = () => `Basic ${process.env.EXTERNAL_API_ACCESS_TOKEN}`;
// A valid UUID that will never exist in the test database.
const NONEXISTENT_UUID = '00000000-0000-0000-0000-000000000001';
// Helper: export a module and return the full export body.
async function exportModule(
server: ReturnType<INestApplication['getHttpServer']>,
orgId: string,
moduleId: string,
query = ''
) {
const res = await request(server)
.post(`/api/ext/export/workspace/${orgId}/modules/${moduleId}${query}`)
.set('Authorization', getExtAuth())
.expect(201);
return res.body;
}
// Helper: import a module and return the response body.
async function importModule(
server: ReturnType<INestApplication['getHttpServer']>,
orgId: string,
payload: Record<string, unknown>
) {
const res = await request(server)
.post(`/api/ext/import/workspace/${orgId}/modules`)
.set('Authorization', getExtAuth())
.send(payload)
.expect(201);
return res.body;
}
// Helper: list modules in a workspace.
async function listModules(server: ReturnType<INestApplication['getHttpServer']>, orgId: string) {
const res = await request(server)
.get(`/api/ext/workspace/${orgId}/modules`)
.set('Authorization', getExtAuth())
.expect(200);
return res.body as { modules: any[]; total: number };
}
describe('ExternalApisModulesController (EE enterprise)', () => {
let app: INestApplication;
beforeAll(async () => {
({ app } = await initTestApp({ edition: 'ee', plan: 'enterprise' }));
});
afterEach(() => {
jest.resetAllMocks();
});
afterAll(async () => {
await closeTestApp(app);
}, 60000);
// ---------------------------------------------------------------------------
// GET /api/ext/workspace/:workspaceId/modules
// ---------------------------------------------------------------------------
describe('GET /api/ext/workspace/:workspaceId/modules', () => {
it('returns 403 without Authorization header', async () => {
const { user } = await createUser(app, { email: 'admin@tooljet.io' });
await request(app.getHttpServer())
.get(`/api/ext/workspace/${user.defaultOrganizationId}/modules`)
.expect(403);
});
it('returns 400 for non-UUID workspaceId', async () => {
await request(app.getHttpServer())
.get('/api/ext/workspace/not-a-uuid/modules')
.set('Authorization', getExtAuth())
.expect(400);
});
it('returns 400 for a valid UUID workspace that does not exist', async () => {
await request(app.getHttpServer())
.get(`/api/ext/workspace/${NONEXISTENT_UUID}/modules`)
.set('Authorization', getExtAuth())
.expect(400);
});
it('returns empty list when workspace has no modules', async () => {
const { user } = await createUser(app, { email: 'admin@tooljet.io' });
const res = await request(app.getHttpServer())
.get(`/api/ext/workspace/${user.defaultOrganizationId}/modules`)
.set('Authorization', getExtAuth())
.expect(200);
expect(res.body.modules).toEqual([]);
expect(res.body.total).toBe(0);
});
it('returns correct response shape for each module', async () => {
const { user } = await createUser(app, { email: 'admin@tooljet.io' });
await createApplication(app, { name: 'Shape Module', user, type: APP_TYPES.MODULE });
const res = await request(app.getHttpServer())
.get(`/api/ext/workspace/${user.defaultOrganizationId}/modules`)
.set('Authorization', getExtAuth())
.expect(200);
expect(res.body.total).toBe(1);
const mod = res.body.modules[0];
expect(mod).toHaveProperty('id');
expect(mod).toHaveProperty('name', 'Shape Module');
expect(mod).toHaveProperty('slug');
expect(mod).toHaveProperty('icon');
expect(mod).toHaveProperty('isPublic');
expect(mod).toHaveProperty('createdAt');
expect(mod).toHaveProperty('updatedAt');
});
it('returns only module-type apps (excludes front-end apps)', async () => {
const { user } = await createUser(app, { email: 'admin@tooljet.io' });
const orgId = user.defaultOrganizationId;
await createApplication(app, { name: 'Front-end app', user, type: APP_TYPES.FRONT_END });
const module1 = await createApplication(app, { name: 'Auth Module', user, type: APP_TYPES.MODULE }, false);
const module2 = await createApplication(app, { name: 'Payment Module', user, type: APP_TYPES.MODULE }, false);
const res = await request(app.getHttpServer())
.get(`/api/ext/workspace/${orgId}/modules`)
.set('Authorization', getExtAuth())
.expect(200);
expect(res.body.total).toBe(2);
const names = res.body.modules.map((m: any) => m.name);
expect(names).toContain(module1.name);
expect(names).toContain(module2.name);
expect(names).not.toContain('Front-end app');
});
it('returns modules belonging to the given workspace only', async () => {
const { user: user1 } = await createUser(app, { email: 'user1@tooljet.io' });
const { user: user2 } = await createUser(app, { email: 'user2@tooljet.io' });
await createApplication(app, { name: 'Module A', user: user1, type: APP_TYPES.MODULE });
await createApplication(app, { name: 'Module B', user: user2, type: APP_TYPES.MODULE });
const res = await request(app.getHttpServer())
.get(`/api/ext/workspace/${user1.defaultOrganizationId}/modules`)
.set('Authorization', getExtAuth())
.expect(200);
expect(res.body.total).toBe(1);
expect(res.body.modules[0].name).toBe('Module A');
});
});
// ---------------------------------------------------------------------------
// POST /api/ext/export/workspace/:workspaceId/modules/:moduleId
// ---------------------------------------------------------------------------
describe('POST /api/ext/export/workspace/:workspaceId/modules/:moduleId', () => {
it('returns 403 without Authorization header', async () => {
const { user } = await createUser(app, { email: 'admin@tooljet.io' });
const mod = await createApplication(app, { name: 'M', user, type: APP_TYPES.MODULE });
await request(app.getHttpServer())
.post(`/api/ext/export/workspace/${user.defaultOrganizationId}/modules/${mod.id}`)
.expect(403);
});
it('returns 400 for non-UUID moduleId', async () => {
const { user } = await createUser(app, { email: 'admin@tooljet.io' });
await request(app.getHttpServer())
.post(`/api/ext/export/workspace/${user.defaultOrganizationId}/modules/not-a-uuid`)
.set('Authorization', getExtAuth())
.expect(400);
});
it('returns 400 for a valid UUID moduleId that does not exist', async () => {
const { user } = await createUser(app, { email: 'admin@tooljet.io' });
await request(app.getHttpServer())
.post(`/api/ext/export/workspace/${user.defaultOrganizationId}/modules/${NONEXISTENT_UUID}`)
.set('Authorization', getExtAuth())
.expect(400);
});
it('returns 400 when module does not belong to workspace', async () => {
const { user: user1 } = await createUser(app, { email: 'user1@tooljet.io' });
const { user: user2 } = await createUser(app, { email: 'user2@tooljet.io' });
const mod = await createApplication(app, { name: 'M', user: user1, type: APP_TYPES.MODULE });
await request(app.getHttpServer())
.post(`/api/ext/export/workspace/${user2.defaultOrganizationId}/modules/${mod.id}`)
.set('Authorization', getExtAuth())
.expect(400);
});
it('returns 400 when app exists but is not a module type', async () => {
const { user } = await createUser(app, { email: 'admin@tooljet.io' });
const frontendApp = await createApplication(app, { name: 'FE', user, type: APP_TYPES.FRONT_END });
await request(app.getHttpServer())
.post(`/api/ext/export/workspace/${user.defaultOrganizationId}/modules/${frontendApp.id}`)
.set('Authorization', getExtAuth())
.expect(400);
});
it('exports module and returns definition with tooljet_version', async () => {
const { user } = await createUser(app, { email: 'admin@tooljet.io' });
const mod = await createApplication(app, { name: 'Auth Module', user, type: APP_TYPES.MODULE });
await createApplicationVersion(app, mod);
const body = await exportModule(app.getHttpServer(), user.defaultOrganizationId, mod.id);
expect(body).toHaveProperty('tooljet_version');
expect(typeof body.tooljet_version).toBe('string');
expect(body).toHaveProperty('app');
expect(Array.isArray(body.app)).toBe(true);
expect(body.app.length).toBeGreaterThan(0);
expect(body.app[0]).toHaveProperty('definition');
});
it('excludes TJDB from export when exportTJDB=false', async () => {
const { user } = await createUser(app, { email: 'admin@tooljet.io' });
const mod = await createApplication(app, { name: 'Auth Module', user, type: APP_TYPES.MODULE });
await createApplicationVersion(app, mod);
const body = await exportModule(app.getHttpServer(), user.defaultOrganizationId, mod.id, '?exportTJDB=false');
expect(body).not.toHaveProperty('tooljet_database');
});
it('does not exclude TJDB when exportTJDB=true explicitly', async () => {
const { user } = await createUser(app, { email: 'admin@tooljet.io' });
const mod = await createApplication(app, { name: 'Auth Module', user, type: APP_TYPES.MODULE });
await createApplicationVersion(app, mod);
// Should not error — TJDB inclusion is allowed even when the module has no tables
const body = await exportModule(app.getHttpServer(), user.defaultOrganizationId, mod.id, '?exportTJDB=true');
expect(body).toHaveProperty('tooljet_version');
expect(body).toHaveProperty('app');
});
});
// ---------------------------------------------------------------------------
// POST /api/ext/import/workspace/:workspaceId/modules
// ---------------------------------------------------------------------------
describe('POST /api/ext/import/workspace/:workspaceId/modules', () => {
it('returns 403 without Authorization header', async () => {
const { user } = await createUser(app, { email: 'admin@tooljet.io' });
await request(app.getHttpServer())
.post(`/api/ext/import/workspace/${user.defaultOrganizationId}/modules`)
.expect(403);
});
it('returns 400 for non-UUID workspaceId', async () => {
await request(app.getHttpServer())
.post('/api/ext/import/workspace/not-a-uuid/modules')
.set('Authorization', getExtAuth())
.expect(400);
});
it('returns 400 for a valid UUID workspace that does not exist', async () => {
await request(app.getHttpServer())
.post(`/api/ext/import/workspace/${NONEXISTENT_UUID}/modules`)
.set('Authorization', getExtAuth())
.send({ tooljet_version: '1.0.0', app: [] })
.expect(400);
});
it('returns 400 when tooljet_version is missing from request body', async () => {
const { user } = await createUser(app, { email: 'admin@tooljet.io' });
await request(app.getHttpServer())
.post(`/api/ext/import/workspace/${user.defaultOrganizationId}/modules`)
.set('Authorization', getExtAuth())
.send({ app: [] })
.expect(400);
});
it('returns 400 when tooljet_version exceeds current version', async () => {
const { user } = await createUser(app, { email: 'admin@tooljet.io' });
await request(app.getHttpServer())
.post(`/api/ext/import/workspace/${user.defaultOrganizationId}/modules`)
.set('Authorization', getExtAuth())
.send({ tooljet_version: '999.0.0', app: [] })
.expect(400);
});
it('accepts import body with only tooljet_version (no app, no tooljet_database)', async () => {
const { user } = await createUser(app, { email: 'admin@tooljet.io' });
const res = await request(app.getHttpServer())
.post(`/api/ext/import/workspace/${user.defaultOrganizationId}/modules`)
.set('Authorization', getExtAuth())
.send({ tooljet_version: '1.0.0' })
.expect(201);
expect(res.body.message).toBe('Module imported successfully.');
});
it('accepts import body with tooljet_database as empty array', async () => {
const { user } = await createUser(app, { email: 'admin@tooljet.io' });
const res = await request(app.getHttpServer())
.post(`/api/ext/import/workspace/${user.defaultOrganizationId}/modules`)
.set('Authorization', getExtAuth())
.send({ tooljet_version: '1.0.0', tooljet_database: [] })
.expect(201);
expect(res.body.message).toBe('Module imported successfully.');
});
it('returns 400 when tooljet_database entry is missing the id field', async () => {
const { user } = await createUser(app, { email: 'admin@tooljet.io' });
await request(app.getHttpServer())
.post(`/api/ext/import/workspace/${user.defaultOrganizationId}/modules`)
.set('Authorization', getExtAuth())
.send({
tooljet_version: '1.0.0',
tooljet_database: [{ table_name: 'test_table', schema: { columns: [] } }],
})
.expect(400);
});
it('returns 400 when tooljet_database entry id is not a valid UUID', async () => {
const { user } = await createUser(app, { email: 'admin@tooljet.io' });
await request(app.getHttpServer())
.post(`/api/ext/import/workspace/${user.defaultOrganizationId}/modules`)
.set('Authorization', getExtAuth())
.send({
tooljet_version: '1.0.0',
tooljet_database: [{ id: 'not-a-uuid', table_name: 'test_table', schema: { columns: [] } }],
})
.expect(400);
});
it('returns 400 when tooljet_database entry is missing table_name', async () => {
const { user } = await createUser(app, { email: 'admin@tooljet.io' });
await request(app.getHttpServer())
.post(`/api/ext/import/workspace/${user.defaultOrganizationId}/modules`)
.set('Authorization', getExtAuth())
.send({
tooljet_version: '1.0.0',
tooljet_database: [{ id: NONEXISTENT_UUID, schema: { columns: [] } }],
})
.expect(400);
});
it('imports module, returns success message, and module appears in workspace listing', async () => {
const { user } = await createUser(app, { email: 'admin@tooljet.io' });
const orgId = user.defaultOrganizationId;
const mod = await createApplication(app, { name: 'Source Module', user, type: APP_TYPES.MODULE });
await createApplicationVersion(app, mod);
const exportBody = await exportModule(app.getHttpServer(), orgId, mod.id);
const importRes = await request(app.getHttpServer())
.post(`/api/ext/import/workspace/${orgId}/modules`)
.set('Authorization', getExtAuth())
.send({
tooljet_version: exportBody.tooljet_version,
app: exportBody.app,
tooljet_database: exportBody.tooljet_database ?? [],
})
.expect(201);
expect(importRes.body.message).toBe('Module imported successfully.');
// Verify the imported module materialised in the workspace (total goes from 1 → 2)
const listing = await listModules(app.getHttpServer(), orgId);
expect(listing.total).toBe(2);
});
it('imports module with appName override and reflects the new name in workspace listing', async () => {
const { user } = await createUser(app, { email: 'admin@tooljet.io' });
const orgId = user.defaultOrganizationId;
const mod = await createApplication(app, { name: 'Original Name', user, type: APP_TYPES.MODULE });
await createApplicationVersion(app, mod);
const exportBody = await exportModule(app.getHttpServer(), orgId, mod.id);
await importModule(app.getHttpServer(), orgId, {
tooljet_version: exportBody.tooljet_version,
appName: 'Renamed Module',
app: exportBody.app,
tooljet_database: exportBody.tooljet_database ?? [],
});
const listing = await listModules(app.getHttpServer(), orgId);
const names = listing.modules.map((m: any) => m.name);
expect(names).toContain('Renamed Module');
});
it('imports into a different workspace than the source', async () => {
const { user: user1 } = await createUser(app, { email: 'user1@tooljet.io' });
const { user: user2 } = await createUser(app, { email: 'user2@tooljet.io' });
const mod = await createApplication(app, { name: 'Portable Module', user: user1, type: APP_TYPES.MODULE });
await createApplicationVersion(app, mod);
const exportBody = await exportModule(app.getHttpServer(), user1.defaultOrganizationId, mod.id);
await importModule(app.getHttpServer(), user2.defaultOrganizationId, {
tooljet_version: exportBody.tooljet_version,
appName: 'Portable Module',
app: exportBody.app,
tooljet_database: exportBody.tooljet_database ?? [],
});
const listing = await listModules(app.getHttpServer(), user2.defaultOrganizationId);
expect(listing.total).toBe(1);
expect(listing.modules[0].name).toBe('Portable Module');
});
it('returns 400 when a front-end app JSON is sent to the module import endpoint', async () => {
const { user } = await createUser(app, { email: 'admin@tooljet.io' });
const orgId = user.defaultOrganizationId;
const frontendApp = await createApplication(app, { name: 'Frontend App', user, type: APP_TYPES.FRONT_END });
await createApplicationVersion(app, frontendApp);
// Export the front-end app via the apps export endpoint
const exportRes = await request(app.getHttpServer())
.post(`/api/ext/export/workspace/${orgId}/apps/${frontendApp.id}`)
.set('Authorization', getExtAuth())
.expect(201);
await request(app.getHttpServer())
.post(`/api/ext/import/workspace/${orgId}/modules`)
.set('Authorization', getExtAuth())
.send({
tooljet_version: exportRes.body.tooljet_version ?? '1.0.0',
app: exportRes.body.app,
tooljet_database: exportRes.body.tooljet_database ?? [],
})
.expect(400);
});
});
// ---------------------------------------------------------------------------
// POST /api/ext/import/workspace/:workspaceId/apps — module JSON rejection
// ---------------------------------------------------------------------------
describe('POST /api/ext/import/workspace/:workspaceId/apps', () => {
it('returns 400 when a module JSON is sent to the app import endpoint', async () => {
const { user } = await createUser(app, { email: 'admin@tooljet.io' });
const orgId = user.defaultOrganizationId;
const mod = await createApplication(app, { name: 'Source Module', user, type: APP_TYPES.MODULE });
await createApplicationVersion(app, mod);
const exportBody = await exportModule(app.getHttpServer(), orgId, mod.id);
await request(app.getHttpServer())
.post(`/api/ext/import/workspace/${orgId}/apps`)
.set('Authorization', getExtAuth())
.send({
tooljet_version: exportBody.tooljet_version,
app: exportBody.app,
tooljet_database: exportBody.tooljet_database ?? [],
})
.expect(400);
});
});
});
describe('ExternalApisModulesController (EE plan: starter)', () => {
let app: INestApplication;
beforeAll(async () => {
({ app } = await initTestApp({ edition: 'ee', plan: 'starter' }));
});
afterEach(() => {
jest.resetAllMocks();
});
afterAll(async () => {
await closeTestApp(app);
}, 60000);
it('GET /api/ext/workspace/:workspaceId/modules returns 403 — externalApi not included in starter plan', async () => {
const { user } = await createUser(app, { email: 'admin@tooljet.io' });
await request(app.getHttpServer())
.get(`/api/ext/workspace/${user.defaultOrganizationId}/modules`)
.set('Authorization', getExtAuth())
.expect(451);
});
});
describe('ExternalApisModulesController (CE)', () => {
let app: INestApplication;
beforeAll(async () => {
({ app } = await initTestApp({ edition: 'ce' }));
});
afterEach(() => {
jest.resetAllMocks();
});
afterAll(async () => {
await closeTestApp(app);
}, 60000);
it('GET /api/ext/workspace/:workspaceId/modules returns 404 — route not registered on CE', async () => {
const { user } = await createUser(app, { email: 'admin@tooljet.io' });
await request(app.getHttpServer())
.get(`/api/ext/workspace/${user.defaultOrganizationId}/modules`)
.set('Authorization', getExtAuth())
.expect(404);
});
it('POST /api/ext/export/workspace/:workspaceId/modules/:moduleId returns 404 — route not registered on CE', async () => {
const { user } = await createUser(app, { email: 'admin@tooljet.io' });
await request(app.getHttpServer())
.post(`/api/ext/export/workspace/${user.defaultOrganizationId}/modules/${NONEXISTENT_UUID}`)
.set('Authorization', getExtAuth())
.expect(404);
});
it('POST /api/ext/import/workspace/:workspaceId/modules returns 404 — route not registered on CE', async () => {
const { user } = await createUser(app, { email: 'admin@tooljet.io' });
await request(app.getHttpServer())
.post(`/api/ext/import/workspace/${user.defaultOrganizationId}/modules`)
.set('Authorization', getExtAuth())
.send({ tooljet_version: '1.0.0' })
.expect(404);
});
});