diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/jira/service.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/cases/jira/service.test.ts index 9dcd4f2ce2af2..80958da3f560c 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/cases/jira/service.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/cases/jira/service.test.ts @@ -16,7 +16,7 @@ import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.moc const logger = loggingSystemMock.create().get() as jest.Mocked; interface ResponseError extends Error { - response?: { data: { errors: Record } }; + response?: { data: { errors: Record; errorMessages?: string[] } }; } jest.mock('axios'); @@ -1096,6 +1096,33 @@ describe('Jira service', () => { ]); }); + test('it should return correct issue when special characters are used', async () => { + const specialCharacterIssuesResponse = [ + { + id: '77145', + key: 'RJ-5696', + fields: { summary: '[th!s^is()a-te+st-{~is*s&ue?or|and\\bye:}]"}]' }, + }, + ]; + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + issues: specialCharacterIssuesResponse, + }, + }) + ); + + const res = await service.getIssues('[th!s^is()a-te+st-{~is*s&ue?or|and\\bye:}]"}]'); + + expect(res).toEqual([ + { + id: '77145', + key: 'RJ-5696', + title: '[th!s^is()a-te+st-{~is*s&ue?or|and\\bye:}]"}]', + }, + ]); + }); + test('it should call request with correct arguments', async () => { requestMock.mockImplementation(() => createAxiosResponse({ @@ -1115,6 +1142,32 @@ describe('Jira service', () => { }); }); + test('it should escape JQL special characters', async () => { + const specialCharacterIssuesResponse = [ + { + id: '77145', + key: 'RJ-5696', + fields: { summary: '[th!s^is()a-te+st-{~is*s&ue?or|and\\bye:}]"}]' }, + }, + ]; + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + issues: specialCharacterIssuesResponse, + }, + }) + ); + + await service.getIssues('[th!s^is()a-te+st-{~is*s&ue?or|and\\bye:}]"}]'); + expect(requestMock).toHaveBeenLastCalledWith({ + axios, + logger, + method: 'get', + configurationUtilities, + url: `https://coolsite.net/rest/api/2/search?jql=project%3D%22CK%22%20and%20summary%20~%22%5C%5C%5Bth%5C%5C!s%5C%5C%5Eis%5C%5C(%5C%5C)a%5C%5C-te%5C%5C%2Bst%5C%5C-%5C%5C%7B%5C%5C~is%5C%5C*s%5C%5C%26ue%5C%5C%3For%5C%5C%7Cand%5C%5Cbye%5C%5C%3A%5C%5C%7D%5C%5C%5D%5C%5C%7D%5C%5C%5D%22`, + }); + }); + test('it should throw an error', async () => { requestMock.mockImplementation(() => { const error: ResponseError = new Error('An error has occurred'); @@ -1127,6 +1180,25 @@ describe('Jira service', () => { ); }); + test('it should show an error from errorMessages', async () => { + requestMock.mockImplementation(() => { + const error: ResponseError = new Error('An error has occurred'); + error.response = { + data: { + errors: { + issuestypes: 'My second error', + }, + errorMessages: ['My first error'], + }, + }; + throw error; + }); + + await expect(service.getIssues('"')).rejects.toThrow( + '[Action][Jira]: Unable to get issues. Error: An error has occurred. Reason: My first error' + ); + }); + test('it should throw if the request is not a JSON', async () => { requestMock.mockImplementation(() => createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/jira/service.ts b/x-pack/plugins/stack_connectors/server/connector_types/cases/jira/service.ts index b0d7571b302cd..771c5f25e92d6 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/cases/jira/service.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/cases/jira/service.ts @@ -31,6 +31,7 @@ import { ResponseError, UpdateIncidentParams, } from './types'; +import { escapeJqlSpecialCharacters } from './utils'; import * as i18n from './translations'; @@ -122,14 +123,14 @@ export const createExternalService = ( const { errorMessages, errors } = errorResponse; - if (errors == null) { - return 'unknown: errorResponse.errors was null'; - } - if (Array.isArray(errorMessages) && errorMessages.length > 0) { return `${errorMessages.join(', ')}`; } + if (errors == null) { + return 'unknown: errorResponse.errors was null'; + } + return Object.entries(errors).reduce((errorMessage, [, value]) => { const msg = errorMessage.length > 0 ? `${errorMessage} ${value}` : value; return msg; @@ -498,8 +499,9 @@ export const createExternalService = ( }; const getIssues = async (title: string) => { + const jqlEscapedTitle = escapeJqlSpecialCharacters(title); const query = `${searchUrl}?jql=${encodeURIComponent( - `project="${projectKey}" and summary ~"${title}"` + `project="${projectKey}" and summary ~"${jqlEscapedTitle}"` )}`; try { diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/jira/utils.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/cases/jira/utils.test.ts new file mode 100644 index 0000000000000..419aa04d2b6ac --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/cases/jira/utils.test.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { escapeJqlSpecialCharacters } from './utils'; + +describe('escapeJqlSpecialCharacters', () => { + it('should escape jql special characters', () => { + const str = '[th!s^is()a-te+st-{~is*s&ue?or|and\\bye:}]"}]'; + const escapedStr = escapeJqlSpecialCharacters(str); + expect(escapedStr).toEqual( + '\\\\[th\\\\!s\\\\^is\\\\(\\\\)a\\\\-te\\\\+st\\\\-\\\\{\\\\~is\\\\*s\\\\&ue\\\\?or\\\\|and\\\\bye\\\\:\\\\}\\\\]\\\\}\\\\]' + ); + }); + + it('should remove double quotes', () => { + const str = '"Hello"'; + const escapedStr = escapeJqlSpecialCharacters(str); + expect(escapedStr).toEqual('Hello'); + }); + + it('should replace single quotes with backslash', () => { + const str = "Javascript's beauty is simplicity!"; + const escapedStr = escapeJqlSpecialCharacters(str); + expect(escapedStr).toEqual('Javascript\\\\s beauty is simplicity\\\\!'); + }); + + it('should replace single backslash with four backslash', () => { + const str = '\\I have one backslash'; + const escapedStr = escapeJqlSpecialCharacters(str); + expect(escapedStr).toEqual('\\\\I have one backslash'); + }); + + it('should not escape other special characters', () => { + const str = ''; + const escapedStr = escapeJqlSpecialCharacters(str); + expect(escapedStr).toEqual(''); + }); + + it('should not escape alpha numeric characters', () => { + const str = 'here is a case 29'; + const escapedStr = escapeJqlSpecialCharacters(str); + expect(escapedStr).toEqual('here is a case 29'); + }); + + it('should not escape unicode spaces', () => { + const str = 'comm\u2000=\u2001"hello"\u3000'; + const escapedStr = escapeJqlSpecialCharacters(str); + expect(escapedStr).toEqual('comm = hello '); + }); + + it('should not escape non ASCII characters', () => { + const str = 'Apple’s amazing idea♥'; + const escapedStr = escapeJqlSpecialCharacters(str); + expect(escapedStr).toEqual('Apple’s amazing idea♥'); + }); +}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/jira/utils.ts b/x-pack/plugins/stack_connectors/server/connector_types/cases/jira/utils.ts new file mode 100644 index 0000000000000..42550b1c12acc --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/cases/jira/utils.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// These characters need to be escaped per Jira's search syntax, see for more details: https://confluence.atlassian.com/jirasoftwareserver/search-syntax-for-text-fields-939938747.html +export const JQL_SPECIAL_CHARACTERS_REGEX = /[-!^+&*()[\]/{}|:?~]/; + +const DOUBLE_BACKSLASH_REGEX = '\\\\$&'; + +export const escapeJqlSpecialCharacters = (str: string) => { + return str + .replaceAll('"', '') + .replaceAll(/\\/g, '\\\\') + .replaceAll(/'/g, '\\\\') + .replaceAll(new RegExp(JQL_SPECIAL_CHARACTERS_REGEX, 'g'), DOUBLE_BACKSLASH_REGEX); +};