import FormData from 'form-data'; import type { IExecuteFunctions, INodeTypeBaseDescription, JsonObject, JsonValue, } from 'n8n-workflow'; import { HttpRequestV3 } from '../../V3/HttpRequestV3.node'; import { createErrorDetails } from '../../V3/utils/error-details'; import type { Mock } from 'vitest'; describe('HttpRequestV3', () => { let node: HttpRequestV3; let executeFunctions: IExecuteFunctions; const baseUrl = 'http://example.com'; const options = { redirect: '', batching: { batch: { batchSize: 1, batchInterval: 1 } }, proxy: '', timeout: '', allowUnauthoridCerts: '', queryParameterArrays: '', response: '', lowercaseHeaders: '', }; beforeEach(() => { const baseDescription: INodeTypeBaseDescription = { displayName: 'HTTP Request', name: 'httpRequest', description: 'Makes an HTTP request and returns the response data', group: [], }; node = new HttpRequestV3(baseDescription); executeFunctions = { getInputData: vi.fn(), getNodeParameter: vi.fn(), getNode: vi.fn(() => { return { type: 'n8n-nodes-base.httpRequest', typeVersion: 3, }; }), getCredentials: vi.fn(), helpers: { request: vi.fn(), requestOAuth1: vi.fn( async () => await Promise.resolve({ statusCode: 200, headers: { 'content-type': 'application/json' }, body: Buffer.from(JSON.stringify({ success: true })), }), ), requestOAuth2: vi.fn( async () => await Promise.resolve({ statusCode: 200, headers: { 'content-type': 'application/json' }, body: Buffer.from(JSON.stringify({ success: true })), }), ), requestWithAuthentication: vi.fn(), requestWithAuthenticationPaginated: vi.fn(), assertBinaryData: vi.fn(), getBinaryStream: vi.fn(), getBinaryMetadata: vi.fn(), binaryToString: vi.fn((buffer: Buffer) => { return buffer.toString(); }), prepareBinaryData: vi.fn(), }, getContext: vi.fn(), sendMessageToUI: vi.fn(), continueOnFail: vi.fn(), getMode: vi.fn(), } as unknown as IExecuteFunctions; }); it('should make a GET request', async () => { (executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]); (executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => { switch (paramName) { case 'method': return 'GET'; case 'url': return baseUrl; case 'authentication': return 'none'; case 'options': return options; default: return undefined; } }); const response = { headers: { 'content-type': 'application/json' }, body: Buffer.from(JSON.stringify({ success: true })), }; (executeFunctions.helpers.request as Mock).mockResolvedValue(response); const result = await node.execute.call(executeFunctions); expect(result).toEqual([[{ json: { success: true }, pairedItem: { item: 0 } }]]); }); it('should handle authentication', async () => { (executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]); (executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => { switch (paramName) { case 'method': return 'GET'; case 'url': return baseUrl; case 'authentication': return 'genericCredentialType'; case 'genericAuthType': return 'httpBasicAuth'; case 'options': return options; default: return undefined; } }); (executeFunctions.getCredentials as Mock).mockResolvedValue({ user: 'username', password: 'password', }); const response = { headers: { 'content-type': 'application/json' }, body: Buffer.from(JSON.stringify({ success: true })), }; (executeFunctions.helpers.request as Mock).mockResolvedValue(response); const result = await node.execute.call(executeFunctions); expect(result).toEqual([[{ json: { success: true }, pairedItem: { item: 0 } }]]); expect(executeFunctions.helpers.request).toHaveBeenCalledWith( expect.objectContaining({ auth: { user: 'username', pass: 'password', }, }), ); }); it('should pass multipart binary uploads as FormData', async () => { (executeFunctions.getNode as Mock).mockReturnValue({ type: 'n8n-nodes-base.httpRequest', typeVersion: 4.4, }); (executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]); (executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => { switch (paramName) { case 'method': return 'POST'; case 'url': return baseUrl; case 'authentication': return 'none'; case 'sendBody': return true; case 'contentType': return 'multipart-form-data'; case 'specifyBody': return 'keypair'; case 'bodyParameters.parameters': return [ { parameterType: 'formBinaryData', // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased name: 'file', value: '', inputDataFieldName: 'data0', }, ]; case 'options': return options; default: return undefined; } }); (executeFunctions.helpers.assertBinaryData as Mock).mockReturnValue({ data: Buffer.from('test file').toString('base64'), fileName: 'invoice.pdf', mimeType: 'application/pdf', }); const response = { headers: { 'content-type': 'application/json' }, body: Buffer.from(JSON.stringify({ success: true })), }; (executeFunctions.helpers.request as Mock).mockResolvedValue(response); await node.execute.call(executeFunctions); expect(executeFunctions.helpers.request).toHaveBeenCalledWith( expect.objectContaining({ formData: expect.any(FormData), }), ); }); it('should include a fallback filename for multipart binary uploads without fileName', async () => { (executeFunctions.getNode as Mock).mockReturnValue({ type: 'n8n-nodes-base.httpRequest', typeVersion: 4.4, }); (executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]); (executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => { switch (paramName) { case 'method': return 'POST'; case 'url': return baseUrl; case 'authentication': return 'none'; case 'sendBody': return true; case 'contentType': return 'multipart-form-data'; case 'specifyBody': return 'keypair'; case 'bodyParameters.parameters': return [ { parameterType: 'formBinaryData', // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased name: 'file', value: '', inputDataFieldName: 'data0', }, ]; case 'options': return options; default: return undefined; } }); (executeFunctions.helpers.assertBinaryData as Mock).mockReturnValue({ data: Buffer.from('test file').toString('base64'), mimeType: 'application/pdf', }); const response = { headers: { 'content-type': 'application/json' }, body: Buffer.from(JSON.stringify({ success: true })), }; (executeFunctions.helpers.request as Mock).mockResolvedValue(response); await node.execute.call(executeFunctions); const requestOptions = (executeFunctions.helpers.request as Mock).mock.calls[0][0]; const body = (requestOptions.formData as FormData).getBuffer().toString('utf8'); expect(body).toContain('filename="file"'); }); describe('Authentication Handling', () => { const authenticationTypes = [ { genericCredentialType: 'httpBasicAuth', credentials: { user: 'username', password: 'password' }, authField: 'auth', authValue: { user: 'username', pass: 'password' }, }, { genericCredentialType: 'httpBearerAuth', credentials: { token: 'bearerToken123' }, authField: 'headers', authValue: { Authorization: 'Bearer bearerToken123' }, }, { genericCredentialType: 'httpDigestAuth', credentials: { user: 'username', password: 'password' }, authField: 'auth', authValue: { user: 'username', pass: 'password', sendImmediately: false }, }, { genericCredentialType: 'httpHeaderAuth', credentials: { name: 'Authorization', value: 'Bearer token' }, authField: 'headers', authValue: { Authorization: 'Bearer token' }, }, { genericCredentialType: 'httpQueryAuth', credentials: { name: 'Token', value: 'secretToken' }, authField: 'qs', authValue: { Token: 'secretToken' }, }, { genericCredentialType: 'oAuth1Api', credentials: { oauth_token: 'token', oauth_token_secret: 'secret' }, authField: 'oauth', authValue: { oauth_token: 'token', oauth_token_secret: 'secret' }, }, { genericCredentialType: 'oAuth2Api', credentials: { access_token: 'accessToken' }, authField: 'auth', authValue: { bearer: 'accessToken' }, }, ]; it.each(authenticationTypes)( 'should handle $genericCredentialType authentication', async ({ genericCredentialType, credentials, authField, authValue }) => { (executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]); (executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => { switch (paramName) { case 'method': return 'GET'; case 'url': return baseUrl; case 'authentication': return 'genericCredentialType'; case 'genericAuthType': return genericCredentialType; case 'options': return options; default: return undefined; } }); (executeFunctions.getCredentials as Mock).mockResolvedValue(credentials); const response = { headers: { 'content-type': 'application/json' }, body: Buffer.from(JSON.stringify({ success: true })), }; (executeFunctions.helpers.request as Mock).mockResolvedValue(response); const result = await node.execute.call(executeFunctions); expect(result).toEqual([[{ json: { success: true }, pairedItem: { item: 0 } }]]); if (genericCredentialType === 'oAuth1Api') { expect(executeFunctions.helpers.requestOAuth1).toHaveBeenCalled(); } else if (genericCredentialType === 'oAuth2Api') { expect(executeFunctions.helpers.requestOAuth2).toHaveBeenCalled(); } else { expect(executeFunctions.helpers.request).toHaveBeenCalledWith( expect.objectContaining({ [authField]: expect.objectContaining(authValue), }), ); } }, ); }); describe('URL Parameter Validation', () => { it('should throw error when URL is undefined', async () => { (executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]); (executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => { switch (paramName) { case 'method': return 'GET'; case 'url': return undefined; case 'authentication': return 'none'; case 'options': return options; default: return undefined; } }); await expect(node.execute.call(executeFunctions)).rejects.toThrow( 'URL parameter must be a string, got undefined', ); }); it('should throw error when URL is null', async () => { (executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]); (executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => { switch (paramName) { case 'method': return 'GET'; case 'url': return null; case 'authentication': return 'none'; case 'options': return options; default: return undefined; } }); await expect(node.execute.call(executeFunctions)).rejects.toThrow( 'URL parameter must be a string, got null', ); }); it('should throw error when URL is a number', async () => { (executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]); (executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => { switch (paramName) { case 'method': return 'GET'; case 'url': return 42; case 'authentication': return 'none'; case 'options': return options; default: return undefined; } }); await expect(node.execute.call(executeFunctions)).rejects.toThrow( 'URL parameter must be a string, got number', ); }); it('should throw error when URL is only whitespace', async () => { (executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]); (executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => { switch (paramName) { case 'method': return 'GET'; case 'url': return ' '; case 'authentication': return 'none'; case 'options': return options; default: return undefined; } }); await expect(node.execute.call(executeFunctions)).rejects.toThrow( 'URL parameter cannot be empty', ); }); it('should trim whitespace from valid URL', async () => { (executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]); (executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => { switch (paramName) { case 'method': return 'GET'; case 'url': return ' http://example.com '; case 'authentication': return 'none'; case 'options': return options; default: return undefined; } }); const response = { headers: { 'content-type': 'application/json' }, body: Buffer.from(JSON.stringify({ success: true })), }; (executeFunctions.helpers.request as Mock).mockResolvedValue(response); const result = await node.execute.call(executeFunctions); expect(result).toEqual([[{ json: { success: true }, pairedItem: { item: 0 } }]]); expect(executeFunctions.helpers.request).toHaveBeenCalledTimes(1); const requestArgs = (executeFunctions.helpers.request as Mock).mock.calls[0][0]; expect(requestArgs.uri ?? requestArgs.url).toBe('http://example.com'); }); }); describe('JSON Parameter Validation', () => { it.each([ { field: 'body', params: { sendBody: true, specifyBody: 'json', jsonBody: '{"valid": true}', 'bodyParameters.parameters': [], }, }, { field: 'query', params: { sendQuery: true, specifyQuery: 'json', jsonQuery: '{"key": "value"}', 'queryParameters.parameters': [], }, }, { field: 'headers', params: { sendHeaders: true, specifyHeaders: 'json', jsonHeaders: '{"X-Custom": "header"}', 'headerParameters.parameters': [], }, }, ])('should accept valid JSON in $field parameter', async ({ params }) => { (executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]); (executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => { switch (paramName) { case 'method': return 'POST'; case 'url': return baseUrl; case 'authentication': return 'none'; case 'options': return options; default: return params[paramName as keyof typeof params] ?? undefined; } }); const response = { headers: { 'content-type': 'application/json' }, body: Buffer.from(JSON.stringify({ success: true })), }; (executeFunctions.helpers.request as Mock).mockResolvedValue(response); const result = await node.execute.call(executeFunctions); expect(result).toBeDefined(); }); it.each([ { field: 'body', fieldName: 'JSON Body', params: { sendBody: true, specifyBody: 'json', jsonBody: '{"invalid: json}', 'bodyParameters.parameters': [], }, }, { field: 'query', fieldName: 'JSON Query Parameters', params: { sendQuery: true, specifyQuery: 'json', jsonQuery: '{not valid}', 'queryParameters.parameters': [], }, }, { field: 'headers', fieldName: 'JSON Headers', params: { sendHeaders: true, specifyHeaders: 'json', jsonHeaders: 'not json at all', 'headerParameters.parameters': [], }, }, ])( 'should throw descriptive error for invalid JSON in $field parameter', async ({ fieldName, params }) => { (executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]); (executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => { switch (paramName) { case 'method': return 'POST'; case 'url': return baseUrl; case 'authentication': return 'none'; case 'options': return options; default: return params[paramName as keyof typeof params] ?? undefined; } }); const response = { headers: { 'content-type': 'application/json' }, body: Buffer.from(JSON.stringify({ success: true })), }; (executeFunctions.helpers.request as Mock).mockResolvedValue(response); await expect(node.execute.call(executeFunctions)).rejects.toThrow( `The value in the "${fieldName}" field is not valid JSON`, ); }, ); }); describe('Response parsing', () => { it('should return empty object for autodetect JSON response with empty body', async () => { (executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]); (executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => { switch (paramName) { case 'method': return 'GET'; case 'url': return baseUrl; case 'authentication': return 'none'; case 'options': return options; default: return undefined; } }); const response = { headers: { 'content-type': 'application/json', 'content-length': '0' }, body: Buffer.from(''), }; (executeFunctions.helpers.request as Mock).mockResolvedValue(response); const result = await node.execute.call(executeFunctions); expect(result).toEqual([[{ json: {}, pairedItem: { item: 0 } }]]); }); it('should return empty object for JSON response format with empty body', async () => { (executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]); (executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => { switch (paramName) { case 'method': return 'GET'; case 'url': return baseUrl; case 'authentication': return 'none'; case 'options': return { ...options, response: { response: { responseFormat: 'json', }, }, }; case 'options.response.response.responseFormat': return 'json'; default: return undefined; } }); const response = { headers: { 'content-type': 'application/json', 'content-length': '0' }, body: '', }; (executeFunctions.helpers.request as Mock).mockResolvedValue(response); const result = await node.execute.call(executeFunctions); expect(result).toEqual([[{ json: {}, pairedItem: { item: 0 } }]]); }); }); describe('Continued request errors', () => { beforeEach(() => { (executeFunctions.getNode as Mock).mockReturnValue({ typeVersion: 4.5, }); }); it('should return null details if response parsing fails', () => { const requestError: JsonObject = {}; Object.defineProperty(requestError, 'response', { get: () => { throw new Error('Unable to read response'); }, }); expect(createErrorDetails(executeFunctions.getNode(), requestError, 0)).toBeNull(); }); it('should use the body from a legacy request error', () => { const responseBody = { error: 'Bad Request' }; const requestError: JsonObject = { statusCode: 400, error: responseBody, response: { headers: { 'x-request-id': 'request-1' }, status: 400, }, }; expect(createErrorDetails(executeFunctions.getNode(), requestError, 0)).toMatchObject({ httpCode: '400', body: responseBody, context: { itemIndex: 0 }, }); }); const errorResponseBodies: Array<{ name: string; body: JsonValue | Buffer; expectedBody: JsonValue; }> = [ { name: 'an object response body', body: { error: 'Bad Request' }, expectedBody: { error: 'Bad Request' }, }, { name: 'a JSON response body encoded as text', body: '{"error":"Bad Request"}', expectedBody: { error: 'Bad Request' }, }, { name: 'a plain-text response body', body: 'The supplied value is invalid', expectedBody: 'The supplied value is invalid', }, { name: 'a Buffer-backed response body', body: Buffer.from('{"error":"Bad Request"}'), expectedBody: { error: 'Bad Request' }, }, { name: 'a null response body', body: null, expectedBody: null, }, { name: 'an array response body', body: ['Bad Request'], expectedBody: ['Bad Request'], }, { name: 'an invalid JSON response body', body: '{"error":"Bad Request"', expectedBody: '{"error":"Bad Request"', }, ]; it.each(errorResponseBodies)( 'should expose $name without changing the legacy error', async ({ body, expectedBody }) => { (executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]); (executeFunctions.continueOnFail as Mock).mockReturnValue(true); (executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => { switch (paramName) { case 'method': return 'GET'; case 'url': return baseUrl; case 'authentication': return 'none'; case 'options': return options; default: return undefined; } }); const requestError = { message: 'Request failed with status code 400', name: 'RequestError', status: 400, statusCode: 400, response: { headers: { 'x-request-id': 'request-1' }, data: body, }, }; (executeFunctions.helpers.request as Mock).mockRejectedValue(requestError); const result = await node.execute.call(executeFunctions); const expectedContext = { itemIndex: 0, ...(body !== null && !Buffer.isBuffer(body) && !Array.isArray(body) && typeof body === 'object' && { data: body, }), }; expect(result[0][0]).toMatchObject({ json: { error: requestError, details: { httpCode: '400', body: expectedBody, context: expectedContext, }, }, pairedItem: { item: 0 }, }); expect(Object.keys(result[0][0].json)).toEqual(['error', 'details']); }, ); it('should retain the matching response body when requests complete out of order', async () => { (executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }, { json: {} }]); (executeFunctions.continueOnFail as Mock).mockReturnValue(true); (executeFunctions.getNodeParameter as Mock).mockImplementation( (paramName: string, itemIndex: number) => { switch (paramName) { case 'method': return 'GET'; case 'url': return `${baseUrl}/${itemIndex}`; case 'authentication': return 'none'; case 'options': return options; default: return undefined; } }, ); (executeFunctions.helpers.request as Mock).mockImplementation( async (requestOptions: { uri: string }) => await new Promise((_, reject) => { const delay = requestOptions.uri.endsWith('/0') ? 10 : 0; setTimeout(() => { reject({ message: 'Request failed', statusCode: 400, response: { data: { request: requestOptions.uri } }, }); }, delay); }), ); const result = await node.execute.call(executeFunctions); expect(result[0][0].json.details).toMatchObject({ body: { request: `${baseUrl}/0` }, }); expect(result[0][1].json.details).toMatchObject({ body: { request: `${baseUrl}/1` }, }); }); it('should retain the matching sanitized request when requests complete out of order', async () => { (executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }, { json: {} }]); (executeFunctions.continueOnFail as Mock).mockReturnValue(false); (executeFunctions.getNodeParameter as Mock).mockImplementation( (paramName: string, itemIndex: number) => { switch (paramName) { case 'method': return 'GET'; case 'url': return `${baseUrl}/${itemIndex}`; case 'authentication': return 'none'; case 'options': return { ...options, batching: { batch: { batchSize: 1, batchInterval: 0 } }, }; default: return undefined; } }, ); const rejectRequests: Array<(error: Error) => void> = []; (executeFunctions.helpers.request as Mock).mockImplementation( async () => await new Promise((_, reject) => { rejectRequests.push(reject); }), ); const execution = node.execute.call(executeFunctions); const requestError = Object.assign(new Error('Request failed'), { statusCode: 400 }); rejectRequests[1](requestError); rejectRequests[0](requestError); await expect(execution).rejects.toMatchObject({ context: { itemIndex: 0, request: { uri: `${baseUrl}/0`, }, }, }); }); }); describe('Cross-Origin Redirects', () => { it('should pass sendCredentialsOnCrossOriginRedirect = true to the request by default for node versions < 4.4', async () => { (executeFunctions.getNode as Mock).mockReturnValue({ typeVersion: 4.3, }); (executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]); (executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => { switch (paramName) { case 'method': return 'GET'; case 'url': return baseUrl; case 'authentication': return 'genericCredentialType'; case 'genericAuthType': return 'httpBasicAuth'; case 'options': return options; default: return undefined; } }); (executeFunctions.getCredentials as Mock).mockResolvedValue({ user: 'username', password: 'password', }); const response = { headers: { 'content-type': 'application/json' }, body: Buffer.from(JSON.stringify({ success: true })), }; (executeFunctions.helpers.request as Mock).mockResolvedValue(response); const result = await node.execute.call(executeFunctions); expect(result).toEqual([[{ json: { success: true }, pairedItem: { item: 0 } }]]); expect(executeFunctions.helpers.request).toHaveBeenCalledWith( expect.objectContaining({ sendCredentialsOnCrossOriginRedirect: true, }), ); }); it('should pass sendCredentialsOnCrossOriginRedirect = false to the request by default for node versions >= 4.4', async () => { (executeFunctions.getNode as Mock).mockReturnValue({ typeVersion: 4.4, }); (executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]); (executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => { switch (paramName) { case 'method': return 'GET'; case 'url': return baseUrl; case 'authentication': return 'genericCredentialType'; case 'genericAuthType': return 'httpBasicAuth'; case 'options': return options; default: return undefined; } }); (executeFunctions.getCredentials as Mock).mockResolvedValue({ user: 'username', password: 'password', }); const response = { headers: { 'content-type': 'application/json' }, body: Buffer.from(JSON.stringify({ success: true })), }; (executeFunctions.helpers.request as Mock).mockResolvedValue(response); const result = await node.execute.call(executeFunctions); expect(result).toEqual([[{ json: { success: true }, pairedItem: { item: 0 } }]]); expect(executeFunctions.helpers.request).toHaveBeenCalledWith( expect.objectContaining({ sendCredentialsOnCrossOriginRedirect: false, }), ); }); it('should use the sendCredentialsOnCrossOriginRedirect parameter to the request if provided', async () => { (executeFunctions.getNode as Mock).mockReturnValue({ typeVersion: 4.4, }); (executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]); (executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => { switch (paramName) { case 'method': return 'GET'; case 'url': return baseUrl; case 'authentication': return 'genericCredentialType'; case 'genericAuthType': return 'httpBasicAuth'; case 'options': return { ...options, sendCredentialsOnCrossOriginRedirect: true }; default: return undefined; } }); (executeFunctions.getCredentials as Mock).mockResolvedValue({ user: 'username', password: 'password', }); const response = { headers: { 'content-type': 'application/json' }, body: Buffer.from(JSON.stringify({ success: true })), }; (executeFunctions.helpers.request as Mock).mockResolvedValue(response); const result = await node.execute.call(executeFunctions); expect(result).toEqual([[{ json: { success: true }, pairedItem: { item: 0 } }]]); expect(executeFunctions.helpers.request).toHaveBeenCalledWith( expect.objectContaining({ sendCredentialsOnCrossOriginRedirect: true, }), ); }); }); describe('Pagination parameter validation', () => { it('should keep valid pagination parameters and ignore invalid parameter names', async () => { const paginationTestOptions = { ...options, response: { response: { neverError: false, responseFormat: 'json', fullResponse: false, outputPropertyName: 'data', }, }, }; (executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]); (executeFunctions.getNodeParameter as Mock).mockImplementation( (paramName: string, _itemIndex: number, defaultValue: unknown) => { switch (paramName) { case 'method': return 'GET'; case 'url': return baseUrl; case 'authentication': return 'none'; case 'options': return paginationTestOptions; case 'options.pagination.pagination': return { paginationMode: 'updateAParameterInEachRequest', parameters: { parameters: [ { type: 'qs', // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased name: 'page', value: '1', }, { type: 'qs', name: 'constructor', value: 'ignored', }, ], }, paginationCompleteWhen: 'responseIsEmpty', statusCodesWhenComplete: '', completeExpression: '', limitPagesFetched: false, maxRequests: 10, requestInterval: 0, }; case 'options.response.response.responseFormat': return 'json'; default: return defaultValue; } }, ); (executeFunctions.helpers.requestWithAuthenticationPaginated as Mock).mockResolvedValue([ { headers: { 'content-type': 'application/json' }, body: { success: true }, statusCode: 200, }, ]); const result = await node.execute.call(executeFunctions); expect(result).toEqual([[{ json: { success: true }, pairedItem: { item: 0 } }]]); expect(executeFunctions.helpers.requestWithAuthenticationPaginated).toHaveBeenCalledTimes(1); const paginationData = (executeFunctions.helpers.requestWithAuthenticationPaginated as Mock) .mock.calls[0][2] as { request: { qs: Record; }; }; expect(paginationData.request.qs).toEqual({ page: '1' }); expect(Object.prototype.hasOwnProperty.call(paginationData.request.qs, 'constructor')).toBe( false, ); }); it('should reject invalid pagination parameter type values', async () => { const paginationTestOptions = { ...options, response: { response: { neverError: false, responseFormat: 'json', fullResponse: false, outputPropertyName: 'data', }, }, }; (executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]); (executeFunctions.helpers.requestWithAuthenticationPaginated as Mock).mockResolvedValue([ { headers: { 'content-type': 'application/json' }, body: { success: true }, statusCode: 200, }, ]); (executeFunctions.helpers.request as Mock).mockResolvedValue({ headers: { 'content-type': 'application/json' }, body: Buffer.from(JSON.stringify({ success: true })), }); (executeFunctions.getNodeParameter as Mock).mockImplementation( (paramName: string, _itemIndex: number, defaultValue: unknown) => { switch (paramName) { case 'method': return 'GET'; case 'url': return baseUrl; case 'authentication': return 'none'; case 'options': return paginationTestOptions; case 'options.pagination.pagination': return { paginationMode: 'updateAParameterInEachRequest', parameters: { parameters: [ { type: '__proto__' as unknown as 'qs', // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased name: 'page', value: '1', }, ], }, paginationCompleteWhen: 'responseIsEmpty', statusCodesWhenComplete: '', completeExpression: '', limitPagesFetched: false, maxRequests: 10, requestInterval: 0, }; default: return defaultValue; } }, ); await expect(node.execute.call(executeFunctions)).rejects.toThrow( 'Parameter type must be one of: body, headers, qs for parameter [1] in pagination settings', ); expect(executeFunctions.helpers.requestWithAuthenticationPaginated).not.toHaveBeenCalled(); }); it('should pass a redacted request snapshot to the paginated helper for a generic header credential', async () => { const headerName = 'x-owner-domain-secret'; const headerValue = 'super-secret-value'; const paginationTestOptions = { ...options, response: { response: { neverError: false, responseFormat: 'json', fullResponse: false, outputPropertyName: 'data', }, }, }; (executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]); (executeFunctions.getNodeParameter as Mock).mockImplementation( (paramName: string, _itemIndex: number, defaultValue: unknown) => { switch (paramName) { case 'method': return 'GET'; case 'url': return baseUrl; case 'authentication': return 'genericCredentialType'; case 'genericAuthType': return 'httpHeaderAuth'; case 'options': return paginationTestOptions; case 'options.pagination.pagination': return { paginationMode: 'responseContainsNextURL', nextURL: `={{ $request.headers['${headerName}'] }}`, paginationCompleteWhen: 'receiveSpecificStatusCodes', statusCodesWhenComplete: '200', completeExpression: '', limitPagesFetched: true, maxRequests: 1, requestInterval: 0, }; default: return defaultValue; } }, ); (executeFunctions.getCredentials as Mock).mockResolvedValue({ name: headerName, value: headerValue, }); (executeFunctions.helpers.requestWithAuthenticationPaginated as Mock).mockResolvedValue([ { headers: { 'content-type': 'application/json' }, body: { success: true }, statusCode: 200, }, ]); await node.execute.call(executeFunctions); expect(executeFunctions.helpers.requestWithAuthenticationPaginated).toHaveBeenCalledTimes(1); // The live request options (1st arg) keep the credential header so the // actual outgoing requests stay authenticated. const liveRequest = (executeFunctions.helpers.requestWithAuthenticationPaginated as Mock).mock .calls[0][0] as { headers: Record }; expect(liveRequest.headers[headerName]).toBe(headerValue); // The sanitized snapshot (6th arg) exposed to pagination expressions has // the credential-derived header redacted. const sanitizedRequest = (executeFunctions.helpers.requestWithAuthenticationPaginated as Mock) .mock.calls[0][5] as { headers: Record }; expect(sanitizedRequest.headers[headerName]).toBe('**hidden**'); }); }); describe('Parallel item scoping fixes', () => { // Build the nested options object the node reads via getNodeParameter('options', itemIndex) const makeOptions = (item: Record) => ({ batching: { batch: { batchSize: 1, batchInterval: 0 } }, redirect: '', proxy: '', timeout: '', allowUnauthorizedCerts: false, queryParameterArrays: '', lowercaseHeaders: true, response: { response: { responseFormat: item.responseFormat ?? 'autodetect', outputPropertyName: item.outputPropertyName ?? 'data', fullResponse: item.fullResponse ?? false, neverError: item.neverError ?? false, }, }, }); // Configures getInputData and getNodeParameter for multiple items. // Per-item values (url, responseFormat, outputPropertyName, fullResponse, neverError) // are stored in each item's json and returned based on itemIndex. const setupItems = (items: Array<{ json: Record }>) => { (executeFunctions.getInputData as Mock).mockReturnValue(items); (executeFunctions.getNodeParameter as Mock).mockImplementation( (paramName: string, itemIndex: number, fallback: unknown) => { const item = items[itemIndex]?.json ?? {}; switch (paramName) { case 'url': return item.url; case 'method': return 'GET'; case 'authentication': return 'none'; case 'options': return makeOptions(item); case 'options.response.response.responseFormat': return item.responseFormat ?? fallback; case 'options.response.response.fullResponse': return item.fullResponse ?? fallback; case 'options.response.response.neverError': return item.neverError ?? fallback; case 'options.response.response.outputPropertyName': return item.outputPropertyName ?? fallback; default: return fallback; } }, ); }; // Mimics prepareBinaryData: derives fileExtension from mimeType but does NOT set fileName. // This lets setFilename() derive the name from requestOptions.uri or responseFileName. const mimeToExt: Record = { 'image/png': 'png', 'text/plain': 'txt', 'application/pdf': 'pdf', }; beforeEach(() => { (executeFunctions.helpers.prepareBinaryData as Mock).mockImplementation( async (buffer: Buffer, _filePath?: string, mimeType?: string) => { const fileExtension = mimeType ? mimeToExt[mimeType] : undefined; const bin: Record = { data: buffer.toString('base64'), mimeType }; if (fileExtension) bin.fileExtension = fileExtension; return bin; }, ); }); it('should preserve a fileName already set by prepareBinaryData', async () => { setupItems([ { json: { url: 'http://example.com/download', responseFormat: 'file', outputPropertyName: 'data', }, }, ]); (executeFunctions.helpers.request as Mock).mockResolvedValue({ statusCode: 200, headers: { 'content-type': 'application/octet-stream' }, body: Buffer.from('data'), }); // Simulate prepareBinaryData parsing Content-Disposition and setting fileName directly (executeFunctions.helpers.prepareBinaryData as Mock).mockResolvedValueOnce({ data: 'ZGF0YQ==', mimeType: 'application/octet-stream', fileName: 'custom.txt', }); const result = await node.execute.call(executeFunctions); // setFilename returns preparedBinaryData.fileName unchanged when already set expect(result[0][0].binary?.data.fileName).toBe('custom.txt'); }); it('should derive filename from the original request URI when no Content-Disposition is present', async () => { setupItems([ { json: { url: 'http://example.com/file1.png', responseFormat: 'file', outputPropertyName: 'data', }, }, { json: { url: 'http://example.com/file2.png', responseFormat: 'file', outputPropertyName: 'data', }, }, ]); (executeFunctions.helpers.request as Mock).mockResolvedValue({ statusCode: 200, headers: { 'content-type': 'image/png' }, body: Buffer.from('img'), }); const result = await node.execute.call(executeFunctions); // setFilename: fileExtension='png', uri ends with 'png' → uri.split('/').pop() expect(result[0][0].binary?.data.fileName).toBe('file1.png'); expect(result[0][1].binary?.data.fileName).toBe('file2.png'); }); it('should use per-item original request URI as filename fallback', async () => { setupItems([ { json: { url: 'http://example.com/fileA.txt', responseFormat: 'file', outputPropertyName: 'data', }, }, { json: { url: 'http://example.com/fileB.txt', responseFormat: 'file', outputPropertyName: 'data', }, }, ]); // Both redirect to same URL; requests[itemIndex].options.uri stays as the original (executeFunctions.helpers.request as Mock).mockResolvedValue({ statusCode: 200, headers: { 'content-type': 'text/plain' }, body: Buffer.from('txt'), }); const result = await node.execute.call(executeFunctions); expect(result[0][0].binary?.data.fileName).toBe('fileA.txt'); expect(result[0][1].binary?.data.fileName).toBe('fileB.txt'); }); it('should not crash when an earlier item fails with continueOnFail (requests[] alignment)', async () => { // Item 0 has an invalid URL → fails in request-build loop → continueOnFail // Item 1 has a valid URL → must still be processed correctly // Without the requests[] placeholder fix, requests[1] === undefined → TypeError (executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }, { json: {} }]); (executeFunctions.continueOnFail as Mock).mockReturnValue(true); (executeFunctions.getNodeParameter as Mock).mockImplementation( (paramName: string, itemIndex: number, fallback: unknown) => { if (paramName === 'url') { // Item 0 → null URL (triggers NodeOperationError in build loop) // Item 1 → valid URL return itemIndex === 0 ? null : 'http://example.com/ok'; } if (paramName === 'method') return 'GET'; if (paramName === 'authentication') return 'none'; if (paramName === 'options') { return { batching: { batch: { batchSize: 1, batchInterval: 0 } }, redirect: '', proxy: '', timeout: '', allowUnauthorizedCerts: false, queryParameterArrays: '', lowercaseHeaders: true, response: { response: { responseFormat: 'json', outputPropertyName: 'data', fullResponse: false, neverError: false, }, }, }; } if (paramName === 'options.response.response.responseFormat') return 'json'; if (paramName === 'options.response.response.fullResponse') return false; if (paramName === 'options.response.response.neverError') return false; if (paramName === 'options.response.response.outputPropertyName') return 'data'; return fallback; }, ); (executeFunctions.helpers.request as Mock).mockResolvedValue({ statusCode: 200, headers: { 'content-type': 'application/json' }, body: { ok: true }, }); const result = await node.execute.call(executeFunctions); // Item 0 → error output (continueOnFail) expect(result[0][0].json.error).toBeDefined(); // Item 1 → valid response, no crash expect(result[0][1].json.ok).toBe(true); }); }); });