feat(serverless): add basic sandbox isolation and flexible driver options (#17176)

## Overview
- Add a DISABLED serverless driver to explicitly turn off execution
- Clarify self-hosting docs with driver options and recommended usage
- Keep integration coverage for serverless function execution (default +
external package example)

## Notes
- Local driver remains the default for development usage; Lambda or
Disabled recommended for production deployments
- No functional changes to Lambda execution

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Introduces flexible serverless execution modes and safer local
execution.
> 
> - **New driver:** `DISABLED` serverless driver with wiring in
`serverless.interface`, factory, module provider, and GraphQL exception
mapping; new exception code `SERVERLESS_FUNCTION_DISABLED`.
> - **Local driver hardening:** Strip `NODE_OPTIONS` when spawning child
processes; cleanup promise signature; better log capture.
> - **Dependency build reliability:** Use `execFile` with bundled Yarn
(`.yarn/releases/yarn-4.9.2.cjs`), strip `NODE_OPTIONS`, improved error
messages, and parallel cleanup excluding `node_modules`.
> - **Docs:** Add serverless section detailing `SERVERLESS_TYPE` options
(LOCAL, LAMBDA, DISABLED), security notice, and recommended configs.
> - **Config/env:** Default
`IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS` set to `true`
(examples/tests default `false`); sample envs updated.
> - **Tests:** Add integration tests and GraphQL helpers for creating,
updating, publishing, executing, and deleting serverless functions,
including external package usage and error paths.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
1a2958cc19. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
This commit is contained in:
Félix Malfait 2026-01-16 15:54:44 +01:00 committed by GitHub
parent 9620961f16
commit 6a709d9c50
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 677 additions and 22 deletions

View file

@ -288,3 +288,44 @@ yarn command:prod cron:workflow:automated-cron-trigger
<Warning>
**Environment-only mode:** If you set `IS_CONFIG_VARIABLES_IN_DB_ENABLED=false`, add these variables to your `.env` file instead.
</Warning>
## Serverless Functions
Twenty supports serverless functions for workflows and custom logic. The execution environment is configured via the `SERVERLESS_TYPE` environment variable.
<Warning>
**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`.
</Warning>
### 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
```
<Note>
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.
</Note>

View file

@ -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

View file

@ -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

View file

@ -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<void> {
// No-op when disabled
}
async execute(): Promise<ServerlessExecuteResult> {
throw new ServerlessFunctionException(
'Serverless function execution is disabled. Set SERVERLESS_TYPE to LOCAL or LAMBDA to enable.',
ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_DISABLED,
);
}
}

View file

@ -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'],
});

View file

@ -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);
}),
);
};

View file

@ -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,

View file

@ -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;

View file

@ -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 || [],
};

View file

@ -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,

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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<object> => {
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<object> => {
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<string, Array<{ category: string; name: string }>>;
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();
});
});

View file

@ -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,
},
});

View file

@ -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 };
};

View file

@ -0,0 +1,37 @@
import gql from 'graphql-tag';
export type ExecuteServerlessFunctionFactoryInput = {
id: string;
payload: Record<string, unknown>;
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',
},
},
});

View file

@ -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 };
};

View file

@ -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,
},
});

View file

@ -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 };
};

View file

@ -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,
},
});

View file

@ -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 };
};