diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 138d61ce1c1..f353d5e0065 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1855,6 +1855,7 @@ export type Mutation = { syncApplication: Scalars['Boolean']; syncRemoteTable: RemoteTable; syncRemoteTableSchemaChanges: RemoteTable; + testHttpRequest: TestHttpRequestOutput; trackAnalytics: Analytics; unsyncRemoteTable: RemoteTable; updateApiKey?: Maybe; @@ -2556,6 +2557,11 @@ export type MutationSyncRemoteTableSchemaChangesArgs = { }; +export type MutationTestHttpRequestArgs = { + input: TestHttpRequestInput; +}; + + export type MutationTrackAnalyticsArgs = { event?: InputMaybe; name?: InputMaybe; @@ -3935,6 +3941,35 @@ export type SystemHealthService = { status: AdminPanelHealthServiceStatus; }; +export type TestHttpRequestInput = { + /** Request body */ + body?: InputMaybe; + /** HTTP headers */ + headers?: InputMaybe; + /** HTTP method */ + method: Scalars['String']; + /** URL to make the request to */ + url: Scalars['String']; +}; + +export type TestHttpRequestOutput = { + __typename?: 'TestHttpRequestOutput'; + /** Error information */ + error?: Maybe; + /** Response headers */ + headers?: Maybe; + /** Message describing the result */ + message: Scalars['String']; + /** Response data */ + result?: Maybe; + /** HTTP status code */ + status?: Maybe; + /** HTTP status text */ + statusText?: Maybe; + /** Whether the request was successful */ + success: Scalars['Boolean']; +}; + export type TimelineCalendarEvent = { __typename?: 'TimelineCalendarEvent'; conferenceLink: LinksMetadata; @@ -6210,6 +6245,13 @@ export type SubmitFormStepMutationVariables = Exact<{ export type SubmitFormStepMutation = { __typename?: 'Mutation', submitFormStep: boolean }; +export type TestHttpRequestMutationVariables = Exact<{ + input: TestHttpRequestInput; +}>; + + +export type TestHttpRequestMutation = { __typename?: 'Mutation', testHttpRequest: { __typename?: 'TestHttpRequestOutput', success: boolean, message: string, result?: any | null, error?: any | null, status?: number | null, statusText?: string | null, headers?: any | null } }; + export type UpdateWorkflowVersionPositionsMutationVariables = Exact<{ input: UpdateWorkflowVersionPositionsInput; }>; @@ -14052,6 +14094,45 @@ export function useSubmitFormStepMutation(baseOptions?: Apollo.MutationHookOptio export type SubmitFormStepMutationHookResult = ReturnType; export type SubmitFormStepMutationResult = Apollo.MutationResult; export type SubmitFormStepMutationOptions = Apollo.BaseMutationOptions; +export const TestHttpRequestDocument = gql` + mutation TestHttpRequest($input: TestHttpRequestInput!) { + testHttpRequest(input: $input) { + success + message + result + error + status + statusText + headers + } +} + `; +export type TestHttpRequestMutationFn = Apollo.MutationFunction; + +/** + * __useTestHttpRequestMutation__ + * + * To run a mutation, you first call `useTestHttpRequestMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useTestHttpRequestMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [testHttpRequestMutation, { data, loading, error }] = useTestHttpRequestMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useTestHttpRequestMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(TestHttpRequestDocument, options); + } +export type TestHttpRequestMutationHookResult = ReturnType; +export type TestHttpRequestMutationResult = Apollo.MutationResult; +export type TestHttpRequestMutationOptions = Apollo.BaseMutationOptions; export const UpdateWorkflowVersionPositionsDocument = gql` mutation UpdateWorkflowVersionPositions($input: UpdateWorkflowVersionPositionsInput!) { updateWorkflowVersionPositions(input: $input) diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 1164c0ba39a..707357bea3c 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -1808,6 +1808,7 @@ export type Mutation = { switchBillingPlan: BillingUpdateOutput; switchSubscriptionInterval: BillingUpdateOutput; syncApplication: Scalars['Boolean']; + testHttpRequest: TestHttpRequestOutput; trackAnalytics: Analytics; updateApiKey?: Maybe; updateCoreView: CoreView; @@ -2477,6 +2478,11 @@ export type MutationSyncApplicationArgs = { }; +export type MutationTestHttpRequestArgs = { + input: TestHttpRequestInput; +}; + + export type MutationTrackAnalyticsArgs = { event?: InputMaybe; name?: InputMaybe; @@ -3781,6 +3787,35 @@ export type SystemHealthService = { status: AdminPanelHealthServiceStatus; }; +export type TestHttpRequestInput = { + /** Request body */ + body?: InputMaybe; + /** HTTP headers */ + headers?: InputMaybe; + /** HTTP method */ + method: Scalars['String']; + /** URL to make the request to */ + url: Scalars['String']; +}; + +export type TestHttpRequestOutput = { + __typename?: 'TestHttpRequestOutput'; + /** Error information */ + error?: Maybe; + /** Response headers */ + headers?: Maybe; + /** Message describing the result */ + message: Scalars['String']; + /** Response data */ + result?: Maybe; + /** HTTP status code */ + status?: Maybe; + /** HTTP status text */ + statusText?: Maybe; + /** Whether the request was successful */ + success: Scalars['Boolean']; +}; + export type TimelineCalendarEvent = { __typename?: 'TimelineCalendarEvent'; conferenceLink: LinksMetadata; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/graphql/mutations/testHttpRequest.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/graphql/mutations/testHttpRequest.ts new file mode 100644 index 00000000000..df252202520 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/graphql/mutations/testHttpRequest.ts @@ -0,0 +1,15 @@ +import { gql } from '@apollo/client'; + +export const TEST_HTTP_REQUEST = gql` + mutation TestHttpRequest($input: TestHttpRequestInput!) { + testHttpRequest(input: $input) { + success + message + result + error + status + statusText + headers + } + } +`; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/hooks/__tests__/useTestHttpRequest.test.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/hooks/__tests__/useTestHttpRequest.test.ts index 299ee81ab1f..88abb2681f9 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/hooks/__tests__/useTestHttpRequest.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/hooks/__tests__/useTestHttpRequest.test.ts @@ -1,12 +1,25 @@ +import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; import { type HttpRequestFormData } from '@/workflow/workflow-steps/workflow-actions/http-request-action/constants/HttpRequest'; +import { useMutation } from '@apollo/client'; import { act, renderHook } from '@testing-library/react'; import React from 'react'; import { RecoilRoot } from 'recoil'; import { resolveInput } from 'twenty-shared/utils'; import { useTestHttpRequest } from '../useTestHttpRequest'; +// Mock Apollo Client +jest.mock('@apollo/client', () => ({ + ...jest.requireActual('@apollo/client'), + useMutation: jest.fn(), +})); + +jest.mock('@/object-metadata/hooks/useApolloCoreClient', () => ({ + useApolloCoreClient: jest.fn(), +})); + // Mock the resolveInput function jest.mock('twenty-shared/utils', () => ({ + ...jest.requireActual('twenty-shared/utils'), resolveInput: jest.fn((input, context) => { // For testing purposes, we'll actually do the replacement for simple cases if (typeof input === 'string') { @@ -57,13 +70,10 @@ jest.mock('twenty-shared/utils', () => ({ }), })); -// Mock fetch -global.fetch = jest.fn(); - -const mockFetch = fetch as jest.MockedFunction; - describe('useTestHttpRequest', () => { const actionId = 'test-action-id'; + const mockApolloClient = {}; + const mockMutate = jest.fn(); const mockFormData: HttpRequestFormData = { url: 'https://api.example.com/users', @@ -81,6 +91,8 @@ describe('useTestHttpRequest', () => { beforeEach(() => { jest.clearAllMocks(); + (useApolloCoreClient as jest.Mock).mockReturnValue(mockApolloClient); + (useMutation as jest.Mock).mockReturnValue([mockMutate]); }); it('should initialize with correct default values', () => { @@ -93,15 +105,18 @@ describe('useTestHttpRequest', () => { expect(result.current.httpRequestTestData).toBeDefined(); }); - it('should handle successful GET request', async () => { - const mockResponse = { - status: 200, - statusText: 'OK', - json: jest.fn().mockResolvedValue({ id: 1, name: 'John' }), - headers: new Map([['content-type', 'application/json']]), - }; - - mockFetch.mockResolvedValueOnce(mockResponse as any); + it('should handle successful GET request with JSON response', async () => { + mockMutate.mockResolvedValueOnce({ + data: { + testHttpRequest: { + success: true, + message: + 'HTTP GET request to https://api.example.com/users completed successfully', + result: { id: 1, name: 'John' }, + error: null, + }, + }, + }); const { result } = renderHook(() => useTestHttpRequest(actionId), { wrapper, @@ -111,15 +126,16 @@ describe('useTestHttpRequest', () => { await result.current.testHttpRequest(mockFormData, mockVariableValues); }); - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.example.com/users', - expect.objectContaining({ - method: 'GET', - headers: expect.objectContaining({ - Authorization: 'Bearer test-token-123', - }), - }), - ); + expect(mockMutate).toHaveBeenCalledWith({ + variables: { + input: { + url: 'https://api.example.com/users', + method: 'GET', + headers: { Authorization: 'Bearer test-token-123' }, + body: undefined, + }, + }, + }); expect(result.current.isTesting).toBe(false); expect(result.current.httpRequestTestData.output?.status).toBe(200); @@ -127,23 +143,26 @@ describe('useTestHttpRequest', () => { '{\n "id": 1,\n "name": "John"\n}', ); expect(result.current.httpRequestTestData.output?.error).toBeUndefined(); + expect(result.current.httpRequestTestData.language).toBe('json'); }); - it('should handle POST request with body', async () => { + it('should handle successful POST request with body', async () => { const postFormData: HttpRequestFormData = { ...mockFormData, method: 'POST', body: { name: 'Jane', email: 'jane@example.com' }, }; - const mockResponse = { - status: 201, - statusText: 'Created', - json: jest.fn().mockResolvedValue({ id: 2, name: 'Jane' }), - headers: new Map([['content-type', 'application/json']]), - }; - - mockFetch.mockResolvedValueOnce(mockResponse as any); + mockMutate.mockResolvedValueOnce({ + data: { + testHttpRequest: { + success: true, + message: 'HTTP POST request completed successfully', + result: { id: 2, name: 'Jane', email: 'jane@example.com' }, + error: null, + }, + }, + }); const { result } = renderHook(() => useTestHttpRequest(actionId), { wrapper, @@ -153,26 +172,32 @@ describe('useTestHttpRequest', () => { await result.current.testHttpRequest(postFormData, mockVariableValues); }); - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.example.com/users', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ name: 'Jane', email: 'jane@example.com' }), - }), - ); + expect(mockMutate).toHaveBeenCalledWith({ + variables: { + input: { + url: 'https://api.example.com/users', + method: 'POST', + headers: { Authorization: 'Bearer test-token-123' }, + body: { name: 'Jane', email: 'jane@example.com' }, + }, + }, + }); - expect(result.current.httpRequestTestData.output?.status).toBe(201); + expect(result.current.httpRequestTestData.output?.status).toBe(200); + expect(result.current.httpRequestTestData.language).toBe('json'); }); - it('should handle non-JSON responses', async () => { - const mockResponse = { - status: 200, - statusText: 'OK', - text: jest.fn().mockResolvedValue('Plain text response'), - headers: new Map([['content-type', 'text/plain']]), - }; - - mockFetch.mockResolvedValueOnce(mockResponse as any); + it('should handle string response from backend', async () => { + mockMutate.mockResolvedValueOnce({ + data: { + testHttpRequest: { + success: true, + message: 'HTTP GET request completed successfully', + result: 'Plain text response', + error: null, + }, + }, + }); const { result } = renderHook(() => useTestHttpRequest(actionId), { wrapper, @@ -188,9 +213,18 @@ describe('useTestHttpRequest', () => { expect(result.current.httpRequestTestData.language).toBe('plaintext'); }); - it('should handle request errors', async () => { - const errorMessage = 'Network error'; - mockFetch.mockRejectedValueOnce(new Error(errorMessage)); + it('should handle backend errors (success=false)', async () => { + const errorMessage = 'HTTP request failed'; + mockMutate.mockResolvedValueOnce({ + data: { + testHttpRequest: { + success: false, + message: 'HTTP GET request to https://api.example.com/users failed', + result: null, + error: errorMessage, + }, + }, + }); const { result } = renderHook(() => useTestHttpRequest(actionId), { wrapper, @@ -206,6 +240,43 @@ describe('useTestHttpRequest', () => { expect(result.current.httpRequestTestData.language).toBe('plaintext'); }); + it('should handle GraphQL mutation errors', async () => { + const errorMessage = 'Network error'; + mockMutate.mockRejectedValueOnce(new Error(errorMessage)); + + const { result } = renderHook(() => useTestHttpRequest(actionId), { + wrapper, + }); + + await act(async () => { + await result.current.testHttpRequest(mockFormData, mockVariableValues); + }); + + expect(result.current.isTesting).toBe(false); + expect(result.current.httpRequestTestData.output?.error).toBe(errorMessage); + expect(result.current.httpRequestTestData.output?.status).toBeUndefined(); + expect(result.current.httpRequestTestData.language).toBe('plaintext'); + }); + + it('should handle missing response data', async () => { + mockMutate.mockResolvedValueOnce({ + data: null, + }); + + const { result } = renderHook(() => useTestHttpRequest(actionId), { + wrapper, + }); + + await act(async () => { + await result.current.testHttpRequest(mockFormData, mockVariableValues); + }); + + expect(result.current.isTesting).toBe(false); + expect(result.current.httpRequestTestData.output?.error).toBe( + 'No response from server', + ); + }); + it('should set isTesting to true during request', async () => { // Create a promise that we can control let resolvePromise: (value: any) => void; @@ -213,7 +284,7 @@ describe('useTestHttpRequest', () => { resolvePromise = resolve; }); - mockFetch.mockReturnValueOnce(mockPromise as any); + mockMutate.mockReturnValueOnce(mockPromise); const { result } = renderHook(() => useTestHttpRequest(actionId), { wrapper, @@ -230,10 +301,14 @@ describe('useTestHttpRequest', () => { // Complete the request await act(async () => { resolvePromise!({ - status: 200, - statusText: 'OK', - json: jest.fn().mockResolvedValue({}), - headers: new Map([['content-type', 'application/json']]), + data: { + testHttpRequest: { + success: true, + message: 'Success', + result: {}, + error: null, + }, + }, }); await mockPromise; }); @@ -255,14 +330,16 @@ describe('useTestHttpRequest', () => { 'trigger.properties.after.name': 'Yo', }; - const mockResponse = { - status: 201, - statusText: 'Created', - json: jest.fn().mockResolvedValue({ success: true }), - headers: new Map([['content-type', 'application/json']]), - }; - - mockFetch.mockResolvedValueOnce(mockResponse as any); + mockMutate.mockResolvedValueOnce({ + data: { + testHttpRequest: { + success: true, + message: 'Success', + result: { success: true }, + error: null, + }, + }, + }); const { result } = renderHook(() => useTestHttpRequest(actionId), { wrapper, @@ -295,4 +372,61 @@ describe('useTestHttpRequest', () => { }, ); }); + + it('should handle non-string error responses', async () => { + mockMutate.mockResolvedValueOnce({ + data: { + testHttpRequest: { + success: false, + message: 'Request failed', + result: null, + error: { + code: 'ERR_CONNECTION_REFUSED', + details: 'Connection refused', + }, + }, + }, + }); + + const { result } = renderHook(() => useTestHttpRequest(actionId), { + wrapper, + }); + + await act(async () => { + await result.current.testHttpRequest(mockFormData, mockVariableValues); + }); + + expect(result.current.httpRequestTestData.output?.error).toBe( + '{"code":"ERR_CONNECTION_REFUSED","details":"Connection refused"}', + ); + }); + + it('should track request duration', async () => { + mockMutate.mockResolvedValueOnce({ + data: { + testHttpRequest: { + success: true, + message: 'Success', + result: { id: 1 }, + error: null, + }, + }, + }); + + const { result } = renderHook(() => useTestHttpRequest(actionId), { + wrapper, + }); + + await act(async () => { + await result.current.testHttpRequest(mockFormData, mockVariableValues); + }); + + expect(result.current.httpRequestTestData.output?.duration).toBeDefined(); + expect(typeof result.current.httpRequestTestData.output?.duration).toBe( + 'number', + ); + expect( + result.current.httpRequestTestData.output?.duration, + ).toBeGreaterThanOrEqual(0); + }); }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/hooks/useTestHttpRequest.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/hooks/useTestHttpRequest.ts index d67f03257e8..68fca289265 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/hooks/useTestHttpRequest.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/hooks/useTestHttpRequest.ts @@ -1,12 +1,20 @@ -import { type HttpRequestFormData } from '@/workflow/workflow-steps/workflow-actions/http-request-action/constants/HttpRequest'; +import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; +import { + type HttpRequestBody, + type HttpRequestFormData, +} from '@/workflow/workflow-steps/workflow-actions/http-request-action/constants/HttpRequest'; +import { TEST_HTTP_REQUEST } from '@/workflow/workflow-steps/workflow-actions/http-request-action/graphql/mutations/testHttpRequest'; import { httpRequestTestDataFamilyState } from '@/workflow/workflow-steps/workflow-actions/http-request-action/states/httpRequestTestDataFamilyState'; -import { type HttpRequestTestData } from '@/workflow/workflow-steps/workflow-actions/http-request-action/types/HttpRequestTestData'; -import { isMethodWithBody } from '@/workflow/workflow-steps/workflow-actions/http-request-action/utils/isMethodWithBody'; +import { useMutation } from '@apollo/client'; +import { isObject, isString } from '@sniptt/guards'; import { useState } from 'react'; import { useRecoilState } from 'recoil'; -import { resolveInput } from 'twenty-shared/utils'; -import { parseDataFromContentType } from 'twenty-shared/workflow'; -import { type HttpRequestBody } from '../constants/HttpRequest'; +import { isDefined, resolveInput } from 'twenty-shared/utils'; +import { + type TestHttpRequestInput, + type TestHttpRequestMutation, + type TestHttpRequestMutationVariables, +} from '~/generated-metadata/graphql'; const convertFlatVariablesToNestedContext = (flatVariables: { [variablePath: string]: any; @@ -32,59 +40,18 @@ const convertFlatVariablesToNestedContext = (flatVariables: { }; export const useTestHttpRequest = (actionId: string) => { + const apolloCoreClient = useApolloCoreClient(); const [isTesting, setIsTesting] = useState(false); const [httpRequestTestData, setHttpRequestTestData] = useRecoilState( httpRequestTestDataFamilyState(actionId), ); - const callFetchRequest = async ( - url: string, - headers: Record, - method: string, - body?: HttpRequestBody | string, - ): Promise => { - const requestOptions: RequestInit = { - method, - headers: headers, - }; - - if (isMethodWithBody(method) && body !== undefined) { - const contentType = headers['content-type']; - - requestOptions.body = parseDataFromContentType(body, contentType); - - if (contentType === 'multipart/form-data') { - const headersCopy = { ...headers }; - delete headersCopy['content-type']; - requestOptions.headers = { ...headersCopy }; - } - } - - const response = await fetch(url as string, requestOptions); - - let responseData: string; - const contentType = response.headers.get('content-type'); - - if (contentType !== null && contentType.includes('application/json')) { - const jsonData = await response.json(); - responseData = JSON.stringify(jsonData, null, 2); - } else { - responseData = await response.text(); - } - - const responseHeaders: Record = {}; - response.headers.forEach((value, key) => { - responseHeaders[key] = value; - }); - - return { - data: responseData, - status: response.status, - statusText: response.statusText, - headers: responseHeaders, - error: undefined, - }; - }; + const [mutate] = useMutation< + TestHttpRequestMutation, + TestHttpRequestMutationVariables + >(TEST_HTTP_REQUEST, { + client: apolloCoreClient, + }); const testHttpRequest = async ( httpRequestFormData: HttpRequestFormData, variableValues: { [variablePath: string]: any }, @@ -108,32 +75,54 @@ export const useTestHttpRequest = (actionId: string) => { ); const substitutedBody: HttpRequestBody | string | undefined = - typeof substitutedBodyRaw === 'string' || - (typeof substitutedBodyRaw === 'object' && substitutedBodyRaw !== null) + isString(substitutedBodyRaw) || + (isObject(substitutedBodyRaw) && isDefined(substitutedBodyRaw)) ? substitutedBodyRaw : undefined; - const output = await callFetchRequest( - substitutedUrl as string, - substitutedHeaders as Record, - httpRequestFormData.method, - substitutedBody, - ); - - const contentType = output?.headers?.['content-type']; - const language = contentType?.includes('application/json') - ? 'json' - : 'plaintext'; - const duration = Date.now() - startTime; - const outputWithDuration = { - ...output, - duration, + const input: TestHttpRequestInput = { + url: substitutedUrl as string, + method: httpRequestFormData.method, + headers: substitutedHeaders as Record, + body: substitutedBody, }; - setHttpRequestTestData((prev) => ({ - ...prev, - output: outputWithDuration, - language, - })); + + const result = await mutate({ + variables: { input }, + }); + + const duration = Date.now() - startTime; + const response = result?.data?.testHttpRequest; + + if (!response) { + throw new Error('No response from server'); + } + + if (response.success === true) { + const resultData = isString(response.result) + ? response.result + : JSON.stringify(response.result, null, 2); + const language = isObject(response.result) ? 'json' : 'plaintext'; + + setHttpRequestTestData((prev) => ({ + ...prev, + output: { + data: resultData, + status: response.status ?? 200, + statusText: response.statusText ?? 'OK', + headers: response.headers ?? {}, + duration, + error: undefined, + }, + language, + })); + } else { + throw new Error( + isString(response.error) + ? response.error + : JSON.stringify(response.error), + ); + } } catch (error) { const duration = Date.now() - startTime; const errorMessage = diff --git a/packages/twenty-server/.env.example b/packages/twenty-server/.env.example index 90277d60875..cd376f8874f 100644 --- a/packages/twenty-server/.env.example +++ b/packages/twenty-server/.env.example @@ -80,3 +80,4 @@ FRONTEND_URL=http://localhost:3001 # IS_CONFIG_VARIABLES_IN_DB_ENABLED=false # ANALYTICS_ENABLED= # CLICKHOUSE_URL=http://default:clickhousePassword@localhost:8123/twenty +# HTTP_TOOL_SAFE_MODE_ENABLED=true diff --git a/packages/twenty-server/src/engine/core-modules/ai/ai.module.ts b/packages/twenty-server/src/engine/core-modules/ai/ai.module.ts index ef197fbef44..86968964d3a 100644 --- a/packages/twenty-server/src/engine/core-modules/ai/ai.module.ts +++ b/packages/twenty-server/src/engine/core-modules/ai/ai.module.ts @@ -14,9 +14,8 @@ import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature- import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { FileModule } from 'src/engine/core-modules/file/file.module'; import { RecordCrudModule } from 'src/engine/core-modules/record-crud/record-crud.module'; -import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service'; +import { ToolModule } from 'src/engine/core-modules/tool/tool.module'; import { SearchArticlesTool } from 'src/engine/core-modules/tool/tools/search-articles-tool/search-articles-tool'; -import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool'; import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; @@ -30,8 +29,8 @@ import { MessagingModule } from 'src/modules/messaging/messaging.module'; @Module({ imports: [ TypeOrmModule.forFeature([RoleEntity, FileEntity]), - TokenModule, FileModule, + TokenModule, FeatureFlagModule, RecordCrudModule, ObjectMetadataModule, @@ -41,6 +40,7 @@ import { MessagingModule } from 'src/modules/messaging/messaging.module'; TwentyORMModule, MessagingModule, PermissionsModule, + ToolModule, ], controllers: [AiController, McpController], providers: [ @@ -48,10 +48,8 @@ import { MessagingModule } from 'src/modules/messaging/messaging.module'; AiModelRegistryService, ToolService, ToolAdapterService, - ToolRegistryService, AIBillingService, McpService, - SendEmailTool, SearchArticlesTool, ], exports: [ @@ -60,9 +58,7 @@ import { MessagingModule } from 'src/modules/messaging/messaging.module'; AIBillingService, ToolService, ToolAdapterService, - ToolRegistryService, McpService, - SendEmailTool, SearchArticlesTool, ], }) diff --git a/packages/twenty-server/src/engine/core-modules/tool/services/tool-registry.service.ts b/packages/twenty-server/src/engine/core-modules/tool/services/tool-registry.service.ts index e7d0ee3e5c5..3db29c89ff1 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/services/tool-registry.service.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/services/tool-registry.service.ts @@ -5,15 +5,19 @@ import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool'; import { type SendEmailInput } from 'src/engine/core-modules/tool/tools/send-email-tool/types/send-email-input.type'; import { type Tool } from 'src/engine/core-modules/tool/types/tool.type'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants'; @Injectable() export class ToolRegistryService { private readonly toolFactories: Map Tool>; - constructor(private readonly sendEmailTool: SendEmailTool) { + constructor( + private readonly sendEmailTool: SendEmailTool, + private readonly twentyConfigService: TwentyConfigService, + ) { this.toolFactories = new Map Tool>([ - [ToolType.HTTP_REQUEST, () => new HttpTool()], + [ToolType.HTTP_REQUEST, () => new HttpTool(twentyConfigService)], [ ToolType.SEND_EMAIL, () => ({ diff --git a/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts b/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts new file mode 100644 index 00000000000..08b8922ea8b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; +import { FileModule } from 'src/engine/core-modules/file/file.module'; +import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service'; +import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool'; +import { SearchArticlesTool } from 'src/engine/core-modules/tool/tools/search-articles-tool/search-articles-tool'; +import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool'; +import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module'; + +@Module({ + imports: [ + MessagingImportManagerModule, + TypeOrmModule.forFeature([FileEntity]), + FileModule, + ], + providers: [HttpTool, SendEmailTool, SearchArticlesTool, ToolRegistryService], + exports: [ToolRegistryService], +}) +export class ToolModule {} diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/http-tool/http-tool.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/http-tool/http-tool.ts index e010a34b05f..82eb75fbcb5 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/http-tool/http-tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/http-tool/http-tool.ts @@ -9,12 +9,17 @@ import { type HttpRequestInput } from 'src/engine/core-modules/tool/tools/http-t import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type'; import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; import { type Tool } from 'src/engine/core-modules/tool/types/tool.type'; +import { getSecureAdapter } from 'src/engine/core-modules/tool/utils/get-secure-axios-adapter.util'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; + @Injectable() export class HttpTool implements Tool { description = 'Make an HTTP request to any URL with configurable method, headers, and body.'; inputSchema = HttpToolParametersZodSchema; + constructor(private readonly twentyConfigService: TwentyConfigService) {} + async execute(parameters: ToolInput): Promise { const { url, method, headers, body } = parameters as HttpRequestInput; const headersCopy = { ...headers }; @@ -36,12 +41,25 @@ export class HttpTool implements Tool { } } - const response = await axios(axiosConfig); + const isSafeModeEnabled = this.twentyConfigService.get( + 'HTTP_TOOL_SAFE_MODE_ENABLED', + ); + + const axiosClient = isSafeModeEnabled + ? axios.create({ + adapter: getSecureAdapter(), + }) + : axios.create(); + + const response = await axiosClient(axiosConfig); return { success: true, message: `HTTP ${method} request to ${url} completed successfully`, result: response.data, + status: response.status, + statusText: response.statusText, + headers: response.headers as Record, }; } catch (error) { if (axios.isAxiosError(error)) { diff --git a/packages/twenty-server/src/engine/core-modules/tool/types/tool-output.type.ts b/packages/twenty-server/src/engine/core-modules/tool/types/tool-output.type.ts index 7050c169aed..63da96d85dc 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/types/tool-output.type.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/types/tool-output.type.ts @@ -3,4 +3,7 @@ export type ToolOutput = { message: string; error?: string; result?: T; + status?: number; + statusText?: string; + headers?: Record; }; diff --git a/packages/twenty-server/src/engine/core-modules/tool/utils/get-secure-axios-adapter.util.ts b/packages/twenty-server/src/engine/core-modules/tool/utils/get-secure-axios-adapter.util.ts new file mode 100644 index 00000000000..1215559d767 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/utils/get-secure-axios-adapter.util.ts @@ -0,0 +1,35 @@ +import dns from 'dns/promises'; + +import axios, { + type AxiosAdapter, + type InternalAxiosRequestConfig, +} from 'axios'; + +import { isPrivateIp } from 'src/engine/core-modules/tool/utils/is-private-ip.util'; +const httpAdapter = axios.getAdapter('http'); + +export const getSecureAdapter = (): AxiosAdapter => { + return async (config: InternalAxiosRequestConfig) => { + if (!config.url) { + throw new Error('URL is required'); + } + + const url = new URL(config.url); + + if (!['http:', 'https:'].includes(url.protocol)) { + throw new Error('URL should use http/https protocol'); + } + + const { hostname } = url; + + const { address: resolvedIp } = await dns.lookup(hostname); + + if (isPrivateIp(resolvedIp)) { + throw new Error( + `Request to internal IP address ${resolvedIp} is not allowed.`, + ); + } + + return httpAdapter(config); + }; +}; diff --git a/packages/twenty-server/src/engine/core-modules/tool/utils/is-private-ip.util.ts b/packages/twenty-server/src/engine/core-modules/tool/utils/is-private-ip.util.ts new file mode 100644 index 00000000000..c1679e45079 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/utils/is-private-ip.util.ts @@ -0,0 +1,96 @@ +// Based on code from node-ip by indutny +// Licensed under MIT License +// https://github.com/indutny/node-ip + +const ipv6Regex = + /^(::)?(((\d{1,3}\.){3}(\d{1,3}){1})?([0-9a-f]){0,4}:{0,2}){1,8}(::)?$/i; + +const fromLong = (ipl: number) => { + return `${ipl >>> 24}.${(ipl >> 16) & 255}.${(ipl >> 8) & 255}.${ipl & 255}`; +}; + +const isLoopback = (addr: string) => { + if (!/\./.test(addr) && !/:/.test(addr)) { + addr = fromLong(Number(addr)); + } + + return ( + /^(::f{4}:)?127\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})/.test(addr) || + /^0177\./.test(addr) || + /^0x7f\./i.test(addr) || + /^fe80::1$/i.test(addr) || + /^::1$/.test(addr) || + /^::$/.test(addr) + ); +}; + +const normalizeToLong = (addr: string) => { + const parts = addr.split('.').map((part) => { + if (part.startsWith('0x') || part.startsWith('0X')) { + return parseInt(part, 16); + } else if (part.startsWith('0') && part !== '0' && /^[0-7]+$/.test(part)) { + return parseInt(part, 8); + } else if (/^[1-9]\d*$/.test(part) || part === '0') { + return parseInt(part, 10); + } else { + return NaN; + } + }); + + if (parts.some(isNaN)) return -1; + + let val = 0; + const n = parts.length; + + switch (n) { + case 1: + val = parts[0]; + break; + case 2: + if (parts[0] > 0xff || parts[1] > 0xffffff) return -1; + val = (parts[0] << 24) | (parts[1] & 0xffffff); + break; + case 3: + if (parts[0] > 0xff || parts[1] > 0xff || parts[2] > 0xffff) return -1; + val = (parts[0] << 24) | (parts[1] << 16) | (parts[2] & 0xffff); + break; + case 4: + if (parts.some((part) => part > 0xff)) return -1; + val = (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]; + break; + default: + return -1; + } + + return val >>> 0; +}; + +const isIpV6 = (hostname: string) => ipv6Regex.test(hostname); + +export const isPrivateIp = (addr: string) => { + if (isLoopback(addr)) { + return true; + } + + if (!isIpV6(addr)) { + const ipl = normalizeToLong(addr); + + if (ipl < 0) { + throw new Error('invalid ipv4 address'); + } + addr = fromLong(ipl); + } + + return ( + /^(::f{4}:)?10\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(addr) || + /^(::f{4}:)?192\.168\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(addr) || + /^(::f{4}:)?172\.(1[6-9]|2\d|30|31)\.([0-9]{1,3})\.([0-9]{1,3})$/i.test( + addr, + ) || + /^(::f{4}:)?169\.254\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(addr) || + /^f[cd][0-9a-f]{2}:/i.test(addr) || + /^fe80:/i.test(addr) || + /^::1$/.test(addr) || + /^::$/.test(addr) + ); +}; 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 55544c999ff..dba5393bc90 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 @@ -66,6 +66,15 @@ export class ConfigVariables { @IsOptional() IS_EMAIL_VERIFICATION_REQUIRED = false; + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.OTHER, + description: + 'Enable safe mode for HTTP requests (prevents private IPs and other security risks)', + type: ConfigVariableType.BOOLEAN, + }) + @IsOptional() + HTTP_TOOL_SAFE_MODE_ENABLED = true; + @ConfigVariablesMetadata({ group: ConfigVariablesGroup.TOKENS_DURATION, description: 'Duration for which the email verification token is valid', diff --git a/packages/twenty-server/src/engine/core-modules/workflow/dtos/test-http-request-input.dto.ts b/packages/twenty-server/src/engine/core-modules/workflow/dtos/test-http-request-input.dto.ts new file mode 100644 index 00000000000..3634736b6c9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workflow/dtos/test-http-request-input.dto.ts @@ -0,0 +1,32 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import graphqlTypeJson from 'graphql-type-json'; + +import { WorkflowHttpRequestActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/types/workflow-http-request-action-input.type'; + +@InputType() +export class TestHttpRequestInput { + @Field(() => String, { + description: 'URL to make the request to', + nullable: false, + }) + url: WorkflowHttpRequestActionInput['url']; + + @Field(() => String, { + description: 'HTTP method', + nullable: false, + }) + method: WorkflowHttpRequestActionInput['method']; + + @Field(() => graphqlTypeJson, { + description: 'HTTP headers', + nullable: true, + }) + headers?: WorkflowHttpRequestActionInput['headers']; + + @Field(() => graphqlTypeJson, { + description: 'Request body', + nullable: true, + }) + body?: WorkflowHttpRequestActionInput['body']; +} diff --git a/packages/twenty-server/src/engine/core-modules/workflow/dtos/test-http-request-output.dto.ts b/packages/twenty-server/src/engine/core-modules/workflow/dtos/test-http-request-output.dto.ts new file mode 100644 index 00000000000..0d42d7bbac8 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workflow/dtos/test-http-request-output.dto.ts @@ -0,0 +1,46 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import graphqlTypeJson from 'graphql-type-json'; + +@ObjectType() +export class TestHttpRequestOutput { + @Field(() => Boolean, { + description: 'Whether the request was successful', + }) + success: boolean; + + @Field(() => String, { + description: 'Message describing the result', + }) + message: string; + + @Field(() => graphqlTypeJson, { + description: 'Response data', + nullable: true, + }) + result?: object; + + @Field(() => graphqlTypeJson, { + description: 'Error information', + nullable: true, + }) + error?: string; + + @Field(() => Number, { + description: 'HTTP status code', + nullable: true, + }) + status?: number; + + @Field(() => String, { + description: 'HTTP status text', + nullable: true, + }) + statusText?: string; + + @Field(() => graphqlTypeJson, { + description: 'Response headers', + nullable: true, + }) + headers?: Record; +} diff --git a/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version-step.resolver.ts b/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version-step.resolver.ts index 749ea484f31..bf432998611 100644 --- a/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version-step.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version-step.resolver.ts @@ -5,10 +5,14 @@ import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/featu import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; +import { ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum'; +import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service'; import { CreateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto'; import { DeleteWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/delete-workflow-version-step-input.dto'; import { DuplicateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/duplicate-workflow-version-step-input.dto'; import { SubmitFormStepInput } from 'src/engine/core-modules/workflow/dtos/submit-form-step-input.dto'; +import { TestHttpRequestInput } from 'src/engine/core-modules/workflow/dtos/test-http-request-input.dto'; +import { TestHttpRequestOutput } from 'src/engine/core-modules/workflow/dtos/test-http-request-output.dto'; import { UpdateWorkflowRunStepInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-run-step-input.dto'; import { UpdateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-version-step-input.dto'; import { WorkflowActionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-action.dto'; @@ -43,6 +47,7 @@ export class WorkflowVersionStepResolver { private readonly workflowVersionStepWorkspaceService: WorkflowVersionStepWorkspaceService, private readonly workflowRunnerWorkspaceService: WorkflowRunnerWorkspaceService, private readonly workflowRunWorkspaceService: WorkflowRunWorkspaceService, + private readonly toolRegistryService: ToolRegistryService, private readonly featureFlagService: FeatureFlagService, ) {} @@ -142,4 +147,17 @@ export class WorkflowVersionStepResolver { }, ); } + + @Mutation(() => TestHttpRequestOutput) + async testHttpRequest( + @Args('input') + { url, method, headers, body }: TestHttpRequestInput, + ): Promise { + return this.toolRegistryService.getTool(ToolType.HTTP_REQUEST).execute({ + url, + method, + headers, + body, + }); + } } diff --git a/packages/twenty-server/src/engine/core-modules/workflow/workflow-api.module.ts b/packages/twenty-server/src/engine/core-modules/workflow/workflow-api.module.ts index 5452e4cca9b..c4498435625 100644 --- a/packages/twenty-server/src/engine/core-modules/workflow/workflow-api.module.ts +++ b/packages/twenty-server/src/engine/core-modules/workflow/workflow-api.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; +import { ToolModule } from 'src/engine/core-modules/tool/tool.module'; import { WorkflowTriggerController } from 'src/engine/core-modules/workflow/controllers/workflow-trigger.controller'; import { WorkflowBuilderResolver } from 'src/engine/core-modules/workflow/resolvers/workflow-builder.resolver'; import { WorkflowTriggerResolver } from 'src/engine/core-modules/workflow/resolvers/workflow-trigger.resolver'; @@ -25,6 +26,7 @@ import { WorkflowTriggerModule } from 'src/modules/workflow/workflow-trigger/wor WorkflowRunModule, WorkflowRunnerModule, PermissionsModule, + ToolModule, ], controllers: [WorkflowTriggerController], providers: [ diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-executor.module.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-executor.module.ts index 9604573e91c..9a90f350a6a 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-executor.module.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-executor.module.ts @@ -1,8 +1,8 @@ import { Module } from '@nestjs/common'; -import { AiModule } from 'src/engine/core-modules/ai/ai.module'; import { BillingModule } from 'src/engine/core-modules/billing/billing.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; +import { ToolModule } from 'src/engine/core-modules/tool/tool.module'; import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module'; import { WorkflowActionFactory } from 'src/modules/workflow/workflow-executor/factories/workflow-action.factory'; @@ -34,7 +34,7 @@ import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow EmptyActionModule, FeatureFlagModule, WorkflowRunQueueModule, - AiModule, + ToolModule, ], providers: [ WorkflowExecutorWorkspaceService, diff --git a/packages/twenty-server/test/integration/metadata/suites/agent/utils/agent-tool-test-utils.ts b/packages/twenty-server/test/integration/metadata/suites/agent/utils/agent-tool-test-utils.ts index b8074570b0d..c79bd7441c8 100644 --- a/packages/twenty-server/test/integration/metadata/suites/agent/utils/agent-tool-test-utils.ts +++ b/packages/twenty-server/test/integration/metadata/suites/agent/utils/agent-tool-test-utils.ts @@ -14,6 +14,7 @@ import { RecordInputTransformerService } from 'src/engine/core-modules/record-tr import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service'; import { SearchArticlesTool } from 'src/engine/core-modules/tool/tools/search-articles-tool/search-articles-tool'; import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { AgentHandoffExecutorService } from 'src/engine/metadata-modules/agent/agent-handoff-executor.service'; import { AgentHandoffService } from 'src/engine/metadata-modules/agent/agent-handoff.service'; import { AgentToolGeneratorService } from 'src/engine/metadata-modules/agent/agent-tool-generator.service'; @@ -201,6 +202,12 @@ export const createAgentToolTestModule = generateWorkflowTools: jest.fn().mockResolvedValue({}), }, }, + { + provide: TwentyConfigService, + useValue: { + get: jest.fn(), + }, + }, ], }).compile();