From cd212e4f78f6704a8b2f1a27f507dcc7784ef6a5 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Wed, 2 Apr 2025 12:49:25 +0200 Subject: [PATCH] fix(Jira Trigger Node): Fix Jira webhook subscriptions on Jira v10+ (#14333) --- .../nodes-base/nodes/Jira/GenericFunctions.ts | 26 +- .../nodes/Jira/JiraTrigger.node.test.ts | 263 ++++++++++++++++++ .../nodes-base/nodes/Jira/JiraTrigger.node.ts | 42 ++- packages/nodes-base/nodes/Jira/types.ts | 25 ++ 4 files changed, 343 insertions(+), 13 deletions(-) create mode 100644 packages/nodes-base/nodes/Jira/JiraTrigger.node.test.ts create mode 100644 packages/nodes-base/nodes/Jira/types.ts diff --git a/packages/nodes-base/nodes/Jira/GenericFunctions.ts b/packages/nodes-base/nodes/Jira/GenericFunctions.ts index 129b78ad73..e75041bbcb 100644 --- a/packages/nodes-base/nodes/Jira/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Jira/GenericFunctions.ts @@ -11,6 +11,8 @@ import type { } from 'n8n-workflow'; import { NodeApiError } from 'n8n-workflow'; +import type { JiraServerInfo, JiraWebhook } from './types'; + export async function jiraSoftwareCloudApiRequest( this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, endpoint: string, @@ -122,8 +124,9 @@ export function eventExists(currentEvents: string[], webhookEvents: string[]) { return true; } -export function getId(url: string) { - return url.split('/').pop(); +export function getWebhookId(webhook: JiraWebhook) { + if (webhook.id) return webhook.id.toString(); + return webhook.self?.split('/').pop(); } export function simplifyIssueOutput(responseData: { @@ -266,3 +269,22 @@ export async function getUsers(this: ILoadOptionsFunctions): Promise b.name.toLowerCase() ? 1 : -1; }); } + +export async function getServerInfo(this: IHookFunctions) { + return await (jiraSoftwareCloudApiRequest.call( + this, + '/api/2/serverInfo', + 'GET', + ) as Promise); +} + +export async function getWebhookEndpoint(this: IHookFunctions) { + const serverInfo = await getServerInfo.call(this).catch(() => null); + + if (!serverInfo || serverInfo.deploymentType === 'Cloud') return '/webhooks/1.0/webhook'; + + // Assume old version when versionNumbers is not set + const majorVersion = serverInfo.versionNumbers?.[0] ?? 1; + + return majorVersion >= 10 ? '/jira-webhook/1.0/webhooks' : '/webhooks/1.0/webhook'; +} diff --git a/packages/nodes-base/nodes/Jira/JiraTrigger.node.test.ts b/packages/nodes-base/nodes/Jira/JiraTrigger.node.test.ts new file mode 100644 index 0000000000..c1a9868672 --- /dev/null +++ b/packages/nodes-base/nodes/Jira/JiraTrigger.node.test.ts @@ -0,0 +1,263 @@ +import { mock, mockDeep } from 'jest-mock-extended'; +import type { + ICredentialDataDecryptedObject, + IDataObject, + IHookFunctions, + INode, +} from 'n8n-workflow'; + +import { testWebhookTriggerNode } from '@test/nodes/TriggerHelpers'; + +import { JiraTrigger } from './JiraTrigger.node'; + +describe('JiraTrigger', () => { + describe('Webhook lifecycle', () => { + let staticData: IDataObject; + + beforeEach(() => { + staticData = {}; + }); + + function mockHookFunctions( + mockRequest: IHookFunctions['helpers']['requestWithAuthentication'], + ) { + const baseUrl = 'https://jira.local'; + const credential = { + email: 'test@n8n.io', + password: 'secret', + domain: baseUrl, + }; + + return mockDeep({ + getWorkflowStaticData: () => staticData, + getNode: jest.fn(() => mock({ typeVersion: 1 })), + getNodeWebhookUrl: jest.fn(() => 'https://n8n.local/webhook/id'), + getNodeParameter: jest.fn((param: string) => { + if (param === 'events') return ['jira:issue_created']; + return {}; + }), + getCredentials: async () => + credential as T, + helpers: { + requestWithAuthentication: mockRequest, + }, + }); + } + + test('should register a webhook subscription on Jira 10', async () => { + const trigger = new JiraTrigger(); + + const mockExistsRequest = jest + .fn() + .mockResolvedValueOnce({ versionNumbers: [10, 0, 1] }) + .mockResolvedValueOnce([]); + + const exists = await trigger.webhookMethods.default?.checkExists.call( + mockHookFunctions(mockExistsRequest), + ); + + expect(mockExistsRequest).toHaveBeenCalledTimes(2); + expect(mockExistsRequest).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ uri: 'https://jira.local/rest/api/2/serverInfo' }), + ); + expect(mockExistsRequest).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ uri: 'https://jira.local/rest/jira-webhook/1.0/webhooks' }), + ); + expect(staticData.endpoint).toBe('/jira-webhook/1.0/webhooks'); + expect(exists).toBe(false); + + const mockCreateRequest = jest.fn().mockResolvedValueOnce({ id: 1 }); + + const created = await trigger.webhookMethods.default?.create.call( + mockHookFunctions(mockCreateRequest), + ); + + expect(mockCreateRequest).toHaveBeenCalledTimes(1); + expect(mockCreateRequest).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: 'POST', + uri: 'https://jira.local/rest/jira-webhook/1.0/webhooks', + body: expect.objectContaining({ + events: ['jira:issue_created'], + excludeBody: false, + filters: {}, + name: 'n8n-webhook:https://n8n.local/webhook/id', + url: 'https://n8n.local/webhook/id', + }), + }), + ); + expect(created).toBe(true); + + const mockDeleteRequest = jest.fn().mockResolvedValueOnce({}); + const deleted = await trigger.webhookMethods.default?.delete.call( + mockHookFunctions(mockDeleteRequest), + ); + + expect(deleted).toBe(true); + expect(mockDeleteRequest).toHaveBeenCalledTimes(1); + expect(mockDeleteRequest).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: 'DELETE', + uri: 'https://jira.local/rest/jira-webhook/1.0/webhooks/1', + }), + ); + }); + + test('should register a webhook subscription on Jira 9', async () => { + const trigger = new JiraTrigger(); + + const mockExistsRequest = jest + .fn() + .mockResolvedValueOnce({ versionNumbers: [9, 0, 1] }) + .mockResolvedValueOnce([]); + + const exists = await trigger.webhookMethods.default?.checkExists.call( + mockHookFunctions(mockExistsRequest), + ); + + expect(mockExistsRequest).toHaveBeenCalledTimes(2); + expect(mockExistsRequest).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ uri: 'https://jira.local/rest/api/2/serverInfo' }), + ); + expect(mockExistsRequest).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ uri: 'https://jira.local/rest/webhooks/1.0/webhook' }), + ); + expect(staticData.endpoint).toBe('/webhooks/1.0/webhook'); + expect(exists).toBe(false); + + const mockCreateRequest = jest.fn().mockResolvedValueOnce({ id: 1 }); + + const created = await trigger.webhookMethods.default?.create.call( + mockHookFunctions(mockCreateRequest), + ); + + expect(mockCreateRequest).toHaveBeenCalledTimes(1); + expect(mockCreateRequest).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: 'POST', + uri: 'https://jira.local/rest/webhooks/1.0/webhook', + body: expect.objectContaining({ + events: ['jira:issue_created'], + excludeBody: false, + filters: {}, + name: 'n8n-webhook:https://n8n.local/webhook/id', + url: 'https://n8n.local/webhook/id', + }), + }), + ); + expect(created).toBe(true); + + const mockDeleteRequest = jest.fn().mockResolvedValueOnce({}); + const deleted = await trigger.webhookMethods.default?.delete.call( + mockHookFunctions(mockDeleteRequest), + ); + + expect(deleted).toBe(true); + expect(mockDeleteRequest).toHaveBeenCalledTimes(1); + expect(mockDeleteRequest).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: 'DELETE', + uri: 'https://jira.local/rest/webhooks/1.0/webhook/1', + }), + ); + }); + + test('should register a webhook subscription on Jira Cloud', async () => { + const trigger = new JiraTrigger(); + + const mockExistsRequest = jest + .fn() + .mockResolvedValueOnce({ deploymentType: 'Cloud', versionNumbers: [1000, 0, 1] }) + .mockResolvedValueOnce([]); + + const exists = await trigger.webhookMethods.default?.checkExists.call( + mockHookFunctions(mockExistsRequest), + ); + + expect(mockExistsRequest).toHaveBeenCalledTimes(2); + expect(mockExistsRequest).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ uri: 'https://jira.local/rest/api/2/serverInfo' }), + ); + expect(mockExistsRequest).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ uri: 'https://jira.local/rest/webhooks/1.0/webhook' }), + ); + expect(staticData.endpoint).toBe('/webhooks/1.0/webhook'); + expect(exists).toBe(false); + + const mockCreateRequest = jest.fn().mockResolvedValueOnce({ id: 1 }); + + const created = await trigger.webhookMethods.default?.create.call( + mockHookFunctions(mockCreateRequest), + ); + + expect(mockCreateRequest).toHaveBeenCalledTimes(1); + expect(mockCreateRequest).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: 'POST', + uri: 'https://jira.local/rest/webhooks/1.0/webhook', + body: expect.objectContaining({ + events: ['jira:issue_created'], + excludeBody: false, + filters: {}, + name: 'n8n-webhook:https://n8n.local/webhook/id', + url: 'https://n8n.local/webhook/id', + }), + }), + ); + expect(created).toBe(true); + + const mockDeleteRequest = jest.fn().mockResolvedValueOnce({}); + const deleted = await trigger.webhookMethods.default?.delete.call( + mockHookFunctions(mockDeleteRequest), + ); + + expect(deleted).toBe(true); + expect(mockDeleteRequest).toHaveBeenCalledTimes(1); + expect(mockDeleteRequest).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: 'DELETE', + uri: 'https://jira.local/rest/webhooks/1.0/webhook/1', + }), + ); + }); + }); + + describe('Webhook', () => { + test('should receive a webhook event', async () => { + const event = { + timestamp: 1743524005044, + webhookEvent: 'jira:issue_created', + issue_event_type_name: 'issue_created', + user: { + self: 'http://localhost:8080/rest/api/2/user?key=JIRAUSER10000', + name: 'elias', + key: 'JIRAUSER10000', + emailAddress: 'elias@meire.dev', + displayName: 'Test', + }, + issue: { + id: '10018', + self: 'http://localhost:8080/rest/api/2/issue/10018', + key: 'TEST-19', + }, + }; + const { responseData } = await testWebhookTriggerNode(JiraTrigger, { + bodyData: event, + }); + + expect(responseData).toEqual({ workflowData: [[{ json: event }]] }); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Jira/JiraTrigger.node.ts b/packages/nodes-base/nodes/Jira/JiraTrigger.node.ts index dad6e96b06..5f46d87232 100644 --- a/packages/nodes-base/nodes/Jira/JiraTrigger.node.ts +++ b/packages/nodes-base/nodes/Jira/JiraTrigger.node.ts @@ -9,7 +9,14 @@ import type { } from 'n8n-workflow'; import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; -import { allEvents, eventExists, getId, jiraSoftwareCloudApiRequest } from './GenericFunctions'; +import { + allEvents, + eventExists, + getWebhookId, + getWebhookEndpoint, + jiraSoftwareCloudApiRequest, +} from './GenericFunctions'; +import type { JiraWebhook } from './types'; export class JiraTrigger implements INodeType { description: INodeTypeDescription = { @@ -411,13 +418,19 @@ export class JiraTrigger implements INodeType { const events = this.getNodeParameter('events') as string[]; - const endpoint = '/webhooks/1.0/webhook'; + const endpoint = await getWebhookEndpoint.call(this); + webhookData.endpoint = endpoint; - const webhooks = await jiraSoftwareCloudApiRequest.call(this, endpoint, 'GET', {}); + const webhooks: JiraWebhook[] = await jiraSoftwareCloudApiRequest.call( + this, + endpoint, + 'GET', + {}, + ); for (const webhook of webhooks) { - if (webhook.url === webhookUrl && eventExists(events, webhook.events as string[])) { - webhookData.webhookId = getId(webhook.self as string); + if (webhook.url === webhookUrl && eventExists(events, webhook.events)) { + webhookData.webhookId = getWebhookId(webhook); return true; } } @@ -429,8 +442,8 @@ export class JiraTrigger implements INodeType { const webhookUrl = this.getNodeWebhookUrl('default') as string; let events = this.getNodeParameter('events', []) as string[]; const additionalFields = this.getNodeParameter('additionalFields') as IDataObject; - const endpoint = '/webhooks/1.0/webhook'; const webhookData = this.getWorkflowStaticData('node'); + const endpoint = webhookData.endpoint as string; let authenticateWebhook = false; @@ -466,7 +479,7 @@ export class JiraTrigger implements INodeType { body.excludeBody = additionalFields.excludeBody as boolean; } - const parameters: any = {}; + const parameters: Record = {}; if (authenticateWebhook) { let httpQueryAuth; @@ -494,13 +507,18 @@ export class JiraTrigger implements INodeType { } if (Object.keys(parameters as IDataObject).length) { - const params = new URLSearchParams(parameters as string).toString(); + const params = new URLSearchParams(parameters).toString(); body.url = `${body.url}?${decodeURIComponent(params)}`; } - const responseData = await jiraSoftwareCloudApiRequest.call(this, endpoint, 'POST', body); + const responseData: JiraWebhook = await jiraSoftwareCloudApiRequest.call( + this, + endpoint, + 'POST', + body, + ); - webhookData.webhookId = getId(responseData.self as string); + webhookData.webhookId = getWebhookId(responseData); return true; }, @@ -508,7 +526,9 @@ export class JiraTrigger implements INodeType { const webhookData = this.getWorkflowStaticData('node'); if (webhookData.webhookId !== undefined) { - const endpoint = `/webhooks/1.0/webhook/${webhookData.webhookId}`; + const baseUrl = webhookData.endpoint as string; + const webhookId = webhookData.webhookId as string; + const endpoint = `${baseUrl}/${webhookId}`; const body = {}; try { diff --git a/packages/nodes-base/nodes/Jira/types.ts b/packages/nodes-base/nodes/Jira/types.ts new file mode 100644 index 0000000000..3038b4f715 --- /dev/null +++ b/packages/nodes-base/nodes/Jira/types.ts @@ -0,0 +1,25 @@ +export type JiraWebhook = { + id: number; + name: string; + createdDate: number; + updatedDate: number; + events: string[]; + configuration: {}; + url: string; + active: boolean; + scopeType: string; + sslVerificationRequired: boolean; + self?: string; // Only available for version < 10 +}; +export type JiraServerInfo = { + baseUrl: string; + version: string; + versionNumbers: number[]; + deploymentType?: 'Cloud' | 'Server'; + buildNumber: number; + buildDate: string; + databaseBuildNumber: number; + serverTime: string; + scmInfo: string; + serverTitle: string; +};