mirror of
https://github.com/twentyhq/twenty
synced 2026-04-21 13:37:22 +00:00
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:
parent
9620961f16
commit
6a709d9c50
21 changed files with 677 additions and 22 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 || [],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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 };
|
||||
};
|
||||
Loading…
Reference in a new issue