From 8e057c1eb63e622d98477466ad91aee7e64f87c4 Mon Sep 17 00:00:00 2001 From: Eugene Long Date: Wed, 31 Mar 2021 10:38:01 +0800 Subject: [PATCH] refactor(webhook-service): add tests for webhook services - moved webhook url validation to new file and updated references - updated tests for webhook validation ref #193 --- src/app/models/form.server.model.ts | 2 +- .../webhook/__tests__/webhook.service.spec.ts | 409 ++++++++++++++++++ .../__tests__/webhook.validation.spec.ts | 13 +- src/app/modules/webhook/webhook.service.ts | 9 +- src/app/modules/webhook/webhook.utils.ts | 61 --- src/app/modules/webhook/webhook.validation.ts | 62 +++ .../modules/webhook/webhook.service.spec.ts | 236 ---------- 7 files changed, 488 insertions(+), 304 deletions(-) create mode 100644 src/app/modules/webhook/__tests__/webhook.service.spec.ts rename tests/unit/backend/modules/webhook/webhook.utils.spec.ts => src/app/modules/webhook/__tests__/webhook.validation.spec.ts (83%) create mode 100644 src/app/modules/webhook/webhook.validation.ts delete mode 100644 tests/unit/backend/modules/webhook/webhook.service.spec.ts diff --git a/src/app/models/form.server.model.ts b/src/app/models/form.server.model.ts index aa8b879aed..16bab05eb7 100644 --- a/src/app/models/form.server.model.ts +++ b/src/app/models/form.server.model.ts @@ -31,7 +31,7 @@ import { IPopulatedUser, IUserSchema } from '../../types/user' import { MB } from '../constants/filesize' import { OverrideProps } from '../modules/form/admin-form/admin-form.types' import { transformEmails } from '../modules/form/form.utils' -import { validateWebhookUrl } from '../modules/webhook/webhook.utils' +import { validateWebhookUrl } from '../modules/webhook/webhook.validation' import getAgencyModel from './agency.server.model' import { diff --git a/src/app/modules/webhook/__tests__/webhook.service.spec.ts b/src/app/modules/webhook/__tests__/webhook.service.spec.ts new file mode 100644 index 0000000000..94d42994df --- /dev/null +++ b/src/app/modules/webhook/__tests__/webhook.service.spec.ts @@ -0,0 +1,409 @@ +import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios' +import { ObjectID } from 'bson' +import { SubmissionNotFoundError } from 'dist/backend/app/modules/submission/submission.errors' +import mongoose from 'mongoose' +import { mocked } from 'ts-jest/utils' + +import getFormModel from 'src/app/models/form.server.model' +import { getEncryptSubmissionModel } from 'src/app/models/submission.server.model' +import { WebhookValidationError } from 'src/app/modules/webhook/webhook.errors' +import * as WebhookValidationModule from 'src/app/modules/webhook/webhook.validation' +import { transformMongoError } from 'src/app/utils/handle-mongo-error' +import * as HasPropModule from 'src/app/utils/has-prop' +import formsgSdk from 'src/config/formsg-sdk' +import { + IEncryptedSubmissionSchema, + IWebhookResponse, + ResponseMode, + WebhookView, +} from 'src/types' + +import dbHandler from 'tests/unit/backend/helpers/jest-db' + +import { saveWebhookRecord, sendWebhook } from '../webhook.service' + +// define suite-wide mocks +jest.mock('axios') +const MockAxios = mocked(axios, true) + +jest.mock('src/app/modules/webhook/webhook.validation') +const MockWebhookValidationModule = mocked(WebhookValidationModule, true) + +// define test constants +const FormModel = getFormModel(mongoose) +const EncryptSubmissionModel = getEncryptSubmissionModel(mongoose) + +const MOCK_WEBHOOK_URL = 'https://form.gov.sg/endpoint' +const DEFAULT_ERROR_MSG = 'a generic error has occurred' +const AXIOS_ERROR_MSG = 'an axios error has occurred' + +const MOCK_AXIOS_SUCCESS_RESPONSE: AxiosResponse = { + data: { + result: 'test-result', + }, + status: 200, + statusText: 'success', + headers: {}, + config: {}, +} +const MOCK_AXIOS_FAILURE_RESPONSE: AxiosResponse = { + data: { + result: 'test-result', + }, + status: 400, + statusText: 'failed', + headers: {}, + config: {}, +} +const MOCK_WEBHOOK_SUCCESS_RESPONSE: Pick = { + response: { + data: '{"result":"test-result"}', + status: 200, + statusText: 'success', + headers: '{}', + }, +} +const MOCK_WEBHOOK_FAILURE_RESPONSE: Pick = { + response: { + data: '{"result":"test-result"}', + status: 400, + statusText: 'failed', + headers: '{}', + }, +} +const MOCK_WEBHOOK_DEFAULT_FORMAT_RESPONSE: Pick< + IWebhookResponse, + 'response' +> = { + response: { + data: '', + status: 0, + statusText: '', + headers: '', + }, +} + +describe('webhook.service', () => { + beforeAll(async () => await dbHandler.connect()) + afterEach(async () => { + await dbHandler.clearDatabase() + }) + afterAll(async () => await dbHandler.closeDatabase()) + + // test variables + let testEncryptedSubmission: IEncryptedSubmissionSchema + let testConfig: AxiosRequestConfig + let testSubmissionWebhookView: WebhookView | null + let testSignature: string + + beforeEach(async () => { + jest.restoreAllMocks() + + // prepare for form creation workflow + const MOCK_ADMIN_OBJ_ID = new ObjectID() + const MOCK_EPOCH = 1487076708000 + const preloaded = await dbHandler.insertFormCollectionReqs({ + userId: MOCK_ADMIN_OBJ_ID, + }) + + jest.spyOn(Date, 'now').mockImplementation(() => MOCK_EPOCH) + + // instantiate new form and save + const testEncryptedForm = new FormModel({ + title: 'Test Form', + admin: preloaded.user._id, + responseMode: ResponseMode.Encrypt, + publicKey: 'fake-public-key', + }) + await testEncryptedForm.save() + + // initialise encrypted submussion + testEncryptedSubmission = new EncryptSubmissionModel({ + form: testEncryptedForm._id, + authType: testEncryptedForm.authType, + myInfoFields: [], + encryptedContent: 'encrypted-content', + verifiedContent: 'verified-content', + version: 1, + webhookResponses: [], + }) + await testEncryptedSubmission.save() + + // initialise webhook related variables + testSubmissionWebhookView = testEncryptedSubmission.getWebhookView() + + testSignature = formsgSdk.webhooks.generateSignature({ + uri: MOCK_WEBHOOK_URL, + submissionId: testEncryptedSubmission._id, + formId: testEncryptedForm._id, + epoch: MOCK_EPOCH, + }) as string + + testConfig = { + headers: { + 'X-FormSG-Signature': `t=${MOCK_EPOCH},s=${testEncryptedSubmission._id},f=${testEncryptedForm._id},v1=${testSignature}`, + }, + maxRedirects: 0, + } + }) + + describe('saveWebhookRecord', () => { + it('should return transform mongo error if database update for webhook fails', async () => { + // Arrange + const mockWebhookResponse = new Object({ + ...MOCK_WEBHOOK_SUCCESS_RESPONSE, + signature: testSignature, + webhookUrl: MOCK_WEBHOOK_URL, + }) as IWebhookResponse + + const mockDBError = new Error(DEFAULT_ERROR_MSG) + + jest + .spyOn(EncryptSubmissionModel, 'addWebhookResponse') + .mockRejectedValueOnce(mockDBError) + + // Act + const actual = await saveWebhookRecord( + testEncryptedSubmission._id, + mockWebhookResponse, + ) + + // Assert + const expectedError = transformMongoError(mockDBError) + + expect(actual.isErr()).toBe(true) + expect(actual._unsafeUnwrapErr()).toEqual(expectedError) + }) + + it('should return submission not found error if submission id cannot be found in database', async () => { + // Arrange + const mockWebhookResponse = new Object({ + ...MOCK_WEBHOOK_SUCCESS_RESPONSE, + signature: testSignature, + webhookUrl: MOCK_WEBHOOK_URL, + }) as IWebhookResponse + + const invalidSubmission = new EncryptSubmissionModel() + + // Act + const actual = await saveWebhookRecord( + invalidSubmission._id, + mockWebhookResponse, + ) + + // Assert + const expectedError = new SubmissionNotFoundError( + 'Unable to find submission ID to update webhook response', + ) + + expect(actual.isErr()).toBe(true) + expect(actual._unsafeUnwrapErr()).toEqual(expectedError) + }) + + it('should return updated submission with new webhook response if the record is successfully saved', async () => { + // Arrange + const mockWebhookResponse = new Object({ + _id: testEncryptedSubmission._id, + created: testEncryptedSubmission.created, + ...MOCK_WEBHOOK_SUCCESS_RESPONSE, + signature: testSignature, + webhookUrl: MOCK_WEBHOOK_URL, + }) as IWebhookResponse + + // Act + const actual = await saveWebhookRecord( + testEncryptedSubmission._id, + mockWebhookResponse, + ) + + // Assert + const originalLength = + testEncryptedSubmission.webhookResponses?.length ?? 0 + const expectedLength = originalLength + 1 + + expect(actual.isOk()).toBe(true) + expect(actual._unsafeUnwrap().webhookResponses).toHaveLength( + expectedLength, + ) + }) + }) + + describe('sendWebhook', () => { + it('should return webhook url validation error if webhook url is not valid', async () => { + // Arrange + MockWebhookValidationModule.validateWebhookUrl.mockReturnValueOnce( + Promise.reject(new WebhookValidationError(DEFAULT_ERROR_MSG)), + ) + + // Act + const actual = await sendWebhook( + testEncryptedSubmission, + MOCK_WEBHOOK_URL, + ) + + // Assert + const expectedError = new WebhookValidationError(DEFAULT_ERROR_MSG) + + expect(actual.isErr()).toBe(true) + expect(actual._unsafeUnwrapErr()).toEqual(expectedError) + }) + + it('should return default webhook url validation error if webhook url is not valid and validate webhook url returns a non webhook url validation error', async () => { + // Arrange + MockWebhookValidationModule.validateWebhookUrl.mockReturnValueOnce( + Promise.reject(new Error()), + ) + + // Act + const actual = await sendWebhook( + testEncryptedSubmission, + MOCK_WEBHOOK_URL, + ) + + // Assert + const expectedError = new WebhookValidationError( + 'Webhook URL is non-HTTPS or points to private IP', + ) + + expect(actual.isErr()).toBe(true) + expect(actual._unsafeUnwrapErr()).toEqual(expectedError) + }) + + it('should resolve with webhook failed with axios error message if axios post fails due to an axios error', async () => { + // Arrange + MockWebhookValidationModule.validateWebhookUrl.mockReturnValueOnce( + Promise.resolve(), + ) + + const MOCK_AXIOS_ERROR: AxiosError = { + name: '', + message: AXIOS_ERROR_MSG, + config: {}, + code: '', + response: MOCK_AXIOS_FAILURE_RESPONSE, + isAxiosError: true, + toJSON: () => jest.fn(), + } + + MockAxios.post.mockImplementationOnce((url, data, config) => { + expect(url).toEqual(MOCK_WEBHOOK_URL) + expect(data).toEqual(testSubmissionWebhookView) + expect(config).toEqual(testConfig) + return Promise.reject(MOCK_AXIOS_ERROR) + }) + MockAxios.isAxiosError.mockReturnValue(true) + + // Act + const actual = await sendWebhook( + testEncryptedSubmission, + MOCK_WEBHOOK_URL, + ) + + // Assert + const expectedResult = new Object({ + errorMessage: AXIOS_ERROR_MSG, + ...MOCK_WEBHOOK_FAILURE_RESPONSE, + signature: testSignature, + webhookUrl: MOCK_WEBHOOK_URL, + }) + + expect(actual.isOk()).toBe(true) + expect(actual._unsafeUnwrap()).toEqual(expectedResult) + }) + + it("should resolve with unknown error's error message and default response format if axios post fails due to an unknown error", async () => { + // Arrange + MockWebhookValidationModule.validateWebhookUrl.mockReturnValueOnce( + Promise.resolve(), + ) + + MockAxios.post.mockImplementationOnce((url, data, config) => { + expect(url).toEqual(MOCK_WEBHOOK_URL) + expect(data).toEqual(testSubmissionWebhookView) + expect(config).toEqual(testConfig) + return Promise.reject(new Error(DEFAULT_ERROR_MSG)) + }) + MockAxios.isAxiosError.mockReturnValue(false) + + // Act + const actual = await sendWebhook( + testEncryptedSubmission, + MOCK_WEBHOOK_URL, + ) + + // Assert + const expectedResult = new Object({ + errorMessage: DEFAULT_ERROR_MSG, + ...MOCK_WEBHOOK_DEFAULT_FORMAT_RESPONSE, + signature: testSignature, + webhookUrl: MOCK_WEBHOOK_URL, + }) + + expect(actual.isOk()).toBe(true) + expect(actual._unsafeUnwrap()).toEqual(expectedResult) + }) + + it('should resolve with an empty error message and default response format if axios post fails due to an unknown error which has no message', async () => { + // Arrange + MockWebhookValidationModule.validateWebhookUrl.mockReturnValueOnce( + Promise.resolve(), + ) + + MockAxios.post.mockImplementationOnce((url, data, config) => { + expect(url).toEqual(MOCK_WEBHOOK_URL) + expect(data).toEqual(testSubmissionWebhookView) + expect(config).toEqual(testConfig) + return Promise.reject(new Error(DEFAULT_ERROR_MSG)) + }) + MockAxios.isAxiosError.mockReturnValue(false) + jest.spyOn(HasPropModule, 'hasProp').mockReturnValueOnce(false) + + // Act + const actual = await sendWebhook( + testEncryptedSubmission, + MOCK_WEBHOOK_URL, + ) + + // Assert + const expectedResult = new Object({ + errorMessage: '', + ...MOCK_WEBHOOK_DEFAULT_FORMAT_RESPONSE, + signature: testSignature, + webhookUrl: MOCK_WEBHOOK_URL, + }) + + expect(actual.isOk()).toBe(true) + expect(actual._unsafeUnwrap()).toEqual(expectedResult) + }) + + it('should resolve without error message if axios post succeeds', async () => { + // Arrange + MockWebhookValidationModule.validateWebhookUrl.mockReturnValueOnce( + Promise.resolve(), + ) + + MockAxios.post.mockImplementationOnce((url, data, config) => { + expect(url).toEqual(MOCK_WEBHOOK_URL) + expect(data).toEqual(testSubmissionWebhookView) + expect(config).toEqual(testConfig) + return Promise.resolve(MOCK_AXIOS_SUCCESS_RESPONSE) + }) + + // Act + const actual = await sendWebhook( + testEncryptedSubmission, + MOCK_WEBHOOK_URL, + ) + + // Assert + const expectedResult = new Object({ + ...MOCK_WEBHOOK_SUCCESS_RESPONSE, + signature: testSignature, + webhookUrl: MOCK_WEBHOOK_URL, + }) + + expect(actual.isOk()).toBe(true) + expect(actual._unsafeUnwrap()).toEqual(expectedResult) + }) + }) +}) diff --git a/tests/unit/backend/modules/webhook/webhook.utils.spec.ts b/src/app/modules/webhook/__tests__/webhook.validation.spec.ts similarity index 83% rename from tests/unit/backend/modules/webhook/webhook.utils.spec.ts rename to src/app/modules/webhook/__tests__/webhook.validation.spec.ts index f93cbbe283..efa735ac22 100644 --- a/tests/unit/backend/modules/webhook/webhook.utils.spec.ts +++ b/src/app/modules/webhook/__tests__/webhook.validation.spec.ts @@ -2,7 +2,7 @@ import { promises as dns } from 'dns' import { mocked } from 'ts-jest/utils' import { WebhookValidationError } from 'src/app/modules/webhook/webhook.errors' -import { validateWebhookUrl } from 'src/app/modules/webhook/webhook.utils' +import { validateWebhookUrl } from 'src/app/modules/webhook/webhook.validation' import config from 'src/config/config' jest.mock('dns', () => ({ @@ -34,7 +34,7 @@ describe('Webhook URL validation', () => { ) }) - it('should reject URLs which do not resolve to any IP', async () => { + it('should reject URLs if dns resolution fails', async () => { MockDns.resolve.mockRejectedValueOnce([]) await expect(validateWebhookUrl(MOCK_WEBHOOK_URL)).rejects.toStrictEqual( new WebhookValidationError( @@ -43,6 +43,15 @@ describe('Webhook URL validation', () => { ) }) + it('should reject URLs which do not resolve to any IPs', async () => { + MockDns.resolve.mockResolvedValueOnce([]) + await expect(validateWebhookUrl(MOCK_WEBHOOK_URL)).rejects.toStrictEqual( + new WebhookValidationError( + `${MOCK_WEBHOOK_URL} does not resolve to any IP address.`, + ), + ) + }) + it('should reject URLs which resolve to private IPs', async () => { MockDns.resolve.mockResolvedValueOnce(['127.0.0.1']) await expect(validateWebhookUrl(MOCK_WEBHOOK_URL)).rejects.toStrictEqual( diff --git a/src/app/modules/webhook/webhook.service.ts b/src/app/modules/webhook/webhook.service.ts index 81a75ec9cf..287acd059a 100644 --- a/src/app/modules/webhook/webhook.service.ts +++ b/src/app/modules/webhook/webhook.service.ts @@ -22,7 +22,8 @@ import { WebhookFailedWithUnknownError, WebhookValidationError, } from './webhook.errors' -import { formatWebhookResponse, validateWebhookUrl } from './webhook.utils' +import { formatWebhookResponse } from './webhook.utils' +import { validateWebhookUrl } from './webhook.validation' const logger = createLoggerWithLabel(module) const EncryptSubmission = getEncryptSubmissionModel(mongoose) @@ -104,7 +105,9 @@ export const sendWebhook = ( meta: logMeta, error, }) - return new WebhookValidationError() + return error instanceof WebhookValidationError + ? error + : new WebhookValidationError() }) .andThen(() => ResultAsync.fromPromise( @@ -176,8 +179,6 @@ export const sendWebhook = ( signature, webhookUrl, errorMessage: axiosError.message, - // Not Axios error so no guarantee of having response. - // Hence allow formatting function to return default shape. response: formatWebhookResponse(axiosError.response), }) }) diff --git a/src/app/modules/webhook/webhook.utils.ts b/src/app/modules/webhook/webhook.utils.ts index 9655bf864b..fef9a3a545 100644 --- a/src/app/modules/webhook/webhook.utils.ts +++ b/src/app/modules/webhook/webhook.utils.ts @@ -1,69 +1,8 @@ import { AxiosResponse } from 'axios' -import { promises as dns } from 'dns' -import ip from 'ip' -import config from '../../../config/config' import { stringifySafe } from '../../../shared/util/stringify-safe' -import { isValidHttpsUrl } from '../../../shared/util/url-validation' import { IWebhookResponse } from '../../../types' -import { WebhookValidationError } from './webhook.errors' - -/** - * Checks that a URL is valid for use in webhooks. - * @param webhookUrl Webhook URL - * @returns Resolves if URL is valid, otherwise rejects. - * @throws {WebhookValidationError} If URL is invalid so webhook should not be attempted. - */ -export const validateWebhookUrl = (webhookUrl: string): Promise => { - return new Promise((resolve, reject) => { - if (!isValidHttpsUrl(webhookUrl)) { - return reject( - new WebhookValidationError(`${webhookUrl} is not a valid HTTPS URL.`), - ) - } - const webhookUrlParsed = new URL(webhookUrl) - const appUrlParsed = new URL(config.app.appUrl) - if (webhookUrlParsed.hostname === appUrlParsed.hostname) { - return reject( - new WebhookValidationError( - `You cannot send responses back to ${config.app.appUrl}.`, - ), - ) - } - dns - .resolve(webhookUrlParsed.hostname) - .then((addresses) => { - if (!addresses.length) { - return reject( - new WebhookValidationError( - `${webhookUrl} does not resolve to any IP address.`, - ), - ) - } - const privateIps = addresses.filter((addr) => ip.isPrivate(addr)) - if (privateIps.length) { - return reject( - new WebhookValidationError( - `${webhookUrl} resolves to the following private IPs: ${privateIps.join( - ', ', - )}`, - ), - ) - } - return resolve() - }) - .catch(() => { - return reject( - new WebhookValidationError( - `Error encountered during DNS resolution for ${webhookUrl}.` + - ` Check that the URL is correct.`, - ), - ) - }) - }) -} - /** * Formats a response object for update in the Submissions collection * @param {response} response Response object returned by axios diff --git a/src/app/modules/webhook/webhook.validation.ts b/src/app/modules/webhook/webhook.validation.ts new file mode 100644 index 0000000000..5e355927d6 --- /dev/null +++ b/src/app/modules/webhook/webhook.validation.ts @@ -0,0 +1,62 @@ +import { promises as dns } from 'dns' +import ip from 'ip' + +import config from '../../../config/config' +import { isValidHttpsUrl } from '../../../shared/util/url-validation' + +import { WebhookValidationError } from './webhook.errors' + +/** + * Checks that a URL is valid for use in webhooks. + * @param webhookUrl Webhook URL + * @returns Resolves if URL is valid, otherwise rejects. + * @throws {WebhookValidationError} If URL is invalid so webhook should not be attempted. + */ +export const validateWebhookUrl = (webhookUrl: string): Promise => { + return new Promise((resolve, reject) => { + if (!isValidHttpsUrl(webhookUrl)) { + return reject( + new WebhookValidationError(`${webhookUrl} is not a valid HTTPS URL.`), + ) + } + const webhookUrlParsed = new URL(webhookUrl) + const appUrlParsed = new URL(config.app.appUrl) + if (webhookUrlParsed.hostname === appUrlParsed.hostname) { + return reject( + new WebhookValidationError( + `You cannot send responses back to ${config.app.appUrl}.`, + ), + ) + } + dns + .resolve(webhookUrlParsed.hostname) + .then((addresses) => { + if (!addresses.length) { + return reject( + new WebhookValidationError( + `${webhookUrl} does not resolve to any IP address.`, + ), + ) + } + const privateIps = addresses.filter((addr) => ip.isPrivate(addr)) + if (privateIps.length) { + return reject( + new WebhookValidationError( + `${webhookUrl} resolves to the following private IPs: ${privateIps.join( + ', ', + )}`, + ), + ) + } + return resolve() + }) + .catch(() => { + return reject( + new WebhookValidationError( + `Error encountered during DNS resolution for ${webhookUrl}.` + + ` Check that the URL is correct.`, + ), + ) + }) + }) +} diff --git a/tests/unit/backend/modules/webhook/webhook.service.spec.ts b/tests/unit/backend/modules/webhook/webhook.service.spec.ts deleted file mode 100644 index d5d7486784..0000000000 --- a/tests/unit/backend/modules/webhook/webhook.service.spec.ts +++ /dev/null @@ -1,236 +0,0 @@ -import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios' -import { ObjectID } from 'bson' -import mongoose from 'mongoose' -import { mocked } from 'ts-jest/utils' - -import getFormModel from 'src/app/models/form.server.model' -import { getEncryptSubmissionModel } from 'src/app/models/submission.server.model' -import { WebhookValidationError } from 'src/app/modules/webhook/webhook.errors' -import { pushData } from 'src/app/modules/webhook/webhook.service' -import { validateWebhookUrl } from 'src/app/modules/webhook/webhook.utils' -import formsgSdk from 'src/config/formsg-sdk' -import { - IEncryptedSubmissionSchema, - IWebhookResponse, - ResponseMode, - WebhookView, -} from 'src/types' - -import dbHandler from 'tests/unit/backend/helpers/jest-db' - -const Form = getFormModel(mongoose) -const EncryptSubmission = getEncryptSubmissionModel(mongoose) - -// Define constants -const MOCK_ADMIN_OBJ_ID = new ObjectID() -const MOCK_WEBHOOK_URL = 'https://form.gov.sg/endpoint' -const ERROR_MSG = 'test-message' -const MOCK_SUCCESS_RESPONSE: AxiosResponse = { - data: { - result: 'test-result', - }, - status: 200, - statusText: 'success', - headers: {}, - config: {}, -} -const MOCK_FAILURE_RESPONSE: AxiosResponse = { - data: { - result: 'test-result', - }, - status: 400, - statusText: 'failed', - headers: {}, - config: {}, -} -const MOCK_STRINGIFIED_SUCCESS_RESPONSE: Pick = { - response: { - data: '{"result":"test-result"}', - status: 200, - statusText: 'success', - headers: '{}', - }, -} -const MOCK_STRINGIFIED_FAILURE_RESPONSE: Pick = { - response: { - data: `{"result":"test-result"}`, - status: 400, - statusText: 'failed', - headers: '{}', - }, -} -const MOCK_EPOCH = 1487076708000 - -// Set up mocks -jest.mock('axios') -const mockAxios = mocked(axios, true) -jest.mock('src/app/modules/webhook/webhook.utils') -const mockValidateWebhookUrl = mocked(validateWebhookUrl, true) -jest.spyOn(Date, 'now').mockImplementation(() => MOCK_EPOCH) - -describe('WebhooksService', () => { - beforeAll(async () => await dbHandler.connect()) - afterEach(async () => { - await dbHandler.clearDatabase() - }) - afterAll(async () => await dbHandler.closeDatabase()) - - let testEncryptSubmission: IEncryptedSubmissionSchema - let testConfig: AxiosRequestConfig - let testSubmissionWebhookView: WebhookView | null - let testSignature: string - - beforeEach(async () => { - const preloaded = await dbHandler.insertFormCollectionReqs({ - userId: MOCK_ADMIN_OBJ_ID, - }) - const testEncryptForm = new Form({ - title: 'Test Form', - admin: preloaded.user._id, - responseMode: ResponseMode.Encrypt, - publicKey: 'fake-public-key', - }) - await testEncryptForm.save() - - testEncryptSubmission = new EncryptSubmission({ - form: testEncryptForm._id, - authType: testEncryptForm.authType, - myInfoFields: [], - encryptedContent: 'encrypted-content', - verifiedContent: 'verified-content', - version: 1, - }) - await testEncryptSubmission.save() - - testSubmissionWebhookView = testEncryptSubmission.getWebhookView() - - testSignature = formsgSdk.webhooks.generateSignature({ - uri: MOCK_WEBHOOK_URL, - submissionId: testEncryptSubmission._id, - formId: testEncryptForm._id, - epoch: MOCK_EPOCH, - }) as string - - testConfig = { - headers: { - 'X-FormSG-Signature': `t=${MOCK_EPOCH},s=${testEncryptSubmission._id},f=${testEncryptForm._id},v1=${testSignature}`, - }, - maxRedirects: 0, - } - }) - - describe('postWebhook', () => { - it('should not make post request if submissionWebhookView is null', async () => { - // Act - await pushData(MOCK_WEBHOOK_URL, null) - - // Assert - expect(mockAxios.post).toHaveBeenCalledTimes(0) - }) - - it('should update submission document with successful webhook response if post succeeds', async () => { - // Arrange - mockAxios.post.mockImplementationOnce((url, data, config) => { - expect(url).toEqual(MOCK_WEBHOOK_URL) - expect(data).toEqual(testSubmissionWebhookView) - expect(config).toEqual(testConfig) - return Promise.resolve(MOCK_SUCCESS_RESPONSE) - }) - mockValidateWebhookUrl.mockImplementationOnce((url) => { - expect(url).toEqual(MOCK_WEBHOOK_URL) - return Promise.resolve() - }) - - // Act - await pushData(MOCK_WEBHOOK_URL, testSubmissionWebhookView) - - // Assert - const submission = await EncryptSubmission.findById( - testEncryptSubmission._id, - ) - expect(submission?.webhookResponses![0]).toEqual( - expect.objectContaining({ - webhookUrl: MOCK_WEBHOOK_URL, - signature: testSignature, - response: expect.objectContaining( - MOCK_STRINGIFIED_SUCCESS_RESPONSE.response, - ), - }), - ) - }) - - it('should update submission document with failed webhook response if validation fails', async () => { - // Arrange - mockValidateWebhookUrl.mockImplementationOnce((url) => { - expect(url).toEqual(MOCK_WEBHOOK_URL) - return Promise.reject(new WebhookValidationError(ERROR_MSG)) - }) - - // Act - await pushData(MOCK_WEBHOOK_URL, testSubmissionWebhookView) - - // Assert - const submission = await EncryptSubmission.findById( - testEncryptSubmission._id, - ) - expect(submission?.webhookResponses![0]).toEqual( - expect.objectContaining({ - webhookUrl: MOCK_WEBHOOK_URL, - signature: testSignature, - errorMessage: ERROR_MSG, - }), - ) - }) - - it('should update submission document with failed webhook response if post fails', async () => { - // Arrange - class MockAxiosError extends Error { - isAxiosError: boolean - toJSON: () => {} - config: Record - response: AxiosResponse - constructor(msg: string, response: AxiosResponse) { - super(msg) - this.isAxiosError = false - this.response = response - this.toJSON = () => { - return {} - } - this.config = {} - } - } - const mockAxiosError: AxiosError = new MockAxiosError( - ERROR_MSG, - MOCK_FAILURE_RESPONSE, - ) - mockAxios.post.mockImplementationOnce((url, data, config) => { - expect(url).toEqual(MOCK_WEBHOOK_URL) - expect(data).toEqual(testSubmissionWebhookView) - expect(config).toEqual(testConfig) - return Promise.reject(mockAxiosError) - }) - mockValidateWebhookUrl.mockImplementationOnce((url) => { - expect(url).toEqual(MOCK_WEBHOOK_URL) - return Promise.resolve() - }) - - // Act - await pushData(MOCK_WEBHOOK_URL, testSubmissionWebhookView) - - // Assert - const submission = await EncryptSubmission.findById( - testEncryptSubmission._id, - ) - expect(submission?.webhookResponses![0]).toEqual( - expect.objectContaining({ - webhookUrl: MOCK_WEBHOOK_URL, - signature: testSignature, - errorMessage: ERROR_MSG, - response: expect.objectContaining( - MOCK_STRINGIFIED_FAILURE_RESPONSE.response, - ), - }), - ) - }) - }) -})