mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 09:36:44 +00:00
fix(Jira Trigger Node): Fix Jira webhook subscriptions on Jira v10+ (#14333)
This commit is contained in:
@@ -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<INodeProper
|
||||
return a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getServerInfo(this: IHookFunctions) {
|
||||
return await (jiraSoftwareCloudApiRequest.call(
|
||||
this,
|
||||
'/api/2/serverInfo',
|
||||
'GET',
|
||||
) as Promise<JiraServerInfo>);
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
263
packages/nodes-base/nodes/Jira/JiraTrigger.node.test.ts
Normal file
263
packages/nodes-base/nodes/Jira/JiraTrigger.node.test.ts
Normal file
@@ -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<IHookFunctions>({
|
||||
getWorkflowStaticData: () => staticData,
|
||||
getNode: jest.fn(() => mock<INode>({ 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 <T extends object = ICredentialDataDecryptedObject>() =>
|
||||
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 }]] });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {};
|
||||
|
||||
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 {
|
||||
|
||||
25
packages/nodes-base/nodes/Jira/types.ts
Normal file
25
packages/nodes-base/nodes/Jira/types.ts
Normal file
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user