diff --git a/packages/nodes-base/nodes/Jenkins/GenericFunctions.ts b/packages/nodes-base/nodes/Jenkins/GenericFunctions.ts index e4b00b1303c..06813326b8a 100644 --- a/packages/nodes-base/nodes/Jenkins/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Jenkins/GenericFunctions.ts @@ -11,34 +11,110 @@ import { NodeApiError } from 'n8n-workflow'; import { removeTrailingSlash } from '@utils/utilities'; +export async function getJenkinsCrumb( + this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, + credentials: IDataObject, +): Promise { + const baseUrl = (credentials.baseUrl as string) || ''; + const options: IRequestOptions = { + headers: { + Accept: 'application/json', + }, + method: 'GET', + auth: { + username: credentials.username as string, + password: credentials.apiKey as string, + }, + sendCredentialsOnCrossOriginRedirect: false, + uri: `${removeTrailingSlash(baseUrl)}/crumbIssuer/api/json`, + json: true, + resolveWithFullResponse: true, + }; + try { + const response = (await this.helpers.request(options)) as { + body?: IDataObject; + headers?: Record; + }; + const body = (response?.body ?? response) as IDataObject; + if (body && body.crumbRequestField && body.crumb) { + const crumbHeaders: IDataObject = { + [body.crumbRequestField as string]: body.crumb as string, + }; + const setCookie = response.headers?.['set-cookie']; + if (setCookie) { + crumbHeaders.Cookie = (Array.isArray(setCookie) ? setCookie : [setCookie]) + .map((cookie) => cookie.split(';', 1)[0]) + .join('; '); + } + return crumbHeaders; + } + } catch { + // Crumb issuer is not enabled (e.g. 404) or unavailable; proceed without it + } + return null; +} + export async function jenkinsApiRequest( this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, method: IHttpRequestMethods, uri: string, qs: IDataObject = {}, - body: any = '', option: IDataObject = {}, ): Promise { const credentials = await this.getCredentials('jenkinsApi'); + const baseUrl = (credentials.baseUrl as string) || ''; + const headers: IDataObject = { + Accept: 'application/json', + }; + + if (['POST', 'PUT', 'DELETE'].includes(method)) { + const crumb = await getJenkinsCrumb.call(this, credentials); + if (crumb) { + Object.assign(headers, crumb); + } + } + + if (option.headers) { + Object.assign(headers, option.headers); + } + let options: IRequestOptions = { - headers: { - Accept: 'application/json', - }, + headers, method, auth: { username: credentials.username as string, password: credentials.apiKey as string, }, - uri: `${removeTrailingSlash(credentials.baseUrl as string)}${uri}`, + sendCredentialsOnCrossOriginRedirect: false, + uri: `${removeTrailingSlash(baseUrl)}${uri}`, json: true, qs, body, }; - options = Object.assign({}, options, option); + options = Object.assign({}, options, option, { headers }); try { return await this.helpers.request(options); } catch (error) { + const rawStatusCode = + error.statusCode ?? error.httpCode ?? error.status ?? error.response?.status; + const statusCode = rawStatusCode !== undefined ? Number(rawStatusCode) : undefined; + const responseHeaders = (error.response?.headers ?? error.headers ?? {}) as Record< + string, + string | undefined + >; + const location = (responseHeaders.location ?? responseHeaders.Location) as string | undefined; + + if ( + statusCode !== undefined && + [302, 303].includes(statusCode) && + typeof location === 'string' && + /\/queue\/item\/\d+/i.test(location) && + !location.includes('/login') + ) { + return { success: true, queueUrl: location }; + } + throw new NodeApiError(this.getNode(), error as JsonObject); } } diff --git a/packages/nodes-base/nodes/Jenkins/test/GenericFunctions.test.ts b/packages/nodes-base/nodes/Jenkins/test/GenericFunctions.test.ts index 37316f818b9..7b62753ac8d 100644 --- a/packages/nodes-base/nodes/Jenkins/test/GenericFunctions.test.ts +++ b/packages/nodes-base/nodes/Jenkins/test/GenericFunctions.test.ts @@ -1,7 +1,7 @@ import type { IExecuteFunctions } from 'n8n-workflow'; import { mockDeep } from 'vitest-mock-extended'; -import { jenkinsApiRequest } from '../GenericFunctions'; +import { getJenkinsCrumb, jenkinsApiRequest } from '../GenericFunctions'; describe('jenkinsApiRequest', () => { it('removes a trailing slash from the credential base URL', async () => { @@ -18,4 +18,390 @@ describe('jenkinsApiRequest', () => { expect.objectContaining({ uri: 'https://jenkins.example.com/api/json' }), ); }); + + it('handles missing base URL gracefully without throwing', async () => { + const executeFunctions = mockDeep(); + executeFunctions.getCredentials.mockResolvedValue({ + username: 'user', + apiKey: 'key', + }); + + await jenkinsApiRequest.call(executeFunctions, 'GET', '/api/json'); + + expect(executeFunctions.helpers.request).toHaveBeenCalledWith( + expect.objectContaining({ uri: '/api/json' }), + ); + }); + + it('fetches and attaches CSRF crumb and session cookie on POST requests when crumb issuer is available', async () => { + const executeFunctions = mockDeep(); + executeFunctions.getCredentials.mockResolvedValue({ + username: 'user', + apiKey: 'key', + baseUrl: 'https://jenkins.example.com', + }); + + executeFunctions.helpers.request.mockImplementation(async (options: any) => { + if (options.uri.endsWith('/crumbIssuer/api/json')) { + return { + body: { + crumbRequestField: 'Jenkins-Crumb', + crumb: 'test-crumb-value-123', + }, + headers: { + 'set-cookie': [ + 'JSESSIONID=test-session-cookie; Path=/; HttpOnly', + 'remember-me=test-token; Path=/; Max-Age=1209600', + ], + }, + }; + } + return { success: true }; + }); + + await jenkinsApiRequest.call(executeFunctions, 'POST', '/job/my-job/build'); + + expect(executeFunctions.helpers.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + uri: 'https://jenkins.example.com/job/my-job/build', + sendCredentialsOnCrossOriginRedirect: false, + headers: expect.objectContaining({ + 'Jenkins-Crumb': 'test-crumb-value-123', + Cookie: 'JSESSIONID=test-session-cookie; remember-me=test-token', + }), + }), + ); + }); + + it('preserves caller option headers alongside the CSRF crumb', async () => { + const executeFunctions = mockDeep(); + executeFunctions.getCredentials.mockResolvedValue({ + username: 'user', + apiKey: 'key', + baseUrl: 'https://jenkins.example.com', + }); + + executeFunctions.helpers.request.mockImplementation(async (options: any) => { + if (options.uri.endsWith('/crumbIssuer/api/json')) { + return { + body: { + crumbRequestField: 'Jenkins-Crumb', + crumb: 'test-crumb-value-123', + }, + }; + } + return { success: true }; + }); + + await jenkinsApiRequest.call( + executeFunctions, + 'POST', + '/job/my-job/buildWithParameters', + {}, + {}, + { + headers: { + 'content-type': 'application/x-www-form-urlencoded', + }, + }, + ); + + expect(executeFunctions.helpers.request).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + Accept: 'application/json', + 'Jenkins-Crumb': 'test-crumb-value-123', + 'content-type': 'application/x-www-form-urlencoded', + }), + }), + ); + }); + + it('fetches and attaches CSRF crumb on PUT and DELETE requests', async () => { + const executeFunctions = mockDeep(); + executeFunctions.getCredentials.mockResolvedValue({ + username: 'user', + apiKey: 'key', + baseUrl: 'https://jenkins.example.com', + }); + + executeFunctions.helpers.request.mockImplementation(async (options: any) => { + if (options.uri.endsWith('/crumbIssuer/api/json')) { + return { + crumbRequestField: 'Jenkins-Crumb', + crumb: 'test-crumb-value-123', + }; + } + return { success: true }; + }); + + await jenkinsApiRequest.call(executeFunctions, 'PUT', '/job/my-job/config.xml'); + expect(executeFunctions.helpers.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'PUT', + headers: expect.objectContaining({ + 'Jenkins-Crumb': 'test-crumb-value-123', + }), + }), + ); + + await jenkinsApiRequest.call(executeFunctions, 'DELETE', '/job/my-job'); + expect(executeFunctions.helpers.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'DELETE', + headers: expect.objectContaining({ + 'Jenkins-Crumb': 'test-crumb-value-123', + }), + }), + ); + }); + + it('proceeds without crumb on POST requests when crumb issuer returns 404', async () => { + const executeFunctions = mockDeep(); + executeFunctions.getCredentials.mockResolvedValue({ + username: 'user', + apiKey: 'key', + baseUrl: 'https://jenkins.example.com', + }); + + executeFunctions.helpers.request.mockImplementation(async (options: any) => { + if (options.uri.endsWith('/crumbIssuer/api/json')) { + throw new Error('404 Not Found'); + } + return { success: true }; + }); + + const result = await jenkinsApiRequest.call(executeFunctions, 'POST', '/job/my-job/build'); + + expect(result).toEqual({ success: true }); + expect(executeFunctions.helpers.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + uri: 'https://jenkins.example.com/job/my-job/build', + }), + ); + }); + + it('does not attempt to fetch CSRF crumb for GET requests', async () => { + const executeFunctions = mockDeep(); + executeFunctions.getCredentials.mockResolvedValue({ + username: 'user', + apiKey: 'key', + baseUrl: 'https://jenkins.example.com', + }); + + executeFunctions.helpers.request.mockResolvedValue({ status: 'ok' }); + + await jenkinsApiRequest.call(executeFunctions, 'GET', '/job/my-job/api/json'); + + expect(executeFunctions.helpers.request).toHaveBeenCalledTimes(1); + expect(executeFunctions.helpers.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + uri: 'https://jenkins.example.com/job/my-job/api/json', + }), + ); + }); + + it('treats 303 queue item redirect as successful build scheduling', async () => { + const executeFunctions = mockDeep(); + executeFunctions.getCredentials.mockResolvedValue({ + username: 'user', + apiKey: 'key', + baseUrl: 'https://jenkins.example.com', + }); + + executeFunctions.helpers.request.mockImplementation(async (options: any) => { + if (options.uri.endsWith('/crumbIssuer/api/json')) { + return null; + } + const error: any = new Error('303 - ""'); + error.statusCode = 303; + error.response = { + status: 303, + headers: { + location: 'https://jenkins.example.com/queue/item/42/', + }, + }; + throw error; + }); + + const result = await jenkinsApiRequest.call( + executeFunctions, + 'POST', + '/job/my-job/buildWithParameters', + ); + + expect(result).toEqual({ + success: true, + queueUrl: 'https://jenkins.example.com/queue/item/42/', + }); + }); + + it('treats 302 queue item redirect as successful build scheduling', async () => { + const executeFunctions = mockDeep(); + executeFunctions.getCredentials.mockResolvedValue({ + username: 'user', + apiKey: 'key', + baseUrl: 'https://jenkins.example.com', + }); + + executeFunctions.helpers.request.mockImplementation(async (options: any) => { + if (options.uri.endsWith('/crumbIssuer/api/json')) { + return null; + } + const error: any = new Error('302 - Found'); + error.statusCode = 302; + error.response = { + status: 302, + headers: { + location: 'https://jenkins.example.com/queue/item/99/', + }, + }; + throw error; + }); + + const result302 = await jenkinsApiRequest.call( + executeFunctions, + 'POST', + '/job/my-job/build', + ); + expect(result302).toEqual({ + success: true, + queueUrl: 'https://jenkins.example.com/queue/item/99/', + }); + }); + + it('returns resolved response when job trigger resolves successfully', async () => { + const executeFunctions = mockDeep(); + executeFunctions.getCredentials.mockResolvedValue({ + username: 'user', + apiKey: 'key', + baseUrl: 'https://jenkins.example.com', + }); + + executeFunctions.helpers.request.mockImplementation(async (options: any) => { + if (options.uri.endsWith('/crumbIssuer/api/json')) { + return null; + } + return { success: true }; + }); + + const result = await jenkinsApiRequest.call( + executeFunctions, + 'POST', + '/job/my-job/build', + ); + expect(result).toEqual({ success: true }); + }); + + it('handles string status codes in redirect responses', async () => { + const executeFunctions = mockDeep(); + executeFunctions.getCredentials.mockResolvedValue({ + username: 'user', + apiKey: 'key', + baseUrl: 'https://jenkins.example.com', + }); + + executeFunctions.helpers.request.mockImplementation(async (options: any) => { + if (options.uri.endsWith('/crumbIssuer/api/json')) { + return null; + } + const error: any = new Error('303 - ""'); + error.httpCode = '303'; + error.headers = { + location: 'https://jenkins.example.com/queue/item/77/', + }; + throw error; + }); + + const result = await jenkinsApiRequest.call( + executeFunctions, + 'POST', + '/job/my-job/build', + ); + expect(result).toEqual({ + success: true, + queueUrl: 'https://jenkins.example.com/queue/item/77/', + }); + }); + + it('rethrows NodeApiError when 303 redirects to login page instead of queue', async () => { + const executeFunctions = mockDeep(); + executeFunctions.getCredentials.mockResolvedValue({ + username: 'user', + apiKey: 'key', + baseUrl: 'https://jenkins.example.com', + }); + + executeFunctions.helpers.request.mockImplementation(async (options: any) => { + if (options.uri.endsWith('/crumbIssuer/api/json')) { + return null; + } + const error: any = new Error('303 - ""'); + error.statusCode = 303; + error.response = { + status: 303, + headers: { + location: 'https://jenkins.example.com/login?from=/job/my-job/build', + }, + }; + throw error; + }); + + await expect( + jenkinsApiRequest.call(executeFunctions, 'POST', '/job/my-job/buildWithParameters'), + ).rejects.toThrow(); + }); + + it('rethrows NodeApiError when redirect contains /login even if /queue/item/ is in query string', async () => { + const executeFunctions = mockDeep(); + executeFunctions.getCredentials.mockResolvedValue({ + username: 'user', + apiKey: 'key', + baseUrl: 'https://jenkins.example.com', + }); + + executeFunctions.helpers.request.mockImplementation(async (options: any) => { + if (options.uri.endsWith('/crumbIssuer/api/json')) { + return null; + } + const error: any = new Error('302 - Found'); + error.statusCode = 302; + error.response = { + status: 302, + headers: { + location: 'https://jenkins.example.com/login?from=/queue/item/42/', + }, + }; + throw error; + }); + + await expect( + jenkinsApiRequest.call(executeFunctions, 'POST', '/job/my-job/build'), + ).rejects.toThrow(); + }); + + it('rethrows NodeApiError when a non-redirect error occurs', async () => { + const executeFunctions = mockDeep(); + executeFunctions.getCredentials.mockResolvedValue({ + username: 'user', + apiKey: 'key', + baseUrl: 'https://jenkins.example.com', + }); + + executeFunctions.helpers.request.mockImplementation(async (options: any) => { + if (options.uri.endsWith('/crumbIssuer/api/json')) { + return null; + } + const error: any = new Error('500 - Internal Server Error'); + error.statusCode = 500; + throw error; + }); + + await expect( + jenkinsApiRequest.call(executeFunctions, 'POST', '/job/my-job/build'), + ).rejects.toThrow(); + }); }); diff --git a/packages/nodes-base/nodes/Jenkins/test/Jenkins.node.test.ts b/packages/nodes-base/nodes/Jenkins/test/Jenkins.node.test.ts index 526f122df0c..99394f992ef 100644 --- a/packages/nodes-base/nodes/Jenkins/test/Jenkins.node.test.ts +++ b/packages/nodes-base/nodes/Jenkins/test/Jenkins.node.test.ts @@ -1,5 +1,9 @@ import { mockDeep } from 'vitest-mock-extended'; -import type { ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow'; +import type { + IExecuteFunctions, + ILoadOptionsFunctions, + INodePropertyOptions, +} from 'n8n-workflow'; import * as GenericFunctions from '../GenericFunctions'; import { Jenkins } from '../Jenkins.node'; @@ -120,4 +124,61 @@ describe('Jenkins node', () => { ]); }); }); + + describe('execute', () => { + it('triggers a job with parameters successfully when jenkinsApiRequest returns queue redirect', async () => { + const executeFunctions = mockDeep(); + executeFunctions.getInputData.mockReturnValue([{ json: {} }]); + executeFunctions.helpers.returnJsonArray.mockImplementation((data: any) => + data.map((item: any) => ({ json: item })), + ); + executeFunctions.getNodeParameter.mockImplementation((name: string) => { + if (name === 'resource') return 'job'; + if (name === 'operation') return 'triggerParams'; + if (name === 'job') return 'demo-job'; + if (name === 'param.params') return [{ name: 'BRANCH', value: 'main' }]; + return undefined; + }); + jenkinsApiRequestSpy.mockResolvedValue({ + success: true, + queueUrl: 'https://jenkins.example.com/queue/item/42/', + }); + + const result = await node.execute.call(executeFunctions); + + expect(jenkinsApiRequestSpy).toHaveBeenCalledWith( + 'POST', + '/job/demo-job/buildWithParameters', + {}, + {}, + { + form: { BRANCH: 'main' }, + headers: { + 'content-type': 'application/x-www-form-urlencoded', + }, + }, + ); + expect(result).toEqual([[{ json: { success: true } }]]); + }); + + it('triggers a parameterless job successfully', async () => { + const executeFunctions = mockDeep(); + executeFunctions.getInputData.mockReturnValue([{ json: {} }]); + executeFunctions.helpers.returnJsonArray.mockImplementation((data: any) => + data.map((item: any) => ({ json: item })), + ); + executeFunctions.getNodeParameter.mockImplementation((name: string) => { + if (name === 'resource') return 'job'; + if (name === 'operation') return 'trigger'; + if (name === 'job') return 'demo-job'; + return undefined; + }); + jenkinsApiRequestSpy.mockResolvedValue({ success: true }); + + const result = await node.execute.call(executeFunctions); + + expect(jenkinsApiRequestSpy).toHaveBeenCalledWith('POST', '/job/demo-job/build'); + expect(result).toEqual([[{ json: { success: true } }]]); + }); + }); });