diff --git a/server/src/dto/validators/tooljet-database.validator.ts b/server/src/dto/validators/tooljet-database.validator.ts index 164087bd0e..db3027a474 100644 --- a/server/src/dto/validators/tooljet-database.validator.ts +++ b/server/src/dto/validators/tooljet-database.validator.ts @@ -112,7 +112,7 @@ export function ValidateTooljetDatabaseSchema(validationOptions?: ValidationOpti } export function ValidateTooljetDatabaseImportSchema(validationOptions?: ValidationOptions) { - return function (object: AppImportRequestDto, propertyName: string) { + return function (object: Record, propertyName: string) { registerDecorator({ target: object.constructor, propertyName: propertyName, diff --git a/server/src/modules/external-apis/ability/index.ts b/server/src/modules/external-apis/ability/index.ts index 31892cf749..a892486264 100644 --- a/server/src/modules/external-apis/ability/index.ts +++ b/server/src/modules/external-apis/ability/index.ts @@ -31,6 +31,9 @@ export class FeatureAbilityFactory extends AbilityFactory FEATURE_KEY.AUTO_RELEASE_APP, FEATURE_KEY.GENERATE_PAT, FEATURE_KEY.VALIDATE_PAT_SESSION, + FEATURE_KEY.LIST_MODULES, + FEATURE_KEY.EXPORT_MODULE, + FEATURE_KEY.IMPORT_MODULE, ], User ); diff --git a/server/src/modules/external-apis/constants/feature.ts b/server/src/modules/external-apis/constants/feature.ts index 4746d59301..d71eb8f480 100644 --- a/server/src/modules/external-apis/constants/feature.ts +++ b/server/src/modules/external-apis/constants/feature.ts @@ -77,5 +77,17 @@ export const FEATURES: FeaturesConfig = { license: LICENSE_FIELD.EXTERNAL_API, isPublic: true, }, + [FEATURE_KEY.LIST_MODULES]: { + license: LICENSE_FIELD.EXTERNAL_API, + isPublic: true, + }, + [FEATURE_KEY.EXPORT_MODULE]: { + license: LICENSE_FIELD.EXTERNAL_API, + isPublic: true, + }, + [FEATURE_KEY.IMPORT_MODULE]: { + license: LICENSE_FIELD.EXTERNAL_API, + isPublic: true, + }, }, }; diff --git a/server/src/modules/external-apis/constants/index.ts b/server/src/modules/external-apis/constants/index.ts index 6d59bd5496..ebe0a9db91 100644 --- a/server/src/modules/external-apis/constants/index.ts +++ b/server/src/modules/external-apis/constants/index.ts @@ -17,6 +17,9 @@ export enum FEATURE_KEY { EXPORT_APP = 'EXPORT_APP', GENERATE_PAT = 'GENERATE_PAT', VALIDATE_PAT_SESSION = 'VALIDATE_PAT_SESSION', + LIST_MODULES = 'LIST_MODULES', + EXPORT_MODULE = 'EXPORT_MODULE', + IMPORT_MODULE = 'IMPORT_MODULE', } export type DefaultDataSourceKind = 'restapi' | 'runjs' | 'runpy' | 'tooljetdb' | 'workflows'; diff --git a/server/src/modules/external-apis/controllers/modules.controller.ts b/server/src/modules/external-apis/controllers/modules.controller.ts new file mode 100644 index 0000000000..79176998ba --- /dev/null +++ b/server/src/modules/external-apis/controllers/modules.controller.ts @@ -0,0 +1,22 @@ +import { MODULES } from '@modules/app/constants/modules'; +import { InitModule } from '@modules/app/decorators/init-module'; +import { Controller, UseGuards } from '@nestjs/common'; +import { FeatureAbilityGuard } from '../ability/guard'; +import { ModuleImportRequestDto, WorkspaceModulesResponseDto } from '../dto'; + +@Controller('ext') +@InitModule(MODULES.EXTERNAL_APIS) +@UseGuards(FeatureAbilityGuard) +export class ExternalApisModulesController { + getAllWorkspaceModules(workspaceId: string): Promise { + throw new Error('Method not implemented.'); + } + + exportModule(moduleId: string, workspaceId: string, exportTjdb: boolean): Promise { + throw new Error('Method not implemented.'); + } + + importModule(workspaceId: string, importresources: ModuleImportRequestDto): Promise<{ message: string }> { + throw new Error('Method not implemented.'); + } +} diff --git a/server/src/modules/external-apis/dto/index.ts b/server/src/modules/external-apis/dto/index.ts index 24f966cc79..5516177bae 100644 --- a/server/src/modules/external-apis/dto/index.ts +++ b/server/src/modules/external-apis/dto/index.ts @@ -293,3 +293,43 @@ export class ValidatePATSessionDto { @IsString() accessToken: string; } + +export class WorkspaceModuleDto { + id: string; + name: string; + icon: string; + slug: string; + isPublic: boolean; + createdAt: Date; + updatedAt: Date; +} + +export class WorkspaceModulesResponseDto { + modules: WorkspaceModuleDto[]; + total: number; +} + +export class ModuleImportDto { + @IsDefined() + @IsObject() + definition: any; +} + +export class ModuleImportRequestDto { + @IsString() + tooljet_version: string; + + @IsOptional() + app?: ModuleImportDto[]; + + @IsOptional() + @IsString() + appName?: string; + + @IsOptional() + @Transform(TjdbSchemaToLatestVersion) + @ValidateNested({ each: true }) + @Type(() => ImportTooljetDatabaseDto) + @ValidateTooljetDatabaseImportSchema({ each: true }) + tooljet_database?: ImportTooljetDatabaseDto[]; +} diff --git a/server/src/modules/external-apis/module.ts b/server/src/modules/external-apis/module.ts index 4d2e657906..351a83c1e5 100644 --- a/server/src/modules/external-apis/module.ts +++ b/server/src/modules/external-apis/module.ts @@ -23,13 +23,19 @@ import { UserRepository } from '@modules/users/repositories/repository'; export class ExternalApiModule extends SubModule { static async register(configs?: { IS_GET_CONTEXT: boolean }, isMainImport: boolean = false): Promise { - const { ExternalApisController, ExternalApisService, ExternalApiUtilService, ExternalApisAppsController } = - await this.getProviders(configs, 'external-apis', [ - 'controller', - 'service', - 'util.service', - 'controllers/apps.controller', - ]); + const { + ExternalApisController, + ExternalApisService, + ExternalApiUtilService, + ExternalApisAppsController, + ExternalApisModulesController, + } = await this.getProviders(configs, 'external-apis', [ + 'controller', + 'service', + 'util.service', + 'controllers/apps.controller', + 'controllers/modules.controller', + ]); return { module: ExternalApiModule, @@ -61,7 +67,7 @@ export class ExternalApiModule extends SubModule { UserRepository, AppsRepository, ], - controllers: isMainImport ? [ExternalApisController, ExternalApisAppsController] : [], + controllers: isMainImport ? [ExternalApisController, ExternalApisAppsController, ExternalApisModulesController] : [], exports: [ExternalApiUtilService], }; } diff --git a/server/src/modules/external-apis/types/index.ts b/server/src/modules/external-apis/types/index.ts index 6970352ff2..0d1f05291b 100644 --- a/server/src/modules/external-apis/types/index.ts +++ b/server/src/modules/external-apis/types/index.ts @@ -21,6 +21,9 @@ interface Features { [FEATURE_KEY.EXPORT_APP]: FeatureConfig; [FEATURE_KEY.GENERATE_PAT]: FeatureConfig; [FEATURE_KEY.VALIDATE_PAT_SESSION]: FeatureConfig; + [FEATURE_KEY.LIST_MODULES]: FeatureConfig; + [FEATURE_KEY.EXPORT_MODULE]: FeatureConfig; + [FEATURE_KEY.IMPORT_MODULE]: FeatureConfig; } export interface FeaturesConfig { diff --git a/server/test/modules/external-apis/e2e/modules.spec.ts b/server/test/modules/external-apis/e2e/modules.spec.ts new file mode 100644 index 0000000000..3e23cdf77e --- /dev/null +++ b/server/test/modules/external-apis/e2e/modules.spec.ts @@ -0,0 +1,545 @@ +/** + * @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, + 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, + orgId: string, + payload: Record +) { + 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, 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); + }); +});