This commit is contained in:
Temo 2026-10-16 17:43:15 +00:00 committed by GitHub
commit 58c48e85c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 531 additions and 8 deletions

View File

@ -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<IDataObject | null> {
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<string, string | string[]>;
};
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<any> {
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);
}
}

View File

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

View File

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