diff --git a/packages/twenty-docs/developers/self-host/capabilities/setup.mdx b/packages/twenty-docs/developers/self-host/capabilities/setup.mdx index 56deccd33a..c6757f15ac 100644 --- a/packages/twenty-docs/developers/self-host/capabilities/setup.mdx +++ b/packages/twenty-docs/developers/self-host/capabilities/setup.mdx @@ -288,3 +288,44 @@ yarn command:prod cron:workflow:automated-cron-trigger **Environment-only mode:** If you set `IS_CONFIG_VARIABLES_IN_DB_ENABLED=false`, add these variables to your `.env` file instead. + +## Serverless Functions + +Twenty supports serverless functions for workflows and custom logic. The execution environment is configured via the `SERVERLESS_TYPE` environment variable. + + +**Security Notice:** The local serverless driver (`SERVERLESS_TYPE=LOCAL`) runs code directly on the host in a Node.js process with no sandboxing. It should only be used for trusted code in development. For production deployments handling untrusted code, we highly recommend using `SERVERLESS_TYPE=LAMBDA` or `SERVERLESS_TYPE=DISABLED`. + + +### Available Drivers + +| Driver | Environment Variable | Use Case | Security Level | +|--------|---------------------|----------|----------------| +| Disabled | `SERVERLESS_TYPE=DISABLED` | Disable serverless functions entirely | N/A | +| Local | `SERVERLESS_TYPE=LOCAL` | Development and trusted environments | Low (no sandboxing) | +| Lambda | `SERVERLESS_TYPE=LAMBDA` | Production with untrusted code | High (hardware-level isolation) | + +### Recommended Configuration + +**For development:** +```bash +SERVERLESS_TYPE=LOCAL # default +``` + +**For production (AWS):** +```bash +SERVERLESS_TYPE=LAMBDA +SERVERLESS_LAMBDA_REGION=us-east-1 +SERVERLESS_LAMBDA_ROLE=arn:aws:iam::123456789:role/your-lambda-role +SERVERLESS_LAMBDA_ACCESS_KEY_ID=your-access-key +SERVERLESS_LAMBDA_SECRET_ACCESS_KEY=your-secret-key +``` + +**To disable serverless functions:** +```bash +SERVERLESS_TYPE=DISABLED +``` + + +When using `SERVERLESS_TYPE=DISABLED`, any attempt to execute a serverless function will return an error. This is useful if you want to run Twenty without serverless function capabilities. + diff --git a/packages/twenty-server/.env.example b/packages/twenty-server/.env.example index c6a47df1dc..4dd179de61 100644 --- a/packages/twenty-server/.env.example +++ b/packages/twenty-server/.env.example @@ -5,6 +5,7 @@ REDIS_URL=redis://localhost:6379 APP_SECRET=replace_me_with_a_random_string SIGN_IN_PREFILLED=true CODE_INTERPRETER_TYPE=local +IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS=false FRONTEND_URL=http://localhost:3001 diff --git a/packages/twenty-server/.env.test b/packages/twenty-server/.env.test index 4be39a0226..78a972934f 100644 --- a/packages/twenty-server/.env.test +++ b/packages/twenty-server/.env.test @@ -9,6 +9,8 @@ SENTRY_DSN=https://ba869cb8fd72d5faeb6643560939cee0@o4505516959793152.ingest.sen MUTATION_MAXIMUM_RECORD_AFFECTED=100 IS_MULTIWORKSPACE_ENABLED=true FRONTEND_URL=http://localhost:3001 +IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS=false + AUTH_GOOGLE_ENABLED=false MESSAGING_PROVIDER_GMAIL_ENABLED=false diff --git a/packages/twenty-server/src/engine/core-modules/serverless/drivers/disabled.driver.ts b/packages/twenty-server/src/engine/core-modules/serverless/drivers/disabled.driver.ts new file mode 100644 index 0000000000..2de17e7ae4 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/serverless/drivers/disabled.driver.ts @@ -0,0 +1,22 @@ +import { + type ServerlessDriver, + type ServerlessExecuteResult, +} from 'src/engine/core-modules/serverless/drivers/interfaces/serverless-driver.interface'; + +import { + ServerlessFunctionException, + ServerlessFunctionExceptionCode, +} from 'src/engine/metadata-modules/serverless-function/serverless-function.exception'; + +export class DisabledDriver implements ServerlessDriver { + async delete(): Promise { + // No-op when disabled + } + + async execute(): Promise { + throw new ServerlessFunctionException( + 'Serverless function execution is disabled. Set SERVERLESS_TYPE to LOCAL or LAMBDA to enable.', + ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_DISABLED, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/serverless/drivers/local.driver.ts b/packages/twenty-server/src/engine/core-modules/serverless/drivers/local.driver.ts index da0e03cc1f..c333e953c3 100644 --- a/packages/twenty-server/src/engine/core-modules/serverless/drivers/local.driver.ts +++ b/packages/twenty-server/src/engine/core-modules/serverless/drivers/local.driver.ts @@ -284,9 +284,13 @@ export class LocalDriver implements ServerlessDriver { stack?: string; stdout: string; stderr: string; - }>((resolve, _) => { + }>((resolve) => { + // Strip NODE_OPTIONS to prevent tsx loader from being inherited + const { NODE_OPTIONS: _n1, ...cleanProcessEnv } = process.env; + const { NODE_OPTIONS: _n2, ...cleanUserEnv } = env; + const child = spawn(process.execPath, [runnerPath], { - env: { ...process.env, ...env }, + env: { ...cleanProcessEnv, ...cleanUserEnv }, stdio: ['pipe', 'pipe', 'pipe', 'ipc'], }); diff --git a/packages/twenty-server/src/engine/core-modules/serverless/drivers/utils/copy-and-build-dependencies.ts b/packages/twenty-server/src/engine/core-modules/serverless/drivers/utils/copy-and-build-dependencies.ts index 0b77c1b732..424bd82381 100644 --- a/packages/twenty-server/src/engine/core-modules/serverless/drivers/utils/copy-and-build-dependencies.ts +++ b/packages/twenty-server/src/engine/core-modules/serverless/drivers/utils/copy-and-build-dependencies.ts @@ -1,12 +1,12 @@ -import { statSync, promises as fs } from 'fs'; -import { promisify } from 'util'; -import { exec } from 'child_process'; +import { execFile } from 'child_process'; +import { promises as fs, statSync } from 'fs'; import { join } from 'path'; +import { promisify } from 'util'; import { getLayerDependenciesDirName } from 'src/engine/core-modules/serverless/drivers/utils/get-layer-dependencies-dir-name'; import type { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity'; -const execPromise = promisify(exec); +const execFilePromise = promisify(execFile); export const copyAndBuildDependencies = async ( buildDirectory: string, @@ -32,23 +32,35 @@ export const copyAndBuildDependencies = async ( recursive: true, }); + const localYarnPath = join(buildDirectory, '.yarn/releases/yarn-4.9.2.cjs'); + + // Strip NODE_OPTIONS to prevent tsx loader from interfering with yarn + const { NODE_OPTIONS: _nodeOptions, ...cleanEnv } = process.env; + try { - await execPromise('yarn', { cwd: buildDirectory }); + await execFilePromise(process.execPath, [localYarnPath], { + cwd: buildDirectory, + env: cleanEnv, + }); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { - throw new Error(error.stdout); + const errorMessage = + [error?.stdout, error?.stderr].filter(Boolean).join('\n') || + 'Failed to install serverless dependencies'; + + throw new Error(errorMessage); } const objects = await fs.readdir(buildDirectory); - objects.forEach((object) => { - const fullPath = join(buildDirectory, object); + await Promise.all( + objects + .filter((object) => object !== 'node_modules') + .map((object) => { + const fullPath = join(buildDirectory, object); - if (object === 'node_modules') return; - - if (statSync(fullPath).isDirectory()) { - fs.rm(fullPath, { recursive: true, force: true }); - } else { - fs.rm(fullPath); - } - }); + return statSync(fullPath).isDirectory() + ? fs.rm(fullPath, { recursive: true, force: true }) + : fs.rm(fullPath); + }), + ); }; diff --git a/packages/twenty-server/src/engine/core-modules/serverless/serverless-module.factory.ts b/packages/twenty-server/src/engine/core-modules/serverless/serverless-module.factory.ts index 31c602f91b..0f112b7b0e 100644 --- a/packages/twenty-server/src/engine/core-modules/serverless/serverless-module.factory.ts +++ b/packages/twenty-server/src/engine/core-modules/serverless/serverless-module.factory.ts @@ -15,6 +15,11 @@ export const serverlessModuleFactory = async ( const options = { fileStorageService }; switch (driverType) { + case ServerlessDriverType.DISABLED: { + return { + type: ServerlessDriverType.DISABLED, + }; + } case ServerlessDriverType.LOCAL: { return { type: ServerlessDriverType.LOCAL, diff --git a/packages/twenty-server/src/engine/core-modules/serverless/serverless.interface.ts b/packages/twenty-server/src/engine/core-modules/serverless/serverless.interface.ts index 704c18e8d8..72cf6d4c9f 100644 --- a/packages/twenty-server/src/engine/core-modules/serverless/serverless.interface.ts +++ b/packages/twenty-server/src/engine/core-modules/serverless/serverless.interface.ts @@ -4,10 +4,15 @@ import { type LambdaDriverOptions } from 'src/engine/core-modules/serverless/dri import { type LocalDriverOptions } from 'src/engine/core-modules/serverless/drivers/local.driver'; export enum ServerlessDriverType { + DISABLED = 'DISABLED', LAMBDA = 'LAMBDA', LOCAL = 'LOCAL', } +export interface DisabledDriverFactoryOptions { + type: ServerlessDriverType.DISABLED; +} + export interface LocalDriverFactoryOptions { type: ServerlessDriverType.LOCAL; options: LocalDriverOptions; @@ -19,6 +24,7 @@ export interface LambdaDriverFactoryOptions { } export type ServerlessModuleOptions = + | DisabledDriverFactoryOptions | LocalDriverFactoryOptions | LambdaDriverFactoryOptions; diff --git a/packages/twenty-server/src/engine/core-modules/serverless/serverless.module.ts b/packages/twenty-server/src/engine/core-modules/serverless/serverless.module.ts index c355a6a522..6dd2da6c41 100644 --- a/packages/twenty-server/src/engine/core-modules/serverless/serverless.module.ts +++ b/packages/twenty-server/src/engine/core-modules/serverless/serverless.module.ts @@ -1,6 +1,7 @@ import { type DynamicModule, Global } from '@nestjs/common'; import { AddPackagesCommand } from 'src/engine/core-modules/serverless/commands/add-packages.command'; +import { DisabledDriver } from 'src/engine/core-modules/serverless/drivers/disabled.driver'; import { LambdaDriver } from 'src/engine/core-modules/serverless/drivers/lambda.driver'; import { LocalDriver } from 'src/engine/core-modules/serverless/drivers/local.driver'; import { SERVERLESS_DRIVER } from 'src/engine/core-modules/serverless/serverless.constants'; @@ -19,9 +20,21 @@ export class ServerlessModule { useFactory: async (...args: any[]) => { const config = await options.useFactory(...args); - return config?.type === ServerlessDriverType.LOCAL - ? new LocalDriver(config.options) - : new LambdaDriver(config.options); + switch (config?.type) { + case ServerlessDriverType.DISABLED: + return new DisabledDriver(); + case ServerlessDriverType.LOCAL: + return new LocalDriver(config.options); + case ServerlessDriverType.LAMBDA: + return new LambdaDriver(config.options); + default: { + const unknownConfig = config as { type?: string }; + + throw new Error( + `Unknown serverless driver type: ${unknownConfig?.type}`, + ); + } + } }, inject: options.inject || [], }; diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index 95ec914748..dda2f62bfa 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -375,7 +375,7 @@ export class ConfigVariables { type: ConfigVariableType.BOOLEAN, }) @IsOptional() - IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS = false; + IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS = true; @ConfigVariablesMetadata({ group: ConfigVariablesGroup.STORAGE_CONFIG, diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.exception.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.exception.ts index 8c035e9dfd..d4a7fac93d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.exception.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.exception.ts @@ -14,6 +14,7 @@ export enum ServerlessFunctionExceptionCode { SERVERLESS_FUNCTION_EXECUTION_LIMIT_REACHED = 'SERVERLESS_FUNCTION_EXECUTION_LIMIT_REACHED', SERVERLESS_FUNCTION_CREATE_FAILED = 'SERVERLESS_FUNCTION_CREATE_FAILED', SERVERLESS_FUNCTION_EXECUTION_TIMEOUT = 'SERVERLESS_FUNCTION_EXECUTION_TIMEOUT', + SERVERLESS_FUNCTION_DISABLED = 'SERVERLESS_FUNCTION_DISABLED', } const getServerlessFunctionExceptionUserFriendlyMessage = ( @@ -38,6 +39,8 @@ const getServerlessFunctionExceptionUserFriendlyMessage = ( return msg`Failed to create function.`; case ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_EXECUTION_TIMEOUT: return msg`Function execution timed out.`; + case ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_DISABLED: + return msg`Serverless function execution is disabled.`; default: assertUnreachable(code); } diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/utils/serverless-function-graphql-api-exception-handler.utils.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/utils/serverless-function-graphql-api-exception-handler.utils.ts index 5448fe0964..cbd32b71f2 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/utils/serverless-function-graphql-api-exception-handler.utils.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/utils/serverless-function-graphql-api-exception-handler.utils.ts @@ -29,6 +29,8 @@ export const serverlessFunctionGraphQLApiExceptionHandler = (error: any) => { case ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_CODE_UNCHANGED: case ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_CREATE_FAILED: throw error; + case ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_DISABLED: + throw new ForbiddenError(error); default: { return assertUnreachable(error.code); } diff --git a/packages/twenty-server/test/integration/metadata/suites/serverless-function/serverless-function-execution.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/serverless-function/serverless-function-execution.integration-spec.ts new file mode 100644 index 0000000000..f8c7dcd238 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/serverless-function/serverless-function-execution.integration-spec.ts @@ -0,0 +1,231 @@ +import { createOneServerlessFunction } from 'test/integration/metadata/suites/serverless-function/utils/create-one-serverless-function.util'; +import { deleteServerlessFunction } from 'test/integration/metadata/suites/serverless-function/utils/delete-serverless-function.util'; +import { executeServerlessFunction } from 'test/integration/metadata/suites/serverless-function/utils/execute-serverless-function.util'; +import { publishServerlessFunction } from 'test/integration/metadata/suites/serverless-function/utils/publish-serverless-function.util'; +import { updateServerlessFunction } from 'test/integration/metadata/suites/serverless-function/utils/update-serverless-function.util'; + +import { ServerlessFunctionExecutionStatus } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result.dto'; + +// Test function using external packages from default layer (lodash.groupby) +const EXTERNAL_PACKAGES_FUNCTION_CODE = { + 'src/index.ts': `import groupBy from 'lodash.groupby'; + +export const main = async (params: { items: Array<{ category: string; name: string }> }): Promise => { + const grouped = groupBy(params.items, 'category'); + return { + grouped, + categories: Object.keys(grouped), + }; +};`, +}; + +// Test function that throws an error +const ERROR_FUNCTION_CODE = { + 'src/index.ts': `export const main = async (params: { shouldFail: boolean }): Promise => { + if (params.shouldFail) { + throw new Error('Intentional test error'); + } + return { success: true }; +};`, +}; + +describe('Serverless Function Execution', () => { + const createdFunctionIds: string[] = []; + + afterAll(async () => { + // Clean up all created functions + for (const functionId of createdFunctionIds) { + try { + await deleteServerlessFunction({ + input: { id: functionId }, + expectToFail: false, + }); + } catch { + // Ignore cleanup errors + } + } + }); + + it('should execute the default serverless function template', async () => { + // Create the function (uses default template) + const { data: createData } = await createOneServerlessFunction({ + input: { + name: 'Test Default Function', + }, + expectToFail: false, + }); + + const functionId = createData?.createOneServerlessFunction?.id; + + expect(functionId).toBeDefined(); + createdFunctionIds.push(functionId); + + // Publish the function + const { data: publishData } = await publishServerlessFunction({ + input: { id: functionId }, + expectToFail: false, + }); + + expect(publishData?.publishServerlessFunction?.latestVersion).toBeDefined(); + + // Execute with the default template's expected params: { a: string, b: number } + const { data: executeData } = await executeServerlessFunction({ + input: { + id: functionId, + payload: { a: 'hello', b: 42 }, + }, + expectToFail: false, + }); + + const result = executeData?.executeOneServerlessFunction; + + if (result?.status !== ServerlessFunctionExecutionStatus.SUCCESS) { + throw new Error(JSON.stringify(result?.error, null, 2)); + } + + expect(result?.status).toBe(ServerlessFunctionExecutionStatus.SUCCESS); + expect(result?.data).toMatchObject({ + message: 'Hello, input: hello and 42', + }); + expect(result?.duration).toBeGreaterThan(0); + }); + + it('should execute a function with external packages (lodash.groupby)', async () => { + // Create the function (uses default template initially) + const { data: createData } = await createOneServerlessFunction({ + input: { + name: 'External Packages Test', + }, + expectToFail: false, + }); + + const functionId = createData?.createOneServerlessFunction?.id; + + expect(functionId).toBeDefined(); + createdFunctionIds.push(functionId); + + // Update with custom code using external packages + await updateServerlessFunction({ + input: { + id: functionId, + update: { + name: 'External Packages Test', + code: EXTERNAL_PACKAGES_FUNCTION_CODE, + }, + }, + expectToFail: false, + }); + + // Publish the function + await publishServerlessFunction({ + input: { id: functionId }, + expectToFail: false, + }); + + // Execute the function with items to group + const { data: executeData } = await executeServerlessFunction({ + input: { + id: functionId, + payload: { + items: [ + { category: 'fruit', name: 'apple' }, + { category: 'vegetable', name: 'carrot' }, + { category: 'fruit', name: 'banana' }, + ], + }, + }, + expectToFail: false, + }); + + const result = executeData?.executeOneServerlessFunction; + + if (result?.status !== ServerlessFunctionExecutionStatus.SUCCESS) { + throw new Error(JSON.stringify(result?.error, null, 2)); + } + + expect(result?.status).toBe(ServerlessFunctionExecutionStatus.SUCCESS); + + const data = result?.data as unknown as { + grouped: Record>; + categories: string[]; + }; + + expect(data?.grouped).toMatchObject({ + fruit: [ + { category: 'fruit', name: 'apple' }, + { category: 'fruit', name: 'banana' }, + ], + vegetable: [{ category: 'vegetable', name: 'carrot' }], + }); + expect(data?.categories).toEqual( + expect.arrayContaining(['fruit', 'vegetable']), + ); + }); + + it('should handle errors thrown by serverless functions', async () => { + // Create the function + const { data: createData } = await createOneServerlessFunction({ + input: { + name: 'Error Test Function', + }, + expectToFail: false, + }); + + const functionId = createData?.createOneServerlessFunction?.id; + + expect(functionId).toBeDefined(); + createdFunctionIds.push(functionId); + + // Update with error-throwing code + await updateServerlessFunction({ + input: { + id: functionId, + update: { + name: 'Error Test Function', + code: ERROR_FUNCTION_CODE, + }, + }, + expectToFail: false, + }); + + // Publish the function + await publishServerlessFunction({ + input: { id: functionId }, + expectToFail: false, + }); + + // Execute with shouldFail = false (should succeed) + const { data: successData } = await executeServerlessFunction({ + input: { + id: functionId, + payload: { shouldFail: false }, + }, + expectToFail: false, + }); + + expect(successData?.executeOneServerlessFunction?.status).toBe( + ServerlessFunctionExecutionStatus.SUCCESS, + ); + expect(successData?.executeOneServerlessFunction?.data).toMatchObject({ + success: true, + }); + + // Execute with shouldFail = true (should return error status) + const { data: errorData } = await executeServerlessFunction({ + input: { + id: functionId, + payload: { shouldFail: true }, + }, + expectToFail: false, // The GraphQL call succeeds, but the function execution fails + }); + + const errorResult = errorData?.executeOneServerlessFunction; + + expect(errorResult?.status).toBe(ServerlessFunctionExecutionStatus.ERROR); + expect(errorResult?.error).toMatchObject({ + errorType: 'UnhandledError', + errorMessage: expect.stringContaining('Intentional test error'), + }); + expect(errorResult?.data).toBeNull(); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/delete-serverless-function-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/delete-serverless-function-query-factory.util.ts new file mode 100644 index 0000000000..6ef55a587b --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/delete-serverless-function-query-factory.util.ts @@ -0,0 +1,22 @@ +import gql from 'graphql-tag'; + +export type DeleteServerlessFunctionFactoryInput = { + id: string; +}; + +export const deleteServerlessFunctionQueryFactory = ({ + input, +}: { + input: DeleteServerlessFunctionFactoryInput; +}) => ({ + query: gql` + mutation DeleteOneServerlessFunction($input: ServerlessFunctionIdInput!) { + deleteOneServerlessFunction(input: $input) { + id + } + } + `, + variables: { + input, + }, +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/delete-serverless-function.util.ts b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/delete-serverless-function.util.ts new file mode 100644 index 0000000000..bcdf37df4b --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/delete-serverless-function.util.ts @@ -0,0 +1,43 @@ +import { + type DeleteServerlessFunctionFactoryInput, + deleteServerlessFunctionQueryFactory, +} from 'test/integration/metadata/suites/serverless-function/utils/delete-serverless-function-query-factory.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; + +export const deleteServerlessFunction = async ({ + input, + expectToFail = false, + token, +}: { + input: DeleteServerlessFunctionFactoryInput; + expectToFail?: boolean; + token?: string; +}): CommonResponseBody<{ + deleteOneServerlessFunction: { id: string }; +}> => { + const graphqlOperation = deleteServerlessFunctionQueryFactory({ + input, + }); + + const response = await makeMetadataAPIRequest(graphqlOperation, token); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: + 'Serverless Function deletion should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'Serverless Function deletion has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +}; diff --git a/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/execute-serverless-function-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/execute-serverless-function-query-factory.util.ts new file mode 100644 index 0000000000..69363ddf2f --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/execute-serverless-function-query-factory.util.ts @@ -0,0 +1,37 @@ +import gql from 'graphql-tag'; + +export type ExecuteServerlessFunctionFactoryInput = { + id: string; + payload: Record; + version?: string; +}; + +const DEFAULT_EXECUTION_RESULT_GQL_FIELDS = ` + data + logs + duration + status + error +`; + +export const executeServerlessFunctionQueryFactory = ({ + input, + gqlFields = DEFAULT_EXECUTION_RESULT_GQL_FIELDS, +}: { + input: ExecuteServerlessFunctionFactoryInput; + gqlFields?: string; +}) => ({ + query: gql` + mutation ExecuteOneServerlessFunction($input: ExecuteServerlessFunctionInput!) { + executeOneServerlessFunction(input: $input) { + ${gqlFields} + } + } + `, + variables: { + input: { + ...input, + version: input.version ?? 'latest', + }, + }, +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/execute-serverless-function.util.ts b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/execute-serverless-function.util.ts new file mode 100644 index 0000000000..81c361c7ca --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/execute-serverless-function.util.ts @@ -0,0 +1,48 @@ +import { + type ExecuteServerlessFunctionFactoryInput, + executeServerlessFunctionQueryFactory, +} from 'test/integration/metadata/suites/serverless-function/utils/execute-serverless-function-query-factory.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; + +import { type ServerlessFunctionExecutionResultDTO } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result.dto'; + +export const executeServerlessFunction = async ({ + input, + gqlFields, + expectToFail = false, + token, +}: { + input: ExecuteServerlessFunctionFactoryInput; + gqlFields?: string; + expectToFail?: boolean; + token?: string; +}): CommonResponseBody<{ + executeOneServerlessFunction: ServerlessFunctionExecutionResultDTO; +}> => { + const graphqlOperation = executeServerlessFunctionQueryFactory({ + input, + gqlFields, + }); + + const response = await makeMetadataAPIRequest(graphqlOperation, token); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: + 'Serverless Function execution should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'Serverless Function execution has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +}; diff --git a/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/publish-serverless-function-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/publish-serverless-function-query-factory.util.ts new file mode 100644 index 0000000000..89ae2bff7d --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/publish-serverless-function-query-factory.util.ts @@ -0,0 +1,31 @@ +import gql from 'graphql-tag'; + +export type PublishServerlessFunctionFactoryInput = { + id: string; +}; + +const DEFAULT_SERVERLESS_FUNCTION_GQL_FIELDS = ` + id + name + latestVersion + publishedVersions +`; + +export const publishServerlessFunctionQueryFactory = ({ + input, + gqlFields = DEFAULT_SERVERLESS_FUNCTION_GQL_FIELDS, +}: { + input: PublishServerlessFunctionFactoryInput; + gqlFields?: string; +}) => ({ + query: gql` + mutation PublishServerlessFunction($input: PublishServerlessFunctionInput!) { + publishServerlessFunction(input: $input) { + ${gqlFields} + } + } + `, + variables: { + input, + }, +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/publish-serverless-function.util.ts b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/publish-serverless-function.util.ts new file mode 100644 index 0000000000..d506c7cba4 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/publish-serverless-function.util.ts @@ -0,0 +1,48 @@ +import { + type PublishServerlessFunctionFactoryInput, + publishServerlessFunctionQueryFactory, +} from 'test/integration/metadata/suites/serverless-function/utils/publish-serverless-function-query-factory.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; + +import { type ServerlessFunctionDTO } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto'; + +export const publishServerlessFunction = async ({ + input, + gqlFields, + expectToFail = false, + token, +}: { + input: PublishServerlessFunctionFactoryInput; + gqlFields?: string; + expectToFail?: boolean; + token?: string; +}): CommonResponseBody<{ + publishServerlessFunction: ServerlessFunctionDTO; +}> => { + const graphqlOperation = publishServerlessFunctionQueryFactory({ + input, + gqlFields, + }); + + const response = await makeMetadataAPIRequest(graphqlOperation, token); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: + 'Serverless Function publish should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'Serverless Function publish has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +}; diff --git a/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/update-serverless-function-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/update-serverless-function-query-factory.util.ts new file mode 100644 index 0000000000..c4f502e9dd --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/update-serverless-function-query-factory.util.ts @@ -0,0 +1,37 @@ +import gql from 'graphql-tag'; +import { type Sources } from 'twenty-shared/types'; + +export type UpdateServerlessFunctionFactoryInput = { + id: string; + update: { + name: string; + description?: string; + code: Sources; + }; +}; + +const DEFAULT_SERVERLESS_FUNCTION_GQL_FIELDS = ` + id + name + description + latestVersion +`; + +export const updateServerlessFunctionQueryFactory = ({ + input, + gqlFields = DEFAULT_SERVERLESS_FUNCTION_GQL_FIELDS, +}: { + input: UpdateServerlessFunctionFactoryInput; + gqlFields?: string; +}) => ({ + query: gql` + mutation UpdateOneServerlessFunction($input: UpdateServerlessFunctionInput!) { + updateOneServerlessFunction(input: $input) { + ${gqlFields} + } + } + `, + variables: { + input, + }, +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/update-serverless-function.util.ts b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/update-serverless-function.util.ts new file mode 100644 index 0000000000..03b26b64c4 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/serverless-function/utils/update-serverless-function.util.ts @@ -0,0 +1,47 @@ +import { + type UpdateServerlessFunctionFactoryInput, + updateServerlessFunctionQueryFactory, +} from 'test/integration/metadata/suites/serverless-function/utils/update-serverless-function-query-factory.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; + +import { type ServerlessFunctionDTO } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto'; + +export const updateServerlessFunction = async ({ + input, + gqlFields, + expectToFail = false, + token, +}: { + input: UpdateServerlessFunctionFactoryInput; + gqlFields?: string; + expectToFail?: boolean; + token?: string; +}): CommonResponseBody<{ + updateOneServerlessFunction: ServerlessFunctionDTO; +}> => { + const graphqlOperation = updateServerlessFunctionQueryFactory({ + input, + gqlFields, + }); + + const response = await makeMetadataAPIRequest(graphqlOperation, token); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'Serverless Function update should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'Serverless Function update has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +};