From ad1ce4eeb88a5a0adc98036949a764a2a66cfbc2 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 25 Oct 2021 05:35:58 -0400 Subject: [PATCH 1/2] Update APM linting dev doc with instructions about how to install the pre-commit hook and a link to the Kibana guide (#115924) (#116094) Co-authored-by: Giorgos Bamparopoulos --- x-pack/plugins/apm/dev_docs/linting.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/x-pack/plugins/apm/dev_docs/linting.md b/x-pack/plugins/apm/dev_docs/linting.md index edf3e813a88e..3dbd7b5b2748 100644 --- a/x-pack/plugins/apm/dev_docs/linting.md +++ b/x-pack/plugins/apm/dev_docs/linting.md @@ -19,3 +19,12 @@ yarn prettier "./x-pack/plugins/apm/**/*.{tsx,ts,js}" --write ``` node scripts/eslint.js x-pack/plugins/apm ``` + +## Install pre-commit hook (optional) +In case you want to run a couple of checks like linting or check the file casing of the files to commit, we provide a way to install a pre-commit hook. To configure it you just need to run the following: + +`node scripts/register_git_hook` + +After the script completes the pre-commit hook will be created within the file .git/hooks/pre-commit. If you choose to not install it, don’t worry, we still run a quick CI check to provide feedback earliest as we can about the same checks. + +More information about linting can be found in the [Kibana Guide](https://www.elastic.co/guide/en/kibana/current/kibana-linting.html). \ No newline at end of file From 296406bf95f9f287769406b72b9ad59d87cb9b08 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 25 Oct 2021 06:10:53 -0400 Subject: [PATCH 2/2] [Connectors] Check connector's responses (#115797) (#116085) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Christos Nasikas --- .../builtin_action_types/jira/service.test.ts | 918 ++++++++++-------- .../builtin_action_types/jira/service.ts | 66 +- .../lib/axios_utils.test.ts | 88 +- .../builtin_action_types/lib/axios_utils.ts | 68 ++ .../resilient/service.test.ts | 388 +++++--- .../builtin_action_types/resilient/service.ts | 32 +- .../swimlane/service.test.ts | 64 +- .../builtin_action_types/swimlane/service.ts | 13 +- .../server/jira_simulation.ts | 19 +- 9 files changed, 1053 insertions(+), 603 deletions(-) diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts index 2300143925b1..1254d86e9906 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts @@ -8,7 +8,7 @@ import axios from 'axios'; import { createExternalService } from './service'; -import * as utils from '../lib/axios_utils'; +import { request, createAxiosResponse } from '../lib/axios_utils'; import { ExternalService } from './types'; import { Logger } from '../../../../../../src/core/server'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; @@ -29,10 +29,10 @@ jest.mock('../lib/axios_utils', () => { }); axios.create = jest.fn(() => axios); -const requestMock = utils.request as jest.Mock; +const requestMock = request as jest.Mock; const configurationUtilities = actionsConfigMock.create(); -const issueTypesResponse = { +const issueTypesResponse = createAxiosResponse({ data: { projects: [ { @@ -49,9 +49,9 @@ const issueTypesResponse = { }, ], }, -}; +}); -const fieldsResponse = { +const fieldsResponse = createAxiosResponse({ data: { projects: [ { @@ -98,7 +98,7 @@ const fieldsResponse = { }, ], }, -}; +}); const issueResponse = { id: '10267', @@ -108,6 +108,31 @@ const issueResponse = { const issuesResponse = [issueResponse]; +const mockNewAPI = () => + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + capabilities: { + 'list-project-issuetypes': + 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', + 'list-issuetype-fields': + 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', + }, + }, + }) + ); + +const mockOldAPI = () => + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + capabilities: { + navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', + }, + }, + }) + ); + describe('Jira service', () => { let service: ExternalService; @@ -183,18 +208,34 @@ describe('Jira service', () => { }); describe('getIncident', () => { + const axiosRes = { + data: { + id: '1', + key: 'CK-1', + fields: { + summary: 'title', + description: 'description', + created: '2021-10-20T19:41:02.754+0300', + updated: '2021-10-20T19:41:02.754+0300', + }, + }, + }; + test('it returns the incident correctly', async () => { - requestMock.mockImplementation(() => ({ - data: { id: '1', key: 'CK-1', fields: { summary: 'title', description: 'description' } }, - })); + requestMock.mockImplementation(() => createAxiosResponse(axiosRes)); const res = await service.getIncident('1'); - expect(res).toEqual({ id: '1', key: 'CK-1', summary: 'title', description: 'description' }); + expect(res).toEqual({ + id: '1', + key: 'CK-1', + summary: 'title', + description: 'description', + created: '2021-10-20T19:41:02.754+0300', + updated: '2021-10-20T19:41:02.754+0300', + }); }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data: { id: '1', key: 'CK-1' }, - })); + requestMock.mockImplementation(() => createAxiosResponse(axiosRes)); await service.getIncident('1'); expect(requestMock).toHaveBeenCalledWith({ @@ -215,9 +256,38 @@ describe('Jira service', () => { '[Action][Jira]: Unable to get incident with id 1. Error: An error has occurred Reason: Required field' ); }); + + test('it should throw if the request is not a JSON', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ ...axiosRes, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.getIncident('1')).rejects.toThrow( + '[Action][Jira]: Unable to get incident with id 1. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json Reason: unknown: errorResponse was null' + ); + }); + + test('it should throw if the required attributes are not there', async () => { + requestMock.mockImplementation(() => createAxiosResponse({ data: { notRequired: 'test' } })); + + await expect(service.getIncident('1')).rejects.toThrow( + '[Action][Jira]: Unable to get incident with id 1. Error: Response is missing at least one of the expected fields: id,key Reason: unknown: errorResponse was null' + ); + }); }); describe('createIncident', () => { + const incident = { + incident: { + summary: 'title', + description: 'desc', + labels: [], + issueType: '10006', + priority: 'High', + parent: 'RJ-107', + }, + }; + test('it creates the incident correctly', async () => { /* The response from Jira when creating an issue contains only the key and the id. The function makes the following calls when creating an issue: @@ -225,24 +295,19 @@ describe('Jira service', () => { 2. Create the issue. 3. Get the created issue with all the necessary fields. */ - requestMock.mockImplementationOnce(() => ({ - data: { id: '1', key: 'CK-1', fields: { summary: 'title', description: 'description' } }, - })); + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { id: '1', key: 'CK-1', fields: { summary: 'title', description: 'description' } }, + }) + ); - requestMock.mockImplementationOnce(() => ({ - data: { id: '1', key: 'CK-1', fields: { created: '2020-04-27T10:59:46.202Z' } }, - })); + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { id: '1', key: 'CK-1', fields: { created: '2020-04-27T10:59:46.202Z' } }, + }) + ); - const res = await service.createIncident({ - incident: { - summary: 'title', - description: 'desc', - labels: [], - issueType: '10006', - priority: 'High', - parent: null, - }, - }); + const res = await service.createIncident(incident); expect(res).toEqual({ title: 'CK-1', @@ -260,24 +325,30 @@ describe('Jira service', () => { 3. Get the created issue with all the necessary fields. */ // getIssueType mocks - requestMock.mockImplementationOnce(() => ({ - data: { - capabilities: { - navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + capabilities: { + navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', + }, }, - }, - })); + }) + ); // getIssueType mocks requestMock.mockImplementationOnce(() => issueTypesResponse); - requestMock.mockImplementationOnce(() => ({ - data: { id: '1', key: 'CK-1', fields: { summary: 'title', description: 'description' } }, - })); + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { id: '1', key: 'CK-1', fields: { summary: 'title', description: 'description' } }, + }) + ); - requestMock.mockImplementationOnce(() => ({ - data: { id: '1', key: 'CK-1', fields: { created: '2020-04-27T10:59:46.202Z' } }, - })); + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { id: '1', key: 'CK-1', fields: { created: '2020-04-27T10:59:46.202Z' } }, + }) + ); const res = await service.createIncident({ incident: { @@ -317,25 +388,31 @@ describe('Jira service', () => { }); test('removes newline characters and trialing spaces from summary', async () => { - requestMock.mockImplementationOnce(() => ({ - data: { - capabilities: { - navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + capabilities: { + navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', + }, }, - }, - })); + }) + ); // getIssueType mocks requestMock.mockImplementationOnce(() => issueTypesResponse); // getIssueType mocks - requestMock.mockImplementationOnce(() => ({ - data: { id: '1', key: 'CK-1', fields: { summary: 'test', description: 'description' } }, - })); + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { id: '1', key: 'CK-1', fields: { summary: 'test', description: 'description' } }, + }) + ); - requestMock.mockImplementationOnce(() => ({ - data: { id: '1', key: 'CK-1', fields: { created: '2020-04-27T10:59:46.202Z' } }, - })); + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { id: '1', key: 'CK-1', fields: { created: '2020-04-27T10:59:46.202Z' } }, + }) + ); await service.createIncident({ incident: { @@ -368,24 +445,17 @@ describe('Jira service', () => { }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data: { - id: '1', - key: 'CK-1', - fields: { created: '2020-04-27T10:59:46.202Z' }, - }, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + id: '1', + key: 'CK-1', + fields: { created: '2020-04-27T10:59:46.202Z' }, + }, + }) + ); - await service.createIncident({ - incident: { - summary: 'title', - description: 'desc', - labels: [], - issueType: '10006', - priority: 'High', - parent: 'RJ-107', - }, - }); + await service.createIncident(incident); expect(requestMock).toHaveBeenCalledWith({ axios, @@ -414,44 +484,55 @@ describe('Jira service', () => { throw error; }); - await expect( - service.createIncident({ - incident: { - summary: 'title', - description: 'desc', - labels: [], - issueType: '10006', - priority: 'High', - parent: null, - }, - }) - ).rejects.toThrow( + await expect(service.createIncident(incident)).rejects.toThrow( '[Action][Jira]: Unable to create incident. Error: An error has occurred. Reason: Required field' ); }); + + test('it should throw if the request is not a JSON', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.createIncident(incident)).rejects.toThrow( + '[Action][Jira]: Unable to create incident. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json. Reason: unknown: errorResponse was null' + ); + }); + + test('it should throw if the required attributes are not there', async () => { + requestMock.mockImplementation(() => createAxiosResponse({ data: { notRequired: 'test' } })); + + await expect(service.createIncident(incident)).rejects.toThrow( + '[Action][Jira]: Unable to create incident. Error: Response is missing at least one of the expected fields: id. Reason: unknown: errorResponse was null' + ); + }); }); describe('updateIncident', () => { + const incident = { + incidentId: '1', + incident: { + summary: 'title', + description: 'desc', + labels: [], + issueType: '10006', + priority: 'High', + parent: 'RJ-107', + }, + }; + test('it updates the incident correctly', async () => { - requestMock.mockImplementation(() => ({ - data: { - id: '1', - key: 'CK-1', - fields: { updated: '2020-04-27T10:59:46.202Z' }, - }, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + id: '1', + key: 'CK-1', + fields: { updated: '2020-04-27T10:59:46.202Z' }, + }, + }) + ); - const res = await service.updateIncident({ - incidentId: '1', - incident: { - summary: 'title', - description: 'desc', - labels: [], - issueType: '10006', - priority: 'High', - parent: null, - }, - }); + const res = await service.updateIncident(incident); expect(res).toEqual({ title: 'CK-1', @@ -462,25 +543,17 @@ describe('Jira service', () => { }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data: { - id: '1', - key: 'CK-1', - fields: { updated: '2020-04-27T10:59:46.202Z' }, - }, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + id: '1', + key: 'CK-1', + fields: { updated: '2020-04-27T10:59:46.202Z' }, + }, + }) + ); - await service.updateIncident({ - incidentId: '1', - incident: { - summary: 'title', - description: 'desc', - labels: [], - issueType: '10006', - priority: 'High', - parent: 'RJ-107', - }, - }); + await service.updateIncident(incident); expect(requestMock).toHaveBeenCalledWith({ axios, @@ -509,41 +582,42 @@ describe('Jira service', () => { throw error; }); - await expect( - service.updateIncident({ - incidentId: '1', - incident: { - summary: 'title', - description: 'desc', - labels: [], - issueType: '10006', - priority: 'High', - parent: null, - }, - }) - ).rejects.toThrow( + await expect(service.updateIncident(incident)).rejects.toThrow( '[Action][Jira]: Unable to update incident with id 1. Error: An error has occurred. Reason: Required field' ); }); + + test('it should throw if the request is not a JSON', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.updateIncident(incident)).rejects.toThrow( + '[Action][Jira]: Unable to update incident with id 1. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json. Reason: unknown: errorResponse was null' + ); + }); }); describe('createComment', () => { + const commentReq = { + incidentId: '1', + comment: { + comment: 'comment', + commentId: 'comment-1', + }, + }; test('it creates the comment correctly', async () => { - requestMock.mockImplementation(() => ({ - data: { - id: '1', - key: 'CK-1', - created: '2020-04-27T10:59:46.202Z', - }, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + id: '1', + key: 'CK-1', + created: '2020-04-27T10:59:46.202Z', + }, + }) + ); - const res = await service.createComment({ - incidentId: '1', - comment: { - comment: 'comment', - commentId: 'comment-1', - }, - }); + const res = await service.createComment(commentReq); expect(res).toEqual({ commentId: 'comment-1', @@ -553,21 +627,17 @@ describe('Jira service', () => { }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data: { - id: '1', - key: 'CK-1', - created: '2020-04-27T10:59:46.202Z', - }, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + id: '1', + key: 'CK-1', + created: '2020-04-27T10:59:46.202Z', + }, + }) + ); - await service.createComment({ - incidentId: '1', - comment: { - comment: 'comment', - commentId: 'comment-1', - }, - }); + await service.createComment(commentReq); expect(requestMock).toHaveBeenCalledWith({ axios, @@ -586,29 +656,33 @@ describe('Jira service', () => { throw error; }); - await expect( - service.createComment({ - incidentId: '1', - comment: { - comment: 'comment', - commentId: 'comment-1', - }, - }) - ).rejects.toThrow( + await expect(service.createComment(commentReq)).rejects.toThrow( '[Action][Jira]: Unable to create comment at incident with id 1. Error: An error has occurred. Reason: Required field' ); }); + + test('it should throw if the request is not a JSON', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.createComment(commentReq)).rejects.toThrow( + '[Action][Jira]: Unable to create comment at incident with id 1. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json. Reason: unknown: errorResponse was null' + ); + }); + + test('it should throw if the required attributes are not there', async () => { + requestMock.mockImplementation(() => createAxiosResponse({ data: { notRequired: 'test' } })); + + await expect(service.createComment(commentReq)).rejects.toThrow( + '[Action][Jira]: Unable to create comment at incident with id 1. Error: Response is missing at least one of the expected fields: id,created. Reason: unknown: errorResponse was null' + ); + }); }); describe('getCapabilities', () => { test('it should return the capabilities', async () => { - requestMock.mockImplementation(() => ({ - data: { - capabilities: { - navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', - }, - }, - })); + mockOldAPI(); const res = await service.getCapabilities(); expect(res).toEqual({ capabilities: { @@ -618,13 +692,7 @@ describe('Jira service', () => { }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data: { - capabilities: { - navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', - }, - }, - })); + mockOldAPI(); await service.getCapabilities(); @@ -649,16 +717,34 @@ describe('Jira service', () => { ); }); - test('it should throw an auth error', async () => { + test('it should return unknown if the error is a string', async () => { requestMock.mockImplementation(() => { const error = new Error('An error has occurred'); - // @ts-ignore this can happen! + // @ts-ignore error.response = { data: 'Unauthorized' }; throw error; }); await expect(service.getCapabilities()).rejects.toThrow( - '[Action][Jira]: Unable to get capabilities. Error: An error has occurred. Reason: Unauthorized' + '[Action][Jira]: Unable to get capabilities. Error: An error has occurred. Reason: unknown: errorResponse.errors was null' + ); + }); + + test('it should throw if the request is not a JSON', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.getCapabilities()).rejects.toThrow( + '[Action][Jira]: Unable to get capabilities. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json. Reason: unknown: errorResponse was null' + ); + }); + + test('it should throw if the required attributes are not there', async () => { + requestMock.mockImplementation(() => createAxiosResponse({ data: { notRequired: 'test' } })); + + await expect(service.getCapabilities()).rejects.toThrow( + '[Action][Jira]: Unable to get capabilities. Error: Response is missing at least one of the expected fields: capabilities. Reason: unknown: errorResponse was null' ); }); }); @@ -666,13 +752,7 @@ describe('Jira service', () => { describe('getIssueTypes', () => { describe('Old API', () => { test('it should return the issue types', async () => { - requestMock.mockImplementationOnce(() => ({ - data: { - capabilities: { - navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', - }, - }, - })); + mockOldAPI(); requestMock.mockImplementationOnce(() => issueTypesResponse); @@ -691,13 +771,7 @@ describe('Jira service', () => { }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementationOnce(() => ({ - data: { - capabilities: { - navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', - }, - }, - })); + mockOldAPI(); requestMock.mockImplementationOnce(() => issueTypesResponse); @@ -713,13 +787,7 @@ describe('Jira service', () => { }); test('it should throw an error', async () => { - requestMock.mockImplementationOnce(() => ({ - data: { - capabilities: { - navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', - }, - }, - })); + mockOldAPI(); requestMock.mockImplementation(() => { const error: ResponseError = new Error('An error has occurred'); @@ -731,25 +799,30 @@ describe('Jira service', () => { '[Action][Jira]: Unable to get issue types. Error: An error has occurred. Reason: Could not get issue types' ); }); + + test('it should throw if the request is not a JSON', async () => { + mockOldAPI(); + + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.getIssueTypes()).rejects.toThrow( + '[Action][Jira]: Unable to get issue types. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json. Reason: unknown: errorResponse was null' + ); + }); }); describe('New API', () => { test('it should return the issue types', async () => { - requestMock.mockImplementationOnce(() => ({ - data: { - capabilities: { - 'list-project-issuetypes': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', - 'list-issuetype-fields': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', - }, - }, - })); + mockNewAPI(); - requestMock.mockImplementationOnce(() => ({ - data: { - values: issueTypesResponse.data.projects[0].issuetypes, - }, - })); + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + values: issueTypesResponse.data.projects[0].issuetypes, + }, + }) + ); const res = await service.getIssueTypes(); @@ -766,22 +839,15 @@ describe('Jira service', () => { }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementationOnce(() => ({ - data: { - capabilities: { - 'list-project-issuetypes': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', - 'list-issuetype-fields': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', - }, - }, - })); + mockNewAPI(); - requestMock.mockImplementationOnce(() => ({ - data: { - values: issueTypesResponse.data.projects[0].issuetypes, - }, - })); + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + values: issueTypesResponse.data.projects[0].issuetypes, + }, + }) + ); await service.getIssueTypes(); @@ -795,16 +861,7 @@ describe('Jira service', () => { }); test('it should throw an error', async () => { - requestMock.mockImplementationOnce(() => ({ - data: { - capabilities: { - 'list-project-issuetypes': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', - 'list-issuetype-fields': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', - }, - }, - })); + mockNewAPI(); requestMock.mockImplementation(() => { const error: ResponseError = new Error('An error has occurred'); @@ -816,19 +873,25 @@ describe('Jira service', () => { '[Action][Jira]: Unable to get issue types. Error: An error has occurred. Reason: Could not get issue types' ); }); + + test('it should throw if the request is not a JSON', async () => { + mockNewAPI(); + + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.getIssueTypes()).rejects.toThrow( + '[Action][Jira]: Unable to get issue types. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json. Reason: unknown: errorResponse was null' + ); + }); }); }); describe('getFieldsByIssueType', () => { describe('Old API', () => { test('it should return the fields', async () => { - requestMock.mockImplementationOnce(() => ({ - data: { - capabilities: { - navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', - }, - }, - })); + mockOldAPI(); requestMock.mockImplementationOnce(() => fieldsResponse); @@ -857,13 +920,7 @@ describe('Jira service', () => { }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementationOnce(() => ({ - data: { - capabilities: { - navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', - }, - }, - })); + mockOldAPI(); requestMock.mockImplementationOnce(() => fieldsResponse); @@ -879,13 +936,7 @@ describe('Jira service', () => { }); test('it should throw an error', async () => { - requestMock.mockImplementationOnce(() => ({ - data: { - capabilities: { - navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', - }, - }, - })); + mockOldAPI(); requestMock.mockImplementation(() => { const error: ResponseError = new Error('An error has occurred'); @@ -897,43 +948,48 @@ describe('Jira service', () => { '[Action][Jira]: Unable to get fields. Error: An error has occurred. Reason: Could not get fields' ); }); + + test('it should throw if the request is not a JSON', async () => { + mockOldAPI(); + + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.getFieldsByIssueType('10006')).rejects.toThrow( + '[Action][Jira]: Unable to get fields. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json. Reason: unknown: errorResponse was null' + ); + }); }); describe('New API', () => { test('it should return the fields', async () => { - requestMock.mockImplementationOnce(() => ({ - data: { - capabilities: { - 'list-project-issuetypes': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', - 'list-issuetype-fields': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', - }, - }, - })); - - requestMock.mockImplementationOnce(() => ({ - data: { - values: [ - { required: true, schema: { type: 'string' }, fieldId: 'summary' }, - { - required: false, - schema: { type: 'string' }, - fieldId: 'priority', - allowedValues: [ - { + mockNewAPI(); + + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + values: [ + { required: true, schema: { type: 'string' }, fieldId: 'summary' }, + { + required: false, + schema: { type: 'string' }, + fieldId: 'priority', + allowedValues: [ + { + name: 'Medium', + id: '3', + }, + ], + defaultValue: { name: 'Medium', id: '3', }, - ], - defaultValue: { - name: 'Medium', - id: '3', }, - }, - ], - }, - })); + ], + }, + }) + ); const res = await service.getFieldsByIssueType('10006'); @@ -954,39 +1010,32 @@ describe('Jira service', () => { }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementationOnce(() => ({ - data: { - capabilities: { - 'list-project-issuetypes': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', - 'list-issuetype-fields': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', - }, - }, - })); - - requestMock.mockImplementationOnce(() => ({ - data: { - values: [ - { required: true, schema: { type: 'string' }, fieldId: 'summary' }, - { - required: true, - schema: { type: 'string' }, - fieldId: 'priority', - allowedValues: [ - { + mockNewAPI(); + + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + values: [ + { required: true, schema: { type: 'string' }, fieldId: 'summary' }, + { + required: true, + schema: { type: 'string' }, + fieldId: 'priority', + allowedValues: [ + { + name: 'Medium', + id: '3', + }, + ], + defaultValue: { name: 'Medium', id: '3', }, - ], - defaultValue: { - name: 'Medium', - id: '3', }, - }, - ], - }, - })); + ], + }, + }) + ); await service.getFieldsByIssueType('10006'); @@ -1000,16 +1049,7 @@ describe('Jira service', () => { }); test('it should throw an error', async () => { - requestMock.mockImplementationOnce(() => ({ - data: { - capabilities: { - 'list-project-issuetypes': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', - 'list-issuetype-fields': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', - }, - }, - })); + mockNewAPI(); requestMock.mockImplementation(() => { const error: ResponseError = new Error('An error has occurred'); @@ -1021,16 +1061,30 @@ describe('Jira service', () => { '[Action][Jira]: Unable to get fields. Error: An error has occurred. Reason: Could not get issue types' ); }); + + test('it should throw if the request is not a JSON', async () => { + mockNewAPI(); + + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.getFieldsByIssueType('10006')).rejects.toThrow( + '[Action][Jira]: Unable to get fields. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json. Reason: unknown: errorResponse was null' + ); + }); }); }); describe('getIssues', () => { test('it should return the issues', async () => { - requestMock.mockImplementation(() => ({ - data: { - issues: issuesResponse, - }, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + issues: issuesResponse, + }, + }) + ); const res = await service.getIssues('Test title'); @@ -1044,11 +1098,13 @@ describe('Jira service', () => { }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data: { - issues: issuesResponse, - }, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + issues: issuesResponse, + }, + }) + ); await service.getIssues('Test title'); expect(requestMock).toHaveBeenLastCalledWith({ @@ -1071,13 +1127,25 @@ describe('Jira service', () => { '[Action][Jira]: Unable to get issues. Error: An error has occurred. Reason: Could not get issue types' ); }); + + test('it should throw if the request is not a JSON', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.getIssues('Test title')).rejects.toThrow( + '[Action][Jira]: Unable to get issues. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json. Reason: unknown: errorResponse was null' + ); + }); }); describe('getIssue', () => { test('it should return a single issue', async () => { - requestMock.mockImplementation(() => ({ - data: issueResponse, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: issueResponse, + }) + ); const res = await service.getIssue('RJ-107'); @@ -1089,11 +1157,13 @@ describe('Jira service', () => { }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data: { - issues: issuesResponse, - }, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + issues: issuesResponse, + }, + }) + ); await service.getIssue('RJ-107'); expect(requestMock).toHaveBeenLastCalledWith({ @@ -1116,81 +1186,105 @@ describe('Jira service', () => { '[Action][Jira]: Unable to get issue with id RJ-107. Error: An error has occurred. Reason: Could not get issue types' ); }); + + test('it should throw if the request is not a JSON', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.getIssue('Test title')).rejects.toThrow( + '[Action][Jira]: Unable to get issue with id Test title. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json. Reason: unknown: errorResponse was null' + ); + }); }); describe('getFields', () => { const callMocks = () => { requestMock - .mockImplementationOnce(() => ({ - data: { - capabilities: { - 'list-project-issuetypes': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', - 'list-issuetype-fields': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', + .mockImplementationOnce(() => + createAxiosResponse({ + data: { + capabilities: { + 'list-project-issuetypes': + 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', + 'list-issuetype-fields': + 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', + }, }, - }, - })) - .mockImplementationOnce(() => ({ - data: { - values: issueTypesResponse.data.projects[0].issuetypes, - }, - })) - .mockImplementationOnce(() => ({ - data: { - capabilities: { - 'list-project-issuetypes': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', - 'list-issuetype-fields': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', + }) + ) + .mockImplementationOnce(() => + createAxiosResponse({ + data: { + values: issueTypesResponse.data.projects[0].issuetypes, }, - }, - })) - .mockImplementationOnce(() => ({ - data: { - capabilities: { - 'list-project-issuetypes': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', - 'list-issuetype-fields': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', + }) + ) + .mockImplementationOnce(() => + createAxiosResponse({ + data: { + capabilities: { + 'list-project-issuetypes': + 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', + 'list-issuetype-fields': + 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', + }, }, - }, - })) - .mockImplementationOnce(() => ({ - data: { - values: [ - { required: true, schema: { type: 'string' }, fieldId: 'summary' }, - { required: true, schema: { type: 'string' }, fieldId: 'description' }, - { - required: false, - schema: { type: 'string' }, - fieldId: 'priority', - allowedValues: [ - { + }) + ) + .mockImplementationOnce(() => + createAxiosResponse({ + data: { + capabilities: { + 'list-project-issuetypes': + 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', + 'list-issuetype-fields': + 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', + }, + }, + }) + ) + .mockImplementationOnce(() => + createAxiosResponse({ + data: { + values: [ + { required: true, schema: { type: 'string' }, fieldId: 'summary' }, + { required: true, schema: { type: 'string' }, fieldId: 'description' }, + { + required: false, + schema: { type: 'string' }, + fieldId: 'priority', + allowedValues: [ + { + name: 'Medium', + id: '3', + }, + ], + defaultValue: { name: 'Medium', id: '3', }, - ], - defaultValue: { - name: 'Medium', - id: '3', }, - }, - ], - }, - })) - .mockImplementationOnce(() => ({ - data: { - values: [ - { required: true, schema: { type: 'string' }, fieldId: 'summary' }, - { required: true, schema: { type: 'string' }, fieldId: 'description' }, - ], - }, - })); + ], + }, + }) + ) + .mockImplementationOnce(() => + createAxiosResponse({ + data: { + values: [ + { required: true, schema: { type: 'string' }, fieldId: 'summary' }, + { required: true, schema: { type: 'string' }, fieldId: 'description' }, + ], + }, + }) + ); }; + beforeEach(() => { jest.resetAllMocks(); }); + test('it should call request with correct arguments', async () => { callMocks(); await service.getFields(); diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts index be0240e705a6..a3262a526e2f 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts @@ -27,7 +27,7 @@ import { } from './types'; import * as i18n from './translations'; -import { request, getErrorMessage } from '../lib/axios_utils'; +import { request, getErrorMessage, throwIfResponseIsNotValid } from '../lib/axios_utils'; import { ActionsConfigurationUtilities } from '../../actions_config'; const VERSION = '2'; @@ -111,19 +111,15 @@ export const createExternalService = ( .filter((item) => !isEmpty(item)) .join(', '); - const createErrorMessage = (errorResponse: ResponseError | string | null | undefined): string => { + const createErrorMessage = (errorResponse: ResponseError | null | undefined): string => { if (errorResponse == null) { - return ''; - } - if (typeof errorResponse === 'string') { - // Jira error.response.data can be string!! - return errorResponse; + return 'unknown: errorResponse was null'; } const { errorMessages, errors } = errorResponse; if (errors == null) { - return ''; + return 'unknown: errorResponse.errors was null'; } if (Array.isArray(errorMessages) && errorMessages.length > 0) { @@ -185,9 +181,14 @@ export const createExternalService = ( configurationUtilities, }); - const { fields, ...rest } = res.data; + throwIfResponseIsNotValid({ + res, + requiredAttributesToBeInTheResponse: ['id', 'key'], + }); + + const { fields, id: incidentId, key } = res.data; - return { ...rest, ...fields }; + return { id: incidentId, key, created: fields.created, updated: fields.updated, ...fields }; } catch (error) { throw new Error( getErrorMessage( @@ -234,6 +235,11 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + requiredAttributesToBeInTheResponse: ['id'], + }); + const updatedIncident = await getIncident(res.data.id); return { @@ -266,7 +272,7 @@ export const createExternalService = ( const fields = createFields(projectKey, incidentWithoutNullValues); try { - await request({ + const res = await request({ axios: axiosInstance, method: 'put', url: `${incidentUrl}/${incidentId}`, @@ -275,6 +281,10 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + }); + const updatedIncident = await getIncident(incidentId as string); return { @@ -309,6 +319,11 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + requiredAttributesToBeInTheResponse: ['id', 'created'], + }); + return { commentId: comment.commentId, externalCommentId: res.data.id, @@ -336,6 +351,11 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + requiredAttributesToBeInTheResponse: ['capabilities'], + }); + return { ...res.data }; } catch (error) { throw new Error( @@ -362,6 +382,10 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + }); + const issueTypes = res.data.projects[0]?.issuetypes ?? []; return normalizeIssueTypes(issueTypes); } else { @@ -373,6 +397,10 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + }); + const issueTypes = res.data.values; return normalizeIssueTypes(issueTypes); } @@ -401,6 +429,10 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + }); + const fields = res.data.projects[0]?.issuetypes[0]?.fields || {}; return normalizeFields(fields); } else { @@ -412,6 +444,10 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + }); + const fields = res.data.values.reduce( (acc: { [x: string]: {} }, value: { fieldId: string }) => ({ ...acc, @@ -471,6 +507,10 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + }); + return normalizeSearchResults(res.data?.issues ?? []); } catch (error) { throw new Error( @@ -495,6 +535,10 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + }); + return normalizeIssue(res.data ?? {}); } catch (error) { throw new Error( diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts index 287f74c6bc70..d0177e0e5a8a 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts @@ -10,7 +10,14 @@ import { Agent as HttpsAgent } from 'https'; import HttpProxyAgent from 'http-proxy-agent'; import { HttpsProxyAgent } from 'https-proxy-agent'; import { Logger } from '../../../../../../src/core/server'; -import { addTimeZoneToDate, request, patch, getErrorMessage } from './axios_utils'; +import { + addTimeZoneToDate, + request, + patch, + getErrorMessage, + throwIfResponseIsNotValid, + createAxiosResponse, +} from './axios_utils'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import { actionsConfigMock } from '../../actions_config.mock'; import { getCustomAgents } from './get_custom_agents'; @@ -292,3 +299,82 @@ describe('getErrorMessage', () => { expect(msg).toBe('[Action][My connector name]: An error has occurred'); }); }); + +describe('throwIfResponseIsNotValid', () => { + const res = createAxiosResponse({ + headers: { ['content-type']: 'application/json' }, + data: { incident: { id: '1' } }, + }); + + test('it does NOT throw if the request is valid', () => { + expect(() => throwIfResponseIsNotValid({ res })).not.toThrow(); + }); + + test('it does throw if the content-type is not json', () => { + expect(() => + throwIfResponseIsNotValid({ + res: { ...res, headers: { ['content-type']: 'text/html' } }, + }) + ).toThrow( + 'Unsupported content type: text/html in GET https://example.com. Supported content types: application/json' + ); + }); + + test('it does throw if the content-type is undefined', () => { + expect(() => + throwIfResponseIsNotValid({ + res: { ...res, headers: {} }, + }) + ).toThrow( + 'Unsupported content type: undefined in GET https://example.com. Supported content types: application/json' + ); + }); + + test('it does throw if the data is not an object or array', () => { + expect(() => + throwIfResponseIsNotValid({ + res: { ...res, data: 'string' }, + }) + ).toThrow('Response is not a valid JSON'); + }); + + test('it does NOT throw if the data is an array', () => { + expect(() => + throwIfResponseIsNotValid({ + res: { ...res, data: ['test'] }, + }) + ).not.toThrow(); + }); + + test.each(['', [], {}])('it does NOT throw if the data is %p', (data) => { + expect(() => + throwIfResponseIsNotValid({ + res: { ...res, data }, + }) + ).not.toThrow(); + }); + + test('it does throw if the required attribute is not in the response', () => { + expect(() => + throwIfResponseIsNotValid({ res, requiredAttributesToBeInTheResponse: ['not-exist'] }) + ).toThrow('Response is missing at least one of the expected fields: not-exist'); + }); + + test('it does throw if the required attribute are defined and the data is an array', () => { + expect(() => + throwIfResponseIsNotValid({ + res: { ...res, data: ['test'] }, + requiredAttributesToBeInTheResponse: ['not-exist'], + }) + ).toThrow('Response is missing at least one of the expected fields: not-exist'); + }); + + test('it does NOT throw if the value of the required attribute is null', () => { + expect(() => + throwIfResponseIsNotValid({ + res: { ...res, data: { id: null } }, + requiredAttributesToBeInTheResponse: ['id'], + }) + ).not.toThrow(); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.ts index af353e1d1da5..43c9d276e657 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { isObjectLike, isEmpty } from 'lodash'; import { AxiosInstance, Method, AxiosResponse, AxiosBasicCredentials } from 'axios'; import { Logger } from '../../../../../../src/core/server'; import { getCustomAgents } from './get_custom_agents'; @@ -76,3 +77,70 @@ export const addTimeZoneToDate = (date: string, timezone = 'GMT'): string => { export const getErrorMessage = (connector: string, msg: string) => { return `[Action][${connector}]: ${msg}`; }; + +export const throwIfResponseIsNotValid = ({ + res, + requiredAttributesToBeInTheResponse = [], +}: { + res: AxiosResponse; + requiredAttributesToBeInTheResponse?: string[]; +}) => { + const requiredContentType = 'application/json'; + const contentType = res.headers['content-type'] ?? 'undefined'; + const data = res.data; + + /** + * Check that the content-type of the response is application/json. + * Then includes is added because the header can be application/json;charset=UTF-8. + */ + if (!contentType.includes(requiredContentType)) { + throw new Error( + `Unsupported content type: ${contentType} in ${res.config.method} ${res.config.url}. Supported content types: ${requiredContentType}` + ); + } + + /** + * Check if the response is a JS object (data != null && typeof data === 'object') + * in case the content type is application/json but for some reason the response is not. + * Empty responses (204 No content) are ignored because the typeof data will be string and + * isObjectLike will fail. + * Axios converts automatically JSON to JS objects. + */ + if (!isEmpty(data) && !isObjectLike(data)) { + throw new Error('Response is not a valid JSON'); + } + + if (requiredAttributesToBeInTheResponse.length > 0) { + const requiredAttributesError = new Error( + `Response is missing at least one of the expected fields: ${requiredAttributesToBeInTheResponse.join( + ',' + )}` + ); + + /** + * If the response is an array and requiredAttributesToBeInTheResponse + * are not empty then we thrown an error assuming that the consumer + * expects an object response and not an array. + */ + + if (Array.isArray(data)) { + throw requiredAttributesError; + } + + requiredAttributesToBeInTheResponse.forEach((attr) => { + // Check only for undefined as null is a valid value + if (data[attr] === undefined) { + throw requiredAttributesError; + } + }); + } +}; + +export const createAxiosResponse = (res: Partial): AxiosResponse => ({ + data: {}, + status: 200, + statusText: 'OK', + headers: { ['content-type']: 'application/json' }, + config: { method: 'GET', url: 'https://example.com' }, + ...res, +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts index ba5554338622..094b8150850d 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts @@ -8,7 +8,7 @@ import axios from 'axios'; import { createExternalService, getValueTextContent, formatUpdateRequest } from './service'; -import * as utils from '../lib/axios_utils'; +import { request, createAxiosResponse } from '../lib/axios_utils'; import { ExternalService } from './types'; import { Logger } from '../../../../../../src/core/server'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; @@ -27,7 +27,7 @@ jest.mock('../lib/axios_utils', () => { }); axios.create = jest.fn(() => axios); -const requestMock = utils.request as jest.Mock; +const requestMock = request as jest.Mock; const now = Date.now; const TIMESTAMP = 1589391874472; const configurationUtilities = actionsConfigMock.create(); @@ -38,44 +38,50 @@ const configurationUtilities = actionsConfigMock.create(); // b) Update the incident // c) Get the updated incident const mockIncidentUpdate = (withUpdateError = false) => { - requestMock.mockImplementationOnce(() => ({ - data: { - id: '1', - name: 'title', - description: { - format: 'html', - content: 'description', + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + id: '1', + name: 'title', + description: { + format: 'html', + content: 'description', + }, + incident_type_ids: [1001, 16, 12], + severity_code: 6, }, - incident_type_ids: [1001, 16, 12], - severity_code: 6, - }, - })); + }) + ); if (withUpdateError) { requestMock.mockImplementationOnce(() => { throw new Error('An error has occurred'); }); } else { - requestMock.mockImplementationOnce(() => ({ + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + success: true, + id: '1', + inc_last_modified_date: 1589391874472, + }, + }) + ); + } + + requestMock.mockImplementationOnce(() => + createAxiosResponse({ data: { - success: true, id: '1', + name: 'title_updated', + description: { + format: 'html', + content: 'desc_updated', + }, inc_last_modified_date: 1589391874472, }, - })); - } - - requestMock.mockImplementationOnce(() => ({ - data: { - id: '1', - name: 'title_updated', - description: { - format: 'html', - content: 'desc_updated', - }, - inc_last_modified_date: 1589391874472, - }, - })); + }) + ); }; describe('IBM Resilient service', () => { @@ -207,24 +213,28 @@ describe('IBM Resilient service', () => { describe('getIncident', () => { test('it returns the incident correctly', async () => { - requestMock.mockImplementation(() => ({ - data: { - id: '1', - name: '1', - description: { - format: 'html', - content: 'description', + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + id: '1', + name: '1', + description: { + format: 'html', + content: 'description', + }, }, - }, - })); + }) + ); const res = await service.getIncident('1'); expect(res).toEqual({ id: '1', name: '1', description: 'description' }); }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data: { id: '1' }, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { id: '1' }, + }) + ); await service.getIncident('1'); expect(requestMock).toHaveBeenCalledWith({ @@ -246,28 +256,42 @@ describe('IBM Resilient service', () => { 'Unable to get incident with id 1. Error: An error has occurred' ); }); + + test('it should throw if the request is not a JSON', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.getIncident('1')).rejects.toThrow( + '[Action][IBM Resilient]: Unable to get incident with id 1. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json.' + ); + }); }); describe('createIncident', () => { + const incident = { + incident: { + name: 'title', + description: 'desc', + incidentTypes: [1001], + severityCode: 6, + }, + }; + test('it creates the incident correctly', async () => { - requestMock.mockImplementation(() => ({ - data: { - id: '1', - name: 'title', - description: 'description', - discovered_date: 1589391874472, - create_date: 1589391874472, - }, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + id: '1', + name: 'title', + description: 'description', + discovered_date: 1589391874472, + create_date: 1589391874472, + }, + }) + ); - const res = await service.createIncident({ - incident: { - name: 'title', - description: 'desc', - incidentTypes: [1001], - severityCode: 6, - }, - }); + const res = await service.createIncident(incident); expect(res).toEqual({ title: '1', @@ -278,24 +302,19 @@ describe('IBM Resilient service', () => { }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data: { - id: '1', - name: 'title', - description: 'description', - discovered_date: 1589391874472, - create_date: 1589391874472, - }, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + id: '1', + name: 'title', + description: 'description', + discovered_date: 1589391874472, + create_date: 1589391874472, + }, + }) + ); - await service.createIncident({ - incident: { - name: 'title', - description: 'desc', - incidentTypes: [1001], - severityCode: 6, - }, - }); + await service.createIncident(incident); expect(requestMock).toHaveBeenCalledWith({ axios, @@ -334,20 +353,39 @@ describe('IBM Resilient service', () => { '[Action][IBM Resilient]: Unable to create incident. Error: An error has occurred' ); }); + + test('it should throw if the request is not a JSON', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.createIncident(incident)).rejects.toThrow( + '[Action][IBM Resilient]: Unable to create incident. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json.' + ); + }); + + test('it should throw if the required attributes are not there', async () => { + requestMock.mockImplementation(() => createAxiosResponse({ data: { notRequired: 'test' } })); + + await expect(service.createIncident(incident)).rejects.toThrow( + '[Action][IBM Resilient]: Unable to create incident. Error: Response is missing at least one of the expected fields: id,create_date.' + ); + }); }); describe('updateIncident', () => { + const req = { + incidentId: '1', + incident: { + name: 'title', + description: 'desc', + incidentTypes: [1001], + severityCode: 6, + }, + }; test('it updates the incident correctly', async () => { mockIncidentUpdate(); - const res = await service.updateIncident({ - incidentId: '1', - incident: { - name: 'title', - description: 'desc', - incidentTypes: [1001], - severityCode: 6, - }, - }); + const res = await service.updateIncident(req); expect(res).toEqual({ title: '1', @@ -430,38 +468,59 @@ describe('IBM Resilient service', () => { test('it should throw an error', async () => { mockIncidentUpdate(true); - await expect( - service.updateIncident({ - incidentId: '1', - incident: { + await expect(service.updateIncident(req)).rejects.toThrow( + '[Action][IBM Resilient]: Unable to update incident with id 1. Error: An error has occurred' + ); + }); + + test('it should throw if the request is not a JSON', async () => { + // get incident request + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + id: '1', name: 'title', - description: 'desc', - incidentTypes: [1001], - severityCode: 5, + description: { + format: 'html', + content: 'description', + }, + incident_type_ids: [1001, 16, 12], + severity_code: 6, }, }) - ).rejects.toThrow( - '[Action][IBM Resilient]: Unable to update incident with id 1. Error: An error has occurred' + ); + + // update incident request + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.updateIncident(req)).rejects.toThrow( + '[Action][IBM Resilient]: Unable to update incident with id 1. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json' ); }); }); describe('createComment', () => { + const req = { + incidentId: '1', + comment: { + comment: 'comment', + commentId: 'comment-1', + }, + }; + test('it creates the comment correctly', async () => { - requestMock.mockImplementation(() => ({ - data: { - id: '1', - create_date: 1589391874472, - }, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + id: '1', + create_date: 1589391874472, + }, + }) + ); - const res = await service.createComment({ - incidentId: '1', - comment: { - comment: 'comment', - commentId: 'comment-1', - }, - }); + const res = await service.createComment(req); expect(res).toEqual({ commentId: 'comment-1', @@ -471,20 +530,16 @@ describe('IBM Resilient service', () => { }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data: { - id: '1', - create_date: 1589391874472, - }, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + id: '1', + create_date: 1589391874472, + }, + }) + ); - await service.createComment({ - incidentId: '1', - comment: { - comment: 'comment', - commentId: 'comment-1', - }, - }); + await service.createComment(req); expect(requestMock).toHaveBeenCalledWith({ axios, @@ -506,27 +561,31 @@ describe('IBM Resilient service', () => { throw new Error('An error has occurred'); }); - await expect( - service.createComment({ - incidentId: '1', - comment: { - comment: 'comment', - commentId: 'comment-1', - }, - }) - ).rejects.toThrow( + await expect(service.createComment(req)).rejects.toThrow( '[Action][IBM Resilient]: Unable to create comment at incident with id 1. Error: An error has occurred' ); }); + + test('it should throw if the request is not a JSON', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.createComment(req)).rejects.toThrow( + '[Action][IBM Resilient]: Unable to create comment at incident with id 1. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json.' + ); + }); }); describe('getIncidentTypes', () => { test('it creates the incident correctly', async () => { - requestMock.mockImplementation(() => ({ - data: { - values: incidentTypes, - }, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + values: incidentTypes, + }, + }) + ); const res = await service.getIncidentTypes(); @@ -545,15 +604,27 @@ describe('IBM Resilient service', () => { '[Action][IBM Resilient]: Unable to get incident types. Error: An error has occurred.' ); }); + + test('it should throw if the request is not a JSON', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.getIncidentTypes()).rejects.toThrow( + '[Action][IBM Resilient]: Unable to get incident types. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json.' + ); + }); }); describe('getSeverity', () => { test('it creates the incident correctly', async () => { - requestMock.mockImplementation(() => ({ - data: { - values: severity, - }, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + values: severity, + }, + }) + ); const res = await service.getSeverity(); @@ -578,17 +649,29 @@ describe('IBM Resilient service', () => { throw new Error('An error has occurred'); }); - await expect(service.getIncidentTypes()).rejects.toThrow( - '[Action][IBM Resilient]: Unable to get incident types. Error: An error has occurred.' + await expect(service.getSeverity()).rejects.toThrow( + '[Action][IBM Resilient]: Unable to get severity. Error: An error has occurred.' + ); + }); + + test('it should throw if the request is not a JSON', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.getSeverity()).rejects.toThrow( + '[Action][IBM Resilient]: Unable to get severity. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json.' ); }); }); describe('getFields', () => { test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data: resilientFields, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: resilientFields, + }) + ); await service.getFields(); expect(requestMock).toHaveBeenCalledWith({ @@ -598,10 +681,13 @@ describe('IBM Resilient service', () => { url: 'https://resilient.elastic.co/rest/orgs/201/types/incident/fields', }); }); + test('it returns common fields correctly', async () => { - requestMock.mockImplementation(() => ({ - data: resilientFields, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: resilientFields, + }) + ); const res = await service.getFields(); expect(res).toEqual(resilientFields); }); @@ -614,5 +700,15 @@ describe('IBM Resilient service', () => { 'Unable to get fields. Error: An error has occurred' ); }); + + test('it should throw if the request is not a JSON', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.getFields()).rejects.toThrow( + '[Action][IBM Resilient]: Unable to get fields. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json.' + ); + }); }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts index 2f385315e439..a469c631fac3 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts @@ -24,7 +24,7 @@ import { } from './types'; import * as i18n from './translations'; -import { getErrorMessage, request } from '../lib/axios_utils'; +import { getErrorMessage, request, throwIfResponseIsNotValid } from '../lib/axios_utils'; import { ActionsConfigurationUtilities } from '../../actions_config'; const VIEW_INCIDENT_URL = `#incidents`; @@ -134,6 +134,10 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + }); + return { ...res.data, description: res.data.description?.content ?? '' }; } catch (error) { throw new Error( @@ -182,6 +186,11 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + requiredAttributesToBeInTheResponse: ['id', 'create_date'], + }); + return { title: `${res.data.id}`, id: `${res.data.id}`, @@ -212,6 +221,10 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + }); + if (!res.data.success) { throw new Error(res.data.message); } @@ -245,6 +258,10 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + }); + return { commentId: comment.commentId, externalCommentId: res.data.id, @@ -270,6 +287,10 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + }); + const incidentTypes = res.data?.values ?? []; return incidentTypes.map((type: { value: string; label: string }) => ({ id: type.value, @@ -292,6 +313,10 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + }); + const incidentTypes = res.data?.values ?? []; return incidentTypes.map((type: { value: string; label: string }) => ({ id: type.value, @@ -312,6 +337,11 @@ export const createExternalService = ( logger, configurationUtilities, }); + + throwIfResponseIsNotValid({ + res, + }); + return res.data ?? []; } catch (error) { throw new Error(getErrorMessage(i18n.NAME, `Unable to get fields. Error: ${error.message}.`)); diff --git a/x-pack/plugins/actions/server/builtin_action_types/swimlane/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/swimlane/service.test.ts index 7b3f310a99e0..21bc4894c571 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/swimlane/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/swimlane/service.test.ts @@ -10,7 +10,7 @@ import axios from 'axios'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import { Logger } from '../../../../../../src/core/server'; import { actionsConfigMock } from '../../actions_config.mock'; -import * as utils from '../lib/axios_utils'; +import { request, createAxiosResponse } from '../lib/axios_utils'; import { createExternalService } from './service'; import { mappings } from './mocks'; import { ExternalService } from './types'; @@ -27,7 +27,7 @@ jest.mock('../lib/axios_utils', () => { }); axios.create = jest.fn(() => axios); -const requestMock = utils.request as jest.Mock; +const requestMock = request as jest.Mock; const configurationUtilities = actionsConfigMock.create(); describe('Swimlane Service', () => { @@ -152,9 +152,7 @@ describe('Swimlane Service', () => { }; test('it creates a record correctly', async () => { - requestMock.mockImplementation(() => ({ - data, - })); + requestMock.mockImplementation(() => createAxiosResponse({ data })); const res = await service.createRecord({ incident, @@ -169,9 +167,7 @@ describe('Swimlane Service', () => { }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data, - })); + requestMock.mockImplementation(() => createAxiosResponse({ data })); await service.createRecord({ incident, @@ -207,6 +203,24 @@ describe('Swimlane Service', () => { `[Action][Swimlane]: Unable to create record in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: unknown` ); }); + + test('it should throw if the request is not a JSON', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ data, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.createRecord({ incident })).rejects.toThrow( + `[Action][Swimlane]: Unable to create record in application with id ${config.appId}. Status: 500. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json. Reason: unknown` + ); + }); + + test('it should throw if the required attributes are not there', async () => { + requestMock.mockImplementation(() => createAxiosResponse({ data: { notRequired: 'test' } })); + + await expect(service.createRecord({ incident })).rejects.toThrow( + `[Action][Swimlane]: Unable to create record in application with id ${config.appId}. Status: 500. Error: Response is missing at least one of the expected fields: id,name,createdDate. Reason: unknown` + ); + }); }); describe('updateRecord', () => { @@ -218,9 +232,7 @@ describe('Swimlane Service', () => { const incidentId = '123'; test('it updates a record correctly', async () => { - requestMock.mockImplementation(() => ({ - data, - })); + requestMock.mockImplementation(() => createAxiosResponse({ data })); const res = await service.updateRecord({ incident, @@ -236,9 +248,7 @@ describe('Swimlane Service', () => { }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data, - })); + requestMock.mockImplementation(() => createAxiosResponse({ data })); await service.updateRecord({ incident, @@ -276,6 +286,24 @@ describe('Swimlane Service', () => { `[Action][Swimlane]: Unable to update record in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: unknown` ); }); + + test('it should throw if the request is not a JSON', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ data, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.updateRecord({ incident, incidentId })).rejects.toThrow( + `[Action][Swimlane]: Unable to update record in application with id ${config.appId}. Status: 500. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json. Reason: unknown` + ); + }); + + test('it should throw if the required attributes are not there', async () => { + requestMock.mockImplementation(() => createAxiosResponse({ data: { notRequired: 'test' } })); + + await expect(service.updateRecord({ incident, incidentId })).rejects.toThrow( + `[Action][Swimlane]: Unable to update record in application with id ${config.appId}. Status: 500. Error: Response is missing at least one of the expected fields: id,name,modifiedDate. Reason: unknown` + ); + }); }); describe('createComment', () => { @@ -289,9 +317,7 @@ describe('Swimlane Service', () => { const createdDate = '2021-06-01T17:29:51.092Z'; test('it updates a record correctly', async () => { - requestMock.mockImplementation(() => ({ - data, - })); + requestMock.mockImplementation(() => createAxiosResponse({ data })); const res = await service.createComment({ comment, @@ -306,9 +332,7 @@ describe('Swimlane Service', () => { }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data, - })); + requestMock.mockImplementation(() => createAxiosResponse({ data })); await service.createComment({ comment, diff --git a/x-pack/plugins/actions/server/builtin_action_types/swimlane/service.ts b/x-pack/plugins/actions/server/builtin_action_types/swimlane/service.ts index f68d22121dbc..d917d7f5677b 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/swimlane/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/swimlane/service.ts @@ -9,7 +9,7 @@ import { Logger } from '@kbn/logging'; import axios from 'axios'; import { ActionsConfigurationUtilities } from '../../actions_config'; -import { getErrorMessage, request } from '../lib/axios_utils'; +import { getErrorMessage, request, throwIfResponseIsNotValid } from '../lib/axios_utils'; import { getBodyForEventAction } from './helpers'; import { CreateCommentParams, @@ -89,6 +89,12 @@ export const createExternalService = ( method: 'post', url: getPostRecordUrl(appId), }); + + throwIfResponseIsNotValid({ + res, + requiredAttributesToBeInTheResponse: ['id', 'name', 'createdDate'], + }); + return { id: res.data.id, title: res.data.name, @@ -124,6 +130,11 @@ export const createExternalService = ( url: getPostRecordIdUrl(appId, params.incidentId), }); + throwIfResponseIsNotValid({ + res, + requiredAttributesToBeInTheResponse: ['id', 'name', 'modifiedDate'], + }); + return { id: res.data.id, title: res.data.name, diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/jira_simulation.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/jira_simulation.ts index 26a9c1bcadf6..e9e6d6732327 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/jira_simulation.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/jira_simulation.ts @@ -30,7 +30,6 @@ export function initPlugin(router: IRouter, path: string) { return jsonResponse(res, 200, { id: '123', key: 'CK-1', - created: '2020-04-27T14:17:45.490Z', }); } ); @@ -48,12 +47,7 @@ export function initPlugin(router: IRouter, path: string) { req: KibanaRequest, res: KibanaResponseFactory ): Promise> { - return jsonResponse(res, 200, { - id: '123', - key: 'CK-1', - created: '2020-04-27T14:17:45.490Z', - updated: '2020-04-27T14:17:45.490Z', - }); + return jsonResponse(res, 204, {}); } ); @@ -73,10 +67,12 @@ export function initPlugin(router: IRouter, path: string) { return jsonResponse(res, 200, { id: '123', key: 'CK-1', - created: '2020-04-27T14:17:45.490Z', - updated: '2020-04-27T14:17:45.490Z', - summary: 'title', - description: 'description', + fields: { + created: '2020-04-27T14:17:45.490Z', + updated: '2020-04-27T14:17:45.490Z', + summary: 'title', + description: 'description', + }, }); } ); @@ -97,6 +93,7 @@ export function initPlugin(router: IRouter, path: string) { return jsonResponse(res, 200, { id: '123', created: '2020-04-27T14:17:45.490Z', + updated: '2020-04-27T14:17:45.490Z', }); } );