Skip to content

Commit cd212e4

Browse files
authored
fix(Jira Trigger Node): Fix Jira webhook subscriptions on Jira v10+ (#14333)
1 parent 8abbc30 commit cd212e4

File tree

4 files changed

+343
-13
lines changed

4 files changed

+343
-13
lines changed

packages/nodes-base/nodes/Jira/GenericFunctions.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import type {
1111
} from 'n8n-workflow';
1212
import { NodeApiError } from 'n8n-workflow';
1313

14+
import type { JiraServerInfo, JiraWebhook } from './types';
15+
1416
export async function jiraSoftwareCloudApiRequest(
1517
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions,
1618
endpoint: string,
@@ -122,8 +124,9 @@ export function eventExists(currentEvents: string[], webhookEvents: string[]) {
122124
return true;
123125
}
124126

125-
export function getId(url: string) {
126-
return url.split('/').pop();
127+
export function getWebhookId(webhook: JiraWebhook) {
128+
if (webhook.id) return webhook.id.toString();
129+
return webhook.self?.split('/').pop();
127130
}
128131

129132
export function simplifyIssueOutput(responseData: {
@@ -266,3 +269,22 @@ export async function getUsers(this: ILoadOptionsFunctions): Promise<INodeProper
266269
return a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1;
267270
});
268271
}
272+
273+
export async function getServerInfo(this: IHookFunctions) {
274+
return await (jiraSoftwareCloudApiRequest.call(
275+
this,
276+
'/api/2/serverInfo',
277+
'GET',
278+
) as Promise<JiraServerInfo>);
279+
}
280+
281+
export async function getWebhookEndpoint(this: IHookFunctions) {
282+
const serverInfo = await getServerInfo.call(this).catch(() => null);
283+
284+
if (!serverInfo || serverInfo.deploymentType === 'Cloud') return '/webhooks/1.0/webhook';
285+
286+
// Assume old version when versionNumbers is not set
287+
const majorVersion = serverInfo.versionNumbers?.[0] ?? 1;
288+
289+
return majorVersion >= 10 ? '/jira-webhook/1.0/webhooks' : '/webhooks/1.0/webhook';
290+
}
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
import { mock, mockDeep } from 'jest-mock-extended';
2+
import type {
3+
ICredentialDataDecryptedObject,
4+
IDataObject,
5+
IHookFunctions,
6+
INode,
7+
} from 'n8n-workflow';
8+
9+
import { testWebhookTriggerNode } from '@test/nodes/TriggerHelpers';
10+
11+
import { JiraTrigger } from './JiraTrigger.node';
12+
13+
describe('JiraTrigger', () => {
14+
describe('Webhook lifecycle', () => {
15+
let staticData: IDataObject;
16+
17+
beforeEach(() => {
18+
staticData = {};
19+
});
20+
21+
function mockHookFunctions(
22+
mockRequest: IHookFunctions['helpers']['requestWithAuthentication'],
23+
) {
24+
const baseUrl = 'https://jira.local';
25+
const credential = {
26+
27+
password: 'secret',
28+
domain: baseUrl,
29+
};
30+
31+
return mockDeep<IHookFunctions>({
32+
getWorkflowStaticData: () => staticData,
33+
getNode: jest.fn(() => mock<INode>({ typeVersion: 1 })),
34+
getNodeWebhookUrl: jest.fn(() => 'https://n8n.local/webhook/id'),
35+
getNodeParameter: jest.fn((param: string) => {
36+
if (param === 'events') return ['jira:issue_created'];
37+
return {};
38+
}),
39+
getCredentials: async <T extends object = ICredentialDataDecryptedObject>() =>
40+
credential as T,
41+
helpers: {
42+
requestWithAuthentication: mockRequest,
43+
},
44+
});
45+
}
46+
47+
test('should register a webhook subscription on Jira 10', async () => {
48+
const trigger = new JiraTrigger();
49+
50+
const mockExistsRequest = jest
51+
.fn()
52+
.mockResolvedValueOnce({ versionNumbers: [10, 0, 1] })
53+
.mockResolvedValueOnce([]);
54+
55+
const exists = await trigger.webhookMethods.default?.checkExists.call(
56+
mockHookFunctions(mockExistsRequest),
57+
);
58+
59+
expect(mockExistsRequest).toHaveBeenCalledTimes(2);
60+
expect(mockExistsRequest).toHaveBeenCalledWith(
61+
expect.any(String),
62+
expect.objectContaining({ uri: 'https://jira.local/rest/api/2/serverInfo' }),
63+
);
64+
expect(mockExistsRequest).toHaveBeenCalledWith(
65+
expect.any(String),
66+
expect.objectContaining({ uri: 'https://jira.local/rest/jira-webhook/1.0/webhooks' }),
67+
);
68+
expect(staticData.endpoint).toBe('/jira-webhook/1.0/webhooks');
69+
expect(exists).toBe(false);
70+
71+
const mockCreateRequest = jest.fn().mockResolvedValueOnce({ id: 1 });
72+
73+
const created = await trigger.webhookMethods.default?.create.call(
74+
mockHookFunctions(mockCreateRequest),
75+
);
76+
77+
expect(mockCreateRequest).toHaveBeenCalledTimes(1);
78+
expect(mockCreateRequest).toHaveBeenCalledWith(
79+
expect.any(String),
80+
expect.objectContaining({
81+
method: 'POST',
82+
uri: 'https://jira.local/rest/jira-webhook/1.0/webhooks',
83+
body: expect.objectContaining({
84+
events: ['jira:issue_created'],
85+
excludeBody: false,
86+
filters: {},
87+
name: 'n8n-webhook:https://n8n.local/webhook/id',
88+
url: 'https://n8n.local/webhook/id',
89+
}),
90+
}),
91+
);
92+
expect(created).toBe(true);
93+
94+
const mockDeleteRequest = jest.fn().mockResolvedValueOnce({});
95+
const deleted = await trigger.webhookMethods.default?.delete.call(
96+
mockHookFunctions(mockDeleteRequest),
97+
);
98+
99+
expect(deleted).toBe(true);
100+
expect(mockDeleteRequest).toHaveBeenCalledTimes(1);
101+
expect(mockDeleteRequest).toHaveBeenCalledWith(
102+
expect.any(String),
103+
expect.objectContaining({
104+
method: 'DELETE',
105+
uri: 'https://jira.local/rest/jira-webhook/1.0/webhooks/1',
106+
}),
107+
);
108+
});
109+
110+
test('should register a webhook subscription on Jira 9', async () => {
111+
const trigger = new JiraTrigger();
112+
113+
const mockExistsRequest = jest
114+
.fn()
115+
.mockResolvedValueOnce({ versionNumbers: [9, 0, 1] })
116+
.mockResolvedValueOnce([]);
117+
118+
const exists = await trigger.webhookMethods.default?.checkExists.call(
119+
mockHookFunctions(mockExistsRequest),
120+
);
121+
122+
expect(mockExistsRequest).toHaveBeenCalledTimes(2);
123+
expect(mockExistsRequest).toHaveBeenCalledWith(
124+
expect.any(String),
125+
expect.objectContaining({ uri: 'https://jira.local/rest/api/2/serverInfo' }),
126+
);
127+
expect(mockExistsRequest).toHaveBeenCalledWith(
128+
expect.any(String),
129+
expect.objectContaining({ uri: 'https://jira.local/rest/webhooks/1.0/webhook' }),
130+
);
131+
expect(staticData.endpoint).toBe('/webhooks/1.0/webhook');
132+
expect(exists).toBe(false);
133+
134+
const mockCreateRequest = jest.fn().mockResolvedValueOnce({ id: 1 });
135+
136+
const created = await trigger.webhookMethods.default?.create.call(
137+
mockHookFunctions(mockCreateRequest),
138+
);
139+
140+
expect(mockCreateRequest).toHaveBeenCalledTimes(1);
141+
expect(mockCreateRequest).toHaveBeenCalledWith(
142+
expect.any(String),
143+
expect.objectContaining({
144+
method: 'POST',
145+
uri: 'https://jira.local/rest/webhooks/1.0/webhook',
146+
body: expect.objectContaining({
147+
events: ['jira:issue_created'],
148+
excludeBody: false,
149+
filters: {},
150+
name: 'n8n-webhook:https://n8n.local/webhook/id',
151+
url: 'https://n8n.local/webhook/id',
152+
}),
153+
}),
154+
);
155+
expect(created).toBe(true);
156+
157+
const mockDeleteRequest = jest.fn().mockResolvedValueOnce({});
158+
const deleted = await trigger.webhookMethods.default?.delete.call(
159+
mockHookFunctions(mockDeleteRequest),
160+
);
161+
162+
expect(deleted).toBe(true);
163+
expect(mockDeleteRequest).toHaveBeenCalledTimes(1);
164+
expect(mockDeleteRequest).toHaveBeenCalledWith(
165+
expect.any(String),
166+
expect.objectContaining({
167+
method: 'DELETE',
168+
uri: 'https://jira.local/rest/webhooks/1.0/webhook/1',
169+
}),
170+
);
171+
});
172+
173+
test('should register a webhook subscription on Jira Cloud', async () => {
174+
const trigger = new JiraTrigger();
175+
176+
const mockExistsRequest = jest
177+
.fn()
178+
.mockResolvedValueOnce({ deploymentType: 'Cloud', versionNumbers: [1000, 0, 1] })
179+
.mockResolvedValueOnce([]);
180+
181+
const exists = await trigger.webhookMethods.default?.checkExists.call(
182+
mockHookFunctions(mockExistsRequest),
183+
);
184+
185+
expect(mockExistsRequest).toHaveBeenCalledTimes(2);
186+
expect(mockExistsRequest).toHaveBeenCalledWith(
187+
expect.any(String),
188+
expect.objectContaining({ uri: 'https://jira.local/rest/api/2/serverInfo' }),
189+
);
190+
expect(mockExistsRequest).toHaveBeenCalledWith(
191+
expect.any(String),
192+
expect.objectContaining({ uri: 'https://jira.local/rest/webhooks/1.0/webhook' }),
193+
);
194+
expect(staticData.endpoint).toBe('/webhooks/1.0/webhook');
195+
expect(exists).toBe(false);
196+
197+
const mockCreateRequest = jest.fn().mockResolvedValueOnce({ id: 1 });
198+
199+
const created = await trigger.webhookMethods.default?.create.call(
200+
mockHookFunctions(mockCreateRequest),
201+
);
202+
203+
expect(mockCreateRequest).toHaveBeenCalledTimes(1);
204+
expect(mockCreateRequest).toHaveBeenCalledWith(
205+
expect.any(String),
206+
expect.objectContaining({
207+
method: 'POST',
208+
uri: 'https://jira.local/rest/webhooks/1.0/webhook',
209+
body: expect.objectContaining({
210+
events: ['jira:issue_created'],
211+
excludeBody: false,
212+
filters: {},
213+
name: 'n8n-webhook:https://n8n.local/webhook/id',
214+
url: 'https://n8n.local/webhook/id',
215+
}),
216+
}),
217+
);
218+
expect(created).toBe(true);
219+
220+
const mockDeleteRequest = jest.fn().mockResolvedValueOnce({});
221+
const deleted = await trigger.webhookMethods.default?.delete.call(
222+
mockHookFunctions(mockDeleteRequest),
223+
);
224+
225+
expect(deleted).toBe(true);
226+
expect(mockDeleteRequest).toHaveBeenCalledTimes(1);
227+
expect(mockDeleteRequest).toHaveBeenCalledWith(
228+
expect.any(String),
229+
expect.objectContaining({
230+
method: 'DELETE',
231+
uri: 'https://jira.local/rest/webhooks/1.0/webhook/1',
232+
}),
233+
);
234+
});
235+
});
236+
237+
describe('Webhook', () => {
238+
test('should receive a webhook event', async () => {
239+
const event = {
240+
timestamp: 1743524005044,
241+
webhookEvent: 'jira:issue_created',
242+
issue_event_type_name: 'issue_created',
243+
user: {
244+
self: 'http://localhost:8080/rest/api/2/user?key=JIRAUSER10000',
245+
name: 'elias',
246+
key: 'JIRAUSER10000',
247+
emailAddress: '[email protected]',
248+
displayName: 'Test',
249+
},
250+
issue: {
251+
id: '10018',
252+
self: 'http://localhost:8080/rest/api/2/issue/10018',
253+
key: 'TEST-19',
254+
},
255+
};
256+
const { responseData } = await testWebhookTriggerNode(JiraTrigger, {
257+
bodyData: event,
258+
});
259+
260+
expect(responseData).toEqual({ workflowData: [[{ json: event }]] });
261+
});
262+
});
263+
});

packages/nodes-base/nodes/Jira/JiraTrigger.node.ts

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,14 @@ import type {
99
} from 'n8n-workflow';
1010
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
1111

12-
import { allEvents, eventExists, getId, jiraSoftwareCloudApiRequest } from './GenericFunctions';
12+
import {
13+
allEvents,
14+
eventExists,
15+
getWebhookId,
16+
getWebhookEndpoint,
17+
jiraSoftwareCloudApiRequest,
18+
} from './GenericFunctions';
19+
import type { JiraWebhook } from './types';
1320

1421
export class JiraTrigger implements INodeType {
1522
description: INodeTypeDescription = {
@@ -411,13 +418,19 @@ export class JiraTrigger implements INodeType {
411418

412419
const events = this.getNodeParameter('events') as string[];
413420

414-
const endpoint = '/webhooks/1.0/webhook';
421+
const endpoint = await getWebhookEndpoint.call(this);
422+
webhookData.endpoint = endpoint;
415423

416-
const webhooks = await jiraSoftwareCloudApiRequest.call(this, endpoint, 'GET', {});
424+
const webhooks: JiraWebhook[] = await jiraSoftwareCloudApiRequest.call(
425+
this,
426+
endpoint,
427+
'GET',
428+
{},
429+
);
417430

418431
for (const webhook of webhooks) {
419-
if (webhook.url === webhookUrl && eventExists(events, webhook.events as string[])) {
420-
webhookData.webhookId = getId(webhook.self as string);
432+
if (webhook.url === webhookUrl && eventExists(events, webhook.events)) {
433+
webhookData.webhookId = getWebhookId(webhook);
421434
return true;
422435
}
423436
}
@@ -429,8 +442,8 @@ export class JiraTrigger implements INodeType {
429442
const webhookUrl = this.getNodeWebhookUrl('default') as string;
430443
let events = this.getNodeParameter('events', []) as string[];
431444
const additionalFields = this.getNodeParameter('additionalFields') as IDataObject;
432-
const endpoint = '/webhooks/1.0/webhook';
433445
const webhookData = this.getWorkflowStaticData('node');
446+
const endpoint = webhookData.endpoint as string;
434447

435448
let authenticateWebhook = false;
436449

@@ -466,7 +479,7 @@ export class JiraTrigger implements INodeType {
466479
body.excludeBody = additionalFields.excludeBody as boolean;
467480
}
468481

469-
const parameters: any = {};
482+
const parameters: Record<string, string> = {};
470483

471484
if (authenticateWebhook) {
472485
let httpQueryAuth;
@@ -494,21 +507,28 @@ export class JiraTrigger implements INodeType {
494507
}
495508

496509
if (Object.keys(parameters as IDataObject).length) {
497-
const params = new URLSearchParams(parameters as string).toString();
510+
const params = new URLSearchParams(parameters).toString();
498511
body.url = `${body.url}?${decodeURIComponent(params)}`;
499512
}
500513

501-
const responseData = await jiraSoftwareCloudApiRequest.call(this, endpoint, 'POST', body);
514+
const responseData: JiraWebhook = await jiraSoftwareCloudApiRequest.call(
515+
this,
516+
endpoint,
517+
'POST',
518+
body,
519+
);
502520

503-
webhookData.webhookId = getId(responseData.self as string);
521+
webhookData.webhookId = getWebhookId(responseData);
504522

505523
return true;
506524
},
507525
async delete(this: IHookFunctions): Promise<boolean> {
508526
const webhookData = this.getWorkflowStaticData('node');
509527

510528
if (webhookData.webhookId !== undefined) {
511-
const endpoint = `/webhooks/1.0/webhook/${webhookData.webhookId}`;
529+
const baseUrl = webhookData.endpoint as string;
530+
const webhookId = webhookData.webhookId as string;
531+
const endpoint = `${baseUrl}/${webhookId}`;
512532
const body = {};
513533

514534
try {

0 commit comments

Comments
 (0)