From b881d63a68e14ec3b2c787f440265cf3a24e0568 Mon Sep 17 00:00:00 2001 From: seaerchin <44049504+seaerchin@users.noreply.github.com> Date: Tue, 20 Apr 2021 16:35:08 +0800 Subject: [PATCH 01/51] refactor: migrate submissions metadata (#1651) * refactor(encrypt-submission): changed handler to be an atomic export together wtih validator * refactor(admin-form/submissions): add api endpoint for submissions/metadata and deprecate old endpt * test(admin-forms/submission): fixes unit and integration tests for submissions/metadata * refactor(submissions/client/factory): changed fe callsites to new be api * refactor(encrypt-submission): inlined validator; reverted pageNum default value --- .../form/admin-form/admin-form.routes.ts | 10 +- .../encrypt-submission.controller.spec.ts | 24 +- .../encrypt-submission.controller.ts | 29 +- .../admin-forms.submissions.routes.spec.ts | 268 ++++++++++++++++++ .../forms/admin-forms.submissions.routes.ts | 19 ++ .../services/submissions.client.factory.js | 8 +- 6 files changed, 329 insertions(+), 29 deletions(-) diff --git a/src/app/modules/form/admin-form/admin-form.routes.ts b/src/app/modules/form/admin-form/admin-form.routes.ts index 46c712e649..020d8cd211 100644 --- a/src/app/modules/form/admin-form/admin-form.routes.ts +++ b/src/app/modules/form/admin-form/admin-form.routes.ts @@ -337,6 +337,7 @@ AdminFormsRouter.get( * Retrieve metadata of responses for a form with encrypted storage * @route GET /:formId/adminform/submissions/metadata * @security session + * @deprecated in favour of GET /api/v3/admin/forms/:formId/submissions/metadata * * @returns 200 with paginated submission metadata when no submissionId is provided * @returns 200 with single submission metadata of submissionId when provided @@ -350,15 +351,6 @@ AdminFormsRouter.get( AdminFormsRouter.get( '/:formId([a-fA-F0-9]{24})/adminform/submissions/metadata', withUserAuthentication, - celebrate({ - [Segments.QUERY]: { - submissionId: Joi.string().optional(), - page: Joi.number().min(1).when('submissionId', { - not: Joi.exist(), - then: Joi.required(), - }), - }, - }), EncryptSubmissionController.handleGetMetadata, ) diff --git a/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.controller.spec.ts b/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.controller.spec.ts index 8f3aa69f8a..56624036a2 100644 --- a/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.controller.spec.ts +++ b/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.controller.spec.ts @@ -30,8 +30,8 @@ import { } from '../../submission.errors' import { getEncryptedResponseUsingQueryParams, + getMetadata, handleGetEncryptedResponse, - handleGetMetadata, streamEncryptedResponses, } from '../encrypt-submission.controller' import * as EncryptSubmissionService from '../encrypt-submission.service' @@ -633,7 +633,7 @@ describe('encrypt-submission.controller', () => { }) }) - describe('handleGetMetadata', () => { + describe('getMetadata', () => { const MOCK_FORM_ID = new ObjectId().toHexString() const MOCK_USER_ID = new ObjectId().toHexString() @@ -684,7 +684,7 @@ describe('encrypt-submission.controller', () => { ) // Act - await handleGetMetadata(mockReq, mockRes, jest.fn()) + await getMetadata(mockReq, mockRes, jest.fn()) // Assert expect(mockRes.json).toHaveBeenCalledWith({ @@ -721,7 +721,7 @@ describe('encrypt-submission.controller', () => { ) // Act - await handleGetMetadata(mockReq, mockRes, jest.fn()) + await getMetadata(mockReq, mockRes, jest.fn()) // Assert expect(mockRes.json).toHaveBeenCalledWith({ @@ -767,7 +767,7 @@ describe('encrypt-submission.controller', () => { ) // Act - await handleGetMetadata(mockReq, mockRes, jest.fn()) + await getMetadata(mockReq, mockRes, jest.fn()) // Assert expect(mockRes.json).toHaveBeenCalledWith(expectedMetadataList) @@ -802,7 +802,7 @@ describe('encrypt-submission.controller', () => { ) // Act - await handleGetMetadata(mockReq, mockRes, jest.fn()) + await getMetadata(mockReq, mockRes, jest.fn()) // Assert expect(mockRes.status).toHaveBeenCalledWith(400) @@ -837,7 +837,7 @@ describe('encrypt-submission.controller', () => { ) // Act - await handleGetMetadata(mockReq, mockRes, jest.fn()) + await getMetadata(mockReq, mockRes, jest.fn()) // Assert expect(mockRes.status).toHaveBeenCalledWith(403) @@ -872,7 +872,7 @@ describe('encrypt-submission.controller', () => { ) // Act - await handleGetMetadata(mockReq, mockRes, jest.fn()) + await getMetadata(mockReq, mockRes, jest.fn()) // Assert expect(mockRes.status).toHaveBeenCalledWith(404) @@ -907,7 +907,7 @@ describe('encrypt-submission.controller', () => { ) // Act - await handleGetMetadata(mockReq, mockRes, jest.fn()) + await getMetadata(mockReq, mockRes, jest.fn()) // Assert expect(mockRes.status).toHaveBeenCalledWith(410) @@ -941,7 +941,7 @@ describe('encrypt-submission.controller', () => { ) // Act - await handleGetMetadata(mockReq, mockRes, jest.fn()) + await getMetadata(mockReq, mockRes, jest.fn()) // Assert expect(mockRes.status).toHaveBeenCalledWith(422) @@ -982,7 +982,7 @@ describe('encrypt-submission.controller', () => { ) // Act - await handleGetMetadata(mockReq, mockRes, jest.fn()) + await getMetadata(mockReq, mockRes, jest.fn()) // Assert expect(mockRes.json).toHaveBeenCalledWith({ @@ -1019,7 +1019,7 @@ describe('encrypt-submission.controller', () => { ) // Act - await handleGetMetadata(mockReq, mockRes, jest.fn()) + await getMetadata(mockReq, mockRes, jest.fn()) // Assert expect(mockRes.json).toHaveBeenCalledWith({ diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts index a79ebabbbb..64bf113dc9 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts @@ -5,7 +5,7 @@ import { Query } from 'express-serve-static-core' import { StatusCodes } from 'http-status-codes' import JSONStream from 'JSONStream' import mongoose from 'mongoose' -import { SetOptional } from 'type-fest' +import { RequireAtLeastOne, SetOptional } from 'type-fest' import { AuthType, @@ -682,7 +682,8 @@ export const handleGetEncryptedResponse: RequestHandler< } /** - * Handler for GET /:formId([a-fA-F0-9]{24})/adminform/submissions/metadata + * Handler for GET /:formId/submissions/metadata + * This is exported solely for testing purposes * * @returns 200 with single submission metadata if query.submissionId is provided * @returns 200 with list of submission metadata with total count (and optional offset if query.page is provided) if query.submissionId is not provided @@ -693,11 +694,15 @@ export const handleGetEncryptedResponse: RequestHandler< * @returns 422 when user in session cannot be retrieved from the database * @returns 500 if any errors occurs whilst querying database */ -export const handleGetMetadata: RequestHandler< +export const getMetadata: RequestHandler< { formId: string }, SubmissionMetadataList | ErrorDto, unknown, - Query & { page?: number; submissionId?: string } + Query & + RequireAtLeastOne< + { page?: number; submissionId?: string }, + 'page' | 'submissionId' + > > = async (req, res) => { const sessionUserId = (req.session as Express.AuthedSession).user._id const { formId } = req.params @@ -753,3 +758,19 @@ export const handleGetMetadata: RequestHandler< }) ) } + +// Handler for GET /:formId/submissions/metadata +export const handleGetMetadata = [ + // NOTE: If submissionId is set, then page is optional. + // Otherwise, if there is no submissionId, then page >= 1 + celebrate({ + [Segments.QUERY]: { + submissionId: Joi.string().optional(), + page: Joi.number().min(1).when('submissionId', { + not: Joi.exist(), + then: Joi.required(), + }), + }, + }), + getMetadata, +] as RequestHandler[] diff --git a/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.submissions.routes.spec.ts b/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.submissions.routes.spec.ts index 09a625a080..4b1f5fa894 100644 --- a/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.submissions.routes.spec.ts +++ b/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.submissions.routes.spec.ts @@ -1192,6 +1192,274 @@ describe('admin-form.submissions.routes', () => { }) }) }) + + describe('GET /:formId/submissions/metadata', () => { + let defaultForm: IFormDocument + + beforeEach(async () => { + defaultForm = (await EncryptFormModel.create({ + title: 'new form', + responseMode: ResponseMode.Encrypt, + publicKey: 'any public key', + admin: defaultUser._id, + })) as IFormDocument + }) + + it('should return 200 with empty results if no metadata exists', async () => { + // Act + const response = await request + .get(`/admin/forms/${defaultForm._id}/submissions/metadata`) + .query({ + page: 1, + }) + + // Assert + expect(response.status).toEqual(200) + expect(response.body).toEqual({ count: 0, metadata: [] }) + }) + + it('should return 200 with requested page of metadata when metadata exists', async () => { + // Arrange + // Create 11 submissions + const submissions = await Promise.all( + times(11, (count) => + createSubmission({ + form: defaultForm, + encryptedContent: `any encrypted content ${count}`, + verifiedContent: `any verified content ${count}`, + }), + ), + ) + const createdSubmissionIds = submissions.map((s) => String(s._id)) + + // Act + const response = await request + .get(`/admin/forms/${defaultForm._id}/submissions/metadata`) + .query({ + page: 1, + }) + + // Assert + const expected = times(10, (index) => ({ + number: 11 - index, + // Loosen refNo checks due to non-deterministic aggregation query. + // Just expect refNo is one of the possible ones. + refNo: expect.toBeOneOf(createdSubmissionIds), + submissionTime: expect.any(String), + })) + expect(response.status).toEqual(200) + // Should be 11, but only return metadata of last 10 submissions due to page size. + expect(response.body).toEqual({ + count: 11, + metadata: expected, + }) + }) + + it('should return 200 with empty results if query.page does not have metadata', async () => { + // Arrange + // Create single submission + await createSubmission({ + form: defaultForm, + encryptedContent: `any encrypted content`, + verifiedContent: `any verified content`, + }) + + // Act + const response = await request + .get(`/admin/forms/${defaultForm._id}/submissions/metadata`) + .query({ + // Page 2 should have no submissions + page: 2, + }) + + // Assert + expect(response.status).toEqual(200) + // Single submission count, but no metadata returned + expect(response.body).toEqual({ + count: 1, + metadata: [], + }) + }) + + it('should return 200 with metadata of single submissionId when query.submissionId is provided', async () => { + // Arrange + // Create 3 submissions + const submissions = await Promise.all( + times(3, (count) => + createSubmission({ + form: defaultForm, + encryptedContent: `any encrypted content ${count}`, + verifiedContent: `any verified content ${count}`, + }), + ), + ) + + // Act + const response = await request + .get(`/admin/forms/${defaultForm._id}/submissions/metadata`) + .query({ + submissionId: String(submissions[1]._id), + }) + + // Assert + expect(response.status).toEqual(200) + // Only return the single submission id's metadata + expect(response.body).toEqual({ + count: 1, + metadata: [ + { + number: 1, + refNo: String(submissions[1]._id), + submissionTime: expect.any(String), + }, + ], + }) + }) + + it('should return 401 when user is not logged in', async () => { + // Arrange + await logoutSession(request) + + // Act + const response = await request + .get(`/admin/forms/${defaultForm._id}/submissions/metadata`) + .query({ + page: 10, + }) + + // Assert + expect(response.status).toEqual(401) + expect(response.body).toEqual({ message: 'User is unauthorized.' }) + }) + + it('should return 403 when user does not have read permissions to form', async () => { + // Arrange + const anotherUser = ( + await dbHandler.insertFormCollectionReqs({ + userId: new ObjectId(), + mailName: 'some-user', + shortName: 'someUser', + }) + ).user + // Form that defaultUser has no access to. + const inaccessibleForm = await EncryptFormModel.create({ + title: 'Collab form', + publicKey: 'some public key', + admin: anotherUser._id, + permissionList: [], + }) + + // Act + const response = await request + .get(`/admin/forms/${inaccessibleForm._id}/submissions/metadata`) + .query({ + page: 10, + }) + + // Assert + expect(response.status).toEqual(403) + expect(response.body).toEqual({ + message: expect.stringContaining( + 'not authorized to perform read operation', + ), + }) + }) + + it('should return 404 when form to retrieve submission metadata for cannot be found', async () => { + // Arrange + const invalidFormId = new ObjectId().toHexString() + + // Act + const response = await request + .get(`/admin/forms/${invalidFormId}/submissions/metadata`) + .query({ + page: 10, + }) + + // Assert + expect(response.status).toEqual(404) + expect(response.body).toEqual({ message: 'Form not found' }) + }) + + it('should return 410 when form to retrieve submission metadata for is archived', async () => { + // Arrange + const archivedForm = await EncryptFormModel.create({ + title: 'archived form', + status: Status.Archived, + responseMode: ResponseMode.Encrypt, + publicKey: 'does not matter', + admin: defaultUser._id, + }) + + // Act + const response = await request + .get(`/admin/forms/${archivedForm._id}/submissions/metadata`) + .query({ + page: 10, + }) + + // Assert + expect(response.status).toEqual(410) + expect(response.body).toEqual({ message: 'Form has been archived' }) + }) + + it('should return 422 when user in session cannot be retrieved from the database', async () => { + // Arrange + // Clear user collection + await dbHandler.clearCollection(UserModel.collection.name) + + // Act + const response = await request + .get(`/admin/forms/${new ObjectId()}/submissions/metadata`) + .query({ + page: 10, + }) + + // Assert + expect(response.status).toEqual(422) + expect(response.body).toEqual({ message: 'User not found' }) + }) + + it('should return 500 when database error occurs whilst retrieving submission metadata list', async () => { + // Arrange + jest + .spyOn(EncryptSubmissionModel, 'findAllMetadataByFormId') + .mockRejectedValueOnce(new Error('ohno')) + + // Act + const response = await request + .get(`/admin/forms/${defaultForm._id}/submissions/metadata`) + .query({ + page: 10, + }) + + // Assert + expect(response.status).toEqual(500) + expect(response.body).toEqual({ + message: expect.stringContaining('ohno'), + }) + }) + + it('should return 500 when database error occurs whilst retrieving single submission metadata', async () => { + // Arrange + jest + .spyOn(EncryptSubmissionModel, 'findSingleMetadata') + .mockRejectedValueOnce(new Error('ohno')) + + // Act + const response = await request + .get(`/admin/forms/${defaultForm._id}/submissions/metadata`) + .query({ + submissionId: new ObjectId().toHexString(), + }) + + // Assert + expect(response.status).toEqual(500) + expect(response.body).toEqual({ + message: expect.stringContaining('ohno'), + }) + }) + }) }) // Helper utils diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.submissions.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.submissions.routes.ts index b51f85f2ac..9252b64a13 100644 --- a/src/app/routes/api/v3/admin/forms/admin-forms.submissions.routes.ts +++ b/src/app/routes/api/v3/admin/forms/admin-forms.submissions.routes.ts @@ -61,3 +61,22 @@ AdminFormsSubmissionsRouter.route( AdminFormsSubmissionsRouter.route( '/:formId([a-fA-F0-9]{24})/submissions/:submissionId([a-fA-F0-9]{24})', ).get(EncryptSubmissionController.handleGetEncryptedResponse) + +/** + * Retrieve metadata of responses for a form with encrypted storage + * @route GET /:formId/submissions/metadata + * @security session + * + * @returns 200 with paginated submission metadata when no submissionId is provided + * @returns 200 with single submission metadata of submissionId when provided + * @returns 401 when user does not exist in session + * @returns 403 when user does not have permissions to access form + * @returns 404 when form cannot be found + * @returns 410 when form is archived + * @returns 422 when user in session cannot be retrieved from the database + * @returns 500 when database error occurs + */ +AdminFormsSubmissionsRouter.get( + '/:formId([a-fA-F0-9]{24})/submissions/metadata', + EncryptSubmissionController.handleGetMetadata, +) diff --git a/src/public/modules/forms/services/submissions.client.factory.js b/src/public/modules/forms/services/submissions.client.factory.js index 04a1cb53ba..2afe413bbf 100644 --- a/src/public/modules/forms/services/submissions.client.factory.js +++ b/src/public/modules/forms/services/submissions.client.factory.js @@ -42,7 +42,6 @@ function SubmissionsFactory( responseModeEnum, FormSgSdk, ) { - const submitAdminUrl = '/:formId/adminform/submissions' const publicSubmitUrl = '/api/v3/forms/:formId/submissions/:responseMode' const previewSubmitUrl = '/v2/submissions/:responseMode/preview/:formId' @@ -186,9 +185,10 @@ function SubmissionsFactory( }, getMetadata: function (params) { const deferred = $q.defer() - let resUrl = `${fixParamsToUrl(params, submitAdminUrl)}/metadata?page=${ - params.page - }` + let resUrl = `${fixParamsToUrl( + params, + `${ADMIN_FORMS_PREFIX}/:formId/submissions/metadata`, + )}?page=${params.page}` if (params.filterBySubmissionRefId) { resUrl += `&submissionId=${params.filterBySubmissionRefId}` From 6b4f90282b4b873704b50c89743db3b1828a6648 Mon Sep 17 00:00:00 2001 From: LoneRifle Date: Tue, 20 Apr 2021 16:54:10 +0800 Subject: [PATCH 02/51] feat(admin-forms): implement retrieval of form settings (#1633) * feat(admin-forms): implement retrieval of form settings [form] - augment `FormSchema.getFullFormById()`, which will partially populate an IPopulatedForm with specified keys, if given - feed the `fields` argument supplied in the abovementioned fn as the projection arg of `Schema.findById()` - implement `FormSchema.pick()`, allowing one to pick only the fields we want out of a given IFormSchema [auth-service] - implement `getFormFieldsAfterPermissionChecks()`, exploiting type-generic parameters to allow callers to specify the shape of the return value - take care to only return fields specified by the caller [admin-form] - declare and implement `handleGetSettings()` in controller and route, taking inspiration from `handleUpdateSettings()` - import `FORM_SETTING_FIELDS` from form.server.model to specify the form setting fields we need TODO: Unit-testing * refactor(admin-form): separate auth and form retrieval concerns - replace `getFormFieldsAfterPermissionChecks()` with the simpler `checkFormForPermissions()`, obliging callers to get their own form - apply DRY and replace part of `getFormAfterPermissionChecks()` with a call to `checkFormForPermissions()` - rework `handleGetSettings()` in `admin-form.controller` so that it handles form retrieval and returning a concise response - avoid being clever; instead of specifying only the fields we need in the form and potentially saving I/O, just get the entire form and use the existing `getSettings()` to return to the caller. The resulting simplicity will probably outweigh any savings in mongodb I/O we get. - we may get a spike in I/O if calls for n parts of a given form ends up fetching the entire form n times from db to app. In that light... - leave `FormService.retrieveFormFields()` where it is, in case we are proven wrong and we have to be conservative about I/O TODO: Unit tests * test: cover for handleGetSettings, retrieveFormFieldsById Rename `retrieveFormFields()` to `retrieveFormFieldsById()` to be consistent with existing methods on FormService * fix(get-settings): remove spurious typecast * refactor(form): rename retrieveFormFieldsById to retrieveFormKeysById * docs(auth): correct jsdoc for checkFormForPermissions * refactor(form-settings): chain http verbs to route --- src/app/models/form.server.model.ts | 3 +- src/app/modules/auth/auth.service.ts | 38 ++- .../form/__tests__/form.service.spec.ts | 92 +++++++ .../__tests__/admin-form.controller.spec.ts | 254 +++++++++++++++++- .../form/admin-form/admin-form.controller.ts | 45 ++++ src/app/modules/form/form.service.ts | 33 +++ .../forms/admin-forms.settings.routes.ts | 62 +++-- src/types/form.ts | 5 +- 8 files changed, 494 insertions(+), 38 deletions(-) diff --git a/src/app/models/form.server.model.ts b/src/app/models/form.server.model.ts index 0638eacbc3..6df67f465d 100644 --- a/src/app/models/form.server.model.ts +++ b/src/app/models/form.server.model.ts @@ -525,8 +525,9 @@ const compileFormModel = (db: Mongoose): IFormModel => { FormSchema.statics.getFullFormById = async function ( this: IFormModel, formId: string, + fields?: (keyof IPopulatedForm)[], ): Promise { - return this.findById(formId).populate({ + return this.findById(formId, fields).populate({ path: 'admin', populate: { path: 'agency', diff --git a/src/app/modules/auth/auth.service.ts b/src/app/modules/auth/auth.service.ts index 9bda20eb17..ccd8d1e38b 100644 --- a/src/app/modules/auth/auth.service.ts +++ b/src/app/modules/auth/auth.service.ts @@ -1,5 +1,5 @@ import mongoose from 'mongoose' -import { errAsync, okAsync, ResultAsync } from 'neverthrow' +import { errAsync, okAsync, Result, ResultAsync } from 'neverthrow' import validator from 'validator' import { LINKS } from '../../../shared/constants' @@ -285,18 +285,34 @@ export const getFormAfterPermissionChecks = ({ IPopulatedForm, FormNotFoundError | FormDeletedError | DatabaseError | ForbiddenFormError > => { - // Step 1: Retrieve full form. - return FormService.retrieveFullFormById(formId).andThen((fullForm) => - // Step 2: Check whether form is available to be retrieved. - assertFormAvailable(fullForm).andThen(() => - // Step 3: Check required permission levels. - getAssertPermissionFn(level)(user, fullForm) - // Step 4: If success, return retrieved form. - .map(() => fullForm), - ), - ) + return FormService.retrieveFullFormById(formId) + .map((form) => ({ form, user })) + .andThen(checkFormForPermissions(level)) } +/** + * Ensures that the given user has the required pre-specified permissions + * for the form. + * + * @returns ok(form) if the user has the required permissions + * @returns err(FormNotFoundError) if form does not exist in the database + * @returns err(FormDeleteError) if form is already archived + * @returns err(ForbiddenFormError if user does not have permission + * @returns err(DatabaseError) if any database error occurs + */ +export const checkFormForPermissions = (level: PermissionLevel) => ({ + user, + form, +}: { + user: IUserSchema + form: IPopulatedForm +}): Result => + // Step 1: Check whether form is available to be retrieved. + assertFormAvailable(form) + // Step 2: Check required permission levels. + .andThen(() => getAssertPermissionFn(level)(user, form)) + .map(() => form) + /** * Retrieves the form of given formId provided that the form is public. * diff --git a/src/app/modules/form/__tests__/form.service.spec.ts b/src/app/modules/form/__tests__/form.service.spec.ts index 20be4ce147..5bcd535d64 100644 --- a/src/app/modules/form/__tests__/form.service.spec.ts +++ b/src/app/modules/form/__tests__/form.service.spec.ts @@ -142,6 +142,98 @@ describe('FormService', () => { }) }) + describe('retrieveFormKeysById', () => { + it('should return form successfully', async () => { + // Arrange + const formId = new ObjectId().toHexString() + const expectedForm = ({ + _id: formId, + title: 'mock title', + admin: { + _id: new ObjectId(), + email: 'mockEmail@example.com', + }, + } as unknown) as IPopulatedForm + const retrieveFormSpy = jest + .spyOn(Form, 'getFullFormById') + .mockResolvedValueOnce(expectedForm) + + // Act + const actualResult = await FormService.retrieveFormKeysById(formId, [ + 'title', + 'admin', + ]) + + // Assert + expect(retrieveFormSpy).toHaveBeenCalledTimes(1) + expect(actualResult.isOk()).toEqual(true) + expect(actualResult._unsafeUnwrap()).toEqual(expectedForm) + }) + + it('should return FormNotFoundError if formId is invalid', async () => { + // Arrange + const formId = new ObjectId().toHexString() + // Resolve query to null. + const retrieveFormSpy = jest + .spyOn(Form, 'getFullFormById') + .mockResolvedValueOnce(null) + + // Act + const actualResult = await FormService.retrieveFormKeysById(formId, [ + 'title', + 'admin', + ]) + + // Assert + expect(retrieveFormSpy).toHaveBeenCalledTimes(1) + expect(actualResult.isErr()).toEqual(true) + expect(actualResult._unsafeUnwrapErr()).toBeInstanceOf(FormNotFoundError) + }) + + it('should still return retrieved form even when it does not contain admin', async () => { + // Arrange + const formId = new ObjectId().toHexString() + const expectedForm = ({ + _id: formId, + title: 'mock title', + // Note no admin key-value. + } as unknown) as IPopulatedForm + const retrieveFormSpy = jest + .spyOn(Form, 'getFullFormById') + .mockResolvedValueOnce(expectedForm) + + // Act + const actualResult = await FormService.retrieveFormKeysById(formId, [ + 'title', + ]) + + // Assert + expect(retrieveFormSpy).toHaveBeenCalledTimes(1) + expect(actualResult.isOk()).toEqual(true) + expect(actualResult._unsafeUnwrap()).toEqual(expectedForm) + }) + + it('should return DatabaseError when error occurs whilst querying database', async () => { + // Arrange + const formId = new ObjectId().toHexString() + // Mock rejection. + const retrieveFormSpy = jest + .spyOn(Form, 'getFullFormById') + .mockRejectedValueOnce(new Error('Some error')) + + // Act + const actualResult = await FormService.retrieveFormKeysById(formId, [ + 'title', + 'admin', + ]) + + // Assert + expect(retrieveFormSpy).toHaveBeenCalledTimes(1) + expect(actualResult.isErr()).toEqual(true) + expect(actualResult._unsafeUnwrapErr()).toBeInstanceOf(DatabaseError) + }) + }) + describe('retrieveFormById', () => { it('should return form successfully', async () => { // Arrange diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts index 2a47cba706..94b373b8d9 100644 --- a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts +++ b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts @@ -1,7 +1,7 @@ import { PresignedPost } from 'aws-sdk/clients/s3' import { ObjectId } from 'bson-ext' import { assignIn, cloneDeep, merge } from 'lodash' -import { err, errAsync, ok, okAsync } from 'neverthrow' +import { err, errAsync, ok, okAsync, Result } from 'neverthrow' import { PassThrough } from 'stream' import { MockedObject } from 'ts-jest/dist/utils/testing' import { mocked } from 'ts-jest/utils' @@ -74,6 +74,7 @@ import { PrivateFormError, TransferOwnershipError, } from '../../form.errors' +import * as FormService from '../../form.service' import * as AdminFormController from '../admin-form.controller' import { CreatePresignedUrlError, @@ -105,6 +106,8 @@ jest.mock('src/app/utils/encryption') const MockEncryptionUtils = mocked(EncryptionUtils) jest.mock('../admin-form.service') const MockAdminFormService = mocked(AdminFormService) +jest.mock('../../form.service') +const MockFormService = mocked(FormService) jest.mock('../../../user/user.service') const MockUserService = mocked(UserService) jest.mock('src/app/services/mail/mail.service') @@ -4807,6 +4810,255 @@ describe('admin-form.controller', () => { }) }) + describe('handleGetSettings', () => { + const MOCK_FORM_SETTINGS: FormSettings = { + authType: AuthType.NIL, + hasCaptcha: false, + inactiveMessage: 'some inactive message', + status: Status.Private, + submissionLimit: 42069, + title: 'mock title', + webhook: { + url: '', + }, + } + const MOCK_USER_ID = new ObjectId().toHexString() + const MOCK_FORM_ID = new ObjectId().toHexString() + const MOCK_USER = { + _id: MOCK_USER_ID, + email: 'somerandom@example.com', + } as IPopulatedUser + const MOCK_FORM = { + admin: MOCK_USER, + _id: MOCK_FORM_ID, + getSettings: () => MOCK_FORM_SETTINGS, + } as IPopulatedForm + + const MOCK_REQ = expressHandler.mockRequest({ + params: { + formId: MOCK_FORM_ID, + }, + session: { + user: { + _id: MOCK_USER_ID, + }, + }, + }) + + it('should return 200 with settings', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + // Mock various services to return expected results. + MockUserService.getPopulatedUserById.mockReturnValueOnce( + okAsync(MOCK_USER), + ) + MockFormService.retrieveFullFormById.mockReturnValueOnce( + okAsync(MOCK_FORM), + ) + const adminCheck = jest.fn( + ({ + form, + }: { + form: IPopulatedForm + }): Result => + ok(form), + ) + MockAuthService.checkFormForPermissions.mockReturnValueOnce(adminCheck) + + // Act + await AdminFormController.handleGetSettings(MOCK_REQ, mockRes, jest.fn()) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(200) + expect(mockRes.json).toHaveBeenCalledWith(MOCK_FORM_SETTINGS) + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect(MockFormService.retrieveFullFormById).toHaveBeenCalledWith( + MOCK_FORM_ID, + ) + expect(MockAuthService.checkFormForPermissions).toHaveBeenCalledWith( + PermissionLevel.Read, + ) + expect(adminCheck).toHaveBeenCalledWith({ + user: MOCK_USER, + form: MOCK_FORM, + }) + }) + + it('should return 403 when current user does not have permissions to view form settings', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + + MockUserService.getPopulatedUserById.mockReturnValueOnce( + okAsync(MOCK_USER), + ) + MockFormService.retrieveFullFormById.mockReturnValueOnce( + okAsync(MOCK_FORM), + ) + + const expectedErrorString = 'no write permissions' + const adminCheck = jest.fn( + (): Result => + err(new ForbiddenFormError(expectedErrorString)), + ) + MockAuthService.checkFormForPermissions.mockReturnValueOnce(adminCheck) + + // Act + await AdminFormController.handleGetSettings(MOCK_REQ, mockRes, jest.fn()) + // Assert + expect(mockRes.status).toHaveBeenCalledWith(403) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect(MockFormService.retrieveFullFormById).toHaveBeenCalledWith( + MOCK_FORM_ID, + ) + expect(MockAuthService.checkFormForPermissions).toHaveBeenCalledWith( + PermissionLevel.Read, + ) + expect(adminCheck).toHaveBeenCalledWith({ + user: MOCK_USER, + form: MOCK_FORM, + }) + }) + + it('should return 404 when form to view settings for cannot be found', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + + MockUserService.getPopulatedUserById.mockReturnValueOnce( + okAsync(MOCK_USER), + ) + + const expectedErrorString = 'nope' + MockFormService.retrieveFullFormById.mockReturnValueOnce( + errAsync(new FormNotFoundError(expectedErrorString)), + ) + + const adminCheck = jest.fn() + MockAuthService.checkFormForPermissions.mockReturnValueOnce(adminCheck) + + // Act + await AdminFormController.handleGetSettings(MOCK_REQ, mockRes, jest.fn()) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(404) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect(MockFormService.retrieveFullFormById).toHaveBeenCalledWith( + MOCK_FORM_ID, + ) + expect(MockAuthService.checkFormForPermissions).toHaveBeenCalledWith( + PermissionLevel.Read, + ) + expect(adminCheck).not.toHaveBeenCalled() + }) + + it('should return 409 when version conflict occurs whilst retrieving form settings', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + + MockUserService.getPopulatedUserById.mockReturnValueOnce( + okAsync(MOCK_USER), + ) + + const expectedErrorString = 'some conflict happened' + MockFormService.retrieveFullFormById.mockReturnValueOnce( + errAsync(new DatabaseConflictError(expectedErrorString)), + ) + + const adminCheck = jest.fn() + MockAuthService.checkFormForPermissions.mockReturnValueOnce(adminCheck) + + // Act + await AdminFormController.handleGetSettings(MOCK_REQ, mockRes, jest.fn()) + + // Assert + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect(MockFormService.retrieveFullFormById).toHaveBeenCalledWith( + MOCK_FORM_ID, + ) + expect(MockAuthService.checkFormForPermissions).toHaveBeenCalledWith( + PermissionLevel.Read, + ) + expect(adminCheck).not.toHaveBeenCalled() + }) + + it('should return 410 when viewing settings of archived form', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + + MockUserService.getPopulatedUserById.mockReturnValueOnce( + okAsync(MOCK_USER), + ) + + const expectedErrorString = 'already deleted' + MockFormService.retrieveFullFormById.mockReturnValueOnce( + errAsync(new FormDeletedError(expectedErrorString)), + ) + + const adminCheck = jest.fn() + MockAuthService.checkFormForPermissions.mockReturnValueOnce(adminCheck) + + // Act + await AdminFormController.handleGetSettings(MOCK_REQ, mockRes, jest.fn()) + + // Assert + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect(MockFormService.retrieveFullFormById).toHaveBeenCalledWith( + MOCK_FORM_ID, + ) + expect(MockAuthService.checkFormForPermissions).toHaveBeenCalledWith( + PermissionLevel.Read, + ) + expect(adminCheck).not.toHaveBeenCalled() + }) + + it('should return 500 when generic database error occurs during settings retrieval', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + + MockUserService.getPopulatedUserById.mockReturnValueOnce( + okAsync(MOCK_USER), + ) + + const expectedErrorString = 'some database error bam' + MockFormService.retrieveFullFormById.mockReturnValueOnce( + errAsync(new DatabaseError(expectedErrorString)), + ) + + const adminCheck = jest.fn() + MockAuthService.checkFormForPermissions.mockReturnValueOnce(adminCheck) + + // Act + await AdminFormController.handleGetSettings(MOCK_REQ, mockRes, jest.fn()) + + // Assert + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect(MockFormService.retrieveFullFormById).toHaveBeenCalledWith( + MOCK_FORM_ID, + ) + expect(MockAuthService.checkFormForPermissions).toHaveBeenCalledWith( + PermissionLevel.Read, + ) + expect(adminCheck).not.toHaveBeenCalled() + }) + }) + describe('handleEmailPreviewSubmission', () => { const MOCK_FIELD_ID = new ObjectId().toHexString() const MOCK_RESPONSES = [ diff --git a/src/app/modules/form/admin-form/admin-form.controller.ts b/src/app/modules/form/admin-form/admin-form.controller.ts index 8c7838596c..e373806e60 100644 --- a/src/app/modules/form/admin-form/admin-form.controller.ts +++ b/src/app/modules/form/admin-form/admin-form.controller.ts @@ -47,6 +47,7 @@ import { mapRouteError as mapEncryptSubmissionError } from '../../submission/enc import * as SubmissionService from '../../submission/submission.service' import * as UserService from '../../user/user.service' import { PrivateFormError } from '../form.errors' +import * as FormService from '../form.service' import { PREVIEW_CORPPASS_UID, @@ -1129,6 +1130,50 @@ export const handleUpdateSettings: RequestHandler< }) } +/** + * Handler for GET /form/:formId/settings. + * @security session + * + * @returns 200 with latest form settings on successful update + * @returns 401 when current user is not logged in + * @returns 403 when current user does not have permissions to obtain form settings + * @returns 404 when form to retrieve settings for cannot be found + * @returns 409 when saving form settings incurs a conflict in the database + * @returns 500 when database error occurs + */ +export const handleGetSettings: RequestHandler< + { formId: string }, + FormSettings | ErrorDto +> = (req, res) => { + const { formId } = req.params + const sessionUserId = (req.session as Express.AuthedSession).user._id + + return UserService.getPopulatedUserById(sessionUserId) + .andThen((user) => + // Retrieve form for settings as well as for permissions checking + FormService.retrieveFullFormById(formId).map((form) => ({ + form, + user, + })), + ) + .andThen(AuthService.checkFormForPermissions(PermissionLevel.Read)) + .map((form) => res.status(StatusCodes.OK).json(form.getSettings())) + .mapErr((error) => { + logger.error({ + message: 'Error occurred when retrieving form settings', + meta: { + action: 'handleGetSettings', + ...createReqMeta(req), + userId: sessionUserId, + formId, + }, + error, + }) + const { errorMessage, statusCode } = mapRouteError(error) + return res.status(statusCode).json({ message: errorMessage }) + }) +} + /** * Handler for POST /v2/submissions/encrypt/preview/:formId. * @security session diff --git a/src/app/modules/form/form.service.ts b/src/app/modules/form/form.service.ts index dd668230a7..916d7d83ad 100644 --- a/src/app/modules/form/form.service.ts +++ b/src/app/modules/form/form.service.ts @@ -112,6 +112,39 @@ export const retrieveFullFormById = ( }) } +/** + * Retrieves the specified form fields of the given formId + * @param formId the id of the form + * @param fields an array of field names to retrieve + * @returns ok(form) if form exists + * @returns err(FormNotFoundError) if the form or form admin does not exist + * @returns err(DatabaseError) if error occurs whilst querying the database + */ +export const retrieveFormKeysById = ( + formId: string, + fields: (keyof IPopulatedForm)[], +): ResultAsync => { + if (!mongoose.Types.ObjectId.isValid(formId)) { + return errAsync(new FormNotFoundError()) + } + + return ResultAsync.fromPromise( + FormModel.getFullFormById(formId, fields), + (error) => { + logger.error({ + message: 'Error retrieving form from database', + meta: { + action: 'retrieveFormKeysById', + }, + error, + }) + return new DatabaseError() + }, + ).andThen((result) => + result ? okAsync(result) : errAsync(new FormNotFoundError()), + ) +} + /** * Retrieves (non-populated) form document of the given formId. * @param formId the id of the form to retrieve diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.settings.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.settings.routes.ts index 54e2677cf9..9880aa6a61 100644 --- a/src/app/routes/api/v3/admin/forms/admin-forms.settings.routes.ts +++ b/src/app/routes/api/v3/admin/forms/admin-forms.settings.routes.ts @@ -3,7 +3,10 @@ import { Router } from 'express' import { AuthType, Status } from '../../../../../../types' import { SettingsUpdateDto } from '../../../../../../types/api' -import { handleUpdateSettings } from '../../../../../modules/form/admin-form/admin-form.controller' +import { + handleGetSettings, + handleUpdateSettings, +} from '../../../../../modules/form/admin-form/admin-form.controller' export const AdminFormsSettingsRouter = Router() @@ -29,26 +32,37 @@ const updateSettingsValidator = celebrate({ }).min(1), }) -/** - * Update form settings according to given subset of settings. - * @route PATCH /admin/forms/:formId/settings - * @group admin - * @param body the subset of settings to patch - * @produces application/json - * @consumes application/json - * @returns 200 with latest form settings on successful update - * @returns 400 when given body fails Joi validation - * @returns 401 when current user is not logged in - * @returns 403 when current user does not have permissions to update form settings - * @returns 404 when form to update settings for cannot be found - * @returns 409 when saving form settings incurs a conflict in the database - * @returns 410 when updating settings for archived form - * @returns 413 when updating settings causes form to be too large to be saved in the database - * @returns 422 when an invalid settings update is attempted on the form - * @returns 422 when user in session cannot be retrieved from the database - * @returns 500 when database error occurs - */ -AdminFormsSettingsRouter.route('/:formId([a-fA-F0-9]{24})/settings').patch( - updateSettingsValidator, - handleUpdateSettings, -) +AdminFormsSettingsRouter.route('/:formId([a-fA-F0-9]{24})/settings') + /** + * Update form settings according to given subset of settings. + * @route PATCH /admin/forms/:formId/settings + * @group admin + * @param body the subset of settings to patch + * @produces application/json + * @consumes application/json + * @returns 200 with latest form settings on successful update + * @returns 400 when given body fails Joi validation + * @returns 401 when current user is not logged in + * @returns 403 when current user does not have permissions to update form settings + * @returns 404 when form to update settings for cannot be found + * @returns 409 when saving form settings incurs a conflict in the database + * @returns 410 when updating settings for archived form + * @returns 413 when updating settings causes form to be too large to be saved in the database + * @returns 422 when an invalid settings update is attempted on the form + * @returns 422 when user in session cannot be retrieved from the database + * @returns 500 when database error occurs + */ + .patch(updateSettingsValidator, handleUpdateSettings) + /** + * Retrieve the settings of the specified form + * @route GET /admin/forms/:formId/settings + * @group admin + * @produces application/json + * @returns 200 with latest form settings on successful update + * @returns 401 when current user is not logged in + * @returns 403 when current user does not have permissions to obtain form settings + * @returns 404 when form to retrieve settings for cannot be found + * @returns 409 when saving form settings incurs a conflict in the database + * @returns 500 when database error occurs + */ + .get(handleGetSettings) diff --git a/src/types/form.ts b/src/types/form.ts index e8964e443a..8a81347e70 100644 --- a/src/types/form.ts +++ b/src/types/form.ts @@ -248,7 +248,10 @@ export type IPopulatedEmailForm = IPopulatedForm & IEmailForm export interface IFormModel extends Model { getOtpData(formId: string): Promise - getFullFormById(formId: string): Promise + getFullFormById( + formId: string, + fields?: (keyof IPopulatedForm)[], + ): Promise deactivateById(formId: string): Promise getMetaByUserIdOrEmail( userId: IUserSchema['_id'], From f1928bb2fb0900768d5d493a36d680dbf4e61ffc Mon Sep 17 00:00:00 2001 From: seaerchin <44049504+seaerchin@users.noreply.github.com> Date: Tue, 20 Apr 2021 18:30:16 +0800 Subject: [PATCH 03/51] refactor: shard public forms router (#1669) * refactor(public-forms): moved subroutes out of main publicforms router realised that public form is getting bloated adn we should not keep every route in the same file but rather, shard by responsiblity. hence, this (mini) pr is for doign that before starting on auth router to prevent excess bloat from accumulating * refactor(public-form/tests): shards tests out also * style(api): changed folder name to forms instead of public --- .../public-forms.feedback.routes.spec.ts | 160 ++++++++ .../public-forms.form.routes.spec.ts | 292 ++++++++++++++ .../public-forms.routes.spec.constants.ts | 0 .../public-forms.submissions.routes.spec.ts} | 370 +----------------- .../routes/api/v3/{public => forms}/index.ts | 0 .../v3/forms/public-forms.feedback.routes.ts | 23 ++ .../api/v3/forms/public-forms.form.routes.ts | 24 ++ .../api/v3/forms/public-forms.routes.ts | 11 + .../public-forms.submissions.routes.ts} | 48 +-- src/app/routes/api/v3/v3.routes.ts | 2 +- 10 files changed, 519 insertions(+), 411 deletions(-) create mode 100644 src/app/routes/api/v3/forms/__tests__/public-forms.feedback.routes.spec.ts create mode 100644 src/app/routes/api/v3/forms/__tests__/public-forms.form.routes.spec.ts rename src/app/routes/api/v3/{public => forms}/__tests__/public-forms.routes.spec.constants.ts (100%) rename src/app/routes/api/v3/{public/__tests__/public-forms.routes.spec.ts => forms/__tests__/public-forms.submissions.routes.spec.ts} (78%) rename src/app/routes/api/v3/{public => forms}/index.ts (100%) create mode 100644 src/app/routes/api/v3/forms/public-forms.feedback.routes.ts create mode 100644 src/app/routes/api/v3/forms/public-forms.form.routes.ts create mode 100644 src/app/routes/api/v3/forms/public-forms.routes.ts rename src/app/routes/api/v3/{public/public-forms.routes.ts => forms/public-forms.submissions.routes.ts} (56%) diff --git a/src/app/routes/api/v3/forms/__tests__/public-forms.feedback.routes.spec.ts b/src/app/routes/api/v3/forms/__tests__/public-forms.feedback.routes.spec.ts new file mode 100644 index 0000000000..8f77df7651 --- /dev/null +++ b/src/app/routes/api/v3/forms/__tests__/public-forms.feedback.routes.spec.ts @@ -0,0 +1,160 @@ +import { getReasonPhrase, StatusCodes } from 'http-status-codes' +import { errAsync } from 'neverthrow' +import supertest, { Session } from 'supertest-session' + +import { DatabaseError } from 'src/app/modules/core/core.errors' +import { Status } from 'src/types' + +import { setupApp } from 'tests/integration/helpers/express-setup' +import { buildCelebrateError } from 'tests/unit/backend/helpers/celebrate' +import dbHandler from 'tests/unit/backend/helpers/jest-db' + +import * as FormService from '../../../../../modules/form/form.service' +import { PublicFormsRouter } from '../public-forms.routes' + +const app = setupApp('/forms', PublicFormsRouter) + +describe('public-form.feedback.routes', () => { + let request: Session + + beforeAll(async () => await dbHandler.connect()) + beforeEach(async () => { + request = supertest(app) + }) + afterEach(async () => { + await dbHandler.clearDatabase() + jest.restoreAllMocks() + }) + afterAll(async () => await dbHandler.closeDatabase()) + describe('POST /forms/:formId/feedback', () => { + it('should return 200 when feedback was successfully saved', async () => { + // Arrange + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + status: Status.Public, + }, + }) + const MOCK_FEEDBACK = { + rating: 5, + comment: 'great mock', + } + const expectedResponse = JSON.parse( + JSON.stringify({ message: 'Successfully submitted feedback' }), + ) + + // Act + const response = await request + .post(`/forms/${form._id}/feedback`) + .send(MOCK_FEEDBACK) + + // Assert + expect(response.status).toEqual(200) + expect(response.body).toEqual(expectedResponse) + }) + + it('should return 400 when form feedback submitted is malformed', async () => { + // Arrange + const { form } = await dbHandler.insertEmailForm() + const MOCK_FEEDBACK = { rating: 6 } + + // Act + const response = await request + .post(`/forms/${form._id}/feedback`) + .send(MOCK_FEEDBACK) + + // Assert + expect(response.status).toEqual(400) + expect(response.body).toEqual( + buildCelebrateError({ + body: { + key: 'rating', + message: '"rating" must be less than or equal to 5', + }, + }), + ) + }) + + it('should return 404 when form is private', async () => { + // Arrange + const { form } = await dbHandler.insertEmailForm() + const MOCK_FEEDBACK = { + rating: 5, + comment: 'great mock', + } + const expectedResponse = JSON.parse( + JSON.stringify({ + message: form.inactiveMessage, + formTitle: form.title, + isPageFound: true, + }), + ) + + // Act + const response = await request + .post(`/forms/${form._id}/feedback`) + .send(MOCK_FEEDBACK) + + // Assert + expect(response.status).toEqual(404) + expect(response.body).toEqual(expectedResponse) + }) + + it('should return 410 when form is archived', async () => { + // Arrange + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + status: Status.Archived, + }, + }) + const MOCK_FEEDBACK = { + rating: 5, + comment: 'great mock', + } + const expectedResponse = JSON.parse( + JSON.stringify({ + message: getReasonPhrase(StatusCodes.GONE), + }), + ) + + // Act + const response = await request + .post(`/forms/${form._id}/feedback`) + .send(MOCK_FEEDBACK) + + // Assert + expect(response.status).toEqual(410) + expect(response.body).toEqual(expectedResponse) + }) + + it('should return 500 when form could not be retrieved due to a database error', async () => { + // Arrange + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + status: Status.Public, + }, + }) + const MOCK_ERROR_MESSAGE = 'mock me' + const MOCK_FEEDBACK = { + rating: 5, + comment: 'great mock', + } + const expectedResponse = JSON.parse( + JSON.stringify({ + message: MOCK_ERROR_MESSAGE, + }), + ) + jest + .spyOn(FormService, 'retrieveFullFormById') + .mockReturnValueOnce(errAsync(new DatabaseError(MOCK_ERROR_MESSAGE))) + + // Act + const response = await request + .post(`/forms/${form._id}/feedback`) + .send(MOCK_FEEDBACK) + + // Assert + expect(response.status).toEqual(500) + expect(response.body).toEqual(expectedResponse) + }) + }) +}) diff --git a/src/app/routes/api/v3/forms/__tests__/public-forms.form.routes.spec.ts b/src/app/routes/api/v3/forms/__tests__/public-forms.form.routes.spec.ts new file mode 100644 index 0000000000..65a0eca891 --- /dev/null +++ b/src/app/routes/api/v3/forms/__tests__/public-forms.form.routes.spec.ts @@ -0,0 +1,292 @@ +import MyInfoClient, { IMyInfoConfig } from '@opengovsg/myinfo-gov-client' +import SPCPAuthClient from '@opengovsg/spcp-auth-client' +import { ObjectId } from 'bson-ext' +import { errAsync } from 'neverthrow' +import supertest, { Session } from 'supertest-session' +import { mocked } from 'ts-jest/utils' + +import { DatabaseError } from 'src/app/modules/core/core.errors' +import { MYINFO_COOKIE_NAME } from 'src/app/modules/myinfo/myinfo.constants' +import { MyInfoCookieState } from 'src/app/modules/myinfo/myinfo.types' +import { AuthType, Status } from 'src/types' + +import { setupApp } from 'tests/integration/helpers/express-setup' +import dbHandler from 'tests/unit/backend/helpers/jest-db' + +import * as AuthService from '../../../../../modules/auth/auth.service' +import { PublicFormsRouter } from '../public-forms.routes' + +import { MOCK_UINFIN } from './public-forms.routes.spec.constants' + +jest.mock('@opengovsg/spcp-auth-client') +const MockAuthClient = mocked(SPCPAuthClient, true) + +jest.mock('@opengovsg/myinfo-gov-client', () => { + return { + MyInfoGovClient: jest.fn().mockReturnValue({ + extractUinFin: jest.fn(), + getPerson: jest.fn(), + }), + MyInfoMode: jest.requireActual('@opengovsg/myinfo-gov-client').MyInfoMode, + MyInfoSource: jest.requireActual('@opengovsg/myinfo-gov-client') + .MyInfoSource, + MyInfoAddressType: jest.requireActual('@opengovsg/myinfo-gov-client') + .MyInfoAddressType, + MyInfoAttribute: jest.requireActual('@opengovsg/myinfo-gov-client') + .MyInfoAttribute, + } +}) + +const MockMyInfoGovClient = mocked( + new MyInfoClient.MyInfoGovClient({} as IMyInfoConfig), + true, +) + +const app = setupApp('/forms', PublicFormsRouter) + +describe('public-form.form.routes', () => { + let request: Session + + const mockSpClient = mocked(MockAuthClient.mock.instances[0], true) + const mockCpClient = mocked(MockAuthClient.mock.instances[1], true) + + beforeAll(async () => await dbHandler.connect()) + beforeEach(async () => { + request = supertest(app) + }) + afterEach(async () => { + await dbHandler.clearDatabase() + jest.restoreAllMocks() + }) + afterAll(async () => await dbHandler.closeDatabase()) + describe('GET /:formId', () => { + const MOCK_COOKIE_PAYLOAD = { + userName: 'mock', + rememberMe: false, + } + + it('should return 200 with public form when form has AuthType.NIL and valid formId', async () => { + // Arrange + const { form } = await dbHandler.insertEmailForm({ + formOptions: { status: Status.Public }, + }) + // NOTE: This is needed to inject admin info into the form + const fullForm = await dbHandler.getFullFormById(form._id) + const expectedResponseBody = JSON.parse( + JSON.stringify({ + form: fullForm.getPublicView(), + isIntranetUser: false, + }), + ) + + // Act + const actualResponse = await request.get(`/forms/${form._id}`) + + // Assert + expect(actualResponse.status).toEqual(200) + expect(actualResponse.body).toEqual(expectedResponseBody) + }) + + it('should return 200 with public form when form has AuthType.SP and valid formId', async () => { + // Arrange + mockSpClient.verifyJWT.mockImplementationOnce((_jwt, cb) => + cb(null, { + userName: MOCK_COOKIE_PAYLOAD.userName, + }), + ) + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + esrvcId: 'mockEsrvcId', + authType: AuthType.SP, + hasCaptcha: false, + status: Status.Public, + }, + }) + const formId = form._id + // NOTE: This is needed to inject admin info into the form + const fullForm = await dbHandler.getFullFormById(formId) + const expectedResponseBody = JSON.parse( + JSON.stringify({ + form: fullForm?.getPublicView(), + spcpSession: { userName: MOCK_COOKIE_PAYLOAD.userName }, + isIntranetUser: false, + }), + ) + + // Act + // Set cookie on request + const actualResponse = await request + .get(`/forms/${form._id}`) + .set('Cookie', ['jwtSp=mockJwt']) + + // Assert + expect(actualResponse.status).toEqual(200) + expect(actualResponse.body).toEqual(expectedResponseBody) + }) + it('should return 200 with public form when form has AuthType.CP and valid formId', async () => { + // Arrange + mockCpClient.verifyJWT.mockImplementationOnce((_jwt, cb) => + cb(null, { + userName: MOCK_COOKIE_PAYLOAD.userName, + userInfo: 'MyCorpPassUEN', + }), + ) + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + esrvcId: 'mockEsrvcId', + authType: AuthType.CP, + hasCaptcha: false, + status: Status.Public, + }, + }) + const formId = form._id + // NOTE: This is needed to inject admin info into the form + const fullForm = await dbHandler.getFullFormById(formId) + const expectedResponseBody = JSON.parse( + JSON.stringify({ + form: fullForm?.getPublicView(), + spcpSession: { userName: MOCK_COOKIE_PAYLOAD.userName }, + isIntranetUser: false, + }), + ) + + // Act + // Set cookie on request + const actualResponse = await request + .get(`/forms/${form._id}`) + .set('Cookie', ['jwtCp=mockJwt']) + + // Assert + expect(actualResponse.status).toEqual(200) + expect(actualResponse.body).toEqual(expectedResponseBody) + }) + it('should return 200 with public form when form has AuthType.MyInfo and valid formId', async () => { + // Arrange + MockMyInfoGovClient.getPerson.mockResolvedValueOnce({ + uinFin: MOCK_UINFIN, + }) + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + esrvcId: 'mockEsrvcId', + authType: AuthType.MyInfo, + hasCaptcha: false, + status: Status.Public, + }, + }) + // NOTE: This is needed to inject admin info into the form + const fullForm = await dbHandler.getFullFormById(form._id) + const expectedResponseBody = JSON.parse( + JSON.stringify({ + form: fullForm.getPublicView(), + spcpSession: { userName: 'S1234567A' }, + isIntranetUser: false, + }), + ) + const cookie = JSON.stringify({ + accessToken: 'mockAccessToken', + usedCount: 0, + state: MyInfoCookieState.Success, + }) + + // Act + const actualResponse = await request + .get(`/forms/${form._id}`) + .set('Cookie', [ + // The j: indicates that the cookie is in JSON + `${MYINFO_COOKIE_NAME}=j:${encodeURIComponent(cookie)}`, + ]) + + // Assert + expect(actualResponse.status).toEqual(200) + expect(actualResponse.body).toEqual(expectedResponseBody) + }) + + it('should return 404 if the form does not exist', async () => { + // Arrange + const cookie = JSON.stringify({ + accessToken: 'mockAccessToken', + usedCount: 0, + state: MyInfoCookieState.Success, + }) + const MOCK_FORM_ID = new ObjectId().toHexString() + const expectedResponseBody = JSON.parse( + JSON.stringify({ + message: 'Form not found', + }), + ) + + // Act + const actualResponse = await request + .get(`/forms/${MOCK_FORM_ID}`) + .set('Cookie', [ + // The j: indicates that the cookie is in JSON + `${MYINFO_COOKIE_NAME}=j:${encodeURIComponent(cookie)}`, + ]) + + // Assert + expect(actualResponse.status).toEqual(404) + expect(actualResponse.body).toEqual(expectedResponseBody) + }) + + it('should return 404 if the form is private', async () => { + // Arrange + const { form } = await dbHandler.insertEmailForm({ + formOptions: { status: Status.Private }, + }) + const expectedResponseBody = JSON.parse( + JSON.stringify({ + message: form.inactiveMessage, + formTitle: form.title, + isPageFound: true, + }), + ) + + // Act + const actualResponse = await request.get(`/forms/${form._id}`) + + // Assert + expect(actualResponse.status).toEqual(404) + expect(actualResponse.body).toEqual(expectedResponseBody) + }) + + it('should return 410 if the form has been archived', async () => { + // Arrange + const { form } = await dbHandler.insertEmailForm({ + formOptions: { status: Status.Archived }, + }) + const expectedResponseBody = JSON.parse( + JSON.stringify({ + message: 'Gone', + }), + ) + + // Act + const actualResponse = await request.get(`/forms/${form._id}`) + + // Assert + expect(actualResponse.status).toEqual(410) + expect(actualResponse.body).toEqual(expectedResponseBody) + }) + + it('should return 500 if a database error occurs', async () => { + // Arrange + const { form } = await dbHandler.insertEmailForm({ + formOptions: { status: Status.Public }, + }) + const expectedError = new DatabaseError('all your base are belong to us') + const expectedResponseBody = JSON.parse( + JSON.stringify({ message: expectedError.message }), + ) + jest + .spyOn(AuthService, 'getFormIfPublic') + .mockReturnValueOnce(errAsync(expectedError)) + + // Act + const actualResponse = await request.get(`/forms/${form._id}`) + + // Assert + expect(actualResponse.status).toEqual(500) + expect(actualResponse.body).toEqual(expectedResponseBody) + }) + }) +}) diff --git a/src/app/routes/api/v3/public/__tests__/public-forms.routes.spec.constants.ts b/src/app/routes/api/v3/forms/__tests__/public-forms.routes.spec.constants.ts similarity index 100% rename from src/app/routes/api/v3/public/__tests__/public-forms.routes.spec.constants.ts rename to src/app/routes/api/v3/forms/__tests__/public-forms.routes.spec.constants.ts diff --git a/src/app/routes/api/v3/public/__tests__/public-forms.routes.spec.ts b/src/app/routes/api/v3/forms/__tests__/public-forms.submissions.routes.spec.ts similarity index 78% rename from src/app/routes/api/v3/public/__tests__/public-forms.routes.spec.ts rename to src/app/routes/api/v3/forms/__tests__/public-forms.submissions.routes.spec.ts index ff29d1744d..2908806e1e 100644 --- a/src/app/routes/api/v3/public/__tests__/public-forms.routes.spec.ts +++ b/src/app/routes/api/v3/forms/__tests__/public-forms.submissions.routes.spec.ts @@ -1,25 +1,18 @@ import MyInfoClient, { IMyInfoConfig } from '@opengovsg/myinfo-gov-client' import SPCPAuthClient from '@opengovsg/spcp-auth-client' -import { ObjectId } from 'bson-ext' -import { getReasonPhrase, StatusCodes } from 'http-status-codes' import { omit } from 'lodash' import mongoose from 'mongoose' -import { errAsync } from 'neverthrow' import supertest, { Session } from 'supertest-session' import { mocked } from 'ts-jest/utils' -import { DatabaseError } from 'src/app/modules/core/core.errors' import { MYINFO_COOKIE_NAME } from 'src/app/modules/myinfo/myinfo.constants' import { MyInfoCookieState } from 'src/app/modules/myinfo/myinfo.types' import getMyInfoHashModel from 'src/app/modules/myinfo/myinfo_hash.model' import { AuthType, IFieldSchema, Status } from 'src/types' import { setupApp } from 'tests/integration/helpers/express-setup' -import { buildCelebrateError } from 'tests/unit/backend/helpers/celebrate' import dbHandler from 'tests/unit/backend/helpers/jest-db' -import * as AuthService from '../../../../../modules/auth/auth.service' -import * as FormService from '../../../../../modules/form/form.service' import { PublicFormsRouter } from '../public-forms.routes' import { @@ -72,7 +65,7 @@ const MockMyInfoGovClient = mocked( const app = setupApp('/forms', PublicFormsRouter) -describe('public-form.routes', () => { +describe('public-form.submissions.routes', () => { let request: Session const mockSpClient = mocked(MockAuthClient.mock.instances[0], true) @@ -87,367 +80,6 @@ describe('public-form.routes', () => { jest.restoreAllMocks() }) afterAll(async () => await dbHandler.closeDatabase()) - describe('GET /:formId', () => { - const MOCK_COOKIE_PAYLOAD = { - userName: 'mock', - rememberMe: false, - } - - it('should return 200 with public form when form has AuthType.NIL and valid formId', async () => { - // Arrange - const { form } = await dbHandler.insertEmailForm({ - formOptions: { status: Status.Public }, - }) - // NOTE: This is needed to inject admin info into the form - const fullForm = await dbHandler.getFullFormById(form._id) - const expectedResponseBody = JSON.parse( - JSON.stringify({ - form: fullForm.getPublicView(), - isIntranetUser: false, - }), - ) - - // Act - const actualResponse = await request.get(`/forms/${form._id}`) - - // Assert - expect(actualResponse.status).toEqual(200) - expect(actualResponse.body).toEqual(expectedResponseBody) - }) - - it('should return 200 with public form when form has AuthType.SP and valid formId', async () => { - // Arrange - mockSpClient.verifyJWT.mockImplementationOnce((_jwt, cb) => - cb(null, { - userName: MOCK_COOKIE_PAYLOAD.userName, - }), - ) - const { form } = await dbHandler.insertEmailForm({ - formOptions: { - esrvcId: 'mockEsrvcId', - authType: AuthType.SP, - hasCaptcha: false, - status: Status.Public, - }, - }) - const formId = form._id - // NOTE: This is needed to inject admin info into the form - const fullForm = await dbHandler.getFullFormById(formId) - const expectedResponseBody = JSON.parse( - JSON.stringify({ - form: fullForm?.getPublicView(), - spcpSession: { userName: MOCK_COOKIE_PAYLOAD.userName }, - isIntranetUser: false, - }), - ) - - // Act - // Set cookie on request - const actualResponse = await request - .get(`/forms/${form._id}`) - .set('Cookie', ['jwtSp=mockJwt']) - - // Assert - expect(actualResponse.status).toEqual(200) - expect(actualResponse.body).toEqual(expectedResponseBody) - }) - it('should return 200 with public form when form has AuthType.CP and valid formId', async () => { - // Arrange - mockCpClient.verifyJWT.mockImplementationOnce((_jwt, cb) => - cb(null, { - userName: MOCK_COOKIE_PAYLOAD.userName, - userInfo: 'MyCorpPassUEN', - }), - ) - const { form } = await dbHandler.insertEmailForm({ - formOptions: { - esrvcId: 'mockEsrvcId', - authType: AuthType.CP, - hasCaptcha: false, - status: Status.Public, - }, - }) - const formId = form._id - // NOTE: This is needed to inject admin info into the form - const fullForm = await dbHandler.getFullFormById(formId) - const expectedResponseBody = JSON.parse( - JSON.stringify({ - form: fullForm?.getPublicView(), - spcpSession: { userName: MOCK_COOKIE_PAYLOAD.userName }, - isIntranetUser: false, - }), - ) - - // Act - // Set cookie on request - const actualResponse = await request - .get(`/forms/${form._id}`) - .set('Cookie', ['jwtCp=mockJwt']) - - // Assert - expect(actualResponse.status).toEqual(200) - expect(actualResponse.body).toEqual(expectedResponseBody) - }) - it('should return 200 with public form when form has AuthType.MyInfo and valid formId', async () => { - // Arrange - MockMyInfoGovClient.getPerson.mockResolvedValueOnce({ - uinFin: MOCK_UINFIN, - }) - const { form } = await dbHandler.insertEmailForm({ - formOptions: { - esrvcId: 'mockEsrvcId', - authType: AuthType.MyInfo, - hasCaptcha: false, - status: Status.Public, - }, - }) - // NOTE: This is needed to inject admin info into the form - const fullForm = await dbHandler.getFullFormById(form._id) - const expectedResponseBody = JSON.parse( - JSON.stringify({ - form: fullForm.getPublicView(), - spcpSession: { userName: 'S1234567A' }, - isIntranetUser: false, - }), - ) - const cookie = JSON.stringify({ - accessToken: 'mockAccessToken', - usedCount: 0, - state: MyInfoCookieState.Success, - }) - - // Act - const actualResponse = await request - .get(`/forms/${form._id}`) - .set('Cookie', [ - // The j: indicates that the cookie is in JSON - `${MYINFO_COOKIE_NAME}=j:${encodeURIComponent(cookie)}`, - ]) - - // Assert - expect(actualResponse.status).toEqual(200) - expect(actualResponse.body).toEqual(expectedResponseBody) - }) - - it('should return 404 if the form does not exist', async () => { - // Arrange - const cookie = JSON.stringify({ - accessToken: 'mockAccessToken', - usedCount: 0, - state: MyInfoCookieState.Success, - }) - const MOCK_FORM_ID = new ObjectId().toHexString() - const expectedResponseBody = JSON.parse( - JSON.stringify({ - message: 'Form not found', - }), - ) - - // Act - const actualResponse = await request - .get(`/forms/${MOCK_FORM_ID}`) - .set('Cookie', [ - // The j: indicates that the cookie is in JSON - `${MYINFO_COOKIE_NAME}=j:${encodeURIComponent(cookie)}`, - ]) - - // Assert - expect(actualResponse.status).toEqual(404) - expect(actualResponse.body).toEqual(expectedResponseBody) - }) - - it('should return 404 if the form is private', async () => { - // Arrange - const { form } = await dbHandler.insertEmailForm({ - formOptions: { status: Status.Private }, - }) - const expectedResponseBody = JSON.parse( - JSON.stringify({ - message: form.inactiveMessage, - formTitle: form.title, - isPageFound: true, - }), - ) - - // Act - const actualResponse = await request.get(`/forms/${form._id}`) - - // Assert - expect(actualResponse.status).toEqual(404) - expect(actualResponse.body).toEqual(expectedResponseBody) - }) - - it('should return 410 if the form has been archived', async () => { - // Arrange - const { form } = await dbHandler.insertEmailForm({ - formOptions: { status: Status.Archived }, - }) - const expectedResponseBody = JSON.parse( - JSON.stringify({ - message: 'Gone', - }), - ) - - // Act - const actualResponse = await request.get(`/forms/${form._id}`) - - // Assert - expect(actualResponse.status).toEqual(410) - expect(actualResponse.body).toEqual(expectedResponseBody) - }) - - it('should return 500 if a database error occurs', async () => { - // Arrange - const { form } = await dbHandler.insertEmailForm({ - formOptions: { status: Status.Public }, - }) - const expectedError = new DatabaseError('all your base are belong to us') - const expectedResponseBody = JSON.parse( - JSON.stringify({ message: expectedError.message }), - ) - jest - .spyOn(AuthService, 'getFormIfPublic') - .mockReturnValueOnce(errAsync(expectedError)) - - // Act - const actualResponse = await request.get(`/forms/${form._id}`) - - // Assert - expect(actualResponse.status).toEqual(500) - expect(actualResponse.body).toEqual(expectedResponseBody) - }) - }) - describe('POST /forms/:formId/feedback', () => { - it('should return 200 when feedback was successfully saved', async () => { - // Arrange - const { form } = await dbHandler.insertEmailForm({ - formOptions: { - status: Status.Public, - }, - }) - const MOCK_FEEDBACK = { - rating: 5, - comment: 'great mock', - } - const expectedResponse = JSON.parse( - JSON.stringify({ message: 'Successfully submitted feedback' }), - ) - - // Act - const response = await request - .post(`/forms/${form._id}/feedback`) - .send(MOCK_FEEDBACK) - - // Assert - expect(response.status).toEqual(200) - expect(response.body).toEqual(expectedResponse) - }) - - it('should return 400 when form feedback submitted is malformed', async () => { - // Arrange - const { form } = await dbHandler.insertEmailForm() - const MOCK_FEEDBACK = { rating: 6 } - - // Act - const response = await request - .post(`/forms/${form._id}/feedback`) - .send(MOCK_FEEDBACK) - - // Assert - expect(response.status).toEqual(400) - expect(response.body).toEqual( - buildCelebrateError({ - body: { - key: 'rating', - message: '"rating" must be less than or equal to 5', - }, - }), - ) - }) - - it('should return 404 when form is private', async () => { - // Arrange - const { form } = await dbHandler.insertEmailForm() - const MOCK_FEEDBACK = { - rating: 5, - comment: 'great mock', - } - const expectedResponse = JSON.parse( - JSON.stringify({ - message: form.inactiveMessage, - formTitle: form.title, - isPageFound: true, - }), - ) - - // Act - const response = await request - .post(`/forms/${form._id}/feedback`) - .send(MOCK_FEEDBACK) - - // Assert - expect(response.status).toEqual(404) - expect(response.body).toEqual(expectedResponse) - }) - - it('should return 410 when form is archived', async () => { - // Arrange - const { form } = await dbHandler.insertEmailForm({ - formOptions: { - status: Status.Archived, - }, - }) - const MOCK_FEEDBACK = { - rating: 5, - comment: 'great mock', - } - const expectedResponse = JSON.parse( - JSON.stringify({ - message: getReasonPhrase(StatusCodes.GONE), - }), - ) - - // Act - const response = await request - .post(`/forms/${form._id}/feedback`) - .send(MOCK_FEEDBACK) - - // Assert - expect(response.status).toEqual(410) - expect(response.body).toEqual(expectedResponse) - }) - - it('should return 500 when form could not be retrieved due to a database error', async () => { - // Arrange - const { form } = await dbHandler.insertEmailForm({ - formOptions: { - status: Status.Public, - }, - }) - const MOCK_ERROR_MESSAGE = 'mock me' - const MOCK_FEEDBACK = { - rating: 5, - comment: 'great mock', - } - const expectedResponse = JSON.parse( - JSON.stringify({ - message: MOCK_ERROR_MESSAGE, - }), - ) - jest - .spyOn(FormService, 'retrieveFullFormById') - .mockReturnValueOnce(errAsync(new DatabaseError(MOCK_ERROR_MESSAGE))) - - // Act - const response = await request - .post(`/forms/${form._id}/feedback`) - .send(MOCK_FEEDBACK) - - // Assert - expect(response.status).toEqual(500) - expect(response.body).toEqual(expectedResponse) - }) - }) describe('POST /forms/:formId/submissions/email', () => { const mockSpClient = mocked(MockAuthClient.mock.instances[0], true) diff --git a/src/app/routes/api/v3/public/index.ts b/src/app/routes/api/v3/forms/index.ts similarity index 100% rename from src/app/routes/api/v3/public/index.ts rename to src/app/routes/api/v3/forms/index.ts diff --git a/src/app/routes/api/v3/forms/public-forms.feedback.routes.ts b/src/app/routes/api/v3/forms/public-forms.feedback.routes.ts new file mode 100644 index 0000000000..ee79212066 --- /dev/null +++ b/src/app/routes/api/v3/forms/public-forms.feedback.routes.ts @@ -0,0 +1,23 @@ +import { Router } from 'express' + +import * as PublicFormController from '../../../../modules/form/public-form/public-form.controller' + +export const PublicFormsFeedbackRouter = Router() + +/** + * Send feedback for a public form + * @route POST /:formId/feedback + * @group forms - endpoints to serve forms + * @param {string} formId.path.required - the form id + * @param {Feedback.model} feedback.body.required - the user's feedback + * @consumes application/json + * @produces application/json + * @returns 200 if feedback was successfully saved + * @returns 400 if form feedback was malformed and hence cannot be saved + * @returns 404 if form with formId does not exist or is private + * @returns 410 if form has been archived + * @returns 500 if database error occurs + */ +PublicFormsFeedbackRouter.route('/:formId([a-fA-F0-9]{24})/feedback').post( + PublicFormController.handleSubmitFeedback, +) diff --git a/src/app/routes/api/v3/forms/public-forms.form.routes.ts b/src/app/routes/api/v3/forms/public-forms.form.routes.ts new file mode 100644 index 0000000000..515925ceec --- /dev/null +++ b/src/app/routes/api/v3/forms/public-forms.form.routes.ts @@ -0,0 +1,24 @@ +import { Router } from 'express' + +import * as PublicFormController from '../../../../modules/form/public-form/public-form.controller' + +export const PublicFormsFormRouter = Router() + +/** + * Returns the specified form to the user, along with any + * identify information obtained from Singpass/Corppass/MyInfo. + * + * WARNING: TemperatureSG batch jobs rely on this endpoint to + * retrieve the master list of personnel for daily reporting. + * Please strictly ensure backwards compatibility. + * @route GET /:formId + * + * @returns 200 with form when form exists and is public + * @returns 404 when form is private or form with given ID does not exist + * @returns 410 when form is archived + * @returns 500 when database error occurs + */ +PublicFormsFormRouter.get( + '/:formId([a-fA-F0-9]{24})', + PublicFormController.handleGetPublicForm, +) diff --git a/src/app/routes/api/v3/forms/public-forms.routes.ts b/src/app/routes/api/v3/forms/public-forms.routes.ts new file mode 100644 index 0000000000..41129728c9 --- /dev/null +++ b/src/app/routes/api/v3/forms/public-forms.routes.ts @@ -0,0 +1,11 @@ +import { Router } from 'express' + +import { PublicFormsFeedbackRouter } from './public-forms.feedback.routes' +import { PublicFormsFormRouter } from './public-forms.form.routes' +import { PublicFormsSubmissionsRouter } from './public-forms.submissions.routes' + +export const PublicFormsRouter = Router() + +PublicFormsRouter.use(PublicFormsSubmissionsRouter) +PublicFormsRouter.use(PublicFormsFeedbackRouter) +PublicFormsRouter.use(PublicFormsFormRouter) diff --git a/src/app/routes/api/v3/public/public-forms.routes.ts b/src/app/routes/api/v3/forms/public-forms.submissions.routes.ts similarity index 56% rename from src/app/routes/api/v3/public/public-forms.routes.ts rename to src/app/routes/api/v3/forms/public-forms.submissions.routes.ts index c3a842b484..b226ea494a 100644 --- a/src/app/routes/api/v3/public/public-forms.routes.ts +++ b/src/app/routes/api/v3/forms/public-forms.submissions.routes.ts @@ -1,31 +1,12 @@ import { Router } from 'express' import { rateLimitConfig } from '../../../../config/config' -import * as PublicFormController from '../../../../modules/form/public-form/public-form.controller' import * as EmailSubmissionController from '../../../../modules/submission/email-submission/email-submission.controller' import * as EncryptSubmissionController from '../../../../modules/submission/encrypt-submission/encrypt-submission.controller' import { CaptchaFactory } from '../../../../services/captcha/captcha.factory' import { limitRate } from '../../../../utils/limit-rate' -export const PublicFormsRouter = Router() - -/** - * Send feedback for a public form - * @route POST /:formId/feedback - * @group forms - endpoints to serve forms - * @param {string} formId.path.required - the form id - * @param {Feedback.model} feedback.body.required - the user's feedback - * @consumes application/json - * @produces application/json - * @returns 200 if feedback was successfully saved - * @returns 400 if form feedback was malformed and hence cannot be saved - * @returns 404 if form with formId does not exist or is private - * @returns 410 if form has been archived - * @returns 500 if database error occurs - */ -PublicFormsRouter.route('/:formId([a-fA-F0-9]{24})/feedback').post( - PublicFormController.handleSubmitFeedback, -) +export const PublicFormsSubmissionsRouter = Router() /** * Submit a form response, processing it as an email to be sent to @@ -43,7 +24,9 @@ PublicFormsRouter.route('/:formId([a-fA-F0-9]{24})/feedback').post( * @returns 200 - submission made * @returns 400 - submission has bad data and could not be processed */ -PublicFormsRouter.route('/:formId([a-fA-F0-9]{24})/submissions/email').post( +PublicFormsSubmissionsRouter.route( + '/:formId([a-fA-F0-9]{24})/submissions/email', +).post( limitRate({ max: rateLimitConfig.submissions }), CaptchaFactory.validateCaptchaParams, EmailSubmissionController.handleEmailSubmission, @@ -61,27 +44,10 @@ PublicFormsRouter.route('/:formId([a-fA-F0-9]{24})/submissions/email').post( * @returns 200 - submission made * @returns 400 - submission has bad data and could not be processed */ -PublicFormsRouter.route('/:formId([a-fA-F0-9]{24})/submissions/encrypt').post( +PublicFormsSubmissionsRouter.route( + '/:formId([a-fA-F0-9]{24})/submissions/encrypt', +).post( limitRate({ max: rateLimitConfig.submissions }), CaptchaFactory.validateCaptchaParams, EncryptSubmissionController.handleEncryptedSubmission, ) - -/** - * Returns the specified form to the user, along with any - * identify information obtained from Singpass/Corppass/MyInfo. - * - * WARNING: TemperatureSG batch jobs rely on this endpoint to - * retrieve the master list of personnel for daily reporting. - * Please strictly ensure backwards compatibility. - * @route GET /:formId - * - * @returns 200 with form when form exists and is public - * @returns 404 when form is private or form with given ID does not exist - * @returns 410 when form is archived - * @returns 500 when database error occurs - */ -PublicFormsRouter.get( - '/:formId([a-fA-F0-9]{24})', - PublicFormController.handleGetPublicForm, -) diff --git a/src/app/routes/api/v3/v3.routes.ts b/src/app/routes/api/v3/v3.routes.ts index f2e5c1b054..945c684441 100644 --- a/src/app/routes/api/v3/v3.routes.ts +++ b/src/app/routes/api/v3/v3.routes.ts @@ -5,8 +5,8 @@ import { AnalyticsRouter } from './analytics' import { AuthRouter } from './auth' import { BillingsRouter } from './billings' import { ClientRouter } from './client' +import { PublicFormsRouter } from './forms' import { NotificationsRouter } from './notifications' -import { PublicFormsRouter } from './public' import { UserRouter } from './user' export const V3Router = Router() From 04ea18af109737eee31d066f4b3b043d64f6b69d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 Apr 2021 17:27:08 +0000 Subject: [PATCH 04/51] fix(deps): bump fp-ts from 2.10.3 to 2.10.4 (#1686) Bumps [fp-ts](https://github.com/gcanti/fp-ts) from 2.10.3 to 2.10.4. - [Release notes](https://github.com/gcanti/fp-ts/releases) - [Changelog](https://github.com/gcanti/fp-ts/blob/master/CHANGELOG.md) - [Commits](https://github.com/gcanti/fp-ts/compare/2.10.3...2.10.4) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 032283e005..ec618a5ecc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12037,9 +12037,9 @@ "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=" }, "fp-ts": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.10.3.tgz", - "integrity": "sha512-Lq9XweGms3tAmCh1AAxBG+1PfBY1zKQ3kD52q3Db6SgoA4xIUKLFZQBhmuZ7fCGmhUPZF32rlSX2/QBP0VMdjg==" + "version": "2.10.4", + "resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.10.4.tgz", + "integrity": "sha512-vMTB5zNc9PnE20q145PNbkiL9P9WegwmKVOFloi/NfHnPdAlcob6I3AKqlH/9u3k3/M/GOftZhcJdBrb+NtnDA==" }, "fragment-cache": { "version": "0.2.1", diff --git a/package.json b/package.json index d221182c62..5c513bb17b 100644 --- a/package.json +++ b/package.json @@ -112,7 +112,7 @@ "file-loader": "^4.3.0", "file-saver": "^2.0.5", "font-awesome": "4.7.0", - "fp-ts": "^2.10.3", + "fp-ts": "^2.10.4", "has-ansi": "^4.0.1", "helmet": "^4.5.0", "http-status-codes": "^2.1.4", From 36be996b34a9bad1d1d8ec64c7218fe30498b889 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 Apr 2021 17:36:15 +0000 Subject: [PATCH 05/51] fix(deps): bump @sentry/browser from 6.2.5 to 6.3.0 (#1687) Bumps [@sentry/browser](https://github.com/getsentry/sentry-javascript) from 6.2.5 to 6.3.0. - [Release notes](https://github.com/getsentry/sentry-javascript/releases) - [Changelog](https://github.com/getsentry/sentry-javascript/blob/master/CHANGELOG.md) - [Commits](https://github.com/getsentry/sentry-javascript/compare/6.2.5...6.3.0) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 62 +++++++++++++++++++++++------------------------ package.json | 2 +- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/package-lock.json b/package-lock.json index ec618a5ecc..324385447b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4591,35 +4591,35 @@ } }, "@sentry/browser": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-6.2.5.tgz", - "integrity": "sha512-nlvaE+D7oaj4MxoY9ikw+krQDOjftnDYJQnOwOraXPk7KYM6YwmkakLuE+x/AkaH3FQVTQF330VAa9d6SWETlA==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-6.3.0.tgz", + "integrity": "sha512-Rse9j5XwN9n7GnfW1mNscTS4YQ0oiBNJcaSk3Mw/vQT872Wh60yKyx5wxAw5GujFZI0NgdyPlZwZ/tGQwirRxA==", "requires": { - "@sentry/core": "6.2.5", - "@sentry/types": "6.2.5", - "@sentry/utils": "6.2.5", + "@sentry/core": "6.3.0", + "@sentry/types": "6.3.0", + "@sentry/utils": "6.3.0", "tslib": "^1.9.3" } }, "@sentry/core": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-6.2.5.tgz", - "integrity": "sha512-I+AkgIFO6sDUoHQticP6I27TT3L+i6TUS03in3IEtpBcSeP2jyhlxI8l/wdA7gsBqUPdQ4GHOOaNgtFIcr8qag==", - "requires": { - "@sentry/hub": "6.2.5", - "@sentry/minimal": "6.2.5", - "@sentry/types": "6.2.5", - "@sentry/utils": "6.2.5", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-6.3.0.tgz", + "integrity": "sha512-voot/lJ9gRXB6bx6tVqbEbD6jOd4Sx6Rfmm6pzfpom9C0q+fjIZTatTLq8GdXj8DzxaH1MBDSwtaq/eC3NqYpA==", + "requires": { + "@sentry/hub": "6.3.0", + "@sentry/minimal": "6.3.0", + "@sentry/types": "6.3.0", + "@sentry/utils": "6.3.0", "tslib": "^1.9.3" } }, "@sentry/hub": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-6.2.5.tgz", - "integrity": "sha512-YlEFdEhcfqpl2HC+/dWXBsBJEljyMzFS7LRRjCk8QANcOdp9PhwQjwebUB4/ulOBjHPP2WZk7fBBd/IKDasTUg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-6.3.0.tgz", + "integrity": "sha512-lAnW3Om66t9IR+t1wya1NpOF9lGbvYG6Ca8wxJJGJ1t2PxKwyxpZKzRx0q8M1QFhlZ5cETCzxmM7lBEZ4QVCBg==", "requires": { - "@sentry/types": "6.2.5", - "@sentry/utils": "6.2.5", + "@sentry/types": "6.3.0", + "@sentry/utils": "6.3.0", "tslib": "^1.9.3" } }, @@ -4651,26 +4651,26 @@ } }, "@sentry/minimal": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/@sentry/minimal/-/minimal-6.2.5.tgz", - "integrity": "sha512-RKP4Qx3p7Cv0oX1cPKAkNVFYM7p2k1t32cNk1+rrVQS4hwlJ7Eg6m6fsqsO+85jd6Ne/FnyYsfo9cDD3ImTlWQ==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@sentry/minimal/-/minimal-6.3.0.tgz", + "integrity": "sha512-ZdPUwdPQkaKroy67NkwQRqmnfKyd/C1OyouM9IqYKyBjAInjOijwwc/Rd91PMHalvCOGfp1scNZYbZ+YFs/qQQ==", "requires": { - "@sentry/hub": "6.2.5", - "@sentry/types": "6.2.5", + "@sentry/hub": "6.3.0", + "@sentry/types": "6.3.0", "tslib": "^1.9.3" } }, "@sentry/types": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.2.5.tgz", - "integrity": "sha512-1Sux6CLYrV9bETMsGP/HuLFLouwKoX93CWzG8BjMueW+Di0OGxZphYjXrGuDs8xO8bAKEVGCHgVQdcB2jevS0w==" + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.3.0.tgz", + "integrity": "sha512-xWyCYDmFPjS5ex60kxOOHbHEs4vs00qHbm0iShQfjl4OSg9S2azkcWofDmX8Xbn0FSOUXgdPCjNJW1B0bPVhCA==" }, "@sentry/utils": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-6.2.5.tgz", - "integrity": "sha512-fJoLUZHrd5MPylV1dT4qL74yNFDl1Ur/dab+pKNSyvnHPnbZ/LRM7aJ8VaRY/A7ZdpRowU+E14e/Yeem2c6gtQ==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-6.3.0.tgz", + "integrity": "sha512-NZzw4oLelgvCsVBG2e+ZtFtaBvgA7rZYtcGFbZTphhAlYoJ6JMCQUzYk0iwJK79yR1quh510x4UE0jynvvToWg==", "requires": { - "@sentry/types": "6.2.5", + "@sentry/types": "6.3.0", "tslib": "^1.9.3" } }, diff --git a/package.json b/package.json index 5c513bb17b..cb97ad5062 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "@opengovsg/myinfo-gov-client": "=4.0.0-beta.0", "@opengovsg/ng-file-upload": "^12.2.15", "@opengovsg/spcp-auth-client": "^1.4.5", - "@sentry/browser": "^6.2.5", + "@sentry/browser": "^6.3.0", "@sentry/integrations": "^6.2.5", "@stablelib/base64": "^1.0.0", "JSONStream": "^1.3.5", From a24c15d08cacd25da25e254191769f9f6ac52c66 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 Apr 2021 17:49:59 +0000 Subject: [PATCH 06/51] fix(deps): bump validator from 13.5.2 to 13.6.0 (#1688) Bumps [validator](https://github.com/validatorjs/validator.js) from 13.5.2 to 13.6.0. - [Release notes](https://github.com/validatorjs/validator.js/releases) - [Changelog](https://github.com/validatorjs/validator.js/blob/13.6.0/CHANGELOG.md) - [Commits](https://github.com/validatorjs/validator.js/compare/13.5.2...13.6.0) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 324385447b..f8a2f5af68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24718,9 +24718,9 @@ } }, "validator": { - "version": "13.5.2", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.5.2.tgz", - "integrity": "sha512-mD45p0rvHVBlY2Zuy3F3ESIe1h5X58GPfAtslBjY7EtTqGquZTj+VX/J4RnHWN8FKq0C9WRVt1oWAcytWRuYLQ==" + "version": "13.6.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.6.0.tgz", + "integrity": "sha512-gVgKbdbHgtxpRyR8K0O6oFZPhhB5tT1jeEHZR0Znr9Svg03U0+r9DXWMrnRAB+HtCStDQKlaIZm42tVsVjqtjg==" }, "vary": { "version": "1.1.2", diff --git a/package.json b/package.json index cb97ad5062..fda436d159 100644 --- a/package.json +++ b/package.json @@ -149,7 +149,7 @@ "ui-select": "^0.19.8", "uid-generator": "^2.0.0", "uuid": "^8.3.2", - "validator": "^13.5.2", + "validator": "^13.6.0", "web-streams-polyfill": "^3.0.3", "whatwg-fetch": "^3.6.2", "winston": "^3.3.3", From 7b135d5452c3b59409af4a06591ae001629841c9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 Apr 2021 17:54:59 +0000 Subject: [PATCH 07/51] fix(deps): bump @babel/runtime from 7.13.10 to 7.13.16 (#1689) Bumps [@babel/runtime](https://github.com/babel/babel/tree/HEAD/packages/babel-runtime) from 7.13.10 to 7.13.16. - [Release notes](https://github.com/babel/babel/releases) - [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md) - [Commits](https://github.com/babel/babel/commits/v7.13.16/packages/babel-runtime) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index f8a2f5af68..77ad16c181 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3656,9 +3656,9 @@ } }, "@babel/runtime": { - "version": "7.13.10", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.13.10.tgz", - "integrity": "sha512-4QPkjJq6Ns3V/RgpEahRk+AGfL0eO6RHHtTWoNNr5mO49G6B5+X6d6THgWEAvTrznU5xYpbAlVKRYcsCgh/Akw==", + "version": "7.13.16", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.13.16.tgz", + "integrity": "sha512-7VsWJsI5USRhBLE/3of+VU2DDNWtYHQlq2IHu2iL15+Yx4qVqP8KllR6JMHQlTKWRyDk9Tw6unkqSusaHXt//A==", "requires": { "regenerator-runtime": "^0.13.4" } diff --git a/package.json b/package.json index fda436d159..0c06a6efc5 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ ] }, "dependencies": { - "@babel/runtime": "^7.13.10", + "@babel/runtime": "^7.13.16", "@joi/date": "^2.1.0", "@opengovsg/angular-daterangepicker-webpack": "^1.1.5", "@opengovsg/angular-legacy-sortablejs-maintained": "^1.0.0", From ade265240b761d1b35be23d4eae429f5ae782280 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 Apr 2021 18:13:32 +0000 Subject: [PATCH 08/51] fix(deps): bump aws-sdk from 2.888.0 to 2.889.0 (#1691) Bumps [aws-sdk](https://github.com/aws/aws-sdk-js) from 2.888.0 to 2.889.0. - [Release notes](https://github.com/aws/aws-sdk-js/releases) - [Changelog](https://github.com/aws/aws-sdk-js/blob/master/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js/compare/v2.888.0...v2.889.0) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 77ad16c181..29c1ded977 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6434,9 +6434,9 @@ "integrity": "sha512-24q5Rh3bno7ldoyCq99d6hpnLI+PAMocdeVaaGt/5BTQMprvDwQToHfNnruqN11odCHZZIQbRBw+nZo1lTCH9g==" }, "aws-sdk": { - "version": "2.888.0", - "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.888.0.tgz", - "integrity": "sha512-9Rg14eneXnrs5Wh5FL42qGEXf7QaqaV/gMHU9SfvAA0SEM390QnwVjCSKF5YAReWjSuJriKJTDiodMI39J+Nrg==", + "version": "2.889.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.889.0.tgz", + "integrity": "sha512-+v77GmIJKXT3GMDg/HF9x8c7RSVU8Imfp/0n0Tuzf5AAE6eavpD3xzHABiK9zO9f+T8XzJDytl66UQ33YXavng==", "requires": { "buffer": "4.9.2", "events": "1.1.1", diff --git a/package.json b/package.json index 0c06a6efc5..69eae72882 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "angular-ui-bootstrap": "~2.5.6", "angular-ui-router": "~1.0.29", "aws-info": "^1.2.0", - "aws-sdk": "^2.888.0", + "aws-sdk": "^2.889.0", "axios": "^0.21.1", "bcrypt": "^5.0.1", "bluebird": "^3.5.2", From 4ac6deb2f7369d41095c103ebd60050a95a7aa83 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 Apr 2021 18:21:12 +0000 Subject: [PATCH 09/51] chore(deps-dev): bump @babel/core from 7.13.15 to 7.13.16 (#1692) Bumps [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) from 7.13.15 to 7.13.16. - [Release notes](https://github.com/babel/babel/releases) - [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md) - [Commits](https://github.com/babel/babel/commits/v7.13.16/packages/babel-core) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 172 +++++++++++++++------------------------------- package.json | 2 +- 2 files changed, 57 insertions(+), 117 deletions(-) diff --git a/package-lock.json b/package-lock.json index 29c1ded977..25eb21ef40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,20 +20,20 @@ "dev": true }, "@babel/core": { - "version": "7.13.15", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.13.15.tgz", - "integrity": "sha512-6GXmNYeNjS2Uz+uls5jalOemgIhnTMeaXo+yBUA72kC2uX/8VW6XyhVIo2L8/q0goKQA3EVKx0KOQpVKSeWadQ==", + "version": "7.13.16", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.13.16.tgz", + "integrity": "sha512-sXHpixBiWWFti0AV2Zq7avpTasr6sIAu7Y396c608541qAU2ui4a193m0KSQmfPSKFZLnQ3cvlKDOm3XkuXm3Q==", "dev": true, "requires": { "@babel/code-frame": "^7.12.13", - "@babel/generator": "^7.13.9", - "@babel/helper-compilation-targets": "^7.13.13", + "@babel/generator": "^7.13.16", + "@babel/helper-compilation-targets": "^7.13.16", "@babel/helper-module-transforms": "^7.13.14", - "@babel/helpers": "^7.13.10", - "@babel/parser": "^7.13.15", + "@babel/helpers": "^7.13.16", + "@babel/parser": "^7.13.16", "@babel/template": "^7.12.13", "@babel/traverse": "^7.13.15", - "@babel/types": "^7.13.14", + "@babel/types": "^7.13.16", "convert-source-map": "^1.7.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -58,23 +58,23 @@ "dev": true }, "@babel/generator": { - "version": "7.13.9", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.13.9.tgz", - "integrity": "sha512-mHOOmY0Axl/JCTkxTU6Lf5sWOg/v8nUa+Xkt4zMTftX0wqmb6Sh7J8gvcehBw7q0AhrhAR+FDacKjCZ2X8K+Sw==", + "version": "7.13.16", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.13.16.tgz", + "integrity": "sha512-grBBR75UnKOcUWMp8WoDxNsWCFl//XCK6HWTrBQKTr5SV9f5g0pNOjdyzi/DTBv12S9GnYPInIXQBTky7OXEMg==", "dev": true, "requires": { - "@babel/types": "^7.13.0", + "@babel/types": "^7.13.16", "jsesc": "^2.5.1", "source-map": "^0.5.0" } }, "@babel/helper-compilation-targets": { - "version": "7.13.13", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.13.13.tgz", - "integrity": "sha512-q1kcdHNZehBwD9jYPh3WyXcsFERi39X4I59I3NadciWtNDyZ6x+GboOxncFK0kXlKIv6BJm5acncehXWUjWQMQ==", + "version": "7.13.16", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.13.16.tgz", + "integrity": "sha512-3gmkYIrpqsLlieFwjkGgLaSHmhnvlAYzZLlYVjlW+QwI+1zE17kGxuJGmIqDQdYp56XdmGeD+Bswx0UTyG18xA==", "dev": true, "requires": { - "@babel/compat-data": "^7.13.12", + "@babel/compat-data": "^7.13.15", "@babel/helper-validator-option": "^7.12.17", "browserslist": "^4.14.5", "semver": "^6.3.0" @@ -100,70 +100,6 @@ "@babel/types": "^7.12.13" } }, - "@babel/helper-member-expression-to-functions": { - "version": "7.13.12", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.13.12.tgz", - "integrity": "sha512-48ql1CLL59aKbU94Y88Xgb2VFy7a95ykGRbJJaaVv+LX5U8wFpLfiGXJJGUozsmA1oEh/o5Bp60Voq7ACyA/Sw==", - "dev": true, - "requires": { - "@babel/types": "^7.13.12" - } - }, - "@babel/helper-module-imports": { - "version": "7.13.12", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.13.12.tgz", - "integrity": "sha512-4cVvR2/1B693IuOvSI20xqqa/+bl7lqAMR59R4iu39R9aOX8/JoYY1sFaNvUMyMBGnHdwvJgUrzNLoUZxXypxA==", - "dev": true, - "requires": { - "@babel/types": "^7.13.12" - } - }, - "@babel/helper-module-transforms": { - "version": "7.13.14", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.13.14.tgz", - "integrity": "sha512-QuU/OJ0iAOSIatyVZmfqB0lbkVP0kDRiKj34xy+QNsnVZi/PA6BoSoreeqnxxa9EHFAIL0R9XOaAR/G9WlIy5g==", - "dev": true, - "requires": { - "@babel/helper-module-imports": "^7.13.12", - "@babel/helper-replace-supers": "^7.13.12", - "@babel/helper-simple-access": "^7.13.12", - "@babel/helper-split-export-declaration": "^7.12.13", - "@babel/helper-validator-identifier": "^7.12.11", - "@babel/template": "^7.12.13", - "@babel/traverse": "^7.13.13", - "@babel/types": "^7.13.14" - } - }, - "@babel/helper-optimise-call-expression": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.12.13.tgz", - "integrity": "sha512-BdWQhoVJkp6nVjB7nkFWcn43dkprYauqtk++Py2eaf/GRDFm5BxRqEIZCiHlZUGAVmtwKcsVL1dC68WmzeFmiA==", - "dev": true, - "requires": { - "@babel/types": "^7.12.13" - } - }, - "@babel/helper-replace-supers": { - "version": "7.13.12", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.13.12.tgz", - "integrity": "sha512-Gz1eiX+4yDO8mT+heB94aLVNCL+rbuT2xy4YfyNqu8F+OI6vMvJK891qGBTqL9Uc8wxEvRW92Id6G7sDen3fFw==", - "dev": true, - "requires": { - "@babel/helper-member-expression-to-functions": "^7.13.12", - "@babel/helper-optimise-call-expression": "^7.12.13", - "@babel/traverse": "^7.13.0", - "@babel/types": "^7.13.12" - } - }, - "@babel/helper-simple-access": { - "version": "7.13.12", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.13.12.tgz", - "integrity": "sha512-7FEjbrx5SL9cWvXioDbnlYTppcZGuCY6ow3/D5vMggb2Ywgu4dMrpTJX0JdQAIcRRUElOIxF3yEooa9gUb9ZbA==", - "dev": true, - "requires": { - "@babel/types": "^7.13.12" - } - }, "@babel/helper-split-export-declaration": { "version": "7.12.13", "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.12.13.tgz", @@ -191,9 +127,9 @@ } }, "@babel/parser": { - "version": "7.13.15", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.13.15.tgz", - "integrity": "sha512-b9COtcAlVEQljy/9fbcMHpG+UIW9ReF+gpaxDHTlZd0c6/UU9ng8zdySAW9sRTzpvcdCHn6bUcbuYUgGzLAWVQ==", + "version": "7.13.16", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.13.16.tgz", + "integrity": "sha512-6bAg36mCwuqLO0hbR+z7PHuqWiCeP7Dzg73OpQwsAB1Eb8HnGEz5xYBzCfbu+YjoaJsJs+qheDxVAuqbt3ILEw==", "dev": true }, "@babel/template": { @@ -224,33 +160,38 @@ } }, "@babel/types": { - "version": "7.13.14", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.13.14.tgz", - "integrity": "sha512-A2aa3QTkWoyqsZZFl56MLUsfmh7O0gN41IPvXAE/++8ojpbz12SszD7JEGYVdn4f9Kt4amIei07swF1h4AqmmQ==", + "version": "7.13.16", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.13.16.tgz", + "integrity": "sha512-7enM8Wxhrl1hB1+k6+xO6RmxpNkaveRWkdpyii8DkrLWRgr0l3x29/SEuhTIkP+ynHsU/Hpjn8Evd/axv/ll6Q==", "dev": true, "requires": { "@babel/helper-validator-identifier": "^7.12.11", - "lodash": "^4.17.19", "to-fast-properties": "^2.0.0" } }, "browserslist": { - "version": "4.16.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.16.3.tgz", - "integrity": "sha512-vIyhWmIkULaq04Gt93txdh+j02yX/JzlyhLYbV3YQCn/zvES3JnY7TifHHvvr1w5hTDluNKMkV05cs4vy8Q7sw==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.16.4.tgz", + "integrity": "sha512-d7rCxYV8I9kj41RH8UKYnvDYCRENUlHRgyXy/Rhr/1BaeLGfiCptEdFE8MIrvGfWbBFNjVYx76SQWvNX1j+/cQ==", "dev": true, "requires": { - "caniuse-lite": "^1.0.30001181", - "colorette": "^1.2.1", - "electron-to-chromium": "^1.3.649", + "caniuse-lite": "^1.0.30001208", + "colorette": "^1.2.2", + "electron-to-chromium": "^1.3.712", "escalade": "^3.1.1", - "node-releases": "^1.1.70" + "node-releases": "^1.1.71" } }, "caniuse-lite": { - "version": "1.0.30001208", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001208.tgz", - "integrity": "sha512-OE5UE4+nBOro8Dyvv0lfx+SRtfVIOM9uhKqFmJeUbGriqhhStgp1A0OyBpgy3OUF8AhYCT+PVwPC1gMl2ZcQMA==", + "version": "1.0.30001214", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001214.tgz", + "integrity": "sha512-O2/SCpuaU3eASWVaesQirZv1MSjUNOvmugaD8zNSJqw6Vv5SGwoOpA9LJs3pNPfM745nxqPvfZY3MQKY4AKHYg==", + "dev": true + }, + "colorette": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz", + "integrity": "sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==", "dev": true }, "debug": { @@ -263,9 +204,9 @@ } }, "electron-to-chromium": { - "version": "1.3.711", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.711.tgz", - "integrity": "sha512-XbklBVCDiUeho0PZQCjC25Ha6uBwqqJeyDhPLwLwfWRAo4x+FZFsmu1pPPkXT+B4MQMQoQULfyaMltDopfeiHQ==", + "version": "1.3.717", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.717.tgz", + "integrity": "sha512-OfzVPIqD1MkJ7fX+yTl2nKyOE4FReeVfMCzzxQS+Kp43hZYwHwThlGP+EGIZRXJsxCM7dqo8Y65NOX/HP12iXQ==", "dev": true }, "json5": { @@ -1418,14 +1359,14 @@ } }, "@babel/helpers": { - "version": "7.13.10", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.13.10.tgz", - "integrity": "sha512-4VO883+MWPDUVRF3PhiLBUFHoX/bsLTGFpFK/HqvvfBZz2D57u9XzPVNFVBTc0PW/CWR9BXTOKt8NF4DInUHcQ==", + "version": "7.13.16", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.13.16.tgz", + "integrity": "sha512-x5otxUaLpdWHl02P4L94wBU+2BJXBkvO+6d6uzQ+xD9/h2hTSAwA5O8QV8GqKx/l8i+VYmKKQg9e2QGTa2Wu3Q==", "dev": true, "requires": { "@babel/template": "^7.12.13", - "@babel/traverse": "^7.13.0", - "@babel/types": "^7.13.0" + "@babel/traverse": "^7.13.15", + "@babel/types": "^7.13.16" }, "dependencies": { "@babel/code-frame": { @@ -1438,12 +1379,12 @@ } }, "@babel/generator": { - "version": "7.13.9", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.13.9.tgz", - "integrity": "sha512-mHOOmY0Axl/JCTkxTU6Lf5sWOg/v8nUa+Xkt4zMTftX0wqmb6Sh7J8gvcehBw7q0AhrhAR+FDacKjCZ2X8K+Sw==", + "version": "7.13.16", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.13.16.tgz", + "integrity": "sha512-grBBR75UnKOcUWMp8WoDxNsWCFl//XCK6HWTrBQKTr5SV9f5g0pNOjdyzi/DTBv12S9GnYPInIXQBTky7OXEMg==", "dev": true, "requires": { - "@babel/types": "^7.13.0", + "@babel/types": "^7.13.16", "jsesc": "^2.5.1", "source-map": "^0.5.0" } @@ -1495,9 +1436,9 @@ } }, "@babel/parser": { - "version": "7.13.15", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.13.15.tgz", - "integrity": "sha512-b9COtcAlVEQljy/9fbcMHpG+UIW9ReF+gpaxDHTlZd0c6/UU9ng8zdySAW9sRTzpvcdCHn6bUcbuYUgGzLAWVQ==", + "version": "7.13.16", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.13.16.tgz", + "integrity": "sha512-6bAg36mCwuqLO0hbR+z7PHuqWiCeP7Dzg73OpQwsAB1Eb8HnGEz5xYBzCfbu+YjoaJsJs+qheDxVAuqbt3ILEw==", "dev": true }, "@babel/template": { @@ -1528,13 +1469,12 @@ } }, "@babel/types": { - "version": "7.13.14", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.13.14.tgz", - "integrity": "sha512-A2aa3QTkWoyqsZZFl56MLUsfmh7O0gN41IPvXAE/++8ojpbz12SszD7JEGYVdn4f9Kt4amIei07swF1h4AqmmQ==", + "version": "7.13.16", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.13.16.tgz", + "integrity": "sha512-7enM8Wxhrl1hB1+k6+xO6RmxpNkaveRWkdpyii8DkrLWRgr0l3x29/SEuhTIkP+ynHsU/Hpjn8Evd/axv/ll6Q==", "dev": true, "requires": { "@babel/helper-validator-identifier": "^7.12.11", - "lodash": "^4.17.19", "to-fast-properties": "^2.0.0" } }, diff --git a/package.json b/package.json index 69eae72882..12ca000314 100644 --- a/package.json +++ b/package.json @@ -156,7 +156,7 @@ "winston-cloudwatch": "^2.5.2" }, "devDependencies": { - "@babel/core": "^7.13.15", + "@babel/core": "^7.13.16", "@babel/plugin-transform-runtime": "^7.13.15", "@babel/preset-env": "^7.13.15", "@opengovsg/mockpass": "^2.6.8", From 8007223b22f418caf807f330165afe060d6cff1d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 Apr 2021 18:30:48 +0000 Subject: [PATCH 10/51] fix(deps): bump @sentry/integrations from 6.2.5 to 6.3.0 (#1690) Bumps [@sentry/integrations](https://github.com/getsentry/sentry-javascript) from 6.2.5 to 6.3.0. - [Release notes](https://github.com/getsentry/sentry-javascript/releases) - [Changelog](https://github.com/getsentry/sentry-javascript/blob/master/CHANGELOG.md) - [Commits](https://github.com/getsentry/sentry-javascript/compare/6.2.5...6.3.0) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 26 +++++--------------------- package.json | 2 +- 2 files changed, 6 insertions(+), 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index 25eb21ef40..61fd61edcc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4564,30 +4564,14 @@ } }, "@sentry/integrations": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/@sentry/integrations/-/integrations-6.2.5.tgz", - "integrity": "sha512-4LOgO8lSeGaRV4w1Y03YWtTqrZdm56ciD7k0GLhv+PcFLpiu0exsS1XSs/9vET5LB5GtIgBTeJNNbxVFvvmv8g==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@sentry/integrations/-/integrations-6.3.0.tgz", + "integrity": "sha512-/bl0wykJr+7zJHmnAulI+/J1kT5AI/019jWSXX7nmfIhp2sRXNUw0jeNVh+xfwrbR6Ik6IleAyzwHNYKzedGVQ==", "requires": { - "@sentry/types": "6.2.5", - "@sentry/utils": "6.2.5", + "@sentry/types": "6.3.0", + "@sentry/utils": "6.3.0", "localforage": "^1.8.1", "tslib": "^1.9.3" - }, - "dependencies": { - "@sentry/types": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.2.5.tgz", - "integrity": "sha512-1Sux6CLYrV9bETMsGP/HuLFLouwKoX93CWzG8BjMueW+Di0OGxZphYjXrGuDs8xO8bAKEVGCHgVQdcB2jevS0w==" - }, - "@sentry/utils": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-6.2.5.tgz", - "integrity": "sha512-fJoLUZHrd5MPylV1dT4qL74yNFDl1Ur/dab+pKNSyvnHPnbZ/LRM7aJ8VaRY/A7ZdpRowU+E14e/Yeem2c6gtQ==", - "requires": { - "@sentry/types": "6.2.5", - "tslib": "^1.9.3" - } - } } }, "@sentry/minimal": { diff --git a/package.json b/package.json index 12ca000314..6ed3928dbd 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "@opengovsg/ng-file-upload": "^12.2.15", "@opengovsg/spcp-auth-client": "^1.4.5", "@sentry/browser": "^6.3.0", - "@sentry/integrations": "^6.2.5", + "@sentry/integrations": "^6.3.0", "@stablelib/base64": "^1.0.0", "JSONStream": "^1.3.5", "abortcontroller-polyfill": "^1.7.1", From a81db0e2bc51d7d83e0f9d7144bcb2225634513b Mon Sep 17 00:00:00 2001 From: Kar Rui Lau Date: Wed, 21 Apr 2021 10:09:01 +0800 Subject: [PATCH 11/51] feat(api-refactor): add specific API for updating of single form field (#1640) * feat: add FormField type that is a union for every possible field * feat: return http 422 status code on EditFieldError instead of 400 * feat(AdminFormService): add updateFormField to update single field * feat(AdminFormCtl): add handleUpdateFormField fn * feat(AdminFormRoutes): add PUT endpoint for updating single form field * feat(AdminFormClientCtl): add specific update field handling in update * feat: update types of mongo subdocs to allow for DocumentArray allows for special array methods such as `id()`, `pull()`, etc * test(AdminForm): update test for invalid field update to return 422 * feat(formUtils): add getFormFieldById helper method uses DocumentArray#id method if available, else uses Array#find * test(formUtils): add unit tests for getFormFieldById * ref: rename admin-forms.build.routes to admin-forms.form.routes for consistency with #1635 * feat: add Form instance method updateFormFieldById seems like a better place to for the manipulation of the form instead of in the service directly * ref(AdminFormSvc): use Form model method in update field service fn * feat(AdminFormRoutes): strengthen Joi validation for update field ensures body._id given is the same as the params.fieldId so ids of fields cannot be changed so easily * feat: add FormFieldDto type for use in update form field handler * fix(formUtils): fix evaluation of whether array is mongoose doc array * test(FormModel): add unit tests for updateFormFieldById instance fn * test(AdminFormSvc): add unit tests for updateFormField fn * test(AdminFormCtl): add unit tests for handleUpdateFormField * ref: group joi validation middleware with controller fn in export * feat: only merge updated field if index of updated field exists * feat(client): call update field endpoint on updating myinfo fields too * test(AdminFormCtl): correct name of _handleUpdateFormField in desc * ref(mongooseUtils): move isMongooseDocumentArray to own util file * ref(formUtils): remove profane usage of else after return * ref: convert constants/update-form-types.js to TypeScript * feat: show error Toast when returned field don't map to current fields --- .../__tests__/form.server.model.spec.ts | 107 +++++- src/app/models/form.server.model.ts | 20 +- .../modules/form/__tests__/form.utils.spec.ts | 68 +++- .../__tests__/admin-form.controller.spec.ts | 341 ++++++++++++++++-- .../__tests__/admin-form.routes.spec.ts | 70 ++-- .../__tests__/admin-form.service.spec.ts | 116 +++++- .../form/admin-form/admin-form.controller.ts | 97 ++++- .../form/admin-form/admin-form.errors.ts | 6 + .../form/admin-form/admin-form.service.ts | 46 ++- .../form/admin-form/admin-form.utils.ts | 6 +- src/app/modules/form/form.utils.ts | 23 ++ .../v3/admin/forms/admin-forms.form.routes.ts | 21 ++ .../api/v3/admin/forms/admin-forms.routes.ts | 1 + src/app/utils/mongoose.ts | 14 + .../admin/constants/update-form-types.ts | 3 + .../admin-form.client.controller.js | 46 ++- .../edit-fields-modal.client.controller.js | 48 +-- ...it-myinfo-field-modal.client.controller.js | 43 ++- src/public/services/AdminFormService.ts | 15 +- src/types/api/form.ts | 14 + src/types/field/index.ts | 67 ++++ src/types/form.ts | 27 +- .../backend/helpers/generate-form-data.ts | 8 + tests/unit/backend/helpers/jest-db.ts | 6 +- 24 files changed, 1066 insertions(+), 147 deletions(-) create mode 100644 src/app/utils/mongoose.ts create mode 100644 src/public/modules/forms/admin/constants/update-form-types.ts diff --git a/src/app/models/__tests__/form.server.model.spec.ts b/src/app/models/__tests__/form.server.model.spec.ts index bd303f59af..d213ba5657 100644 --- a/src/app/models/__tests__/form.server.model.spec.ts +++ b/src/app/models/__tests__/form.server.model.spec.ts @@ -1,6 +1,7 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ import { ObjectId } from 'bson-ext' import { merge, omit, orderBy, pick } from 'lodash' -import mongoose from 'mongoose' +import mongoose, { Types } from 'mongoose' import getFormModel, { FORM_PUBLIC_FIELDS, @@ -8,7 +9,10 @@ import getFormModel, { getEncryptedFormModel, } from 'src/app/models/form.server.model' import { + BasicField, + FormFieldWithId, IEncryptedForm, + IFieldSchema, IFormSchema, IPopulatedUser, Permission, @@ -16,6 +20,7 @@ import { Status, } from 'src/types' +import { generateDefaultField } from 'tests/unit/backend/helpers/generate-form-data' import dbHandler from 'tests/unit/backend/helpers/jest-db' const Form = getFormModel(mongoose) @@ -1306,5 +1311,105 @@ describe('Form Model', () => { ) }) }) + + describe('updateFormFieldById', () => { + let form: IFormSchema + + beforeEach(async () => { + form = await Form.create({ + admin: populatedAdmin._id, + responseMode: ResponseMode.Email, + title: 'mock email form', + emails: [populatedAdmin.email], + form_fields: [ + generateDefaultField(BasicField.Checkbox), + generateDefaultField(BasicField.HomeNo, { + title: 'some mock title', + }), + generateDefaultField(BasicField.Email), + ], + }) + }) + + it('should return updated form when successfully updating form field', async () => { + // Arrange + const originalFormFields = (form.form_fields as Types.DocumentArray).toObject() + + const newField = { + ...originalFormFields[1], + title: 'another mock title', + } + + // Act + const actual = await form.updateFormFieldById(newField._id, newField) + + // Assert + expect(actual).not.toBeNull() + // Current fields should not be touched + expect( + (actual?.form_fields as Types.DocumentArray).toObject(), + ).toEqual([originalFormFields[0], newField, originalFormFields[2]]) + }) + + it('should return null if fieldId does not correspond to any field in the form', async () => { + // Arrange + const invalidFieldId = new ObjectId().toHexString() + const someNewField = { + description: 'this does not matter', + } as FormFieldWithId + + // Act + const actual = await form.updateFormFieldById( + invalidFieldId, + someNewField, + ) + + // Assert + expect(actual).toBeNull() + }) + + it('should return validation error if field type of new field does not match the field to update', async () => { + // Arrange + const originalFormFields = (form.form_fields as Types.DocumentArray).toObject() + + const newField: FormFieldWithId = { + ...originalFormFields[1], + // Updating field type from HomeNo to Mobile. + fieldType: BasicField.Mobile, + title: 'another mock title', + } + + // Act + const actual = await form + .updateFormFieldById(newField._id, newField) + .catch((err) => err) + + // Assert + expect(actual).toBeInstanceOf(mongoose.Error.ValidationError) + expect(actual.message).toEqual( + expect.stringContaining('Changing form field type is not allowed'), + ) + }) + + it('should return validation error if model validation fails whilst updating field', async () => { + // Arrange + const originalFormFields = (form.form_fields as Types.DocumentArray).toObject() + + const newField: FormFieldWithId = { + ...originalFormFields[2], + title: 'another mock title', + // Invalid value for email field. + isVerifiable: 'some string, but this should be boolean', + } + + // Act + const actual = await form + .updateFormFieldById(newField._id, newField) + .catch((err) => err) + + // Assert + expect(actual).toBeInstanceOf(mongoose.Error.ValidationError) + }) + }) }) }) diff --git a/src/app/models/form.server.model.ts b/src/app/models/form.server.model.ts index 6df67f465d..aa9797d100 100644 --- a/src/app/models/form.server.model.ts +++ b/src/app/models/form.server.model.ts @@ -7,6 +7,7 @@ import { AuthType, BasicField, Colors, + FormFieldWithId, FormLogoState, FormMetaView, FormOtpData, @@ -30,7 +31,7 @@ import { 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 { getFormFieldById, transformEmails } from '../modules/form/form.utils' import { validateWebhookUrl } from '../modules/webhook/webhook.validation' import getAgencyModel from './agency.server.model' @@ -495,6 +496,23 @@ const compileFormModel = (db: Mongoose): IFormModel => { return this.save() } + FormDocumentSchema.methods.updateFormFieldById = function ( + this: IFormDocument, + fieldId: string, + newField: FormFieldWithId, + ) { + const fieldToUpdate = getFormFieldById(this.form_fields, fieldId) + if (!fieldToUpdate) return Promise.resolve(null) + + if (fieldToUpdate.fieldType !== newField.fieldType) { + this.invalidate('form_fields', 'Changing form field type is not allowed') + } else { + fieldToUpdate.set(newField) + } + + return this.save() + } + // Statics // Method to retrieve data for OTP verification FormSchema.statics.getOtpData = async function ( diff --git a/src/app/modules/form/__tests__/form.utils.spec.ts b/src/app/modules/form/__tests__/form.utils.spec.ts index 968f45c678..0f63b8504c 100644 --- a/src/app/modules/form/__tests__/form.utils.spec.ts +++ b/src/app/modules/form/__tests__/form.utils.spec.ts @@ -1,6 +1,11 @@ -import { Permission } from 'src/types' +import { ObjectId } from 'bson-ext' +import { Types } from 'mongoose' -import { getCollabEmailsWithPermission } from '../form.utils' +import { BasicField, IFieldSchema, Permission } from 'src/types' + +import { generateDefaultField } from 'tests/unit/backend/helpers/generate-form-data' + +import { getCollabEmailsWithPermission, getFormFieldById } from '../form.utils' const MOCK_EMAIL_1 = 'a@abc.com' const MOCK_EMAIL_2 = 'b@def.com' @@ -42,4 +47,63 @@ describe('form.utils', () => { expect(result).toEqual([MOCK_EMAIL_2]) }) }) + + describe('getFormFieldById', () => { + it('should return form field with valid id when form fields given is a primitive array', async () => { + // Arrange + const fieldToFind = generateDefaultField(BasicField.HomeNo) + const formFields = [generateDefaultField(BasicField.Date), fieldToFind] + + // Act + const result = getFormFieldById(formFields, fieldToFind._id) + + // Assert + expect(result).toEqual(fieldToFind) + }) + + it('should return form field with valid id when form fields given is a mongoose document array', async () => { + // Arrange + const fieldToFind = generateDefaultField(BasicField.Number) + // Should not turn this unit test into an integration test, so mocking return and leaving responsibility to mongoose. + const mockDocArray = ({ + 0: generateDefaultField(BasicField.LongText), + 1: fieldToFind, + isMongooseDocumentArray: true, + id: jest.fn().mockReturnValue(fieldToFind), + } as unknown) as Types.DocumentArray + + // Act + const result = getFormFieldById(mockDocArray, fieldToFind._id) + + // Assert + expect(result).toEqual(fieldToFind) + expect(mockDocArray.id).toHaveBeenCalledWith(fieldToFind._id) + }) + + it('should return null when given form fields are undefined', async () => { + // Arrange + const someFieldId = new ObjectId() + + // Act + const result = getFormFieldById(undefined, someFieldId) + + // Assert + expect(result).toEqual(null) + }) + + it('should return null when no fields correspond to given field id', async () => { + // Arrange + const invalidFieldId = new ObjectId() + const formFields = [ + generateDefaultField(BasicField.Date), + generateDefaultField(BasicField.Date), + ] + + // Act + const result = getFormFieldById(formFields, invalidFieldId) + + // Assert + expect(result).toEqual(null) + }) + }) }) diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts index 94b373b8d9..c22fa288fb 100644 --- a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts +++ b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts @@ -46,6 +46,7 @@ import { FormSettings, IEmailSubmissionSchema, IEncryptedSubmissionSchema, + IFieldSchema, IForm, IFormSchema, IPopulatedEmailForm, @@ -57,7 +58,7 @@ import { ResponseMode, Status, } from 'src/types' -import { EncryptSubmissionDto } from 'src/types/api' +import { EncryptSubmissionDto, FieldUpdateDto } from 'src/types/api' import { generateDefaultField, @@ -79,6 +80,7 @@ import * as AdminFormController from '../admin-form.controller' import { CreatePresignedUrlError, EditFieldError, + FieldNotFoundError, InvalidFileTypeError, } from '../admin-form.errors' import * as AdminFormService from '../admin-form.service' @@ -4160,39 +4162,6 @@ describe('admin-form.controller', () => { expect(editFormFieldSpy).not.toHaveBeenCalled() }) - it('should return 400 when performing invalid update to form fields', async () => { - // Arrange - const mockRes = expressHandler.mockResponse() - - // Mock services to return expected results. - MockUserService.getPopulatedUserById.mockReturnValueOnce( - okAsync(MOCK_USER), - ) - MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( - okAsync(MOCK_FORM), - ) - // Return error when editing form fields - const expectedErrorString = 'invalid field update' - editFormFieldSpy.mockReturnValueOnce( - errAsync(new EditFieldError(expectedErrorString)), - ) - - // Act - await AdminFormController.handleUpdateForm( - MOCK_EDIT_FIELD_REQ, - mockRes, - jest.fn(), - ) - - // Assert - expect(mockRes.status).toHaveBeenCalledWith(400) - expect(mockRes.json).toHaveBeenCalledWith({ - message: expectedErrorString, - }) - expect(editFormFieldSpy).toHaveBeenCalledTimes(1) - expect(updateFormSpy).not.toHaveBeenCalled() - }) - it('should return 403 when current user does not have permissions to update form', async () => { // Arrange const mockRes = expressHandler.mockResponse() @@ -4344,6 +4313,39 @@ describe('admin-form.controller', () => { expect(updateFormSpy).not.toHaveBeenCalled() }) + it('should return 422 when performing invalid update to form fields', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + + // Mock services to return expected results. + MockUserService.getPopulatedUserById.mockReturnValueOnce( + okAsync(MOCK_USER), + ) + MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( + okAsync(MOCK_FORM), + ) + // Return error when editing form fields + const expectedErrorString = 'invalid field update' + editFormFieldSpy.mockReturnValueOnce( + errAsync(new EditFieldError(expectedErrorString)), + ) + + // Act + await AdminFormController.handleUpdateForm( + MOCK_EDIT_FIELD_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(422) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(editFormFieldSpy).toHaveBeenCalledTimes(1) + expect(updateFormSpy).not.toHaveBeenCalled() + }) + it('should return 422 when an invalid update is attempted on the form', async () => { // Arrange const mockRes = expressHandler.mockResponse() @@ -6796,4 +6798,275 @@ describe('admin-form.controller', () => { }) }) }) + + describe('_handleUpdateFormField', () => { + const MOCK_USER_ID = new ObjectId().toHexString() + const MOCK_FORM_ID = new ObjectId().toHexString() + const MOCK_USER = { + _id: MOCK_USER_ID, + email: 'somerandom@example.com', + } as IPopulatedUser + const MOCK_FIELD = generateDefaultField(BasicField.Rating) + const MOCK_UPDATED_FIELD = { + ...MOCK_FIELD, + title: 'some new title', + } as FieldUpdateDto + + const MOCK_FORM = { + admin: MOCK_USER, + _id: MOCK_FORM_ID, + title: 'mock title', + } as IPopulatedForm + + const MOCK_REQ = expressHandler.mockRequest({ + params: { + formId: MOCK_FORM_ID, + fieldId: MOCK_FIELD._id, + }, + body: MOCK_UPDATED_FIELD, + session: { + user: { + _id: MOCK_USER_ID, + }, + }, + }) + + beforeEach(() => { + // Mock various services to return expected results. + MockUserService.getPopulatedUserById.mockReturnValue(okAsync(MOCK_USER)) + MockAuthService.getFormAfterPermissionChecks.mockReturnValue( + okAsync(MOCK_FORM), + ) + MockAdminFormService.updateFormField.mockReturnValue( + okAsync(MOCK_UPDATED_FIELD as IFieldSchema), + ) + }) + it('should return 200 with updated form field', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleUpdateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(200) + expect(mockRes.json).toHaveBeenCalledWith(MOCK_UPDATED_FIELD) + expect(MockAdminFormService.updateFormField).toHaveBeenCalledWith( + MOCK_FORM, + String(MOCK_FIELD._id), + MOCK_REQ.body, + ) + }) + + it('should return 404 when field cannot be found', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + MockAdminFormService.updateFormField.mockReturnValueOnce( + errAsync(new FieldNotFoundError()), + ) + + // Act + await AdminFormController._handleUpdateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(404) + expect(mockRes.json).toHaveBeenCalledWith({ + message: 'Field to modify not found', + }) + expect(MockAdminFormService.updateFormField).toHaveBeenCalledWith( + MOCK_FORM, + String(MOCK_FIELD._id), + MOCK_REQ.body, + ) + }) + + it('should return 403 when current user does not have permissions to update form', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + const expectedErrorString = 'no write permissions' + MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( + errAsync(new ForbiddenFormError(expectedErrorString)), + ) + + // Act + await AdminFormController._handleUpdateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(403) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.updateFormField).not.toHaveBeenCalled() + }) + + it('should return 404 when form to update form field for cannot be found', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + + const expectedErrorString = 'nope' + MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( + errAsync(new FormNotFoundError(expectedErrorString)), + ) + + // Act + await AdminFormController._handleUpdateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(404) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.updateFormField).not.toHaveBeenCalled() + }) + + it('should return 410 when form to update form field for is already archived', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + + const expectedErrorString = 'already deleted' + MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( + errAsync(new FormDeletedError(expectedErrorString)), + ) + + // Act + await AdminFormController._handleUpdateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(410) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.updateFormField).not.toHaveBeenCalled() + }) + + it('should return 413 when updated form is too large to be saved in the database', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + + const expectedErrorString = 'payload too large' + MockAdminFormService.updateFormField.mockReturnValueOnce( + errAsync(new DatabasePayloadSizeError(expectedErrorString)), + ) + + // Act + await AdminFormController._handleUpdateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(413) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.updateFormField).toHaveBeenCalledWith( + MOCK_FORM, + String(MOCK_FIELD._id), + MOCK_REQ.body, + ) + }) + + it('should return 422 when performing invalid update to form field', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + const expectedErrorString = 'invalid field update' + MockAdminFormService.updateFormField.mockReturnValueOnce( + errAsync(new DatabaseValidationError(expectedErrorString)), + ) + + // Act + await AdminFormController._handleUpdateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(422) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.updateFormField).toHaveBeenCalledWith( + MOCK_FORM, + String(MOCK_FIELD._id), + MOCK_REQ.body, + ) + }) + + it('should return 422 when user in session cannot be retrieved from the database', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + + const expectedErrorString = 'user not in session??!!' + MockUserService.getPopulatedUserById.mockReturnValueOnce( + errAsync(new MissingUserError(expectedErrorString)), + ) + + // Act + await AdminFormController._handleUpdateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(422) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect( + MockAuthService.getFormAfterPermissionChecks, + ).not.toHaveBeenCalled() + expect(MockAdminFormService.updateFormField).not.toHaveBeenCalled() + }) + + it('should return 500 when generic database error occurs during form field update', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + + const expectedErrorString = 'some database error bam' + MockAdminFormService.updateFormField.mockReturnValueOnce( + errAsync(new DatabaseError(expectedErrorString)), + ) + + // Act + await AdminFormController._handleUpdateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(500) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.updateFormField).toHaveBeenCalledWith( + MOCK_FORM, + String(MOCK_FIELD._id), + MOCK_REQ.body, + ) + }) + }) }) diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.routes.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.routes.spec.ts index a746e0fa30..a690f42933 100644 --- a/src/app/modules/form/admin-form/__tests__/admin-form.routes.spec.ts +++ b/src/app/modules/form/admin-form/__tests__/admin-form.routes.spec.ts @@ -1524,41 +1524,6 @@ describe('admin-form.routes', () => { expect(response.body).toEqual(jsonParseStringify(expected)) }) - it('should return 400 when form has invalid updates to be performed', async () => { - // Arrange - const formToUpdate = (await EmailFormModel.create({ - title: 'Form to update', - emails: [defaultUser.email], - admin: defaultUser._id, - form_fields: [generateDefaultField(BasicField.Date)], - })) as IPopulatedForm - // Delete field - const clonedForm = cloneDeep(formToUpdate) - clonedForm.form_fields = [] - await clonedForm.save() - - // Act - const response = await request - .put(`/${formToUpdate._id}/adminform`) - .send({ - form: { - editFormField: { - action: { name: EditFieldActions.Update }, - field: { - ...formToUpdate.form_fields[0].toObject(), - description: 'some new description', - }, - }, - }, - }) - - // Assert - expect(response.status).toEqual(400) - expect(response.body).toEqual({ - message: 'Field to be updated does not exist', - }) - }) - it('should return 401 when user is not logged in', async () => { // Arrange await logoutSession(request) @@ -1647,6 +1612,41 @@ describe('admin-form.routes', () => { expect(response.body).toEqual({ message: 'Form has been archived' }) }) + it('should return 422 when form has invalid updates to be performed', async () => { + // Arrange + const formToUpdate = (await EmailFormModel.create({ + title: 'Form to update', + emails: [defaultUser.email], + admin: defaultUser._id, + form_fields: [generateDefaultField(BasicField.Date)], + })) as IPopulatedForm + // Delete field + const clonedForm = cloneDeep(formToUpdate) + clonedForm.form_fields = [] + await clonedForm.save() + + // Act + const response = await request + .put(`/${formToUpdate._id}/adminform`) + .send({ + form: { + editFormField: { + action: { name: EditFieldActions.Update }, + field: { + ...formToUpdate.form_fields[0].toObject(), + description: 'some new description', + }, + }, + }, + }) + + // Assert + expect(response.status).toEqual(422) + expect(response.body).toEqual({ + message: 'Field to be updated does not exist', + }) + }) + it('should return 422 when user in session cannot be found in the database', async () => { // Arrange const formToArchive = await EmailFormModel.create({ diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts index db632f7da3..29d962914c 100644 --- a/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts +++ b/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ import { PresignedPost } from 'aws-sdk/clients/s3' import { ObjectId } from 'bson-ext' import { assignIn, cloneDeep, merge, omit } from 'lodash' @@ -37,7 +38,7 @@ import { ResponseMode, Status, } from 'src/types' -import { SettingsUpdateDto } from 'src/types/api' +import { FieldUpdateDto, SettingsUpdateDto } from 'src/types/api' import { generateDefaultField } from 'tests/unit/backend/helpers/generate-form-data' @@ -45,6 +46,7 @@ import { TransferOwnershipError } from '../../form.errors' import { CreatePresignedUrlError, EditFieldError, + FieldNotFoundError, InvalidFileTypeError, } from '../admin-form.errors' import { @@ -57,6 +59,7 @@ import { getDashboardForms, transferFormOwnership, updateForm, + updateFormField, updateFormSettings, } from '../admin-form.service' import { @@ -167,7 +170,7 @@ describe('admin-form.service', () => { // Mock external service success. const s3Spy = jest .spyOn(aws.s3, 'createPresignedPost') - // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore .mockImplementationOnce((_obj, cb) => { cb(null, expectedPresignedPostUrl) @@ -184,7 +187,7 @@ describe('admin-form.service', () => { // Check that the correct bucket was used. expect(s3Spy).toHaveBeenCalledWith( expect.objectContaining({ Bucket: aws.imageS3Bucket }), - // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore expect.any(Function), ) @@ -233,7 +236,7 @@ describe('admin-form.service', () => { // Check that the correct bucket was used. expect(s3Spy).toHaveBeenCalledWith( expect.objectContaining({ Bucket: aws.imageS3Bucket }), - // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore expect.any(Function), ) @@ -257,7 +260,7 @@ describe('admin-form.service', () => { // Mock external service success. const s3Spy = jest .spyOn(aws.s3, 'createPresignedPost') - // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore .mockImplementationOnce((_obj, cb) => { cb(null, expectedPresignedPostUrl) @@ -274,7 +277,7 @@ describe('admin-form.service', () => { // Check that the correct bucket was used. expect(s3Spy).toHaveBeenCalledWith( expect.objectContaining({ Bucket: aws.logoS3Bucket }), - // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore expect.any(Function), ) @@ -323,7 +326,7 @@ describe('admin-form.service', () => { // Check that the correct bucket was used. expect(s3Spy).toHaveBeenCalledWith( expect.objectContaining({ Bucket: aws.logoS3Bucket }), - // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore expect.any(Function), ) @@ -818,7 +821,7 @@ describe('admin-form.service', () => { } const createSpy = jest .spyOn(FormModel, 'create') - // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore .mockRejectedValueOnce(new mongoose.Error.ValidationError() as never) @@ -841,7 +844,6 @@ describe('admin-form.service', () => { publicKey: 'some key', } const createSpy = jest.spyOn(FormModel, 'create').mockRejectedValueOnce( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore new mongoose.Error.VersionError({}, 1, ['none']) as never, ) @@ -980,7 +982,7 @@ describe('admin-form.service', () => { .spyOn(AdminFormUtils, 'getUpdatedFormFields') .mockReturnValueOnce(ok(mockUpdatedFields)) // Mock database save error. - // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore const mockError = new mongoose.Error.VersionError({}, 1, ['none']) MOCK_INTIAL_FORM.save.mockRejectedValueOnce(mockError as never) @@ -1095,14 +1097,14 @@ describe('admin-form.service', () => { const EMAIL_UPDATE_SPY = jest .spyOn(EmailFormModel, 'findByIdAndUpdate') - // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore .mockReturnValue({ exec: jest.fn().mockResolvedValue(MOCK_UPDATED_FORM), }) const ENCRYPT_UPDATE_SPY = jest .spyOn(EncryptFormModel, 'findByIdAndUpdate') - // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore .mockReturnValue({ exec: jest.fn().mockResolvedValue(MOCK_UPDATED_FORM), @@ -1163,11 +1165,9 @@ describe('admin-form.service', () => { title: 'does not matter', } // Mock database error - // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore ENCRYPT_UPDATE_SPY.mockReturnValueOnce({ exec: jest.fn().mockRejectedValue( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore new mongoose.Error.ValidationError({ errors: 'some error' }), ), @@ -1191,4 +1191,92 @@ describe('admin-form.service', () => { expect(MOCK_UPDATED_FORM.getSettings).toHaveBeenCalledTimes(0) }) }) + + describe('updateFormField', () => { + it('should return updated form field', async () => { + // Arrange + const fieldToUpdate = generateDefaultField(BasicField.YesNo, { + title: 'random title', + }) + const mockNewField = { + ...fieldToUpdate, + title: 'new title', + } as FieldUpdateDto + + const mockUpdatedForm = { + title: 'some mock form', + form_fields: [mockNewField], + } + const mockForm = ({ + ...mockUpdatedForm, + form_fields: [fieldToUpdate], + updateFormFieldById: jest.fn().mockResolvedValue(mockUpdatedForm), + } as unknown) as IPopulatedForm + + // Act + const actual = await updateFormField( + mockForm, + fieldToUpdate._id, + mockNewField, + ) + + // Assert + expect(actual._unsafeUnwrap()).toEqual(mockNewField) + expect(mockForm.updateFormFieldById).toHaveBeenCalledWith( + fieldToUpdate._id, + mockNewField, + ) + }) + + it('should return FieldNotFoundError when field update returns null', async () => { + // Arrange + const mockForm = ({ + title: 'another mock form', + form_fields: [], + updateFormFieldById: jest.fn().mockResolvedValue(null), + } as unknown) as IPopulatedForm + + const invalidFieldId = new ObjectId().toHexString() + const mockNewField = generateDefaultField( + BasicField.Number, + ) as FieldUpdateDto + + // Act + const actual = await updateFormField( + mockForm, + invalidFieldId, + mockNewField, + ) + + // Assert + expect(actual._unsafeUnwrapErr()).toEqual(new FieldNotFoundError()) + }) + + it('should return DatabaseValidationError when field model update throws a validation error', async () => { + // Arrange + const mockForm = ({ + title: 'another another mock form', + form_fields: [], + updateFormFieldById: jest.fn().mockRejectedValue( + // @ts-ignore + new mongoose.Error.ValidationError(), + ), + } as unknown) as IPopulatedForm + + const invalidFieldId = new ObjectId().toHexString() + const mockNewField = generateDefaultField( + BasicField.Number, + ) as FieldUpdateDto + + // Act + const actual = await updateFormField( + mockForm, + invalidFieldId, + mockNewField, + ) + + // Assert + expect(actual._unsafeUnwrapErr()).toBeInstanceOf(DatabaseValidationError) + }) + }) }) diff --git a/src/app/modules/form/admin-form/admin-form.controller.ts b/src/app/modules/form/admin-form/admin-form.controller.ts index e373806e60..4d9ebd9522 100644 --- a/src/app/modules/form/admin-form/admin-form.controller.ts +++ b/src/app/modules/form/admin-form/admin-form.controller.ts @@ -8,6 +8,7 @@ import { ResultAsync } from 'neverthrow' import { AuthType, + BasicField, FieldResponse, FormMetaView, FormSettings, @@ -18,6 +19,8 @@ import { import { EncryptSubmissionDto, ErrorDto, + FieldUpdateDto, + FormFieldDto, SettingsUpdateDto, } from '../../../../types/api' import { createLoggerWithLabel } from '../../../config/logger' @@ -1076,7 +1079,7 @@ export const handleUpdateForm: RequestHandler< } /** - * Handler for PATCH /form/:formId/settings. + * Handler for PATCH /forms/:formId/settings. * @security session * * @returns 200 with updated form settings @@ -1130,6 +1133,59 @@ export const handleUpdateSettings: RequestHandler< }) } +/** + * NOTE: Exported for testing. + * Private handler for PUT /forms/:formId/fields/:fieldId + * @precondition Must be preceded by request validation + */ +export const _handleUpdateFormField: RequestHandler< + { + formId: string + fieldId: string + }, + FormFieldDto | ErrorDto, + FieldUpdateDto +> = (req, res) => { + const { formId, fieldId } = req.params + const sessionUserId = (req.session as Express.AuthedSession).user._id + + // Step 1: Retrieve currently logged in user. + return ( + UserService.getPopulatedUserById(sessionUserId) + .andThen((user) => + // Step 2: Retrieve form with write permission check. + AuthService.getFormAfterPermissionChecks({ + user, + formId, + level: PermissionLevel.Write, + }), + ) + // Step 3: User has permissions, update form field of retrieved form. + .andThen((form) => + AdminFormService.updateFormField(form, fieldId, req.body), + ) + .map((updatedFormField) => + res.status(StatusCodes.OK).json(updatedFormField), + ) + .mapErr((error) => { + logger.error({ + message: 'Error occurred when updating form field', + meta: { + action: 'handleUpdateFormField', + ...createReqMeta(req), + userId: sessionUserId, + formId, + fieldId, + updateFieldBody: req.body, + }, + error, + }) + const { errorMessage, statusCode } = mapRouteError(error) + return res.status(statusCode).json({ message: errorMessage }) + }) + ) +} + /** * Handler for GET /form/:formId/settings. * @security session @@ -1399,3 +1455,42 @@ export const handleEmailPreviewSubmission: RequestHandler< submissionId: submission.id, }) } + +/** + * Handler for PUT /forms/:formId/fields/:fieldId + * @security session + * + * @returns 200 with updated form field + * @returns 403 when current user does not have permissions to update form field + * @returns 404 when form cannot be found + * @returns 404 when form field cannot be found + * @returns 410 when updating form field of an archived form + * @returns 413 when updating form field causes form to be too large to be saved in the database + * @returns 422 when an invalid form field update is attempted on the form + * @returns 422 when user in session cannot be retrieved from the database + * @returns 500 when database error occurs + */ +export const handleUpdateFormField = [ + celebrate( + { + [Segments.BODY]: Joi.object({ + // Ensures given field is same as accessed field. + _id: Joi.string().valid(Joi.ref('$params.fieldId')).required(), + fieldType: Joi.string() + .valid(...Object.values(BasicField)) + .required(), + description: Joi.string().allow('').required(), + required: Joi.boolean().required(), + title: Joi.string().required(), + disabled: Joi.boolean().required(), + // Allow other field related key-values to be provided and let the model + // layer handle the validation. + }).unknown(true), + }, + undefined, + // Required so req.body can be validated against values in req.params. + // See https://github.com/arb/celebrate#celebrateschema-joioptions-opts. + { reqContext: true }, + ), + _handleUpdateFormField, +] diff --git a/src/app/modules/form/admin-form/admin-form.errors.ts b/src/app/modules/form/admin-form/admin-form.errors.ts index effd7a0934..e7c7594eba 100644 --- a/src/app/modules/form/admin-form/admin-form.errors.ts +++ b/src/app/modules/form/admin-form/admin-form.errors.ts @@ -17,3 +17,9 @@ export class EditFieldError extends ApplicationError { super(message) } } + +export class FieldNotFoundError extends ApplicationError { + constructor(message = 'Field to modify not found') { + super(message) + } +} diff --git a/src/app/modules/form/admin-form/admin-form.service.ts b/src/app/modules/form/admin-form/admin-form.service.ts index 461bc64906..d38660b408 100644 --- a/src/app/modules/form/admin-form/admin-form.service.ts +++ b/src/app/modules/form/admin-form/admin-form.service.ts @@ -19,7 +19,7 @@ import { IPopulatedForm, IUserSchema, } from '../../../../types' -import { SettingsUpdateDto } from '../../../../types/api' +import { FieldUpdateDto, SettingsUpdateDto } from '../../../../types/api' import { aws as AwsConfig } from '../../../config/config' import { createLoggerWithLabel } from '../../../config/logger' import getFormModel from '../../../models/form.server.model' @@ -33,16 +33,19 @@ import { DatabaseError, DatabasePayloadSizeError, DatabaseValidationError, + PossibleDatabaseError, } from '../../core/core.errors' import { MissingUserError } from '../../user/user.errors' import * as UserService from '../../user/user.service' import { FormNotFoundError, TransferOwnershipError } from '../form.errors' import { getFormModelByResponseMode } from '../form.service' +import { getFormFieldById } from '../form.utils' import { PRESIGNED_POST_EXPIRY_SECS } from './admin-form.constants' import { CreatePresignedUrlError, EditFieldError, + FieldNotFoundError, InvalidFileTypeError, } from './admin-form.errors' import { @@ -411,6 +414,47 @@ export const duplicateForm = ( ) } +/** + * Updates the targeted form field with the new field provided + * @param form the form the field to update belongs to + * @param fieldId the id of the field to update + * @param newField the new field to replace with + * @returns ok(updatedField) + * @returns err(FieldNotFoundError) if fieldId does not correspond to any field in the form + * @returns err(PossibleDatabaseError) when database errors arise + */ +export const updateFormField = ( + form: IPopulatedForm, + fieldId: string, + newField: FieldUpdateDto, +): ResultAsync => { + return ResultAsync.fromPromise( + form.updateFormFieldById(fieldId, newField), + (error) => { + logger.error({ + message: 'Error encountered while updating form field', + meta: { + action: 'updateFormField', + formId: form._id, + fieldId, + newField, + }, + error, + }) + + return transformMongoError(error) + }, + ).andThen((updatedForm) => { + if (!updatedForm) { + return errAsync(new FieldNotFoundError()) + } + const updatedFormField = getFormFieldById(updatedForm.form_fields, fieldId) + return updatedFormField + ? okAsync(updatedFormField) + : errAsync(new FieldNotFoundError()) + }) +} + /** * Updates form fields of given form depending on the given editFormFieldParams * @param originalForm the original form to update form fields for diff --git a/src/app/modules/form/admin-form/admin-form.utils.ts b/src/app/modules/form/admin-form/admin-form.utils.ts index 6f6009a3b4..c800b1eed2 100644 --- a/src/app/modules/form/admin-form/admin-form.utils.ts +++ b/src/app/modules/form/admin-form/admin-form.utils.ts @@ -31,6 +31,7 @@ import { import { CreatePresignedUrlError, EditFieldError, + FieldNotFoundError, InvalidFileTypeError, } from './admin-form.errors' import { @@ -55,13 +56,13 @@ export const mapRouteError = ( coreErrorMessage?: string, ): ErrorResponseData => { switch (error.constructor) { - case EditFieldError: case InvalidFileTypeError: case CreatePresignedUrlError: return { statusCode: StatusCodes.BAD_REQUEST, errorMessage: error.message, } + case FieldNotFoundError: case FormNotFoundError: return { statusCode: StatusCodes.NOT_FOUND, @@ -78,6 +79,7 @@ export const mapRouteError = ( statusCode: StatusCodes.FORBIDDEN, errorMessage: error.message, } + case EditFieldError: case DatabaseValidationError: case MissingUserError: return { @@ -345,7 +347,7 @@ const reorderField = ( * @returns err(EditFieldError) if any errors occur whilst updating fields */ export const getUpdatedFormFields = ( - currentFormFields: IPopulatedForm['form_fields'], + currentFormFields: IFieldSchema[], editFieldParams: EditFormFieldParams, ): EditFormFieldResult => { const { field: fieldToUpdate, action } = editFieldParams diff --git a/src/app/modules/form/form.utils.ts b/src/app/modules/form/form.utils.ts index 3e28efb482..f914221f31 100644 --- a/src/app/modules/form/form.utils.ts +++ b/src/app/modules/form/form.utils.ts @@ -1,11 +1,13 @@ import { IEncryptedFormSchema, + IFieldSchema, IFormSchema, IPopulatedEmailForm, IPopulatedForm, Permission, ResponseMode, } from '../../../types' +import { isMongooseDocumentArray } from '../../utils/mongoose' // Converts 'test@hotmail.com, test@gmail.com' to ['test@hotmail.com', 'test@gmail.com'] export const transformEmailString = (v: string): string[] => { @@ -76,3 +78,24 @@ export const isEmailModeForm = ( ): form is IPopulatedEmailForm => { return form.responseMode === ResponseMode.Email } + +/** + * Finds and returns form field in given form by its id + * @param formFields the form fields to search from + * @param fieldId the id of the field to retrieve + * @returns the form field if found, `null` otherwise + */ +export const getFormFieldById = ( + formFields: IFormSchema['form_fields'], + fieldId: IFieldSchema['_id'], +): IFieldSchema | null => { + if (!formFields) { + return null + } + + if (isMongooseDocumentArray(formFields)) { + return formFields.id(fieldId) + } + + return formFields.find((f) => fieldId === String(f._id)) ?? null +} diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.form.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.form.routes.ts index 0f3537e76c..12a1d3b048 100644 --- a/src/app/routes/api/v3/admin/forms/admin-forms.form.routes.ts +++ b/src/app/routes/api/v3/admin/forms/admin-forms.form.routes.ts @@ -90,3 +90,24 @@ AdminFormsFormRouter.post( '/:formId([a-fA-F0-9]{24})/collaborators/transfer-owner', AdminFormController.handleTransferFormOwnership, ) + +/** + * Update form field according to given new body. + * @route PUT /admin/forms/:formId/fields/:fieldId + * + * @param body the new field to override current field + * @returns 200 with updated form field + * @returns 400 when given body fails Joi validation + * @returns 401 when current user is not logged in + * @returns 403 when current user does not have permissions to update form field + * @returns 404 when form cannot be found + * @returns 404 when field cannot be found + * @returns 410 when updating form field for archived form + * @returns 422 when an invalid form field update is attempted on the form + * @returns 422 when user in session cannot be retrieved from the database + * @returns 500 when database error occurs + */ +AdminFormsFormRouter.put( + '/:formId([a-fA-F0-9]{24})/fields/:fieldId([a-fA-F0-9]{24})', + AdminFormController.handleUpdateFormField, +) diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts index 8b99b246a5..426ba12405 100644 --- a/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts +++ b/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts @@ -16,3 +16,4 @@ AdminFormsRouter.use(AdminFormsSettingsRouter) AdminFormsRouter.use(AdminFormsFeedbackRouter) AdminFormsRouter.use(AdminFormsFormRouter) AdminFormsRouter.use(AdminFormsSubmissionsRouter) +AdminFormsRouter.use(AdminFormsFormRouter) diff --git a/src/app/utils/mongoose.ts b/src/app/utils/mongoose.ts new file mode 100644 index 0000000000..875071bac8 --- /dev/null +++ b/src/app/utils/mongoose.ts @@ -0,0 +1,14 @@ +import { Document, Types } from 'mongoose' + +/** + * Type guard for whether given array is a mongoose DocumentArray + * @param array the array to check + */ +export const isMongooseDocumentArray = ( + array: T[] & { isMongooseDocumentArray?: boolean }, +): array is Types.DocumentArray => { + /** + * @see {mongoose.Types.DocumentArray.isMongooseDocumentArray} + */ + return !!array.isMongooseDocumentArray +} diff --git a/src/public/modules/forms/admin/constants/update-form-types.ts b/src/public/modules/forms/admin/constants/update-form-types.ts new file mode 100644 index 0000000000..b86c410856 --- /dev/null +++ b/src/public/modules/forms/admin/constants/update-form-types.ts @@ -0,0 +1,3 @@ +export const UPDATE_FORM_TYPES = { + UpdateField: 'update-field', +} diff --git a/src/public/modules/forms/admin/controllers/admin-form.client.controller.js b/src/public/modules/forms/admin/controllers/admin-form.client.controller.js index b4700eb019..a9605f9ec7 100644 --- a/src/public/modules/forms/admin/controllers/admin-form.client.controller.js +++ b/src/public/modules/forms/admin/controllers/admin-form.client.controller.js @@ -1,8 +1,10 @@ 'use strict' const { StatusCodes } = require('http-status-codes') +const get = require('lodash/get') const { LogicType } = require('../../../../../types') const AdminFormService = require('../../../../services/AdminFormService') +const { UPDATE_FORM_TYPES } = require('../constants/update-form-types') // All viewable tabs. readOnly is true if that tab cannot be used to edit form. const VIEW_TABS = [ @@ -151,7 +153,7 @@ function AdminFormController( break case StatusCodes.UNPROCESSABLE_ENTITY: // Validation can fail for many reasons, so return more specific message - errorMessage = _.get( + errorMessage = get( error, 'data.message', 'Your changes contain invalid input.', @@ -179,13 +181,41 @@ function AdminFormController( * @returns Promise */ $scope.updateForm = (update) => { - return FormApi.update({ formId: $scope.myform._id }, { form: update }) - .$promise.then((savedForm) => { - // Updating this form updates lastModified - // and also updates myform if a formToUse is passed in - $scope.myform = savedForm - }) - .catch(handleUpdateError) + const updateType = get(update, 'type') + + switch (updateType) { + case UPDATE_FORM_TYPES.UpdateField: { + const { fieldId, body } = update + return $q + .when( + AdminFormService.updateSingleFormField( + $scope.myform._id, + fieldId, + body, + ), + ) + .then((updatedFormField) => { + // merge back into the form fields + const updateIndex = $scope.myform.form_fields.findIndex( + (f) => f._id === fieldId, + ) + if (updateIndex !== -1) { + $scope.myform.form_fields[updateIndex] = updatedFormField + } else { + Toastr.error('An error occurred while saving your changes.') + } + }) + .catch(handleUpdateError) + } + default: + return FormApi.update({ formId: $scope.myform._id }, { form: update }) + .$promise.then((savedForm) => { + // Updating this form updates lastModified + // and also updates myform if a formToUse is passed in + $scope.myform = savedForm + }) + .catch(handleUpdateError) + } } /** diff --git a/src/public/modules/forms/admin/controllers/edit-fields-modal.client.controller.js b/src/public/modules/forms/admin/controllers/edit-fields-modal.client.controller.js index 9ea4350b31..03a1fcab79 100644 --- a/src/public/modules/forms/admin/controllers/edit-fields-modal.client.controller.js +++ b/src/public/modules/forms/admin/controllers/edit-fields-modal.client.controller.js @@ -2,12 +2,14 @@ const axios = require('axios').default const values = require('lodash/values') +const cloneDeep = require('lodash/cloneDeep') const { EditFieldActions, VALID_UPLOAD_FILE_TYPES, MAX_UPLOAD_FILE_SIZE, } = require('shared/constants') +const { UPDATE_FORM_TYPES } = require('../constants/update-form-types') const { uploadImage } = require('../../../../services/FileHandlerService') const CancelToken = axios.CancelToken @@ -456,9 +458,10 @@ function EditFieldsModalController( return } - const field = vm.field + const field = cloneDeep(vm.field) if (field.fieldOptionsFromText) { field.fieldOptions = field.fieldOptionsFromText.split('\n') + delete field.fieldOptionsFromText } else { field.fieldOptions = field.manualOptions } @@ -473,6 +476,8 @@ function EditFieldsModalController( .map((s) => s.trim()) .filter((s) => s) } + delete field.allowedEmailDomainsFromText + delete field.allowedEmailDomainsPlaceholder } // set total attachment size left @@ -483,27 +488,28 @@ function EditFieldsModalController( previousAttachmentSize } - // TODO: Separate code flow for create and update, ideally calling PUT and PATCH endpoints - const editFormField = - vm.field.globalId === undefined - ? // Create a new field - { - action: { - name: EditFieldActions.Create, - }, - field: vm.field, - } - : // Edit existing field - { - action: { - name: EditFieldActions.Update, - }, - field: vm.field, - } - vm.saveInProgress = true - externalScope - .updateField({ editFormField }) + // No id, creation + let updateFieldPromise + if (!field._id) { + updateFieldPromise = externalScope.updateField({ + editFormField: { + action: { + name: EditFieldActions.Create, + }, + field, + }, + }) + } else { + // Update field + updateFieldPromise = externalScope.updateField({ + fieldId: field._id, + body: field, + type: UPDATE_FORM_TYPES.UpdateField, + }) + } + + return updateFieldPromise .then((error) => { if (!error) { $uibModalInstance.close() diff --git a/src/public/modules/forms/admin/controllers/edit-myinfo-field-modal.client.controller.js b/src/public/modules/forms/admin/controllers/edit-myinfo-field-modal.client.controller.js index daac8c74ef..343d2f1576 100644 --- a/src/public/modules/forms/admin/controllers/edit-myinfo-field-modal.client.controller.js +++ b/src/public/modules/forms/admin/controllers/edit-myinfo-field-modal.client.controller.js @@ -1,6 +1,7 @@ 'use strict' const { EditFieldActions } = require('shared/constants') +const { UPDATE_FORM_TYPES } = require('../constants/update-form-types') angular .module('forms') @@ -29,25 +30,29 @@ function EditMyInfoFieldController( vm.verifiedForF = externalScope.currField.myInfo.verified.includes('F') vm.saveMyInfoField = function () { - // TODO: Separate code flow for create and update, ideally calling PUT and PATCH endpoints - const editFormField = - externalScope.currField.globalId === undefined - ? // Create a new field - { - action: { - name: EditFieldActions.Create, - }, - field: externalScope.currField, - } - : // Edit existing field - { - action: { - name: EditFieldActions.Update, - }, - field: externalScope.currField, - } - - updateField({ editFormField }).then((error) => { + // No id, creation + let updateFieldPromise + const field = externalScope.currField + // Field creation + if (!field._id) { + updateFieldPromise = updateField({ + editFormField: { + action: { + name: EditFieldActions.Create, + }, + field, + }, + }) + } else { + // Update field + updateFieldPromise = updateField({ + fieldId: field._id, + body: field, + type: UPDATE_FORM_TYPES.UpdateField, + }) + } + + return updateFieldPromise.then((error) => { if (!error) { $uibModalInstance.close() externalScope.closeMobileFields() diff --git a/src/public/services/AdminFormService.ts b/src/public/services/AdminFormService.ts index 8645270fb8..301b38c212 100644 --- a/src/public/services/AdminFormService.ts +++ b/src/public/services/AdminFormService.ts @@ -1,7 +1,7 @@ import axios from 'axios' import { FormSettings } from '../../types' -import { SettingsUpdateDto } from '../../types/api' +import { FieldUpdateDto, SettingsUpdateDto } from '../../types/api' const ADMIN_FORM_ENDPOINT = '/api/v3/admin/forms' @@ -16,3 +16,16 @@ export const updateFormSettings = async ( ) .then(({ data }) => data) } + +export const updateSingleFormField = async ( + formId: string, + fieldId: string, + updateFieldBody: FieldUpdateDto, +): Promise => { + return axios + .put( + `${ADMIN_FORM_ENDPOINT}/${formId}/fields/${fieldId}`, + updateFieldBody, + ) + .then(({ data }) => data) +} diff --git a/src/types/api/form.ts b/src/types/api/form.ts index 28bcb00e30..f5afb4d4d8 100644 --- a/src/types/api/form.ts +++ b/src/types/api/form.ts @@ -1,3 +1,17 @@ +import { LeanDocument } from 'mongoose' +import { ConditionalPick, Primitive } from 'type-fest' + +import { FormFieldSchema, FormFieldWithId } from '../field' import { FormSettings } from '../form' export type SettingsUpdateDto = Partial + +export type FieldUpdateDto = FormFieldWithId + +/** + * Form field POJO with functions removed + */ +export type FormFieldDto = ConditionalPick< + LeanDocument, + Primitive +> diff --git a/src/types/field/index.ts b/src/types/field/index.ts index f8c5a56b90..1a32232cfa 100644 --- a/src/types/field/index.ts +++ b/src/types/field/index.ts @@ -1,3 +1,23 @@ +import { IAttachmentField, IAttachmentFieldSchema } from './attachmentField' +import { ICheckboxField, ICheckboxFieldSchema } from './checkboxField' +import { IDateField, IDateFieldSchema } from './dateField' +import { IDecimalField, IDecimalFieldSchema } from './decimalField' +import { IDropdownField, IDropdownFieldSchema } from './dropdownField' +import { IEmailField, IEmailFieldSchema } from './emailField' +import { IHomenoField, IHomenoFieldSchema } from './homeNoField' +import { IImageField, IImageFieldSchema } from './imageField' +import { ILongTextField, ILongTextFieldSchema } from './longTextField' +import { IMobileField, IMobileFieldSchema } from './mobileField' +import { INricField, INricFieldSchema } from './nricField' +import { INumberField, INumberFieldSchema } from './numberField' +import { IRadioField, IRadioFieldSchema } from './radioField' +import { IRatingField, IRatingFieldSchema } from './ratingField' +import { ISectionField, ISectionFieldSchema } from './sectionField' +import { IShortTextField, IShortTextFieldSchema } from './shortTextField' +import { IStatementField, IStatementFieldSchema } from './statementField' +import { ITableField, ITableFieldSchema } from './tableField' +import { IYesNoField, IYesNoFieldSchema } from './yesNoField' + export * from './fieldTypes' export * from './baseField' export * from './attachmentField' @@ -19,3 +39,50 @@ export * from './shortTextField' export * from './statementField' export * from './tableField' export * from './yesNoField' + +export type FormFieldSchema = + | IAttachmentFieldSchema + | ICheckboxFieldSchema + | IDateFieldSchema + | IDecimalFieldSchema + | IDropdownFieldSchema + | IEmailFieldSchema + | IHomenoFieldSchema + | IImageFieldSchema + | ILongTextFieldSchema + | IMobileFieldSchema + | INricFieldSchema + | INumberFieldSchema + | IRadioFieldSchema + | IRatingFieldSchema + | ISectionFieldSchema + | IShortTextFieldSchema + | IStatementFieldSchema + | ITableFieldSchema + | IYesNoFieldSchema + +export type FormField = + | IAttachmentField + | ICheckboxField + | IDateField + | IDecimalField + | IDropdownField + | IEmailField + | IHomenoField + | IImageField + | ILongTextField + | IMobileField + | INricField + | INumberField + | IRadioField + | IRatingField + | ISectionField + | IShortTextField + | IStatementField + | ITableField + | IYesNoField + +/** + * Form field POJO with id + */ +export type FormFieldWithId = FormField & { _id: string } diff --git a/src/types/form.ts b/src/types/form.ts index 8a81347e70..0f226cbad3 100644 --- a/src/types/form.ts +++ b/src/types/form.ts @@ -1,10 +1,10 @@ -import { Document, LeanDocument, Model, ToObjectOptions } from 'mongoose' +import { Document, LeanDocument, Model, ToObjectOptions, Types } from 'mongoose' import { Merge, SetRequired } from 'type-fest' import { OverrideProps } from '../app/modules/form/admin-form/admin-form.types' import { PublicView } from './database' -import { IFieldSchema, MyInfoAttribute } from './field' +import { FormFieldWithId, IFieldSchema, MyInfoAttribute } from './field' import { ILogicSchema } from './form_logic' import { FormLogoState, IFormLogo } from './form_logo' import { IPopulatedUser, IUserSchema, PublicUser } from './user' @@ -161,6 +161,23 @@ export type FormSettings = Pick< > export interface IFormSchema extends IForm, Document, PublicView { + form_fields?: Types.DocumentArray | IFieldSchema[] + form_logics?: Types.DocumentArray | ILogicSchema[] + + /** + * Replaces the field corresponding to given id to given new field + * @param fieldId the id of the field to update + * @param newField the new field to replace with + * @returns updated form after the update if field update is successful + * @returns null if field not found + * @throws validation error on invalid updates, or if new field type is different from current field type + */ + updateFormFieldById( + this: T, + fieldId: string, + newField: FormFieldWithId, + ): Promise + /** * Returns the dashboard form view of the form. * @param admin the admin to inject into the returned object @@ -202,6 +219,7 @@ export interface IFormSchema extends IForm, Document, PublicView { * Schema type with defaults populated and thus set to be defined. */ export interface IFormDocument extends IFormSchema { + form_fields: NonNullable form_logics: NonNullable permissionList: NonNullable hasCaptcha: NonNullable @@ -212,7 +230,6 @@ export interface IFormDocument extends IFormSchema { // Hence, using Exclude here over NonNullable. submissionLimit: Exclude isListed: NonNullable - form_fields: NonNullable startPage: SetRequired, 'colorTheme'> endPage: SetRequired< NonNullable, @@ -228,7 +245,7 @@ export interface IPopulatedForm extends Omit { export interface IEncryptedForm extends IForm { publicKey: string - emails: never + emails?: never } export type IEncryptedFormSchema = IEncryptedForm & IFormSchema @@ -239,7 +256,7 @@ export interface IEmailForm extends IForm { // string type is allowed due to a setter on the form schema that transforms // strings to string array. emails: string[] | string - publicKey: never + publicKey?: never } export type IEmailFormSchema = IEmailForm & IFormSchema diff --git a/tests/unit/backend/helpers/generate-form-data.ts b/tests/unit/backend/helpers/generate-form-data.ts index 79a4175ab2..5be6395eac 100644 --- a/tests/unit/backend/helpers/generate-form-data.ts +++ b/tests/unit/backend/helpers/generate-form-data.ts @@ -24,6 +24,7 @@ import { IDropdownFieldSchema, IField, IFieldSchema, + IHomenoFieldSchema, IImageFieldSchema, ILongTextField, IMobileField, @@ -145,6 +146,13 @@ export const generateDefaultField = ( getQuestion: () => defaultParams.title, ...customParams, } as IMobileFieldSchema + case BasicField.HomeNo: + return { + ...defaultParams, + allowIntlNumbers: false, + getQuestion: () => defaultParams.title, + ...customParams, + } as IHomenoFieldSchema default: return { ...defaultParams, diff --git a/tests/unit/backend/helpers/jest-db.ts b/tests/unit/backend/helpers/jest-db.ts index 26ca430cab..dd7a1aa100 100644 --- a/tests/unit/backend/helpers/jest-db.ts +++ b/tests/unit/backend/helpers/jest-db.ts @@ -9,7 +9,9 @@ import { import getUserModel from 'src/app/models/user.server.model' import { IAgencySchema, + IEmailForm, IEmailFormSchema, + IEncryptedForm, IEncryptedFormSchema, IPopulatedForm, IUserSchema, @@ -141,7 +143,7 @@ const insertEmailForm = async ({ mailName?: string mailDomain?: string shortName?: string - formOptions?: Partial + formOptions?: Partial } = {}): Promise<{ form: IEmailFormSchema user: IUserSchema @@ -184,7 +186,7 @@ const insertEncryptForm = async ({ mailName?: string mailDomain?: string shortName?: string - formOptions?: Partial + formOptions?: Partial } = {}): Promise<{ form: IEncryptedFormSchema user: IUserSchema From 6cd232a0ecdc45ffcebaf8447eb28811c8f2de57 Mon Sep 17 00:00:00 2001 From: Kar Rui Lau Date: Wed, 21 Apr 2021 11:58:55 +0800 Subject: [PATCH 12/51] feat(api-refactor): implement specific create field api (#1671) * feat(AdminFormSvc): add dummy createFormField function * feat(AdminFormCtl): add handleCreateFormField controller and router fn * feat(FormModel): add insertFormField model instance method * feat(AdminFormSvc): add and use createFormField service fn * feat(AdminFormCtl): Joi prevent globalId from being provided * feat(client): impl createSingleFormField service fn for field creation * feat(AdminFormCtl): loosen Joi validation required keys most of the loosened keys have sane defaults already * fix(AdminFormCtl): remove incorrect fieldId type declaration * test(AdminFormCtl): add unit tests for _handleCreateFormField fn * test(AdminFormSvc): add unit tests for createFormField fn * test(FormModel): add unit tests for insertFormField instance method --- .../__tests__/form.server.model.spec.ts | 36 +++ src/app/models/form.server.model.ts | 19 +- .../__tests__/admin-form.controller.spec.ts | 295 +++++++++++++++++- .../__tests__/admin-form.service.spec.ts | 69 +++- .../form/admin-form/admin-form.controller.ts | 81 +++++ .../form/admin-form/admin-form.service.ts | 45 ++- .../v3/admin/forms/admin-forms.form.routes.ts | 5 + .../admin/constants/update-form-types.ts | 1 + .../admin-form.client.controller.js | 10 + .../edit-fields-modal.client.controller.js | 9 +- ...it-myinfo-field-modal.client.controller.js | 9 +- src/public/services/AdminFormService.ts | 19 +- src/types/api/form.ts | 4 +- src/types/form.ts | 15 +- 14 files changed, 593 insertions(+), 24 deletions(-) diff --git a/src/app/models/__tests__/form.server.model.spec.ts b/src/app/models/__tests__/form.server.model.spec.ts index d213ba5657..36665d40a2 100644 --- a/src/app/models/__tests__/form.server.model.spec.ts +++ b/src/app/models/__tests__/form.server.model.spec.ts @@ -1411,5 +1411,41 @@ describe('Form Model', () => { expect(actual).toBeInstanceOf(mongoose.Error.ValidationError) }) }) + + describe('insertFormField', () => { + it('should return updated document with inserted form field', async () => { + // Arrange + const newField = generateDefaultField(BasicField.Checkbox) + expect(validForm.form_fields).toBeEmpty() + + // Act + const actual = await validForm.insertFormField(newField) + + // Assert + const expectedField = { + ...omit(newField, 'getQuestion'), + _id: new ObjectId(newField._id), + } + // @ts-ignore + expect(actual?.form_fields.toObject()).toEqual([expectedField]) + }) + + it('should return validation error if model validation fails whilst creating field', async () => { + // Arrange + const newField = { + ...generateDefaultField(BasicField.Email), + // Invalid value for email field. + isVerifiable: 'some string, but this should be boolean', + } + + // Act + const actual = await validForm + .insertFormField(newField) + .catch((err) => err) + + // Assert + expect(actual).toBeInstanceOf(mongoose.Error.ValidationError) + }) + }) }) }) diff --git a/src/app/models/form.server.model.ts b/src/app/models/form.server.model.ts index aa9797d100..1f90eb7d06 100644 --- a/src/app/models/form.server.model.ts +++ b/src/app/models/form.server.model.ts @@ -1,12 +1,19 @@ import BSON from 'bson-ext' import { compact, pick, uniq } from 'lodash' -import mongoose, { Mongoose, Query, Schema, SchemaOptions } from 'mongoose' +import mongoose, { + Mongoose, + Query, + Schema, + SchemaOptions, + Types, +} from 'mongoose' import validator from 'validator' import { AuthType, BasicField, Colors, + FormField, FormFieldWithId, FormLogoState, FormMetaView, @@ -16,6 +23,7 @@ import { IEmailFormSchema, IEncryptedFormModel, IEncryptedFormSchema, + IFieldSchema, IFormDocument, IFormModel, IFormSchema, @@ -513,6 +521,15 @@ const compileFormModel = (db: Mongoose): IFormModel => { return this.save() } + FormDocumentSchema.methods.insertFormField = function ( + this: IFormDocument, + newField: FormField, + ) { + // eslint-disable-next-line @typescript-eslint/no-extra-semi + ;(this.form_fields as Types.DocumentArray).push(newField) + return this.save() + } + // Statics // Method to retrieve data for OTP verification FormSchema.statics.getOtpData = async function ( diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts index c22fa288fb..2b4db181c7 100644 --- a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts +++ b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts @@ -1,6 +1,6 @@ import { PresignedPost } from 'aws-sdk/clients/s3' import { ObjectId } from 'bson-ext' -import { assignIn, cloneDeep, merge } from 'lodash' +import { assignIn, cloneDeep, merge, pick } from 'lodash' import { err, errAsync, ok, okAsync, Result } from 'neverthrow' import { PassThrough } from 'stream' import { MockedObject } from 'ts-jest/dist/utils/testing' @@ -58,7 +58,11 @@ import { ResponseMode, Status, } from 'src/types' -import { EncryptSubmissionDto, FieldUpdateDto } from 'src/types/api' +import { + EncryptSubmissionDto, + FieldCreateDto, + FieldUpdateDto, +} from 'src/types/api' import { generateDefaultField, @@ -7069,4 +7073,291 @@ describe('admin-form.controller', () => { ) }) }) + + describe('_handleCreateFormField', () => { + const MOCK_FORM_ID = new ObjectId() + const MOCK_USER_ID = new ObjectId() + const MOCK_USER = { + _id: MOCK_USER_ID, + email: 'somerandom@example.com', + } as IPopulatedUser + const MOCK_FORM = { + admin: MOCK_USER, + _id: MOCK_FORM_ID, + title: 'mock title', + } as IPopulatedForm + + const MOCK_RETURNED_FIELD = generateDefaultField(BasicField.Nric) + const MOCK_CREATE_FIELD_BODY = pick(MOCK_RETURNED_FIELD, [ + 'fieldType', + 'title', + ]) as FieldCreateDto + const MOCK_REQ = expressHandler.mockRequest({ + session: { + user: { + _id: MOCK_USER_ID, + }, + }, + params: { + formId: String(MOCK_FORM_ID), + }, + body: MOCK_CREATE_FIELD_BODY, + }) + + beforeEach(() => { + MockUserService.getPopulatedUserById.mockReturnValue(okAsync(MOCK_USER)) + MockAuthService.getFormAfterPermissionChecks.mockReturnValue( + okAsync(MOCK_FORM), + ) + MockAdminFormService.createFormField.mockReturnValue( + okAsync(MOCK_RETURNED_FIELD), + ) + }) + + it('should return 200 with created form field', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleCreateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(200) + expect(mockRes.json).toHaveBeenCalledWith(MOCK_RETURNED_FIELD) + expect(MockAdminFormService.createFormField).toHaveBeenCalledWith( + MOCK_FORM, + MOCK_CREATE_FIELD_BODY, + ) + }) + + it('should return 403 when current user does not have permissions to create a form field', async () => { + // Arrange + const expectedErrorString = 'no permissions pls' + MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( + errAsync(new ForbiddenFormError(expectedErrorString)), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleCreateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(403) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.createFormField).not.toHaveBeenCalled() + }) + + it('should return 404 when form cannot be found', async () => { + // Arrange + const expectedErrorString = 'no form pls' + MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( + errAsync(new FormNotFoundError(expectedErrorString)), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleCreateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(404) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.createFormField).not.toHaveBeenCalled() + }) + + it('should return 410 when attempting to create a form field for an archived form', async () => { + // Arrange + const expectedErrorString = 'form gone pls' + MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( + errAsync(new FormDeletedError(expectedErrorString)), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleCreateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(410) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.createFormField).not.toHaveBeenCalled() + }) + + it('should return 413 when creating a form field causes the form to be too large to be saved in the database', async () => { + // Arrange + const expectedErrorString = 'payload too large' + MockAdminFormService.createFormField.mockReturnValueOnce( + errAsync(new DatabasePayloadSizeError(expectedErrorString)), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleCreateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(413) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.createFormField).toHaveBeenCalledWith( + MOCK_FORM, + MOCK_CREATE_FIELD_BODY, + ) + }) + + it('should return 422 when DatabaseValidationError occurs', async () => { + // Arrange + const expectedErrorString = 'invalid thing' + MockAdminFormService.createFormField.mockReturnValueOnce( + errAsync(new DatabaseValidationError(expectedErrorString)), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleCreateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(422) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.createFormField).toHaveBeenCalledWith( + MOCK_FORM, + MOCK_CREATE_FIELD_BODY, + ) + }) + + it('should return 422 when user in session cannot be retrieved from the database', async () => { + // Arrange + const expectedErrorString = 'user gone' + MockUserService.getPopulatedUserById.mockReturnValueOnce( + errAsync(new MissingUserError(expectedErrorString)), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleCreateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(422) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.createFormField).not.toHaveBeenCalled() + }) + + it('should return 500 when database error occurs whilst retrieving user from database', async () => { + // Arrange + const expectedErrorString = 'database error' + MockUserService.getPopulatedUserById.mockReturnValueOnce( + errAsync(new DatabaseError(expectedErrorString)), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleCreateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(500) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect( + MockAuthService.getFormAfterPermissionChecks, + ).not.toHaveBeenCalled() + expect(MockAdminFormService.createFormField).not.toHaveBeenCalled() + }) + + it('should return 500 when database error occurs whilst retrieving form from database', async () => { + // Arrange + const expectedErrorString = 'database error' + MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( + errAsync(new DatabaseError(expectedErrorString)), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleCreateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(500) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith( + { + user: MOCK_USER, + formId: String(MOCK_FORM_ID), + level: PermissionLevel.Write, + }, + ) + expect(MockAdminFormService.createFormField).not.toHaveBeenCalled() + }) + + it('should return 500 when database error occurs whilst creating form field', async () => { + // Arrange + const expectedErrorString = 'database error' + MockAdminFormService.createFormField.mockReturnValueOnce( + errAsync(new DatabaseError(expectedErrorString)), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await AdminFormController._handleCreateFormField( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(500) + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(MockAdminFormService.createFormField).toHaveBeenCalledWith( + MOCK_FORM, + MOCK_CREATE_FIELD_BODY, + ) + }) + }) }) diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts index 29d962914c..2c2cd1bc4d 100644 --- a/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts +++ b/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ import { PresignedPost } from 'aws-sdk/clients/s3' import { ObjectId } from 'bson-ext' -import { assignIn, cloneDeep, merge, omit } from 'lodash' +import { assignIn, cloneDeep, merge, omit, pick } from 'lodash' import mongoose from 'mongoose' import { err, errAsync, ok, okAsync } from 'neverthrow' import { mocked } from 'ts-jest/utils' @@ -38,7 +38,11 @@ import { ResponseMode, Status, } from 'src/types' -import { FieldUpdateDto, SettingsUpdateDto } from 'src/types/api' +import { + FieldCreateDto, + FieldUpdateDto, + SettingsUpdateDto, +} from 'src/types/api' import { generateDefaultField } from 'tests/unit/backend/helpers/generate-form-data' @@ -52,6 +56,7 @@ import { import { archiveForm, createForm, + createFormField, createPresignedPostUrlForImages, createPresignedPostUrlForLogos, duplicateForm, @@ -1279,4 +1284,64 @@ describe('admin-form.service', () => { expect(actual._unsafeUnwrapErr()).toBeInstanceOf(DatabaseValidationError) }) }) + + describe('createFormField', () => { + it('should return created form field', async () => { + // Arrange + const initialFields = [ + generateDefaultField(BasicField.Mobile), + generateDefaultField(BasicField.Image), + ] + const expectedCreatedField = generateDefaultField(BasicField.Nric, { + title: 'some nric title', + }) + const mockUpdatedForm = { + title: 'some mock form', + // Append created field to end of form_fields. + form_fields: [...initialFields, expectedCreatedField], + } + const mockForm = ({ + title: 'some mock form', + form_fields: initialFields, + insertFormField: jest.fn().mockResolvedValue(mockUpdatedForm), + } as unknown) as IPopulatedForm + const formCreateParams = pick(expectedCreatedField, [ + 'title', + 'fieldType', + ]) as FieldCreateDto + + // Act + const actual = await createFormField(mockForm, formCreateParams) + + // Assert + // Should return last element in form_field + expect(actual._unsafeUnwrap()).toEqual(expectedCreatedField) + }) + + it('should return DatabaseValidationError when field model update throws a validation error', async () => { + // Arrange + const initialFields = [ + generateDefaultField(BasicField.Mobile), + generateDefaultField(BasicField.Image), + ] + const mockForm = ({ + title: 'some mock form', + form_fields: initialFields, + insertFormField: jest.fn().mockRejectedValue( + // @ts-ignore + new mongoose.Error.ValidationError({ errors: 'does not matter' }), + ), + } as unknown) as IPopulatedForm + const formCreateParams = { + fieldType: BasicField.ShortText, + title: 'some title', + } as FieldCreateDto + + // Act + const actual = await createFormField(mockForm, formCreateParams) + + // Assert + expect(actual._unsafeUnwrapErr()).toBeInstanceOf(DatabaseValidationError) + }) + }) }) diff --git a/src/app/modules/form/admin-form/admin-form.controller.ts b/src/app/modules/form/admin-form/admin-form.controller.ts index 4d9ebd9522..26a74970e8 100644 --- a/src/app/modules/form/admin-form/admin-form.controller.ts +++ b/src/app/modules/form/admin-form/admin-form.controller.ts @@ -19,6 +19,7 @@ import { import { EncryptSubmissionDto, ErrorDto, + FieldCreateDto, FieldUpdateDto, FormFieldDto, SettingsUpdateDto, @@ -1494,3 +1495,83 @@ export const handleUpdateFormField = [ ), _handleUpdateFormField, ] + +/** + * NOTE: Exported for testing. + * Private handler for POST /forms/:formId/fields + * @precondition Must be preceded by request validation + * @security session + * + * @returns 200 with created form field + * @returns 403 when current user does not have permissions to create a form field + * @returns 404 when form cannot be found + * @returns 410 when creating form field for an archived form + * @returns 413 when creating form field causes form to be too large to be saved in the database + * @returns 422 when an invalid form field creation is attempted on the form + * @returns 422 when user in session cannot be retrieved from the database + * @returns 500 when database error occurs + */ +export const _handleCreateFormField: RequestHandler< + { formId: string }, + FormFieldDto | ErrorDto, + FieldCreateDto +> = (req, res) => { + const { formId } = req.params + const sessionUserId = (req.session as Express.AuthedSession).user._id + + // Step 1: Retrieve currently logged in user. + return ( + UserService.getPopulatedUserById(sessionUserId) + .andThen((user) => + // Step 2: Retrieve form with write permission check. + AuthService.getFormAfterPermissionChecks({ + user, + formId, + level: PermissionLevel.Write, + }), + ) + // Step 3: User has permissions, proceed to create form field with provided body. + .andThen((form) => AdminFormService.createFormField(form, req.body)) + .map((createdFormField) => + res.status(StatusCodes.OK).json(createdFormField), + ) + .mapErr((error) => { + logger.error({ + message: 'Error occurred when creating form field', + meta: { + action: '_handleCreateFormField', + ...createReqMeta(req), + userId: sessionUserId, + formId, + createFieldBody: req.body, + }, + error, + }) + const { errorMessage, statusCode } = mapRouteError(error) + return res.status(statusCode).json({ message: errorMessage }) + }) + ) +} + +/** + * Handler for POST /forms/:formId/fields + */ +export const handleCreateFormField = [ + celebrate({ + [Segments.BODY]: Joi.object({ + // Ensures id is not provided. + _id: Joi.any().forbidden(), + globalId: Joi.any().forbidden(), + fieldType: Joi.string() + .valid(...Object.values(BasicField)) + .required(), + title: Joi.string().required(), + description: Joi.string().allow(''), + required: Joi.boolean(), + disabled: Joi.boolean(), + // Allow other field related key-values to be provided and let the model + // layer handle the validation. + }).unknown(true), + }), + _handleCreateFormField, +] diff --git a/src/app/modules/form/admin-form/admin-form.service.ts b/src/app/modules/form/admin-form/admin-form.service.ts index d38660b408..5b20c17249 100644 --- a/src/app/modules/form/admin-form/admin-form.service.ts +++ b/src/app/modules/form/admin-form/admin-form.service.ts @@ -1,5 +1,5 @@ import { PresignedPost } from 'aws-sdk/clients/s3' -import { assignIn, omit } from 'lodash' +import { assignIn, last, omit } from 'lodash' import mongoose from 'mongoose' import { errAsync, okAsync, ResultAsync } from 'neverthrow' import { Except, Merge } from 'type-fest' @@ -19,7 +19,11 @@ import { IPopulatedForm, IUserSchema, } from '../../../../types' -import { FieldUpdateDto, SettingsUpdateDto } from '../../../../types/api' +import { + FieldCreateDto, + FieldUpdateDto, + SettingsUpdateDto, +} from '../../../../types/api' import { aws as AwsConfig } from '../../../config/config' import { createLoggerWithLabel } from '../../../config/logger' import getFormModel from '../../../models/form.server.model' @@ -455,6 +459,43 @@ export const updateFormField = ( }) } +/** + * Inserts a new form field into given form's fields with the field provided + * @param form the form to insert the new field into + * @param newField the new field to insert + * @returns ok(created form field) + * @returns err(PossibleDatabaseError) when database errors arise + */ +export const createFormField = ( + form: IPopulatedForm, + newField: FieldCreateDto, +): ResultAsync< + IFieldSchema, + PossibleDatabaseError | FormNotFoundError | FieldNotFoundError +> => { + return ResultAsync.fromPromise(form.insertFormField(newField), (error) => { + logger.error({ + message: 'Error encountered while inserting new form field', + meta: { + action: 'createFormField', + formId: form._id, + newField, + }, + error, + }) + + return transformMongoError(error) + }).andThen((updatedForm) => { + if (!updatedForm) { + return errAsync(new FormNotFoundError()) + } + const updatedField = last(updatedForm.form_fields) + return updatedField + ? okAsync(updatedField) + : errAsync(new FieldNotFoundError()) + }) +} + /** * Updates form fields of given form depending on the given editFormFieldParams * @param originalForm the original form to update form fields for diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.form.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.form.routes.ts index 12a1d3b048..c691a804c4 100644 --- a/src/app/routes/api/v3/admin/forms/admin-forms.form.routes.ts +++ b/src/app/routes/api/v3/admin/forms/admin-forms.form.routes.ts @@ -111,3 +111,8 @@ AdminFormsFormRouter.put( '/:formId([a-fA-F0-9]{24})/fields/:fieldId([a-fA-F0-9]{24})', AdminFormController.handleUpdateFormField, ) + +AdminFormsFormRouter.post( + '/:formId([a-fA-F0-9]{24})/fields', + AdminFormController.handleCreateFormField, +) diff --git a/src/public/modules/forms/admin/constants/update-form-types.ts b/src/public/modules/forms/admin/constants/update-form-types.ts index b86c410856..c3408354e1 100644 --- a/src/public/modules/forms/admin/constants/update-form-types.ts +++ b/src/public/modules/forms/admin/constants/update-form-types.ts @@ -1,3 +1,4 @@ export const UPDATE_FORM_TYPES = { UpdateField: 'update-field', + CreateField: 'create-field', } diff --git a/src/public/modules/forms/admin/controllers/admin-form.client.controller.js b/src/public/modules/forms/admin/controllers/admin-form.client.controller.js index a9605f9ec7..39b7bb6b03 100644 --- a/src/public/modules/forms/admin/controllers/admin-form.client.controller.js +++ b/src/public/modules/forms/admin/controllers/admin-form.client.controller.js @@ -184,6 +184,16 @@ function AdminFormController( const updateType = get(update, 'type') switch (updateType) { + case UPDATE_FORM_TYPES.CreateField: { + const { body } = update + return $q + .when(AdminFormService.createSingleFormField($scope.myform._id, body)) + .then((updatedFormField) => { + // insert created field into form + $scope.myform.form_fields.push(updatedFormField) + }) + .catch(handleUpdateError) + } case UPDATE_FORM_TYPES.UpdateField: { const { fieldId, body } = update return $q diff --git a/src/public/modules/forms/admin/controllers/edit-fields-modal.client.controller.js b/src/public/modules/forms/admin/controllers/edit-fields-modal.client.controller.js index 03a1fcab79..3901bb5406 100644 --- a/src/public/modules/forms/admin/controllers/edit-fields-modal.client.controller.js +++ b/src/public/modules/forms/admin/controllers/edit-fields-modal.client.controller.js @@ -5,7 +5,6 @@ const values = require('lodash/values') const cloneDeep = require('lodash/cloneDeep') const { - EditFieldActions, VALID_UPLOAD_FILE_TYPES, MAX_UPLOAD_FILE_SIZE, } = require('shared/constants') @@ -493,12 +492,8 @@ function EditFieldsModalController( let updateFieldPromise if (!field._id) { updateFieldPromise = externalScope.updateField({ - editFormField: { - action: { - name: EditFieldActions.Create, - }, - field, - }, + body: field, + type: UPDATE_FORM_TYPES.CreateField, }) } else { // Update field diff --git a/src/public/modules/forms/admin/controllers/edit-myinfo-field-modal.client.controller.js b/src/public/modules/forms/admin/controllers/edit-myinfo-field-modal.client.controller.js index 343d2f1576..302119d645 100644 --- a/src/public/modules/forms/admin/controllers/edit-myinfo-field-modal.client.controller.js +++ b/src/public/modules/forms/admin/controllers/edit-myinfo-field-modal.client.controller.js @@ -1,6 +1,5 @@ 'use strict' -const { EditFieldActions } = require('shared/constants') const { UPDATE_FORM_TYPES } = require('../constants/update-form-types') angular @@ -36,12 +35,8 @@ function EditMyInfoFieldController( // Field creation if (!field._id) { updateFieldPromise = updateField({ - editFormField: { - action: { - name: EditFieldActions.Create, - }, - field, - }, + body: field, + type: UPDATE_FORM_TYPES.CreateField, }) } else { // Update field diff --git a/src/public/services/AdminFormService.ts b/src/public/services/AdminFormService.ts index 301b38c212..2beed7cb06 100644 --- a/src/public/services/AdminFormService.ts +++ b/src/public/services/AdminFormService.ts @@ -1,7 +1,12 @@ import axios from 'axios' import { FormSettings } from '../../types' -import { FieldUpdateDto, SettingsUpdateDto } from '../../types/api' +import { + FieldCreateDto, + FieldUpdateDto, + FormFieldDto, + SettingsUpdateDto, +} from '../../types/api' const ADMIN_FORM_ENDPOINT = '/api/v3/admin/forms' @@ -29,3 +34,15 @@ export const updateSingleFormField = async ( ) .then(({ data }) => data) } + +export const createSingleFormField = async ( + formId: string, + createFieldBody: FieldCreateDto, +): Promise => { + return axios + .post( + `${ADMIN_FORM_ENDPOINT}/${formId}/fields`, + createFieldBody, + ) + .then(({ data }) => data) +} diff --git a/src/types/api/form.ts b/src/types/api/form.ts index f5afb4d4d8..6892d28a89 100644 --- a/src/types/api/form.ts +++ b/src/types/api/form.ts @@ -1,13 +1,15 @@ import { LeanDocument } from 'mongoose' import { ConditionalPick, Primitive } from 'type-fest' -import { FormFieldSchema, FormFieldWithId } from '../field' +import { FormField, FormFieldSchema, FormFieldWithId } from '../field' import { FormSettings } from '../form' export type SettingsUpdateDto = Partial export type FieldUpdateDto = FormFieldWithId +export type FieldCreateDto = FormField + /** * Form field POJO with functions removed */ diff --git a/src/types/form.ts b/src/types/form.ts index 0f226cbad3..06549398ce 100644 --- a/src/types/form.ts +++ b/src/types/form.ts @@ -4,7 +4,12 @@ import { Merge, SetRequired } from 'type-fest' import { OverrideProps } from '../app/modules/form/admin-form/admin-form.types' import { PublicView } from './database' -import { FormFieldWithId, IFieldSchema, MyInfoAttribute } from './field' +import { + FormField, + FormFieldWithId, + IFieldSchema, + MyInfoAttribute, +} from './field' import { ILogicSchema } from './form_logic' import { FormLogoState, IFormLogo } from './form_logo' import { IPopulatedUser, IUserSchema, PublicUser } from './user' @@ -178,6 +183,14 @@ export interface IFormSchema extends IForm, Document, PublicView { newField: FormFieldWithId, ): Promise + /** + * Inserts a form field into the form + * @param newField the new field to insert + * @returns updated form after the insertion if field insertion is successful + * @throws validation error on invalid updates + */ + insertFormField(this: T, newField: FormField): Promise + /** * Returns the dashboard form view of the form. * @param admin the admin to inject into the returned object From b99b40c7985688907cf2b8649aab90bbd174e114 Mon Sep 17 00:00:00 2001 From: orbitalsqwib <21305518+orbitalsqwib@users.noreply.github.com> Date: Wed, 21 Apr 2021 14:38:49 +0800 Subject: [PATCH 13/51] refactor(preview-api): duplicate adminform preview endpoints for /api/v3 (#1643) * refactor(admin-form-api): duplicate adminform form endpoints for /api/v3i - duplicate and update adminform form related endpoints - duplicate integration tests for new endpoint - update v3 router to use new endpoints - update frontend api calls to use new endpoints * refactor(admin-form-api): remove unneeded joi-date extension * refactor(preview-api): duplicate adminform preview endpoints for /api/v3 - duplicate and update adminform preview related endpoints - duplicate integration tests for new endpoint - update v3 router to use new endpoints - update frontend api calls to use new endpoints * ref(preview-api): remove duplicate user auth middleware from routes * ref(preview-api): consolidate middleware into controller - shift validators into admin-form controller - update handler methods to include middleware as request handler array - update controller integration tests - update old routes to use new handler - update new routes to use new handler --- .../__tests__/admin-form.controller.spec.ts | 124 +- .../form/admin-form/admin-form.controller.ts | 21 +- .../form/admin-form/admin-form.routes.ts | 5 - .../admin-forms.preview.routes.spec.ts | 1029 +++++++++++++++++ .../admin/forms/admin-forms.preview.routes.ts | 59 + .../api/v3/admin/forms/admin-forms.routes.ts | 3 +- .../forms/services/form-api.client.factory.js | 2 +- .../services/submissions.client.factory.js | 4 +- 8 files changed, 1140 insertions(+), 107 deletions(-) create mode 100644 src/app/routes/api/v3/admin/forms/__tests__/admin-forms.preview.routes.spec.ts create mode 100644 src/app/routes/api/v3/admin/forms/admin-forms.preview.routes.ts diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts index 2b4db181c7..ec8fdc05a2 100644 --- a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts +++ b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts @@ -5065,7 +5065,7 @@ describe('admin-form.controller', () => { }) }) - describe('handleEmailPreviewSubmission', () => { + describe('submitEmailPreview', () => { const MOCK_FIELD_ID = new ObjectId().toHexString() const MOCK_RESPONSES = [ generateUnprocessedSingleAnswerResponse(BasicField.Email, { @@ -5153,11 +5153,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -5228,11 +5224,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -5281,11 +5273,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -5334,11 +5322,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -5391,11 +5375,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -5448,11 +5428,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -5505,11 +5481,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -5562,11 +5534,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -5619,11 +5587,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -5676,11 +5640,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -5733,11 +5693,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -5793,11 +5749,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -5853,11 +5805,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -5913,11 +5861,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -5982,11 +5926,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -6051,11 +5991,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEmailPreviewSubmission( - mockReq, - mockRes, - jest.fn(), - ) + await AdminFormController.submitEmailPreview(mockReq, mockRes, jest.fn()) expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( MOCK_USER_ID, @@ -6110,7 +6046,7 @@ describe('admin-form.controller', () => { }) }) - describe('handleEncryptPreviewSubmission', () => { + describe('submitEncryptPreview', () => { const MOCK_RESPONSES = [ generateUnprocessedSingleAnswerResponse(BasicField.Email), ] @@ -6182,7 +6118,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEncryptPreviewSubmission( + await AdminFormController.submitEncryptPreview( mockReq, mockRes, jest.fn(), @@ -6246,7 +6182,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEncryptPreviewSubmission( + await AdminFormController.submitEncryptPreview( mockReq, mockRes, jest.fn(), @@ -6294,7 +6230,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEncryptPreviewSubmission( + await AdminFormController.submitEncryptPreview( mockReq, mockRes, jest.fn(), @@ -6342,7 +6278,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEncryptPreviewSubmission( + await AdminFormController.submitEncryptPreview( mockReq, mockRes, jest.fn(), @@ -6394,7 +6330,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEncryptPreviewSubmission( + await AdminFormController.submitEncryptPreview( mockReq, mockRes, jest.fn(), @@ -6446,7 +6382,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEncryptPreviewSubmission( + await AdminFormController.submitEncryptPreview( mockReq, mockRes, jest.fn(), @@ -6498,7 +6434,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEncryptPreviewSubmission( + await AdminFormController.submitEncryptPreview( mockReq, mockRes, jest.fn(), @@ -6550,7 +6486,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEncryptPreviewSubmission( + await AdminFormController.submitEncryptPreview( mockReq, mockRes, jest.fn(), @@ -6602,7 +6538,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEncryptPreviewSubmission( + await AdminFormController.submitEncryptPreview( mockReq, mockRes, jest.fn(), @@ -6654,7 +6590,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEncryptPreviewSubmission( + await AdminFormController.submitEncryptPreview( mockReq, mockRes, jest.fn(), @@ -6709,7 +6645,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEncryptPreviewSubmission( + await AdminFormController.submitEncryptPreview( mockReq, mockRes, jest.fn(), @@ -6764,7 +6700,7 @@ describe('admin-form.controller', () => { }) const mockRes = expressHandler.mockResponse() - await AdminFormController.handleEncryptPreviewSubmission( + await AdminFormController.submitEncryptPreview( mockReq, mockRes, jest.fn(), diff --git a/src/app/modules/form/admin-form/admin-form.controller.ts b/src/app/modules/form/admin-form/admin-form.controller.ts index 26a74970e8..5b6238dbf7 100644 --- a/src/app/modules/form/admin-form/admin-form.controller.ts +++ b/src/app/modules/form/admin-form/admin-form.controller.ts @@ -40,12 +40,14 @@ import { createCorppassParsedResponses, createSingpassParsedResponses, } from '../../spcp/spcp.util' +import * as EmailSubmissionMiddleware from '../../submission/email-submission/email-submission.middleware' import * as EmailSubmissionService from '../../submission/email-submission/email-submission.service' import { mapAttachmentsFromResponses, mapRouteError as mapEmailSubmissionError, SubmissionEmailObj, } from '../../submission/email-submission/email-submission.util' +import * as EncryptSubmissionMiddleware from '../../submission/encrypt-submission/encrypt-submission.middleware' import * as EncryptSubmissionService from '../../submission/encrypt-submission/encrypt-submission.service' import { mapRouteError as mapEncryptSubmissionError } from '../../submission/encrypt-submission/encrypt-submission.utils' import * as SubmissionService from '../../submission/submission.service' @@ -1243,7 +1245,7 @@ export const handleGetSettings: RequestHandler< * @returns 422 when user ID in session is not found in database * @returns 500 when database error occurs */ -export const handleEncryptPreviewSubmission: RequestHandler< +export const submitEncryptPreview: RequestHandler< { formId: string }, { message: string; submissionId: string } | ErrorDto, EncryptSubmissionDto @@ -1253,7 +1255,7 @@ export const handleEncryptPreviewSubmission: RequestHandler< // No need to process attachments as we don't do anything with them const { encryptedContent, responses, version } = req.body const logMeta = { - action: 'handleEncryptPreviewSubmission', + action: 'submitEncryptPreview', formId, } @@ -1319,6 +1321,11 @@ export const handleEncryptPreviewSubmission: RequestHandler< }) } +export const handleEncryptPreviewSubmission = [ + EncryptSubmissionMiddleware.validateEncryptSubmissionParams, + submitEncryptPreview, +] as RequestHandler[] + /** * Handler for POST /v2/submissions/encrypt/preview/:formId. * @security session @@ -1331,7 +1338,7 @@ export const handleEncryptPreviewSubmission: RequestHandler< * @returns 422 when user ID in session is not found in database * @returns 500 when database error occurs */ -export const handleEmailPreviewSubmission: RequestHandler< +export const submitEmailPreview: RequestHandler< { formId: string }, { message: string; submissionId?: string }, { responses: FieldResponse[]; isPreview: boolean }, @@ -1342,7 +1349,7 @@ export const handleEmailPreviewSubmission: RequestHandler< // No need to process attachments as we don't do anything with them const { responses } = req.body const logMeta = { - action: 'handleEmailPreviewSubmission', + action: 'submitEmailPreview', formId, ...createReqMeta(req as Request), } @@ -1457,6 +1464,12 @@ export const handleEmailPreviewSubmission: RequestHandler< }) } +export const handleEmailPreviewSubmission = [ + EmailSubmissionMiddleware.receiveEmailSubmission, + EmailSubmissionMiddleware.validateResponseParams, + submitEmailPreview, +] as RequestHandler[] + /** * Handler for PUT /forms/:formId/fields/:fieldId * @security session diff --git a/src/app/modules/form/admin-form/admin-form.routes.ts b/src/app/modules/form/admin-form/admin-form.routes.ts index 020d8cd211..c76ffd2571 100644 --- a/src/app/modules/form/admin-form/admin-form.routes.ts +++ b/src/app/modules/form/admin-form/admin-form.routes.ts @@ -9,9 +9,7 @@ import { Router } from 'express' import { VALID_UPLOAD_FILE_TYPES } from '../../../../shared/constants' import { ResponseMode } from '../../../../types' import { withUserAuthentication } from '../../auth/auth.middlewares' -import * as EmailSubmissionMiddleware from '../../submission/email-submission/email-submission.middleware' import * as EncryptSubmissionController from '../../submission/encrypt-submission/encrypt-submission.controller' -import * as EncryptSubmissionMiddleware from '../../submission/encrypt-submission/encrypt-submission.middleware' import * as AdminFormController from './admin-form.controller' import { DuplicateFormBody } from './admin-form.types' @@ -433,7 +431,6 @@ AdminFormsRouter.post( AdminFormsRouter.post( '/v2/submissions/encrypt/preview/:formId([a-fA-F0-9]{24})', withUserAuthentication, - EncryptSubmissionMiddleware.validateEncryptSubmissionParams, AdminFormController.handleEncryptPreviewSubmission, ) @@ -453,7 +450,5 @@ AdminFormsRouter.post( AdminFormsRouter.post( '/v2/submissions/email/preview/:formId([a-fA-F0-9]{24})', withUserAuthentication, - EmailSubmissionMiddleware.receiveEmailSubmission, - EmailSubmissionMiddleware.validateResponseParams, AdminFormController.handleEmailPreviewSubmission, ) diff --git a/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.preview.routes.spec.ts b/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.preview.routes.spec.ts new file mode 100644 index 0000000000..854d8786de --- /dev/null +++ b/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.preview.routes.spec.ts @@ -0,0 +1,1029 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import { ObjectId } from 'bson-ext' +import { omit } from 'lodash' +import mongoose from 'mongoose' +import supertest, { Session } from 'supertest-session' + +import getFormModel, { + getEmailFormModel, + getEncryptedFormModel, +} from 'src/app/models/form.server.model' +import getUserModel from 'src/app/models/user.server.model' +import { + MOCK_ATTACHMENT_FIELD, + MOCK_ATTACHMENT_RESPONSE, + MOCK_CHECKBOX_FIELD, + MOCK_CHECKBOX_RESPONSE, + MOCK_OPTIONAL_VERIFIED_FIELD, + MOCK_OPTIONAL_VERIFIED_RESPONSE, + MOCK_SECTION_FIELD, + MOCK_SECTION_RESPONSE, + MOCK_TEXT_FIELD, + MOCK_TEXTFIELD_RESPONSE, +} from 'src/app/modules/submission/email-submission/__tests__/email-submission.test.constants' +import { + BasicField, + IFieldSchema, + IFormSchema, + IUserSchema, + ResponseMode, + Status, +} from 'src/types' +import { EncryptSubmissionDto } from 'src/types/api' + +import { + createAuthedSession, + logoutSession, +} from 'tests/integration/helpers/express-auth' +import { setupApp } from 'tests/integration/helpers/express-setup' +import { buildCelebrateError } from 'tests/unit/backend/helpers/celebrate' +import { + generateDefaultField, + generateUnprocessedSingleAnswerResponse, +} from 'tests/unit/backend/helpers/generate-form-data' +import dbHandler from 'tests/unit/backend/helpers/jest-db' +import { jsonParseStringify } from 'tests/unit/backend/helpers/serialize-data' + +import { AdminFormsRouter } from '../admin-forms.routes' + +// Prevent rate limiting. +jest.mock('src/app/utils/limit-rate') +jest.mock('nodemailer', () => ({ + createTransport: jest.fn().mockReturnValue({ + sendMail: jest.fn().mockResolvedValue(true), + }), +})) + +const UserModel = getUserModel(mongoose) +const FormModel = getFormModel(mongoose) +const EmailFormModel = getEmailFormModel(mongoose) +const EncryptFormModel = getEncryptedFormModel(mongoose) + +const app = setupApp('/admin/forms', AdminFormsRouter, { + setupWithAuth: true, +}) + +describe('admin-form.preview.routes', () => { + let request: Session + let defaultUser: IUserSchema + + beforeAll(async () => await dbHandler.connect()) + beforeEach(async () => { + request = supertest(app) + const { user } = await dbHandler.insertFormCollectionReqs() + // Default all requests to come from authenticated user. + request = await createAuthedSession(user.email, request) + defaultUser = user + }) + afterEach(async () => { + await dbHandler.clearDatabase() + jest.restoreAllMocks() + }) + afterAll(async () => await dbHandler.closeDatabase()) + + describe('GET /admin/forms/:formId/preview', () => { + it("should return 200 with own form's public form view even when private", async () => { + // Arrange + const formToPreview = await EncryptFormModel.create({ + title: 'some form', + admin: defaultUser._id, + publicKey: 'some random key', + // Private status. + status: Status.Private, + }) + + // Act + const response = await request.get( + `/admin/forms/${formToPreview._id}/preview`, + ) + + // Assert + const expectedForm = ( + await formToPreview + .populate({ + path: 'admin', + populate: { + path: 'agency', + }, + }) + .execPopulate() + ).getPublicView() + expect(response.status).toEqual(200) + expect(response.body).toEqual({ + form: jsonParseStringify(expectedForm), + }) + }) + + it("should return 200 with collaborator's form's public form view", async () => { + // Arrange + const anotherUser = ( + await dbHandler.insertFormCollectionReqs({ + userId: new ObjectId(), + mailName: 'some-user', + shortName: 'someUser', + }) + ).user + const collabFormToPreview = await EmailFormModel.create({ + title: 'some form', + admin: anotherUser._id, + emails: [anotherUser.email], + // Only read permissions. + permissionList: [{ email: defaultUser.email }], + }) + + // Act + const response = await request.get( + `/admin/forms/${collabFormToPreview._id}/preview`, + ) + + // Assert + const expectedForm = ( + await collabFormToPreview + .populate({ + path: 'admin', + populate: { + path: 'agency', + }, + }) + .execPopulate() + ).getPublicView() + expect(response.status).toEqual(200) + expect(response.body).toEqual({ form: jsonParseStringify(expectedForm) }) + }) + it('should return 403 when user does not have read permissions for form', async () => { + // Arrange + const anotherUser = ( + await dbHandler.insertFormCollectionReqs({ + userId: new ObjectId(), + mailName: 'some-user', + shortName: 'someUser', + }) + ).user + const unauthedForm = await EmailFormModel.create({ + title: 'some form', + admin: anotherUser._id, + emails: [anotherUser.email], + // defaultUser does not have read permissions. + }) + + // Act + const response = await request.get( + `/admin/forms/${unauthedForm._id}/preview`, + ) + + // Arrange + expect(response.status).toEqual(403) + expect(response.body).toEqual({ + message: expect.stringContaining( + 'not authorized to perform read operation', + ), + }) + }) + + it('should return 404 when form to preview cannot be found', async () => { + // Act + const response = await request.get( + `/admin/forms/${new ObjectId()}/preview`, + ) + + // Arrange + expect(response.status).toEqual(404) + expect(response.body).toEqual({ + message: 'Form not found', + }) + }) + + it('should return 410 when form is already archived', async () => { + // Arrange + const archivedForm = await EncryptFormModel.create({ + title: 'archived form', + status: Status.Archived, + responseMode: ResponseMode.Encrypt, + publicKey: 'does not matter', + admin: defaultUser._id, + }) + + // Act + const response = await request.get( + `/admin/forms/${archivedForm._id}/preview`, + ) + + // Arrange + expect(response.status).toEqual(410) + expect(response.body).toEqual({ + message: 'Form has been archived', + }) + }) + + it('should return 422 when user in session cannot be found in the database', async () => { + // Arrange + const formToPreview = await EmailFormModel.create({ + title: 'some other form', + admin: defaultUser._id, + status: Status.Public, + emails: [defaultUser.email], + }) + // Delete user after login. + await dbHandler.clearCollection(UserModel.collection.name) + + // Act + const response = await request.get( + `/admin/forms/${formToPreview._id}/preview`, + ) + + // Assert + expect(response.status).toEqual(422) + expect(response.body).toEqual({ + message: 'User not found', + }) + }) + + it('should return 500 when database error occurs whilst retrieving form to preview', async () => { + // Arrange + const formToPreview = await EmailFormModel.create({ + title: 'some other form', + admin: defaultUser._id, + status: Status.Public, + emails: [defaultUser.email], + }) + // Mock database error. + jest + .spyOn(FormModel, 'getFullFormById') + .mockRejectedValueOnce(new Error('something went wrong')) + + // Act + const response = await request.get( + `/admin/forms/${formToPreview._id}/preview`, + ) + + // Assert + expect(response.status).toEqual(500) + expect(response.body).toEqual({ + message: 'Something went wrong. Please try again.', + }) + }) + }) + + describe('POST /admin/forms/:formId/preview/submissions/email', () => { + const SUBMISSIONS_ENDPT_BASE = '/admin/forms' + it('should return 200 when submission is valid', async () => { + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + hasCaptcha: false, + status: Status.Public, + form_fields: [MOCK_TEXT_FIELD], + admin: defaultUser._id, + }, + mailDomain: 'test2.gov.sg', + }) + + const response = await request + .post(`${SUBMISSIONS_ENDPT_BASE}/${form._id}/preview/submissions/email`) + // MOCK_RESPONSE contains all required keys + .field( + 'body', + JSON.stringify({ + isPreview: false, + responses: [MOCK_TEXTFIELD_RESPONSE], + }), + ) + .query({ captchaResponse: 'null' }) + + expect(response.status).toBe(200) + expect(response.body).toEqual({ + message: 'Form submission successful.', + submissionId: expect.any(String), + }) + }) + + it('should return 200 when answer is empty string for optional field', async () => { + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + hasCaptcha: false, + status: Status.Public, + form_fields: [ + { ...MOCK_TEXT_FIELD, required: false } as IFieldSchema, + ], + admin: defaultUser._id, + }, + // Avoid default mail domain so that user emails in the database don't conflict + mailDomain: 'test2.gov.sg', + }) + + const response = await request + .post(`${SUBMISSIONS_ENDPT_BASE}/${form._id}/preview/submissions/email`) + .field( + 'body', + JSON.stringify({ + isPreview: false, + responses: [{ ...MOCK_TEXTFIELD_RESPONSE, answer: '' }], + }), + ) + .query({ captchaResponse: 'null' }) + + expect(response.status).toBe(200) + expect(response.body).toEqual({ + message: 'Form submission successful.', + submissionId: expect.any(String), + }) + }) + + it('should return 200 when attachment response has filename and content', async () => { + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + hasCaptcha: false, + status: Status.Public, + form_fields: [MOCK_ATTACHMENT_FIELD], + admin: defaultUser._id, + }, + // Avoid default mail domain so that user emails in the database don't conflict + mailDomain: 'test2.gov.sg', + }) + + const response = await request + .post(`${SUBMISSIONS_ENDPT_BASE}/${form._id}/preview/submissions/email`) + .field( + 'body', + JSON.stringify({ + isPreview: false, + responses: [ + { + ...MOCK_ATTACHMENT_RESPONSE, + content: MOCK_ATTACHMENT_RESPONSE.content.toString('binary'), + }, + ], + }), + ) + .query({ captchaResponse: 'null' }) + + expect(response.status).toBe(200) + expect(response.body).toEqual({ + message: 'Form submission successful.', + submissionId: expect.any(String), + }) + }) + + it('should return 200 when response has isHeader key', async () => { + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + hasCaptcha: false, + status: Status.Public, + form_fields: [MOCK_SECTION_FIELD], + admin: defaultUser._id, + }, + // Avoid default mail domain so that user emails in the database don't conflict + mailDomain: 'test2.gov.sg', + }) + + const response = await request + .post(`${SUBMISSIONS_ENDPT_BASE}/${form._id}/preview/submissions/email`) + .field( + 'body', + JSON.stringify({ + isPreview: false, + responses: [{ ...MOCK_SECTION_RESPONSE, isHeader: true }], + }), + ) + .query({ captchaResponse: 'null' }) + + expect(response.status).toBe(200) + expect(response.body).toEqual({ + message: 'Form submission successful.', + submissionId: expect.any(String), + }) + }) + + it('should return 200 when signature is empty string for optional verified field', async () => { + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + hasCaptcha: false, + status: Status.Public, + form_fields: [MOCK_OPTIONAL_VERIFIED_FIELD], + admin: defaultUser._id, + }, + // Avoid default mail domain so that user emails in the database don't conflict + mailDomain: 'test2.gov.sg', + }) + + const response = await request + .post(`${SUBMISSIONS_ENDPT_BASE}/${form._id}/preview/submissions/email`) + .field( + 'body', + JSON.stringify({ + isPreview: false, + responses: [{ ...MOCK_OPTIONAL_VERIFIED_RESPONSE, signature: '' }], + }), + ) + .query({ captchaResponse: 'null' }) + + expect(response.status).toBe(200) + expect(response.body).toEqual({ + message: 'Form submission successful.', + submissionId: expect.any(String), + }) + }) + + it('should return 200 when response has answerArray and no answer', async () => { + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + hasCaptcha: false, + status: Status.Public, + form_fields: [MOCK_CHECKBOX_FIELD], + admin: defaultUser._id, + }, + // Avoid default mail domain so that user emails in the database don't conflict + mailDomain: 'test2.gov.sg', + }) + + const response = await request + .post(`${SUBMISSIONS_ENDPT_BASE}/${form._id}/preview/submissions/email`) + .field( + 'body', + JSON.stringify({ + isPreview: false, + responses: [MOCK_CHECKBOX_RESPONSE], + }), + ) + .query({ captchaResponse: 'null' }) + + expect(response.status).toBe(200) + expect(response.body).toEqual({ + message: 'Form submission successful.', + submissionId: expect.any(String), + }) + }) + + it('should return 400 when isPreview key is missing', async () => { + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + hasCaptcha: false, + status: Status.Public, + admin: defaultUser._id, + }, + // Avoid default mail domain so that user emails in the database don't conflict + mailDomain: 'test2.gov.sg', + }) + + const response = await request + .post(`${SUBMISSIONS_ENDPT_BASE}/${form._id}/preview/submissions/email`) + // Note missing isPreview + .field('body', JSON.stringify({ responses: [] })) + .query({ captchaResponse: 'null' }) + + expect(response.status).toBe(400) + expect(response.body.message).toEqual( + 'celebrate request validation failed', + ) + }) + + it('should return 400 when responses key is missing', async () => { + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + hasCaptcha: false, + status: Status.Public, + admin: defaultUser._id, + }, + // Avoid default mail domain so that user emails in the database don't conflict + mailDomain: 'test2.gov.sg', + }) + + const response = await request + .post(`${SUBMISSIONS_ENDPT_BASE}/${form._id}/preview/submissions/email`) + // Note missing responses + .field('body', JSON.stringify({ isPreview: false })) + .query({ captchaResponse: 'null' }) + + expect(response.status).toBe(400) + expect(response.body.message).toEqual( + 'celebrate request validation failed', + ) + }) + + it('should return 400 when response is missing _id', async () => { + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + hasCaptcha: false, + status: Status.Public, + admin: defaultUser._id, + }, + // Avoid default mail domain so that user emails in the database don't conflict + mailDomain: 'test2.gov.sg', + }) + + const response = await request + .post(`${SUBMISSIONS_ENDPT_BASE}/${form._id}/preview/submissions/email`) + .field( + 'body', + JSON.stringify({ + isPreview: false, + responses: [omit(MOCK_TEXTFIELD_RESPONSE, '_id')], + }), + ) + .query({ captchaResponse: 'null' }) + + expect(response.status).toBe(400) + expect(response.body.message).toEqual( + 'celebrate request validation failed', + ) + }) + + it('should return 400 when response is missing fieldType', async () => { + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + hasCaptcha: false, + status: Status.Public, + admin: defaultUser._id, + }, + // Avoid default mail domain so that user emails in the database don't conflict + mailDomain: 'test2.gov.sg', + }) + + const response = await request + .post(`${SUBMISSIONS_ENDPT_BASE}/${form._id}/preview/submissions/email`) + .field( + 'body', + JSON.stringify({ + isPreview: false, + responses: [omit(MOCK_TEXTFIELD_RESPONSE, 'fieldType')], + }), + ) + .query({ captchaResponse: 'null' }) + + expect(response.status).toBe(400) + expect(response.body.message).toEqual( + 'celebrate request validation failed', + ) + }) + + it('should return 400 when response has invalid fieldType', async () => { + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + hasCaptcha: false, + status: Status.Public, + admin: defaultUser._id, + }, + // Avoid default mail domain so that user emails in the database don't conflict + mailDomain: 'test2.gov.sg', + }) + + const response = await request + .post(`${SUBMISSIONS_ENDPT_BASE}/${form._id}/preview/submissions/email`) + .field( + 'body', + JSON.stringify({ + isPreview: false, + responses: [ + { ...MOCK_TEXTFIELD_RESPONSE, fieldType: 'definitelyInvalid' }, + ], + }), + ) + .query({ captchaResponse: 'null' }) + + expect(response.status).toBe(400) + expect(response.body.message).toEqual( + 'celebrate request validation failed', + ) + }) + + it('should return 400 when response is missing answer', async () => { + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + hasCaptcha: false, + status: Status.Public, + admin: defaultUser._id, + }, + // Avoid default mail domain so that user emails in the database don't conflict + mailDomain: 'test2.gov.sg', + }) + + const response = await request + .post(`${SUBMISSIONS_ENDPT_BASE}/${form._id}/preview/submissions/email`) + .field( + 'body', + JSON.stringify({ + isPreview: false, + responses: [omit(MOCK_TEXTFIELD_RESPONSE, 'answer')], + }), + ) + .query({ captchaResponse: 'null' }) + + expect(response.status).toBe(400) + expect(response.body.message).toEqual( + 'celebrate request validation failed', + ) + }) + + it('should return 400 when response has both answer and answerArray', async () => { + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + hasCaptcha: false, + status: Status.Public, + admin: defaultUser._id, + }, + // Avoid default mail domain so that user emails in the database don't conflict + mailDomain: 'test2.gov.sg', + }) + + const response = await request + .post(`${SUBMISSIONS_ENDPT_BASE}/${form._id}/preview/submissions/email`) + .field( + 'body', + JSON.stringify({ + isPreview: false, + responses: [{ ...MOCK_TEXTFIELD_RESPONSE, answerArray: [] }], + }), + ) + .query({ captchaResponse: 'null' }) + + expect(response.status).toBe(400) + expect(response.body.message).toEqual( + 'celebrate request validation failed', + ) + }) + + it('should return 400 when attachment response has filename but not content', async () => { + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + hasCaptcha: false, + status: Status.Public, + admin: defaultUser._id, + }, + // Avoid default mail domain so that user emails in the database don't conflict + mailDomain: 'test2.gov.sg', + }) + + const response = await request + .post(`${SUBMISSIONS_ENDPT_BASE}/${form._id}/preview/submissions/email`) + .field( + 'body', + JSON.stringify({ + isPreview: false, + responses: [omit(MOCK_ATTACHMENT_RESPONSE), 'content'], + }), + ) + .query({ captchaResponse: 'null' }) + + expect(response.status).toBe(400) + expect(response.body.message).toEqual( + 'celebrate request validation failed', + ) + }) + + it('should return 400 when attachment response has content but not filename', async () => { + const { form } = await dbHandler.insertEmailForm({ + formOptions: { + hasCaptcha: false, + status: Status.Public, + admin: defaultUser._id, + }, + // Avoid default mail domain so that user emails in the database don't conflict + mailDomain: 'test2.gov.sg', + }) + + const response = await request + .post(`${SUBMISSIONS_ENDPT_BASE}/${form._id}/preview/submissions/email`) + .field( + 'body', + JSON.stringify({ + isPreview: false, + responses: [omit(MOCK_ATTACHMENT_RESPONSE), 'filename'], + }), + ) + .query({ captchaResponse: 'null' }) + + expect(response.status).toBe(400) + expect(response.body.message).toEqual( + 'celebrate request validation failed', + ) + }) + }) + + describe('POST /admin/forms/:formId/preview/submissions/encrypt', () => { + const MOCK_FIELD_ID = new ObjectId().toHexString() + const MOCK_ATTACHMENT_FIELD_ID = new ObjectId().toHexString() + const MOCK_RESPONSE = omit( + generateUnprocessedSingleAnswerResponse(BasicField.Email, { + _id: MOCK_FIELD_ID, + answer: 'a@abc.com', + }), + 'question', + ) + const MOCK_ENCRYPTED_CONTENT = `${'a'.repeat(44)};${'a'.repeat( + 32, + )}:${'a'.repeat(4)}` + const MOCK_VERSION = 1 + const MOCK_SUBMISSION_BODY: EncryptSubmissionDto = { + responses: [MOCK_RESPONSE], + encryptedContent: MOCK_ENCRYPTED_CONTENT, + version: MOCK_VERSION, + isPreview: false, + attachments: { + [MOCK_ATTACHMENT_FIELD_ID]: { + encryptedFile: { + binary: '10101', + nonce: 'mockNonce', + submissionPublicKey: 'mockPublicKey', + }, + }, + }, + } + let mockForm: IFormSchema + + beforeEach(async () => { + mockForm = await EncryptFormModel.create({ + title: 'mock form', + publicKey: 'some public key', + admin: defaultUser._id, + form_fields: [ + generateDefaultField(BasicField.Email, { _id: MOCK_FIELD_ID }), + ], + }) + }) + + it('should return 200 with submission ID when request is valid', async () => { + const response = await request + .post(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .send(MOCK_SUBMISSION_BODY) + + expect(response.body.message).toBe('Form submission successful.') + expect(mongoose.isValidObjectId(response.body.submissionId)).toBe(true) + expect(response.status).toBe(200) + }) + + it('should return 401 when user is not signed in', async () => { + await logoutSession(request) + + const response = await request + .post(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .send(MOCK_SUBMISSION_BODY) + + expect(response.status).toBe(401) + expect(response.body).toEqual({ message: 'User is unauthorized.' }) + }) + + it('should return 400 when responses are not provided in body', async () => { + const response = await request + .post(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .send(omit(MOCK_SUBMISSION_BODY, 'responses')) + + expect(response.status).toBe(400) + expect(response.body).toEqual( + buildCelebrateError({ body: { key: 'responses' } }), + ) + }) + + it('should return 400 when responses are missing _id field', async () => { + const response = await request + .post(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .send({ + ...MOCK_SUBMISSION_BODY, + responses: [omit(MOCK_RESPONSE, '_id')], + }) + + expect(response.status).toBe(400) + expect(response.body).toEqual( + buildCelebrateError({ + body: { + key: 'responses.0._id', + message: '"responses[0]._id" is required', + }, + }), + ) + }) + + it('should return 400 when responses are missing answer field', async () => { + const response = await request + .post(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .send({ + ...MOCK_SUBMISSION_BODY, + responses: [omit(MOCK_RESPONSE, 'answer')], + }) + + expect(response.status).toBe(400) + expect(response.body).toEqual( + buildCelebrateError({ + body: { + key: 'responses.0.answer', + message: '"responses[0].answer" is required', + }, + }), + ) + }) + + it('should return 400 when responses are missing fieldType', async () => { + const response = await request + .post(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .send({ + ...MOCK_SUBMISSION_BODY, + responses: [omit(MOCK_RESPONSE, 'fieldType')], + }) + + expect(response.status).toBe(400) + expect(response.body).toEqual( + buildCelebrateError({ + body: { + key: 'responses.0.fieldType', + message: '"responses[0].fieldType" is required', + }, + }), + ) + }) + + it('should return 400 when a fieldType is malformed', async () => { + const response = await request + .post(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .send({ + ...MOCK_SUBMISSION_BODY, + responses: [{ ...MOCK_RESPONSE, fieldType: 'malformed' }], + }) + + expect(response.status).toBe(400) + expect(response.body).toEqual( + buildCelebrateError({ + body: { + key: 'responses.0.fieldType', + message: expect.stringContaining( + '"responses[0].fieldType" must be one of ', + ), + }, + }), + ) + }) + + it('should return 400 when encryptedContent is not provided in body', async () => { + const response = await request + .post(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .send(omit(MOCK_SUBMISSION_BODY, 'encryptedContent')) + + expect(response.status).toBe(400) + expect(response.body).toEqual( + buildCelebrateError({ + body: { + key: 'encryptedContent', + }, + }), + ) + }) + + it('should return 400 when version is not provided in body', async () => { + const response = await request + .post(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .send(omit(MOCK_SUBMISSION_BODY, 'version')) + + expect(response.status).toBe(400) + expect(response.body).toEqual( + buildCelebrateError({ + body: { + key: 'version', + }, + }), + ) + }) + + it('should return 400 when encryptedContent is malformed', async () => { + const response = await request + .post(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .send({ ...MOCK_SUBMISSION_BODY, encryptedContent: 'abc' }) + + expect(response.status).toBe(400) + expect(response.body).toEqual( + buildCelebrateError({ + body: { + key: 'encryptedContent', + message: 'Invalid encryptedContent.', + }, + }), + ) + }) + + it('should return 400 when attachment field ID is malformed', async () => { + const invalidKey = 'invalidFieldId' + const response = await request + .post(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .send({ + ...MOCK_SUBMISSION_BODY, + attachments: { + [invalidKey]: { + encryptedFile: { + binary: '10101', + nonce: 'mockNonce', + submissionPublicKey: 'mockPublicKey', + }, + }, + }, + }) + + expect(response.status).toBe(400) + expect(response.body).toEqual( + buildCelebrateError({ + body: { + key: `attachments.${invalidKey}`, + message: `"attachments.${invalidKey}" is not allowed`, + }, + }), + ) + }) + + it('should return 400 when attachment is missing encryptedFile key', async () => { + const response = await request + .post(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .send({ + ...MOCK_SUBMISSION_BODY, + attachments: { + [MOCK_ATTACHMENT_FIELD_ID]: {}, + }, + }) + + expect(response.status).toBe(400) + expect(response.body).toEqual( + buildCelebrateError({ + body: { + key: `attachments.${MOCK_ATTACHMENT_FIELD_ID}.encryptedFile`, + message: `"attachments.${MOCK_ATTACHMENT_FIELD_ID}.encryptedFile" is required`, + }, + }), + ) + }) + + it('should return 400 when attachment is missing binary', async () => { + const response = await request + .post(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .send({ + ...MOCK_SUBMISSION_BODY, + attachments: { + [MOCK_ATTACHMENT_FIELD_ID]: { + encryptedFile: { + // binary is missing + nonce: 'mockNonce', + submissionPublicKey: 'mockPublicKey', + }, + }, + }, + }) + + expect(response.status).toBe(400) + expect(response.body).toEqual( + buildCelebrateError({ + body: { + key: `attachments.${MOCK_ATTACHMENT_FIELD_ID}.encryptedFile.binary`, + message: `"attachments.${MOCK_ATTACHMENT_FIELD_ID}.encryptedFile.binary" is required`, + }, + }), + ) + }) + + it('should return 400 when attachment is missing nonce', async () => { + const response = await request + .post(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .send({ + ...MOCK_SUBMISSION_BODY, + attachments: { + [MOCK_ATTACHMENT_FIELD_ID]: { + encryptedFile: { + binary: '10101', + // nonce is missing + submissionPublicKey: 'mockPublicKey', + }, + }, + }, + }) + + expect(response.status).toBe(400) + expect(response.body).toEqual( + buildCelebrateError({ + body: { + key: `attachments.${MOCK_ATTACHMENT_FIELD_ID}.encryptedFile.nonce`, + message: `"attachments.${MOCK_ATTACHMENT_FIELD_ID}.encryptedFile.nonce" is required`, + }, + }), + ) + }) + + it('should return 400 when attachment is missing public key', async () => { + const response = await request + .post(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .send({ + ...MOCK_SUBMISSION_BODY, + attachments: { + [MOCK_ATTACHMENT_FIELD_ID]: { + encryptedFile: { + binary: '10101', + nonce: 'mockNonce', + // missing public key + }, + }, + }, + }) + + expect(response.status).toBe(400) + expect(response.body).toEqual( + buildCelebrateError({ + body: { + key: `attachments.${MOCK_ATTACHMENT_FIELD_ID}.encryptedFile.submissionPublicKey`, + message: `"attachments.${MOCK_ATTACHMENT_FIELD_ID}.encryptedFile.submissionPublicKey" is required`, + }, + }), + ) + }) + }) +}) diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.preview.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.preview.routes.ts new file mode 100644 index 0000000000..7d873426dd --- /dev/null +++ b/src/app/routes/api/v3/admin/forms/admin-forms.preview.routes.ts @@ -0,0 +1,59 @@ +import { Router } from 'express' + +import * as AdminFormController from '../../../../../modules/form/admin-form/admin-form.controller' + +export const AdminFormsPreviewRouter = Router() + +/** + * Return the preview form to the user. + * Allows for both public and private forms, only for users with at least read permission. + * @route GET api/v3/admin/forms/:formId/preview + * @security session + * + * @returns 200 with target form's public view + * @returns 403 when user does not have permissions to access form + * @returns 404 when form cannot be found + * @returns 410 when form is archived + * @returns 422 when user in session cannot be retrieved from the database + * @returns 500 when database error occurs + */ +AdminFormsPreviewRouter.get( + '/:formId([a-fA-F0-9]{24})/preview', + AdminFormController.handlePreviewAdminForm, +) + +/** + * Submit an email mode form in preview mode + * @route POST api/v3/admin/forms/:formId([a-fA-F0-9]{24})/preview/submissions/email + * @security session + * + * @returns 200 if submission was valid + * @returns 400 when error occurs while processing submission or submission is invalid + * @returns 403 when user does not have read permissions for form + * @returns 404 when form cannot be found + * @returns 410 when form is archived + * @returns 422 when user in session cannot be retrieved from the database + * @returns 500 when database error occurs + */ +AdminFormsPreviewRouter.post( + '/:formId([a-fA-F0-9]{24})/preview/submissions/email', + AdminFormController.handleEmailPreviewSubmission, +) + +/** + * Submit an encrypt mode form in preview mode + * @route POST api/v3/admin/forms/:formId([a-fA-F0-9]{24})/preview/submissions/encrypt + * @security session + * + * @returns 200 if submission was valid + * @returns 400 when error occurs while processing submission or submission is invalid + * @returns 403 when user does not have read permissions for form + * @returns 404 when form cannot be found + * @returns 410 when form is archived + * @returns 422 when user in session cannot be retrieved from the database + * @returns 500 when database error occurs + */ +AdminFormsPreviewRouter.post( + '/:formId([a-fA-F0-9]{24})/preview/submissions/encrypt', + AdminFormController.handleEncryptPreviewSubmission, +) diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts index 426ba12405..fb4287704d 100644 --- a/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts +++ b/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts @@ -4,6 +4,7 @@ import { withUserAuthentication } from '../../../../../modules/auth/auth.middlew import { AdminFormsFeedbackRouter } from './admin-forms.feedback.routes' import { AdminFormsFormRouter } from './admin-forms.form.routes' +import { AdminFormsPreviewRouter } from './admin-forms.preview.routes' import { AdminFormsSettingsRouter } from './admin-forms.settings.routes' import { AdminFormsSubmissionsRouter } from './admin-forms.submissions.routes' @@ -16,4 +17,4 @@ AdminFormsRouter.use(AdminFormsSettingsRouter) AdminFormsRouter.use(AdminFormsFeedbackRouter) AdminFormsRouter.use(AdminFormsFormRouter) AdminFormsRouter.use(AdminFormsSubmissionsRouter) -AdminFormsRouter.use(AdminFormsFormRouter) +AdminFormsRouter.use(AdminFormsPreviewRouter) diff --git a/src/public/modules/forms/services/form-api.client.factory.js b/src/public/modules/forms/services/form-api.client.factory.js index 784d12da49..c7e223b54b 100644 --- a/src/public/modules/forms/services/form-api.client.factory.js +++ b/src/public/modules/forms/services/form-api.client.factory.js @@ -134,7 +134,7 @@ function FormApi($resource, FormErrorService, FormFields) { }, // Used for previewing the form from the form admin page. Must be a viewer, collaborator or admin. preview: { - url: resourceUrl + '/preview', + url: '/api/v3/admin/forms/:formId/preview', method: 'GET', interceptor: getInterceptor(true, 'previewForm'), }, diff --git a/src/public/modules/forms/services/submissions.client.factory.js b/src/public/modules/forms/services/submissions.client.factory.js index 2afe413bbf..8491e80dbc 100644 --- a/src/public/modules/forms/services/submissions.client.factory.js +++ b/src/public/modules/forms/services/submissions.client.factory.js @@ -42,10 +42,10 @@ function SubmissionsFactory( responseModeEnum, FormSgSdk, ) { + const previewSubmitUrl = + '/api/v3/admin/forms/:formId/preview/submissions/:responseMode' const publicSubmitUrl = '/api/v3/forms/:formId/submissions/:responseMode' - const previewSubmitUrl = '/v2/submissions/:responseMode/preview/:formId' - const ADMIN_FORMS_PREFIX = '/api/v3/admin/forms' const generateDownloadUrl = (params, downloadAttachments) => { From ea97e784acf23b4b9dba630d9a44c0445a106bec Mon Sep 17 00:00:00 2001 From: tshuli <63710093+tshuli@users.noreply.github.com> Date: Wed, 21 Apr 2021 15:55:00 +0800 Subject: [PATCH 14/51] refactor: Extract delete logic endpoint (#1586) * refactor: v3 delete logic api * test: integration tests * chore: use general form model * refactor: use mongoose $pull operator instead of manual filtering * chore: return transformMongoError * refactor: use ObjectId() * chore: add unit tests * refactor: default message for LogicNotFoundError * refactor: shift logic to separate router * chore: add test case for logicId cannot be found * chore: reset mock forms in beforeEach * refactor: FormModel * refactor: shift tests to admin-forms.logic.routes.spec * refactor: simplify return of deleteFormLogic * chore: frontend changes for delete logic api * chore: simplify * refactor: use model static method * chore: use $q to splice only upon success * refactor: pass in formId string instead of form object * test: add unit tests for static method * chore: return form instead of true * nit: typo Co-authored-by: Antariksh Mahajan * chore: add details to error msg * chore: add test for multiple logic case * build: merge conflict * chore: not send message on logic delete * chore: add return true for axios * chore: splice upon promise success * chore: cast form._id to string * chore: update tests Co-authored-by: Antariksh Mahajan --- .../__tests__/form.server.model.spec.ts | 100 ++++++++- src/app/models/form.server.model.ts | 18 ++ .../__tests__/admin-form.controller.spec.ts | 202 +++++++++++++++++- .../__tests__/admin-form.service.spec.ts | 113 +++++++++- .../form/admin-form/admin-form.controller.ts | 48 +++++ .../form/admin-form/admin-form.service.ts | 55 ++++- .../form/admin-form/admin-form.utils.ts | 6 + src/app/modules/form/form.errors.ts | 9 + .../admin-forms.logic.routes.spec.ts | 199 +++++++++++++++++ .../admin/forms/admin-forms.logic.routes.ts | 21 ++ .../api/v3/admin/forms/admin-forms.routes.ts | 19 ++ .../components/edit-logic.client.component.js | 22 +- src/public/services/AdminFormService.ts | 9 + src/types/form.ts | 1 + 14 files changed, 814 insertions(+), 8 deletions(-) create mode 100644 src/app/routes/api/v3/admin/forms/__tests__/admin-forms.logic.routes.spec.ts create mode 100644 src/app/routes/api/v3/admin/forms/admin-forms.logic.routes.ts diff --git a/src/app/models/__tests__/form.server.model.spec.ts b/src/app/models/__tests__/form.server.model.spec.ts index 36665d40a2..67d7650ea3 100644 --- a/src/app/models/__tests__/form.server.model.spec.ts +++ b/src/app/models/__tests__/form.server.model.spec.ts @@ -14,6 +14,7 @@ import { IEncryptedForm, IFieldSchema, IFormSchema, + ILogicSchema, IPopulatedUser, Permission, ResponseMode, @@ -384,7 +385,7 @@ describe('Form Model', () => { expect(actualSavedObject).toEqual(expectedObject) // Remove indeterministic id from actual permission list - const actualPermissionList = (saved.toObject() as IEncryptedForm).permissionList?.map( + const actualPermissionList = ((saved.toObject() as unknown) as IEncryptedForm).permissionList?.map( (permission) => omit(permission, '_id'), ) expect(actualPermissionList).toEqual(permissionList) @@ -1053,6 +1054,103 @@ describe('Form Model', () => { expect(actual).toEqual(expected) }) }) + + describe('deleteFormLogic', () => { + const logicId = new ObjectId().toHexString() + const mockFormLogic = { + form_logics: [ + { + _id: logicId, + id: logicId, + } as ILogicSchema, + ], + } + + it('should return form upon successful delete', async () => { + // arrange + const formParams = merge({}, MOCK_EMAIL_FORM_PARAMS, { + admin: populatedAdmin, + status: Status.Public, + responseMode: ResponseMode.Email, + ...mockFormLogic, + }) + const form = await Form.create(formParams) + + // act + const modifiedForm = await Form.deleteFormLogic(form._id, logicId) + + // assert + // Form should be returned + expect(modifiedForm).not.toBeNull() + + // Form should have correct status, responsemode + expect(modifiedForm?.responseMode).not.toBeNull() + expect(modifiedForm?.responseMode).toEqual(ResponseMode.Email) + expect(modifiedForm?.status).not.toBeNull() + expect(modifiedForm?.status).toEqual(Status.Public) + + // Check that form logic has been deleted + expect(modifiedForm?.form_logics).toBeEmpty() + }) + + it('should return form with remaining logic upon successful delete of one logic', async () => { + // arrange + + const logicId2 = new ObjectId().toHexString() + const mockFormLogicMultiple = { + form_logics: [ + { + _id: logicId, + id: logicId, + } as ILogicSchema, + { + _id: logicId2, + id: logicId2, + } as ILogicSchema, + ], + } + + const formParams = merge({}, MOCK_EMAIL_FORM_PARAMS, { + admin: populatedAdmin, + status: Status.Public, + responseMode: ResponseMode.Email, + ...mockFormLogicMultiple, + }) + const form = await Form.create(formParams) + + // act + const modifiedForm = await Form.deleteFormLogic(form._id, logicId) + + // assert + // Form should be returned + expect(modifiedForm).not.toBeNull() + + // Form should have correct status, responsemode + expect(modifiedForm?.responseMode).not.toBeNull() + expect(modifiedForm?.responseMode).toEqual(ResponseMode.Email) + expect(modifiedForm?.status).not.toBeNull() + expect(modifiedForm?.status).toEqual(Status.Public) + + // Check that correct form logic has been deleted + expect(modifiedForm?.form_logics).toBeDefined() + expect(modifiedForm?.form_logics).toHaveLength(1) + const logic = modifiedForm?.form_logics || ['some logic'] + expect((logic[0] as any)['_id'].toString()).toEqual(logicId2) + }) + + it('should return null if formId is invalid', async () => { + // arrange + + const invalidFormId = new ObjectId().toHexString() + + // act + const modifiedForm = await Form.deleteFormLogic(invalidFormId, logicId) + + // assert + // should return null + expect(modifiedForm).toBeNull() + }) + }) }) describe('Methods', () => { diff --git a/src/app/models/form.server.model.ts b/src/app/models/form.server.model.ts index 1f90eb7d06..a88bb0d1c5 100644 --- a/src/app/models/form.server.model.ts +++ b/src/app/models/form.server.model.ts @@ -613,6 +613,24 @@ const compileFormModel = (db: Mongoose): IFormModel => { ) } + // Deletes specified form logic. + FormSchema.statics.deleteFormLogic = async function ( + this: IFormModel, + formId: string, + logicId: string, + ): Promise { + return this.findByIdAndUpdate( + mongoose.Types.ObjectId(formId), + { + $pull: { form_logics: { _id: logicId } }, + }, + { + new: true, + runValidators: true, + }, + ).exec() + } + // Hooks FormSchema.pre('validate', function (next) { // Reject save if form document is too large diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts index ec8fdc05a2..634674ff12 100644 --- a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts +++ b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts @@ -49,6 +49,7 @@ import { IFieldSchema, IForm, IFormSchema, + ILogicSchema, IPopulatedEmailForm, IPopulatedEncryptedForm, IPopulatedForm, @@ -76,6 +77,7 @@ import { ForbiddenFormError, FormDeletedError, FormNotFoundError, + LogicNotFoundError, PrivateFormError, TransferOwnershipError, } from '../../form.errors' @@ -6777,6 +6779,7 @@ describe('admin-form.controller', () => { MockAuthService.getFormAfterPermissionChecks.mockReturnValue( okAsync(MOCK_FORM), ) + MockAdminFormService.updateFormField.mockReturnValue( okAsync(MOCK_UPDATED_FIELD as IFieldSchema), ) @@ -7017,6 +7020,7 @@ describe('admin-form.controller', () => { _id: MOCK_USER_ID, email: 'somerandom@example.com', } as IPopulatedUser + const MOCK_FORM = { admin: MOCK_USER, _id: MOCK_FORM_ID, @@ -7039,7 +7043,6 @@ describe('admin-form.controller', () => { }, body: MOCK_CREATE_FIELD_BODY, }) - beforeEach(() => { MockUserService.getPopulatedUserById.mockReturnValue(okAsync(MOCK_USER)) MockAuthService.getFormAfterPermissionChecks.mockReturnValue( @@ -7296,4 +7299,201 @@ describe('admin-form.controller', () => { ) }) }) + + describe('handleDeleteLogic', () => { + const MOCK_USER_ID = new ObjectId().toHexString() + const MOCK_FORM_ID = new ObjectId().toHexString() + const MOCK_USER = { + _id: MOCK_USER_ID, + email: 'somerandom@example.com', + } as IPopulatedUser + + const logicId = new ObjectId().toHexString() + const mockFormLogic = { + form_logics: [ + { + _id: logicId, + id: logicId, + } as ILogicSchema, + ], + } + + const MOCK_FORM = { + admin: MOCK_USER, + _id: MOCK_FORM_ID, + title: 'mock title', + ...mockFormLogic, + } as IPopulatedForm + + const mockReq = expressHandler.mockRequest({ + params: { + formId: MOCK_FORM_ID, + logicId, + }, + session: { + user: { + _id: MOCK_USER_ID, + }, + }, + }) + const mockRes = expressHandler.mockResponse() + beforeEach(() => { + MockUserService.getPopulatedUserById.mockReturnValue(okAsync(MOCK_USER)) + MockAuthService.getFormAfterPermissionChecks.mockReturnValue( + okAsync(MOCK_FORM), + ) + MockAdminFormService.deleteFormLogic.mockReturnValue(okAsync(true)) + }) + + it('should call all services correctly when request is valid', async () => { + await AdminFormController.handleDeleteLogic(mockReq, mockRes, jest.fn()) + + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith( + { + user: MOCK_USER, + formId: MOCK_FORM_ID, + level: PermissionLevel.Write, + }, + ) + expect(MockAdminFormService.deleteFormLogic).toHaveBeenCalledWith( + MOCK_FORM, + logicId, + ) + + expect(mockRes.sendStatus).toHaveBeenCalledWith(200) + }) + + it('should return 403 when user does not have permissions to delete logic', async () => { + MockAuthService.getFormAfterPermissionChecks.mockReturnValue( + errAsync( + new ForbiddenFormError('not authorized to perform write operation'), + ), + ) + + await AdminFormController.handleDeleteLogic(mockReq, mockRes, jest.fn()) + + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith( + { + user: MOCK_USER, + formId: MOCK_FORM_ID, + level: PermissionLevel.Write, + }, + ) + expect(MockAdminFormService.deleteFormLogic).not.toHaveBeenCalled() + + expect(mockRes.status).toHaveBeenCalledWith(403) + + expect(mockRes.json).toHaveBeenCalledWith({ + message: 'not authorized to perform write operation', + }) + }) + + it('should return 404 when logicId cannot be found', async () => { + MockAdminFormService.deleteFormLogic.mockReturnValue( + errAsync(new LogicNotFoundError()), + ) + + await AdminFormController.handleDeleteLogic(mockReq, mockRes, jest.fn()) + + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith( + { + user: MOCK_USER, + formId: MOCK_FORM_ID, + level: PermissionLevel.Write, + }, + ) + + expect(MockAdminFormService.deleteFormLogic).toHaveBeenCalledWith( + MOCK_FORM, + logicId, + ) + + expect(mockRes.status).toHaveBeenCalledWith(404) + + expect(mockRes.json).toHaveBeenCalledWith({ + message: 'logicId does not exist on form', + }) + }) + + it('should return 404 when form cannot be found', async () => { + MockAuthService.getFormAfterPermissionChecks.mockReturnValue( + errAsync(new FormNotFoundError()), + ) + + await AdminFormController.handleDeleteLogic(mockReq, mockRes, jest.fn()) + + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith( + { + user: MOCK_USER, + formId: MOCK_FORM_ID, + level: PermissionLevel.Write, + }, + ) + expect(MockAdminFormService.deleteFormLogic).not.toHaveBeenCalled() + + expect(mockRes.status).toHaveBeenCalledWith(404) + + expect(mockRes.json).toHaveBeenCalledWith({ + message: 'Form not found', + }) + }) + + it('should return 422 when user in session cannot be retrieved from the database', async () => { + MockUserService.getPopulatedUserById.mockReturnValue( + errAsync(new MissingUserError()), + ) + + await AdminFormController.handleDeleteLogic(mockReq, mockRes, jest.fn()) + + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect( + MockAuthService.getFormAfterPermissionChecks, + ).not.toHaveBeenCalled() + + expect(MockAdminFormService.deleteFormLogic).not.toHaveBeenCalled() + + expect(mockRes.status).toHaveBeenCalledWith(422) + + expect(mockRes.json).toHaveBeenCalledWith({ + message: 'User not found', + }) + }) + + it('should return 500 when database error occurs', async () => { + MockUserService.getPopulatedUserById.mockReturnValue( + errAsync(new DatabaseError()), + ) + + await AdminFormController.handleDeleteLogic(mockReq, mockRes, jest.fn()) + + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect( + MockAuthService.getFormAfterPermissionChecks, + ).not.toHaveBeenCalled() + + expect(MockAdminFormService.deleteFormLogic).not.toHaveBeenCalled() + + expect(mockRes.status).toHaveBeenCalledWith(500) + + expect(mockRes.json).toHaveBeenCalledWith({ + message: 'Something went wrong. Please try again.', + }) + }) + }) }) diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts index 2c2cd1bc4d..f78b8aeb8e 100644 --- a/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts +++ b/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts @@ -31,6 +31,7 @@ import { IEmailFormSchema, IFormDocument, IFormSchema, + ILogicSchema, IPopulatedForm, IPopulatedUser, IUserSchema, @@ -46,7 +47,7 @@ import { import { generateDefaultField } from 'tests/unit/backend/helpers/generate-form-data' -import { TransferOwnershipError } from '../../form.errors' +import { LogicNotFoundError, TransferOwnershipError } from '../../form.errors' import { CreatePresignedUrlError, EditFieldError, @@ -59,6 +60,7 @@ import { createFormField, createPresignedPostUrlForImages, createPresignedPostUrlForLogos, + deleteFormLogic, duplicateForm, editFormFields, getDashboardForms, @@ -1344,4 +1346,113 @@ describe('admin-form.service', () => { expect(actual._unsafeUnwrapErr()).toBeInstanceOf(DatabaseValidationError) }) }) + describe('deleteFormLogic', () => { + const logicId = new ObjectId().toHexString() + const mockFormLogic = { + form_logics: [ + { + _id: logicId, + id: logicId, + } as ILogicSchema, + ], + } + + const DELETE_SPY = jest.spyOn(FormModel, 'deleteFormLogic') + + let mockEmailForm: IPopulatedForm, mockEncryptForm: IPopulatedForm + + beforeEach(() => { + mockEmailForm = ({ + _id: new ObjectId(), + status: Status.Public, + responseMode: ResponseMode.Email, + ...mockFormLogic, + } as unknown) as IPopulatedForm + mockEncryptForm = ({ + _id: new ObjectId(), + status: Status.Public, + responseMode: ResponseMode.Encrypt, + ...mockFormLogic, + } as unknown) as IPopulatedForm + }) + + it('should return ok(form) on successful form logic delete for email mode form', async () => { + // Arrange + const UPDATE_SPY = jest + .spyOn(FormModel, 'findByIdAndUpdate') + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + .mockReturnValue({ + exec: jest.fn().mockResolvedValue(mockEmailForm), + }) + + // Act + const actualResult = await deleteFormLogic(mockEmailForm, logicId) + + // Assert + expect(actualResult.isOk()).toEqual(true) + expect(actualResult._unsafeUnwrap()).toEqual(mockEmailForm) + + expect(UPDATE_SPY).toHaveBeenCalledWith( + mockEmailForm._id, + { + $pull: { form_logics: { _id: logicId } }, + }, + { + new: true, + runValidators: true, + }, + ) + + expect(DELETE_SPY).toHaveBeenCalledWith( + String(mockEmailForm._id), + logicId, + ) + }) + + it('should return ok(form) on successful form logic delete for encrypt mode form', async () => { + // Arrange + const UPDATE_SPY = jest + .spyOn(FormModel, 'findByIdAndUpdate') + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + .mockReturnValue({ + exec: jest.fn().mockResolvedValue(mockEncryptForm), + }) + + // Act + const actualResult = await deleteFormLogic(mockEncryptForm, logicId) + + // Assert + expect(actualResult.isOk()).toEqual(true) + expect(actualResult._unsafeUnwrap()).toEqual(mockEncryptForm) + + expect(UPDATE_SPY).toHaveBeenCalledWith( + mockEncryptForm._id, + { + $pull: { form_logics: { _id: logicId } }, + }, + { + new: true, + runValidators: true, + }, + ) + + expect(DELETE_SPY).toHaveBeenCalledWith( + String(mockEncryptForm._id), + logicId, + ) + }) + + it('should return LogicNotFoundError if logic does not exist on form', async () => { + // Act + const wrongLogicId = new ObjectId().toHexString() + const actualResult = await deleteFormLogic(mockEmailForm, wrongLogicId) + + // Assert + expect(actualResult.isErr()).toEqual(true) + expect(actualResult._unsafeUnwrapErr()).toEqual(new LogicNotFoundError()) + expect(DELETE_SPY).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/app/modules/form/admin-form/admin-form.controller.ts b/src/app/modules/form/admin-form/admin-form.controller.ts index 5b6238dbf7..c8d41b87c0 100644 --- a/src/app/modules/form/admin-form/admin-form.controller.ts +++ b/src/app/modules/form/admin-form/admin-form.controller.ts @@ -1565,6 +1565,54 @@ export const _handleCreateFormField: RequestHandler< }) ) } +/* + * Handler for DELETE /forms/:formId/logic/:logicId + * @security session + * + * @returns 200 with success message when successfully deleted + * @returns 403 when user does not have permissions to delete logic + * @returns 404 when form cannot be found + * @returns 422 when user in session cannot be retrieved from the database + * @returns 500 when database error occurs + */ +export const handleDeleteLogic: RequestHandler = (req, res) => { + const { formId, logicId } = req.params + const sessionUserId = (req.session as Express.AuthedSession).user._id + + // Step 1: Retrieve currently logged in user. + return ( + UserService.getPopulatedUserById(sessionUserId) + .andThen((user) => + // Step 2: Retrieve form with write permission check. + AuthService.getFormAfterPermissionChecks({ + user, + formId, + level: PermissionLevel.Write, + }), + ) + + // Step 3: Delete form logic + .andThen((retrievedForm) => + AdminFormService.deleteFormLogic(retrievedForm, logicId), + ) + .map(() => res.sendStatus(StatusCodes.OK)) + .mapErr((error) => { + logger.error({ + message: 'Error occurred when deleting form logic', + meta: { + action: 'handleDeleteLogic', + ...createReqMeta(req), + userId: sessionUserId, + formId, + logicId, + }, + error, + }) + const { errorMessage, statusCode } = mapRouteError(error) + return res.status(statusCode).json({ message: errorMessage }) + }) + ) +} /** * Handler for POST /forms/:formId/fields diff --git a/src/app/modules/form/admin-form/admin-form.service.ts b/src/app/modules/form/admin-form/admin-form.service.ts index 5b20c17249..0fdb0e33f5 100644 --- a/src/app/modules/form/admin-form/admin-form.service.ts +++ b/src/app/modules/form/admin-form/admin-form.service.ts @@ -41,7 +41,11 @@ import { } from '../../core/core.errors' import { MissingUserError } from '../../user/user.errors' import * as UserService from '../../user/user.service' -import { FormNotFoundError, TransferOwnershipError } from '../form.errors' +import { + FormNotFoundError, + LogicNotFoundError, + TransferOwnershipError, +} from '../form.errors' import { getFormModelByResponseMode } from '../form.service' import { getFormFieldById } from '../form.utils' @@ -623,3 +627,52 @@ export const updateFormSettings = ( return okAsync(updatedForm.getSettings()) }) } + +/** + * Deletes form logic. + * @param form The original form to delete logic in + * @param logicId the logicId to delete + * @returns ok(true) on success + * @returns err(database errors) if db error is thrown during logic delete + * @returns err(LogicNotFoundError) if logicId does not exist on form + */ +export const deleteFormLogic = ( + form: IPopulatedForm, + logicId: string, +): ResultAsync => { + // First check if specified logic exists + if (!form.form_logics.some((logic) => logic.id === logicId)) { + logger.error({ + message: 'Error occurred - logicId to be deleted does not exist', + meta: { + action: 'deleteFormLogic', + formId: form._id, + logicId, + }, + }) + return errAsync(new LogicNotFoundError()) + } + + // Remove specified logic and then update form logic + return ResultAsync.fromPromise( + FormModel.deleteFormLogic(String(form._id), logicId), + (error) => { + logger.error({ + message: 'Error occurred when deleting form logic', + meta: { + action: 'deleteFormLogic', + formId: form._id, + logicId, + }, + error, + }) + return transformMongoError(error) + }, + // On success, return true + ).andThen((updatedForm) => { + if (!updatedForm) { + return errAsync(new FormNotFoundError()) + } + return okAsync(updatedForm) + }) +} diff --git a/src/app/modules/form/admin-form/admin-form.utils.ts b/src/app/modules/form/admin-form/admin-form.utils.ts index c800b1eed2..06593dcf67 100644 --- a/src/app/modules/form/admin-form/admin-form.utils.ts +++ b/src/app/modules/form/admin-form/admin-form.utils.ts @@ -24,6 +24,7 @@ import { ForbiddenFormError, FormDeletedError, FormNotFoundError, + LogicNotFoundError, PrivateFormError, TransferOwnershipError, } from '../form.errors' @@ -68,6 +69,11 @@ export const mapRouteError = ( statusCode: StatusCodes.NOT_FOUND, errorMessage: error.message, } + case LogicNotFoundError: + return { + statusCode: StatusCodes.NOT_FOUND, + errorMessage: error.message, + } case FormDeletedError: return { statusCode: StatusCodes.GONE, diff --git a/src/app/modules/form/form.errors.ts b/src/app/modules/form/form.errors.ts index 65ea70cfeb..50dacb5589 100644 --- a/src/app/modules/form/form.errors.ts +++ b/src/app/modules/form/form.errors.ts @@ -51,3 +51,12 @@ export class TransferOwnershipError extends ApplicationError { super(message) } } + +/** + * Error to be returned when form logic cannot be found + */ +export class LogicNotFoundError extends ApplicationError { + constructor(message = 'logicId does not exist on form') { + super(message) + } +} diff --git a/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.logic.routes.spec.ts b/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.logic.routes.spec.ts new file mode 100644 index 0000000000..6c5cdeadaa --- /dev/null +++ b/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.logic.routes.spec.ts @@ -0,0 +1,199 @@ +import { ObjectId } from 'bson-ext' +import mongoose from 'mongoose' +import supertest, { Session } from 'supertest-session' + +import getUserModel from 'src/app/models/user.server.model' +import { ILogicSchema } from 'src/types' + +import { createAuthedSession } from 'tests/integration/helpers/express-auth' +import { setupApp } from 'tests/integration/helpers/express-setup' +import dbHandler from 'tests/unit/backend/helpers/jest-db' + +import { AdminFormsRouter } from '../admin-forms.routes' + +const UserModel = getUserModel(mongoose) + +const app = setupApp('/admin/forms', AdminFormsRouter, { + setupWithAuth: true, +}) + +describe('admin-form.logic.routes', () => { + let request: Session + + beforeAll(async () => await dbHandler.connect()) + beforeEach(async () => { + request = supertest(app) + }) + afterEach(async () => { + await dbHandler.clearDatabase() + jest.restoreAllMocks() + }) + afterAll(async () => await dbHandler.closeDatabase()) + + describe('DELETE /forms/:formId/logic/:logicId', () => { + it('should return 200 on successful form logic delete for email mode form', async () => { + // Arrange + const formLogicId = new ObjectId() + const { form: formToUpdate, user } = await dbHandler.insertEmailForm({ + formOptions: { + form_logics: [ + { + _id: formLogicId, + } as ILogicSchema, + ], + }, + }) + const session = await createAuthedSession(user.email, request) + + // Act + const response = await session + .delete(`/admin/forms/${formToUpdate._id}/logic/${formLogicId}`) + .send() + + // Assert + expect(response.status).toEqual(200) + }) + + it('should return 200 on successful form logic delete for encrypt mode form', async () => { + // Arrange + const formLogicId = new ObjectId() + const { form: formToUpdate, user } = await dbHandler.insertEncryptForm({ + formOptions: { + form_logics: [ + { + _id: formLogicId, + } as ILogicSchema, + ], + }, + }) + const session = await createAuthedSession(user.email, request) + + // Act + const response = await session + .delete(`/admin/forms/${formToUpdate._id}/logic/${formLogicId}`) + .send() + + // Assert + expect(response.status).toEqual(200) + }) + + it('should return 403 when current user does not have permissions to delete form logic', async () => { + // Arrange + const formLogicId = new ObjectId() + const { form: formToUpdate, agency } = await dbHandler.insertEncryptForm({ + formOptions: { + form_logics: [ + { + _id: formLogicId, + } as ILogicSchema, + ], + }, + }) + const diffUser = await dbHandler.insertUser({ + mailName: 'newUser', + agencyId: agency._id, + }) + // Log in as different user. + const session = await createAuthedSession(diffUser.email, request) + + // Act + const response = await session + .delete(`/admin/forms/${formToUpdate._id}/logic/${formLogicId}`) + .send() + + // Assert + expect(response.status).toEqual(403) + expect(response.body).toEqual({ + message: expect.stringContaining( + 'not authorized to perform write operation', + ), + }) + }) + + it('should return 404 with error message if logicId does not exist', async () => { + // Arrange + const formLogicId = new ObjectId() + const wrongLogicId = new ObjectId() + const { form: formToUpdate, user } = await dbHandler.insertEmailForm({ + formOptions: { + form_logics: [ + { + _id: formLogicId, + } as ILogicSchema, + ], + }, + }) + const session = await createAuthedSession(user.email, request) + + // Act + const response = await session + .delete(`/admin/forms/${formToUpdate._id}/logic/${wrongLogicId}`) + .send() + + // Assert + const expectedResponse = { + message: 'logicId does not exist on form', + } + expect(response.status).toEqual(404) + expect(response.body).toEqual(expectedResponse) + }) + + it('should return 404 with error message if form does not exist', async () => { + // Arrange + const formLogicId = new ObjectId() + const { user } = await dbHandler.insertEmailForm({ + formOptions: { + form_logics: [ + { + _id: formLogicId, + } as ILogicSchema, + ], + }, + }) + const session = await createAuthedSession(user.email, request) + + // Act + const wrongFormId = new ObjectId() + const response = await session + .delete(`/admin/forms/${wrongFormId}/logic/${formLogicId}`) + .send() + + // Assert + const expectedResponse = { + message: 'Form not found', + } + expect(response.status).toEqual(404) + expect(response.body).toEqual(expectedResponse) + }) + + it('should return 422 when userId cannot be found in the database', async () => { + // Arrange + const formLogicId = new ObjectId() + const { form: formToUpdate, user } = await dbHandler.insertEmailForm({ + formOptions: { + form_logics: [ + { + _id: formLogicId, + } as ILogicSchema, + ], + }, + }) + const session = await createAuthedSession(user.email, request) + + // Delete user after login. + await dbHandler.clearCollection(UserModel.collection.name) + + // Act + const response = await session + .delete(`/admin/forms/${formToUpdate._id}/logic/${formLogicId}`) + .send() + + // Assert + const expectedResponse = { + message: 'User not found', + } + expect(response.status).toEqual(422) + expect(response.body).toEqual(expectedResponse) + }) + }) +}) diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.logic.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.logic.routes.ts new file mode 100644 index 0000000000..595c89671f --- /dev/null +++ b/src/app/routes/api/v3/admin/forms/admin-forms.logic.routes.ts @@ -0,0 +1,21 @@ +import { Router } from 'express' + +import * as AdminFormController from '../../../../../modules/form/admin-form/admin-form.controller' + +export const AdminFormsLogicRouter = Router() + +/** + * Deletes a logic. + * @route DELETE /admin/forms/:formId/logic/:logicId + * @group admin + * @produces application/json + * @consumes application/json + * @returns 200 with success message when successfully deleted + * @returns 403 when user does not have permissions to delete logic + * @returns 404 when form cannot be found + * @returns 422 when user in session cannot be retrieved from the database + * @returns 500 when database error occurs + */ +AdminFormsLogicRouter.route( + '/:formId([a-fA-F0-9]{24})/logic/:logicId([a-fA-F0-9]{24})', +).delete(AdminFormController.handleDeleteLogic) diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts index fb4287704d..0d7f4a6884 100644 --- a/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts +++ b/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts @@ -1,9 +1,11 @@ import { Router } from 'express' import { withUserAuthentication } from '../../../../../modules/auth/auth.middlewares' +import { handleDeleteLogic } from '../../../../../modules/form/admin-form/admin-form.controller' import { AdminFormsFeedbackRouter } from './admin-forms.feedback.routes' import { AdminFormsFormRouter } from './admin-forms.form.routes' +import { AdminFormsLogicRouter } from './admin-forms.logic.routes' import { AdminFormsPreviewRouter } from './admin-forms.preview.routes' import { AdminFormsSettingsRouter } from './admin-forms.settings.routes' import { AdminFormsSubmissionsRouter } from './admin-forms.submissions.routes' @@ -18,3 +20,20 @@ AdminFormsRouter.use(AdminFormsFeedbackRouter) AdminFormsRouter.use(AdminFormsFormRouter) AdminFormsRouter.use(AdminFormsSubmissionsRouter) AdminFormsRouter.use(AdminFormsPreviewRouter) +AdminFormsRouter.use(AdminFormsLogicRouter) + +/** + * Deletes a logic. + * @route DELETE /admin/forms/:formId/logic/:logicId + * @group admin + * @produces application/json + * @consumes application/json + * @returns 200 with success message when successfully deleted + * @returns 403 when user does not have permissions to delete logic + * @returns 404 when form cannot be found + * @returns 422 when user in session cannot be retrieved from the database + * @returns 500 when database error occurs + */ +AdminFormsRouter.route( + '/:formId([a-fA-F0-9]{24})/logic/:logicId([a-fA-F0-9]{24})', +).delete(handleDeleteLogic) diff --git a/src/public/modules/forms/admin/components/edit-logic.client.component.js b/src/public/modules/forms/admin/components/edit-logic.client.component.js index 79b752fb91..abef915000 100644 --- a/src/public/modules/forms/admin/components/edit-logic.client.component.js +++ b/src/public/modules/forms/admin/components/edit-logic.client.component.js @@ -1,6 +1,7 @@ 'use strict' const { LogicType } = require('../../../../../types') +const AdminFormService = require('../../../../services/AdminFormService') angular.module('forms').component('editLogicComponent', { templateUrl: 'modules/forms/admin/componentViews/edit-logic.client.view.html', @@ -9,11 +10,17 @@ angular.module('forms').component('editLogicComponent', { isLogicError: '<', updateForm: '&', }, - controller: ['$uibModal', 'FormFields', editLogicComponentController], + controller: [ + '$uibModal', + 'FormFields', + 'Toastr', + '$q', + editLogicComponentController, + ], controllerAs: 'vm', }) -function editLogicComponentController($uibModal, FormFields) { +function editLogicComponentController($uibModal, FormFields, Toastr, $q) { const vm = this vm.LogicType = LogicType const getNewCondition = function () { @@ -74,8 +81,15 @@ function editLogicComponentController($uibModal, FormFields) { } vm.deleteLogic = function (logicIndex) { - vm.myform.form_logics.splice(logicIndex, 1) - updateLogic({ form_logics: vm.myform.form_logics }) + const logicIdToDelete = vm.myform.form_logics[logicIndex]._id + $q.when(AdminFormService.deleteFormLogic(vm.myform._id, logicIdToDelete)) + .then(() => { + vm.myform.form_logics.splice(logicIndex, 1) + }) + .catch((logicDeleteError) => { + console.error(logicDeleteError) + Toastr.error('Failed to delete logic, please refresh and try again!') + }) } vm.openEditLogicModal = function (currLogic, isNew, logicIndex = -1) { diff --git a/src/public/services/AdminFormService.ts b/src/public/services/AdminFormService.ts index 2beed7cb06..ef6b115954 100644 --- a/src/public/services/AdminFormService.ts +++ b/src/public/services/AdminFormService.ts @@ -46,3 +46,12 @@ export const createSingleFormField = async ( ) .then(({ data }) => data) } + +export const deleteFormLogic = async ( + formId: string, + logicId: string, +): Promise => { + return axios + .delete(`${ADMIN_FORM_ENDPOINT}/${formId}/logic/${logicId}`) + .then(() => true) +} diff --git a/src/types/form.ts b/src/types/form.ts index 06549398ce..09fbbb6e14 100644 --- a/src/types/form.ts +++ b/src/types/form.ts @@ -282,6 +282,7 @@ export interface IFormModel extends Model { formId: string, fields?: (keyof IPopulatedForm)[], ): Promise + deleteFormLogic(formId: string, logicId: string): Promise deactivateById(formId: string): Promise getMetaByUserIdOrEmail( userId: IUserSchema['_id'], From 336fd8d281713445370e170e82ab02e373564a6b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 22 Apr 2021 02:24:53 +0000 Subject: [PATCH 15/51] fix(deps): bump @babel/runtime from 7.13.16 to 7.13.17 (#1700) Bumps [@babel/runtime](https://github.com/babel/babel/tree/HEAD/packages/babel-runtime) from 7.13.16 to 7.13.17. - [Release notes](https://github.com/babel/babel/releases) - [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md) - [Commits](https://github.com/babel/babel/commits/v7.13.17/packages/babel-runtime) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 61fd61edcc..b0ee6bd6d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3596,9 +3596,9 @@ } }, "@babel/runtime": { - "version": "7.13.16", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.13.16.tgz", - "integrity": "sha512-7VsWJsI5USRhBLE/3of+VU2DDNWtYHQlq2IHu2iL15+Yx4qVqP8KllR6JMHQlTKWRyDk9Tw6unkqSusaHXt//A==", + "version": "7.13.17", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.13.17.tgz", + "integrity": "sha512-NCdgJEelPTSh+FEFylhnP1ylq848l1z9t9N0j1Lfbcw0+KXGjsTvUmkxy+voLLXB5SOKMbLLx4jxYliGrYQseA==", "requires": { "regenerator-runtime": "^0.13.4" } diff --git a/package.json b/package.json index 6ed3928dbd..24df9080d4 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ ] }, "dependencies": { - "@babel/runtime": "^7.13.16", + "@babel/runtime": "^7.13.17", "@joi/date": "^2.1.0", "@opengovsg/angular-daterangepicker-webpack": "^1.1.5", "@opengovsg/angular-legacy-sortablejs-maintained": "^1.0.0", From ad597a764b598aefdfe406322f4e80f9945744c8 Mon Sep 17 00:00:00 2001 From: Antariksh Mahajan Date: Thu, 22 Apr 2021 11:43:19 +0800 Subject: [PATCH 16/51] feat(webhooks): streamline webhook response data (#1696) * feat: remove useless fields and make others required * feat: remove logic to store useless fields * feat: allow data and headers to be empty strings * test: update tests --- .../__tests__/submission.server.model.spec.ts | 12 +++++++++--- src/app/models/submission.server.model.ts | 17 ++++++++++++----- .../webhook/__tests__/webhook.service.spec.ts | 11 ----------- src/app/modules/webhook/webhook.service.ts | 7 ------- src/app/modules/webhook/webhook.utils.ts | 1 - src/types/submission.ts | 6 +++--- 6 files changed, 24 insertions(+), 30 deletions(-) diff --git a/src/app/models/__tests__/submission.server.model.spec.ts b/src/app/models/__tests__/submission.server.model.spec.ts index 766a4cb86b..d99438fecf 100644 --- a/src/app/models/__tests__/submission.server.model.spec.ts +++ b/src/app/models/__tests__/submission.server.model.spec.ts @@ -3,6 +3,7 @@ import { times } from 'lodash' import mongoose from 'mongoose' import getSubmissionModel, { + getEmailSubmissionModel, getEncryptSubmissionModel, } from 'src/app/models/submission.server.model' @@ -17,6 +18,7 @@ import { const Submission = getSubmissionModel(mongoose) const EncryptedSubmission = getEncryptSubmissionModel(mongoose) +const EmailSubmission = getEmailSubmissionModel(mongoose) // TODO: Add more tests for the rest of the submission schema. describe('Submission Model', () => { @@ -107,7 +109,7 @@ describe('Submission Model', () => { // Arrange const formId = new ObjectID() - const submission = await Submission.create({ + const submission = await EncryptedSubmission.create({ submissionType: SubmissionType.Encrypt, form: formId, encryptedContent: MOCK_ENCRYPTED_CONTENT, @@ -136,7 +138,7 @@ describe('Submission Model', () => { it('should return null view with non-encryptSubmission type', async () => { // Arrange const formId = new ObjectID() - const submission = await Submission.create({ + const submission = await EmailSubmission.create({ submissionType: SubmissionType.Email, form: formId, encryptedContent: MOCK_ENCRYPTED_CONTENT, @@ -182,7 +184,6 @@ describe('Submission Model', () => { response: { data: '{"result":"test-result"}', status: 200, - statusText: 'success', headers: '{}', }, }) as IWebhookResponse @@ -223,6 +224,11 @@ describe('Submission Model', () => { created: submission.created, signature: 'some signature', webhookUrl: 'https://form.gov.sg/endpoint', + response: { + status: 200, + headers: '', + data: '', + }, } as IWebhookResponse const invalidSubmissionId = new ObjectID().toHexString() diff --git a/src/app/models/submission.server.model.ts b/src/app/models/submission.server.model.ts index b8314c5d51..4d0e434d4f 100644 --- a/src/app/models/submission.server.model.ts +++ b/src/app/models/submission.server.model.ts @@ -126,12 +126,19 @@ EmailSubmissionSchema.methods.getWebhookView = function (): null { const webhookResponseSchema = new Schema( { - webhookUrl: String, - signature: String, - errorMessage: String, + webhookUrl: { + type: String, + required: true, + }, + signature: { + type: String, + required: true, + }, response: { - status: Number, - statusText: String, + status: { + type: Number, + required: true, + }, headers: String, data: String, }, diff --git a/src/app/modules/webhook/__tests__/webhook.service.spec.ts b/src/app/modules/webhook/__tests__/webhook.service.spec.ts index 03be2ea88d..e0e5485dda 100644 --- a/src/app/modules/webhook/__tests__/webhook.service.spec.ts +++ b/src/app/modules/webhook/__tests__/webhook.service.spec.ts @@ -9,7 +9,6 @@ import { getEncryptSubmissionModel } from 'src/app/models/submission.server.mode 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 { IEncryptedSubmissionSchema, IWebhookResponse, @@ -59,7 +58,6 @@ const MOCK_WEBHOOK_SUCCESS_RESPONSE: Pick = { response: { data: '{"result":"test-result"}', status: 200, - statusText: 'success', headers: '{}', }, } @@ -67,7 +65,6 @@ const MOCK_WEBHOOK_FAILURE_RESPONSE: Pick = { response: { data: '{"result":"test-result"}', status: 400, - statusText: 'failed', headers: '{}', }, } @@ -78,7 +75,6 @@ const MOCK_WEBHOOK_DEFAULT_FORMAT_RESPONSE: Pick< response: { data: '', status: 0, - statusText: '', headers: '', }, } @@ -305,7 +301,6 @@ describe('webhook.service', () => { // Assert const expectedResult = { - errorMessage: AXIOS_ERROR_MSG, ...MOCK_WEBHOOK_FAILURE_RESPONSE, signature: testSignature, webhookUrl: MOCK_WEBHOOK_URL, @@ -334,7 +329,6 @@ describe('webhook.service', () => { // Assert const expectedResult = { - errorMessage: DEFAULT_ERROR_MSG, ...MOCK_WEBHOOK_DEFAULT_FORMAT_RESPONSE, signature: testSignature, webhookUrl: MOCK_WEBHOOK_URL, @@ -359,9 +353,6 @@ describe('webhook.service', () => { MockAxios.post.mockRejectedValue(mockOriginalError) MockAxios.isAxiosError.mockReturnValue(false) - const hasPropSpy = jest - .spyOn(HasPropModule, 'hasProp') - .mockReturnValueOnce(false) // Act const actual = await sendWebhook( @@ -371,7 +362,6 @@ describe('webhook.service', () => { // Assert const expectedResult = { - errorMessage: '', ...MOCK_WEBHOOK_DEFAULT_FORMAT_RESPONSE, signature: testSignature, webhookUrl: MOCK_WEBHOOK_URL, @@ -380,7 +370,6 @@ describe('webhook.service', () => { expect( MockWebhookValidationModule.validateWebhookUrl, ).toHaveBeenCalledWith(MOCK_WEBHOOK_URL) - expect(hasPropSpy).toHaveBeenCalledWith(mockOriginalError, 'message') expect(MockAxios.post).toHaveBeenCalledWith( MOCK_WEBHOOK_URL, testSubmissionWebhookView, diff --git a/src/app/modules/webhook/webhook.service.ts b/src/app/modules/webhook/webhook.service.ts index 5632573728..ce4e6bf55c 100644 --- a/src/app/modules/webhook/webhook.service.ts +++ b/src/app/modules/webhook/webhook.service.ts @@ -12,7 +12,6 @@ import formsgSdk from '../../config/formsg-sdk' import { createLoggerWithLabel } from '../../config/logger' import { getEncryptSubmissionModel } from '../../models/submission.server.model' import { transformMongoError } from '../../utils/handle-mongo-error' -import { hasProp } from '../../utils/has-prop' import { PossibleDatabaseError } from '../core/core.errors' import { SubmissionNotFoundError } from '../submission/submission.errors' @@ -159,14 +158,9 @@ export const sendWebhook = ( // Webhook was posted but failed if (error instanceof WebhookFailedWithUnknownError) { - const originalError = error.meta.originalError - const errorMessage = hasProp(originalError, 'message') - ? originalError.message - : '' return okAsync({ signature, webhookUrl, - errorMessage, // Not Axios error so no guarantee of having response. // Hence allow formatting function to return default shape. response: formatWebhookResponse(), @@ -177,7 +171,6 @@ export const sendWebhook = ( return okAsync({ signature, webhookUrl, - errorMessage: axiosError.message, response: formatWebhookResponse(axiosError.response), }) }) diff --git a/src/app/modules/webhook/webhook.utils.ts b/src/app/modules/webhook/webhook.utils.ts index fef9a3a545..e3331465b4 100644 --- a/src/app/modules/webhook/webhook.utils.ts +++ b/src/app/modules/webhook/webhook.utils.ts @@ -11,7 +11,6 @@ export const formatWebhookResponse = ( response?: AxiosResponse, ): IWebhookResponse['response'] => ({ status: response?.status ?? 0, - statusText: response?.statusText ?? '', headers: stringifySafe(response?.headers) ?? '', data: stringifySafe(response?.data) ?? '', }) diff --git a/src/types/submission.ts b/src/types/submission.ts index 0edf9d46d0..150a833b36 100644 --- a/src/types/submission.ts +++ b/src/types/submission.ts @@ -1,4 +1,3 @@ -import { AxiosResponse } from 'axios' import { Document, Model, QueryCursor } from 'mongoose' import { MyInfoAttribute } from './field' @@ -105,9 +104,10 @@ export type IEncryptedSubmissionSchema = IEncryptedSubmission & export interface IWebhookResponse { webhookUrl: string signature: string - errorMessage?: string - response?: Omit, 'config' | 'request' | 'headers'> & { + response: { + status: number headers: string + data: string } } From 21449b87653a87f17b12b209cb6d6302f9469ca9 Mon Sep 17 00:00:00 2001 From: Foo Yong Jie Date: Thu, 22 Apr 2021 13:32:12 +0800 Subject: [PATCH 17/51] fix: remove verified prefix on blank verified fields (#1701) * fix(email): remove verified tag for blank fields * docs: verified prefix should not be added if field is blank --- .../submission/email-submission/email-submission.util.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app/modules/submission/email-submission/email-submission.util.ts b/src/app/modules/submission/email-submission/email-submission.util.ts index 6f0bf35461..5f3c838500 100644 --- a/src/app/modules/submission/email-submission/email-submission.util.ts +++ b/src/app/modules/submission/email-submission/email-submission.util.ts @@ -106,11 +106,15 @@ const getMyInfoPrefix = ( /** * Determines the prefix for a question based on whether it was verified * by a user during form submission. + * + * Verified prefixes are not added for optional fields that are left blank. * @param response * @returns the prefix */ const getVerifiedPrefix = (response: ResponseFormattedForEmail): string => { - return response.isUserVerified ? VERIFIED_PREFIX : '' + const { answer, isUserVerified } = response + const isAnswerBlank = answer === '' + return isUserVerified && !isAnswerBlank ? VERIFIED_PREFIX : '' } /** From 78d0164d153fc894e1b1ee69a0745b3e703118e1 Mon Sep 17 00:00:00 2001 From: orbitalsqwib <21305518+orbitalsqwib@users.noreply.github.com> Date: Thu, 22 Apr 2021 13:34:14 +0800 Subject: [PATCH 18/51] refactor(preview-api): duplicate adminform presign endpoints for /api/v3 (#1644) * refactor(admin-form-api): duplicate adminform form endpoints for /api/v3i - duplicate and update adminform form related endpoints - duplicate integration tests for new endpoint - update v3 router to use new endpoints - update frontend api calls to use new endpoints * refactor(admin-form-api): remove unneeded joi-date extension * refactor(preview-api): duplicate adminform preview endpoints for /api/v3 - duplicate and update adminform preview related endpoints - duplicate integration tests for new endpoint - update v3 router to use new endpoints - update frontend api calls to use new endpoints * refactor(preview-api): duplicate adminform presign endpoints for /api/v3 - duplicate and update adminform presigned image/logo endpoints - duplicate integration tests for new endpoint - update v3 router to use new endpoints - update frontend api calls to use new endpoints * ref(presign-api): remove duplicate user auth middleware from route * ref(presign-api): consolidate validators into controller - shift validators into admin-form controller - update handler methods to include validators as request handler array - update controller integration tests - update old routes to use new handler - update new routes to use new handleri --- .../__tests__/admin-form.controller.spec.ts | 32 +- .../form/admin-form/admin-form.controller.ts | 29 +- .../form/admin-form/admin-form.routes.ts | 13 - .../admin-forms.presign.routes.spec.ts | 528 ++++++++++++++++++ .../admin/forms/admin-forms.presign.routes.ts | 45 ++ .../api/v3/admin/forms/admin-forms.routes.ts | 2 + src/public/services/FileHandlerService.ts | 4 +- .../__tests__/FileHandlerService.test.ts | 4 +- 8 files changed, 620 insertions(+), 37 deletions(-) create mode 100644 src/app/routes/api/v3/admin/forms/__tests__/admin-forms.presign.routes.spec.ts create mode 100644 src/app/routes/api/v3/admin/forms/admin-forms.presign.routes.ts diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts index 634674ff12..accc17eb51 100644 --- a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts +++ b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts @@ -903,7 +903,7 @@ describe('admin-form.controller', () => { }) }) - describe('handleCreatePresignedPostUrlForImages', () => { + describe('createPresignedPostUrlForImages', () => { const MOCK_USER_ID = new ObjectId().toHexString() const MOCK_FORM_ID = new ObjectId().toHexString() const MOCK_USER = { @@ -953,7 +953,7 @@ describe('admin-form.controller', () => { ) // Act - await AdminFormController.handleCreatePresignedPostUrlForImages( + await AdminFormController.createPresignedPostUrlForImages( MOCK_REQ, mockRes, jest.fn(), @@ -980,7 +980,7 @@ describe('admin-form.controller', () => { ) // Act - await AdminFormController.handleCreatePresignedPostUrlForImages( + await AdminFormController.createPresignedPostUrlForImages( MOCK_REQ, mockRes, jest.fn(), @@ -1010,7 +1010,7 @@ describe('admin-form.controller', () => { ) // Act - await AdminFormController.handleCreatePresignedPostUrlForImages( + await AdminFormController.createPresignedPostUrlForImages( MOCK_REQ, mockRes, jest.fn(), @@ -1036,7 +1036,7 @@ describe('admin-form.controller', () => { const mockRes = expressHandler.mockResponse() // Act - await AdminFormController.handleCreatePresignedPostUrlForImages( + await AdminFormController.createPresignedPostUrlForImages( MOCK_REQ, mockRes, jest.fn(), @@ -1065,7 +1065,7 @@ describe('admin-form.controller', () => { const mockRes = expressHandler.mockResponse() // Act - await AdminFormController.handleCreatePresignedPostUrlForImages( + await AdminFormController.createPresignedPostUrlForImages( MOCK_REQ, mockRes, jest.fn(), @@ -1094,7 +1094,7 @@ describe('admin-form.controller', () => { const mockRes = expressHandler.mockResponse() // Act - await AdminFormController.handleCreatePresignedPostUrlForImages( + await AdminFormController.createPresignedPostUrlForImages( MOCK_REQ, mockRes, jest.fn(), @@ -1120,7 +1120,7 @@ describe('admin-form.controller', () => { ) // Act - await AdminFormController.handleCreatePresignedPostUrlForImages( + await AdminFormController.createPresignedPostUrlForImages( MOCK_REQ, mockRes, jest.fn(), @@ -1138,7 +1138,7 @@ describe('admin-form.controller', () => { }) }) - describe('handleCreatePresignedPostUrlForLogos', () => { + describe('createPresignedPostUrlForLogos', () => { const MOCK_USER_ID = new ObjectId().toHexString() const MOCK_FORM_ID = new ObjectId().toHexString() const MOCK_USER = { @@ -1188,7 +1188,7 @@ describe('admin-form.controller', () => { ) // Act - await AdminFormController.handleCreatePresignedPostUrlForLogos( + await AdminFormController.createPresignedPostUrlForLogos( MOCK_REQ, mockRes, jest.fn(), @@ -1215,7 +1215,7 @@ describe('admin-form.controller', () => { ) // Act - await AdminFormController.handleCreatePresignedPostUrlForLogos( + await AdminFormController.createPresignedPostUrlForLogos( MOCK_REQ, mockRes, jest.fn(), @@ -1245,7 +1245,7 @@ describe('admin-form.controller', () => { ) // Act - await AdminFormController.handleCreatePresignedPostUrlForLogos( + await AdminFormController.createPresignedPostUrlForLogos( MOCK_REQ, mockRes, jest.fn(), @@ -1271,7 +1271,7 @@ describe('admin-form.controller', () => { const mockRes = expressHandler.mockResponse() // Act - await AdminFormController.handleCreatePresignedPostUrlForLogos( + await AdminFormController.createPresignedPostUrlForLogos( MOCK_REQ, mockRes, jest.fn(), @@ -1300,7 +1300,7 @@ describe('admin-form.controller', () => { const mockRes = expressHandler.mockResponse() // Act - await AdminFormController.handleCreatePresignedPostUrlForLogos( + await AdminFormController.createPresignedPostUrlForLogos( MOCK_REQ, mockRes, jest.fn(), @@ -1329,7 +1329,7 @@ describe('admin-form.controller', () => { const mockRes = expressHandler.mockResponse() // Act - await AdminFormController.handleCreatePresignedPostUrlForLogos( + await AdminFormController.createPresignedPostUrlForLogos( MOCK_REQ, mockRes, jest.fn(), @@ -1355,7 +1355,7 @@ describe('admin-form.controller', () => { ) // Act - await AdminFormController.handleCreatePresignedPostUrlForLogos( + await AdminFormController.createPresignedPostUrlForLogos( MOCK_REQ, mockRes, jest.fn(), diff --git a/src/app/modules/form/admin-form/admin-form.controller.ts b/src/app/modules/form/admin-form/admin-form.controller.ts index c8d41b87c0..d7cbfb5ba3 100644 --- a/src/app/modules/form/admin-form/admin-form.controller.ts +++ b/src/app/modules/form/admin-form/admin-form.controller.ts @@ -6,6 +6,7 @@ import { StatusCodes } from 'http-status-codes' import JSONStream from 'JSONStream' import { ResultAsync } from 'neverthrow' +import { VALID_UPLOAD_FILE_TYPES } from '../../../../shared/constants' import { AuthType, BasicField, @@ -146,6 +147,16 @@ const transferFormOwnershipValidator = celebrate({ }, }) +const fileUploadValidator = celebrate({ + [Segments.BODY]: { + fileId: Joi.string().required(), + fileMd5Hash: Joi.string().base64().required(), + fileType: Joi.string() + .valid(...VALID_UPLOAD_FILE_TYPES) + .required(), + }, +}) + /** * Handler for GET /adminform endpoint. * @security session @@ -283,7 +294,7 @@ export const handlePreviewAdminForm: RequestHandler<{ formId: string }> = ( * @returns 410 when form is archived * @returns 422 when user in session cannot be retrieved from the database */ -export const handleCreatePresignedPostUrlForImages: RequestHandler< +export const createPresignedPostUrlForImages: RequestHandler< { formId: string }, unknown, { @@ -320,7 +331,7 @@ export const handleCreatePresignedPostUrlForImages: RequestHandler< logger.error({ message: 'Presigning post data encountered an error', meta: { - action: 'handleCreatePresignedPostUrlForImages', + action: 'createPresignedPostUrlForImages', ...createReqMeta(req), }, error, @@ -332,6 +343,11 @@ export const handleCreatePresignedPostUrlForImages: RequestHandler< ) } +export const handleCreatePresignedPostUrlForImages = [ + fileUploadValidator, + createPresignedPostUrlForImages, +] as RequestHandler[] + /** * Handler for POST /:formId([a-fA-F0-9]{24})/adminform/logos. * @security session @@ -343,7 +359,7 @@ export const handleCreatePresignedPostUrlForImages: RequestHandler< * @returns 410 when form is archived * @returns 422 when user in session cannot be retrieved from the database */ -export const handleCreatePresignedPostUrlForLogos: RequestHandler< +export const createPresignedPostUrlForLogos: RequestHandler< ParamsDictionary, unknown, { @@ -380,7 +396,7 @@ export const handleCreatePresignedPostUrlForLogos: RequestHandler< logger.error({ message: 'Presigning post data encountered an error', meta: { - action: 'handleCreatePresignedPostUrlForLogos', + action: 'createPresignedPostUrlForLogos', ...createReqMeta(req), }, error, @@ -392,6 +408,11 @@ export const handleCreatePresignedPostUrlForLogos: RequestHandler< ) } +export const handleCreatePresignedPostUrlForLogos = [ + fileUploadValidator, + createPresignedPostUrlForLogos, +] as RequestHandler[] + // Validates that the ending date >= starting date const validateDateRange = celebrate({ [Segments.QUERY]: Joi.object() diff --git a/src/app/modules/form/admin-form/admin-form.routes.ts b/src/app/modules/form/admin-form/admin-form.routes.ts index c76ffd2571..e9594cb1cb 100644 --- a/src/app/modules/form/admin-form/admin-form.routes.ts +++ b/src/app/modules/form/admin-form/admin-form.routes.ts @@ -6,7 +6,6 @@ import JoiDate from '@joi/date' import { celebrate, Joi as BaseJoi, Segments } from 'celebrate' import { Router } from 'express' -import { VALID_UPLOAD_FILE_TYPES } from '../../../../shared/constants' import { ResponseMode } from '../../../../types' import { withUserAuthentication } from '../../auth/auth.middlewares' import * as EncryptSubmissionController from '../../submission/encrypt-submission/encrypt-submission.controller' @@ -45,16 +44,6 @@ const duplicateFormValidator = celebrate({ }), }) -const fileUploadValidator = celebrate({ - [Segments.BODY]: { - fileId: Joi.string().required(), - fileMd5Hash: Joi.string().base64().required(), - fileType: Joi.string() - .valid(...VALID_UPLOAD_FILE_TYPES) - .required(), - }, -}) - AdminFormsRouter.route('/adminform') // All HTTP methods of route protected with authentication. .all(withUserAuthentication) @@ -390,7 +379,6 @@ AdminFormsRouter.get( AdminFormsRouter.post( '/:formId([a-fA-F0-9]{24})/adminform/images', withUserAuthentication, - fileUploadValidator, AdminFormController.handleCreatePresignedPostUrlForImages, ) @@ -411,7 +399,6 @@ AdminFormsRouter.post( AdminFormsRouter.post( '/:formId([a-fA-F0-9]{24})/adminform/logos', withUserAuthentication, - fileUploadValidator, AdminFormController.handleCreatePresignedPostUrlForLogos, ) diff --git a/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.presign.routes.spec.ts b/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.presign.routes.spec.ts new file mode 100644 index 0000000000..8dbcdc420c --- /dev/null +++ b/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.presign.routes.spec.ts @@ -0,0 +1,528 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import { ObjectId } from 'bson-ext' +import mongoose from 'mongoose' +import SparkMD5 from 'spark-md5' +import supertest, { Session } from 'supertest-session' + +import { aws } from 'src/app/config/config' +import { getEncryptedFormModel } from 'src/app/models/form.server.model' +import getUserModel from 'src/app/models/user.server.model' +import { VALID_UPLOAD_FILE_TYPES } from 'src/shared/constants' +import { IUserSchema, ResponseMode, Status } from 'src/types' + +import { createAuthedSession } from 'tests/integration/helpers/express-auth' +import { setupApp } from 'tests/integration/helpers/express-setup' +import { buildCelebrateError } from 'tests/unit/backend/helpers/celebrate' +import dbHandler from 'tests/unit/backend/helpers/jest-db' + +import { AdminFormsRouter } from '../admin-forms.routes' + +// Prevent rate limiting. +jest.mock('src/app/utils/limit-rate') +jest.mock('nodemailer', () => ({ + createTransport: jest.fn().mockReturnValue({ + sendMail: jest.fn().mockResolvedValue(true), + }), +})) + +const UserModel = getUserModel(mongoose) +const EncryptFormModel = getEncryptedFormModel(mongoose) + +const app = setupApp('/admin/forms', AdminFormsRouter, { + setupWithAuth: true, +}) + +describe('admin-form.presign.routes', () => { + let request: Session + let defaultUser: IUserSchema + + beforeAll(async () => await dbHandler.connect()) + beforeEach(async () => { + request = supertest(app) + const { user } = await dbHandler.insertFormCollectionReqs() + // Default all requests to come from authenticated user. + request = await createAuthedSession(user.email, request) + defaultUser = user + }) + afterEach(async () => { + await dbHandler.clearDatabase() + jest.restoreAllMocks() + }) + afterAll(async () => await dbHandler.closeDatabase()) + + describe('POST /admin/forms/:formId/images/presign', () => { + const DEFAULT_POST_PARAMS = { + fileId: 'some file id', + fileMd5Hash: SparkMD5.hash('test file name'), + fileType: VALID_UPLOAD_FILE_TYPES[0], + } + + it('should return 200 with presigned POST URL object', async () => { + // Arrange + const form = await EncryptFormModel.create({ + title: 'form', + admin: defaultUser._id, + publicKey: 'does not matter', + }) + + // Act + const response = await request + .post(`/admin/forms/${form._id}/images/presign`) + .send(DEFAULT_POST_PARAMS) + + // Assert + expect(response.status).toEqual(200) + // Should equal mocked result. + expect(response.body).toEqual({ + url: expect.any(String), + fields: expect.objectContaining({ + 'Content-MD5': DEFAULT_POST_PARAMS.fileMd5Hash, + 'Content-Type': DEFAULT_POST_PARAMS.fileType, + key: DEFAULT_POST_PARAMS.fileId, + // Should have correct permissions. + acl: 'public-read', + bucket: expect.any(String), + }), + }) + }) + + it('should return 400 when body.fileId is missing', async () => { + // Act + const response = await request + .post(`/admin/forms/${new ObjectId()}/images/presign`) + .send({ + // missing fileId. + fileMd5Hash: SparkMD5.hash('test file name'), + fileType: VALID_UPLOAD_FILE_TYPES[0], + }) + + // Assert + expect(response.status).toEqual(400) + expect(response.body).toEqual( + buildCelebrateError({ + body: { key: 'fileId' }, + }), + ) + }) + + it('should return 400 when body.fileId is an empty string', async () => { + // Act + const response = await request + .post(`/admin/forms/${new ObjectId()}/images/presign`) + .send({ + fileId: '', + fileMd5Hash: SparkMD5.hash('test file name'), + fileType: VALID_UPLOAD_FILE_TYPES[1], + }) + + // Assert + expect(response.status).toEqual(400) + expect(response.body).toEqual( + buildCelebrateError({ + body: { + key: 'fileId', + message: '"fileId" is not allowed to be empty', + }, + }), + ) + }) + + it('should return 400 when body.fileType is missing', async () => { + // Act + const response = await request + .post(`/admin/forms/${new ObjectId()}/images/presign`) + .send({ + fileId: 'some id', + fileMd5Hash: SparkMD5.hash('test file name'), + // Missing fileType. + }) + + // Assert + expect(response.status).toEqual(400) + expect(response.body).toEqual( + buildCelebrateError({ + body: { key: 'fileType' }, + }), + ) + }) + + it('should return 400 when body.fileType is invalid', async () => { + // Act + const response = await request + .post(`/admin/forms/${new ObjectId()}/images/presign`) + .send({ + fileId: 'some id', + fileMd5Hash: SparkMD5.hash('test file name'), + fileType: 'some random type', + }) + + // Assert + expect(response.status).toEqual(400) + expect(response.body).toEqual( + buildCelebrateError({ + body: { + key: 'fileType', + message: `"fileType" must be one of [${VALID_UPLOAD_FILE_TYPES.join( + ', ', + )}]`, + }, + }), + ) + }) + + it('should return 400 when body.fileMd5Hash is missing', async () => { + // Act + const response = await request + .post(`/admin/forms/${new ObjectId()}/images/presign`) + .send({ + fileId: 'some id', + // Missing fileMd5Hash + fileType: VALID_UPLOAD_FILE_TYPES[2], + }) + + // Assert + expect(response.status).toEqual(400) + expect(response.body).toEqual( + buildCelebrateError({ + body: { key: 'fileMd5Hash' }, + }), + ) + }) + + it('should return 400 when body.fileMd5Hash is not a base64 string', async () => { + // Act + const response = await request + .post(`/admin/forms/${new ObjectId()}/images/presign`) + .send({ + fileId: 'some id', + fileMd5Hash: 'rubbish hash', + fileType: VALID_UPLOAD_FILE_TYPES[2], + }) + + // Assert + expect(response.status).toEqual(400) + expect(response.body).toEqual( + buildCelebrateError({ + body: { + key: 'fileMd5Hash', + message: '"fileMd5Hash" must be a valid base64 string', + }, + }), + ) + }) + + it('should return 400 when creating presigned POST URL object errors', async () => { + // Arrange + // Mock error. + jest + .spyOn(aws.s3, 'createPresignedPost') + // @ts-ignore + .mockImplementationOnce((_opts, cb) => + cb(new Error('something went wrong')), + ) + const form = await EncryptFormModel.create({ + title: 'form', + admin: defaultUser._id, + publicKey: 'does not matter', + }) + + // Act + const response = await request + .post(`/admin/forms/${form._id}/images/presign`) + .send(DEFAULT_POST_PARAMS) + + // Assert + expect(response.status).toEqual(400) + expect(response.body).toEqual({ + message: 'Error occurred whilst uploading file', + }) + }) + + it('should return 404 when form to upload image to cannot be found', async () => { + // Arrange + const invalidFormId = new ObjectId().toHexString() + + // Act + const response = await request + .post(`/admin/forms/${invalidFormId}/images/presign`) + .send(DEFAULT_POST_PARAMS) + + // Assert + expect(response.status).toEqual(404) + expect(response.body).toEqual({ message: 'Form not found' }) + }) + + it('should return 410 when form to upload image to is already archived', async () => { + // Arrange + const archivedForm = await EncryptFormModel.create({ + title: 'archived form', + status: Status.Archived, + responseMode: ResponseMode.Encrypt, + publicKey: 'does not matter', + admin: defaultUser._id, + }) + + // Act + const response = await request + .post(`/admin/forms/${archivedForm._id}/images/presign`) + .send(DEFAULT_POST_PARAMS) + + // Assert + expect(response.status).toEqual(410) + expect(response.body).toEqual({ message: 'Form has been archived' }) + }) + + it('should return 422 when user in session cannot be retrieved from the database', async () => { + // Arrange + // Clear user collection + await dbHandler.clearCollection(UserModel.collection.name) + + // Act + const response = await request + .post(`/admin/forms/${new ObjectId()}/images/presign`) + .send(DEFAULT_POST_PARAMS) + + // Assert + expect(response.status).toEqual(422) + expect(response.body).toEqual({ message: 'User not found' }) + }) + }) + + describe('POST /admin/forms/:formId/logos/presign', () => { + const DEFAULT_POST_PARAMS = { + fileId: 'some other file id', + fileMd5Hash: SparkMD5.hash('test file name again'), + fileType: VALID_UPLOAD_FILE_TYPES[2], + } + + it('should return 200 with presigned POST URL object', async () => { + // Arrange + const form = await EncryptFormModel.create({ + title: 'form', + admin: defaultUser._id, + publicKey: 'does not matter', + }) + + // Act + const response = await request + .post(`/admin/forms/${form._id}/logos/presign`) + .send(DEFAULT_POST_PARAMS) + + // Assert + expect(response.status).toEqual(200) + // Should equal mocked result. + expect(response.body).toEqual({ + url: expect.any(String), + fields: expect.objectContaining({ + 'Content-MD5': DEFAULT_POST_PARAMS.fileMd5Hash, + 'Content-Type': DEFAULT_POST_PARAMS.fileType, + key: DEFAULT_POST_PARAMS.fileId, + // Should have correct permissions. + acl: 'public-read', + bucket: expect.any(String), + }), + }) + }) + + it('should return 400 when body.fileId is missing', async () => { + // Act + const response = await request + .post(`/admin/forms/${new ObjectId()}/logos/presign`) + .send({ + // missing fileId. + fileMd5Hash: SparkMD5.hash('test file name'), + fileType: VALID_UPLOAD_FILE_TYPES[0], + }) + + // Assert + expect(response.status).toEqual(400) + expect(response.body).toEqual( + buildCelebrateError({ + body: { key: 'fileId' }, + }), + ) + }) + + it('should return 400 when body.fileId is an empty string', async () => { + // Act + const response = await request + .post(`/admin/forms/${new ObjectId()}/logos/presign`) + .send({ + fileId: '', + fileMd5Hash: SparkMD5.hash('test file name'), + fileType: VALID_UPLOAD_FILE_TYPES[1], + }) + + // Assert + expect(response.status).toEqual(400) + expect(response.body).toEqual( + buildCelebrateError({ + body: { + key: 'fileId', + message: '"fileId" is not allowed to be empty', + }, + }), + ) + }) + + it('should return 400 when body.fileType is missing', async () => { + // Act + const response = await request + .post(`/admin/forms/${new ObjectId()}/logos/presign`) + .send({ + fileId: 'some id', + fileMd5Hash: SparkMD5.hash('test file name'), + // Missing fileType. + }) + + // Assert + expect(response.status).toEqual(400) + expect(response.body).toEqual( + buildCelebrateError({ + body: { key: 'fileType' }, + }), + ) + }) + + it('should return 400 when body.fileType is invalid', async () => { + // Act + const response = await request + .post(`/admin/forms/${new ObjectId()}/logos/presign`) + .send({ + fileId: 'some id', + fileMd5Hash: SparkMD5.hash('test file name'), + fileType: 'some random type', + }) + + // Assert + expect(response.status).toEqual(400) + expect(response.body).toEqual( + buildCelebrateError({ + body: { + key: 'fileType', + message: `"fileType" must be one of [${VALID_UPLOAD_FILE_TYPES.join( + ', ', + )}]`, + }, + }), + ) + }) + + it('should return 400 when body.fileMd5Hash is missing', async () => { + // Act + const response = await request + .post(`/admin/forms/${new ObjectId()}/logos/presign`) + .send({ + fileId: 'some id', + // Missing fileMd5Hash + fileType: VALID_UPLOAD_FILE_TYPES[2], + }) + + // Assert + expect(response.status).toEqual(400) + expect(response.body).toEqual( + buildCelebrateError({ + body: { key: 'fileMd5Hash' }, + }), + ) + }) + + it('should return 400 when body.fileMd5Hash is not a base64 string', async () => { + // Act + const response = await request + .post(`/admin/forms/${new ObjectId()}/logos/presign`) + .send({ + fileId: 'some id', + fileMd5Hash: 'rubbish hash', + fileType: VALID_UPLOAD_FILE_TYPES[2], + }) + + // Assert + expect(response.status).toEqual(400) + expect(response.body).toEqual( + buildCelebrateError({ + body: { + key: 'fileMd5Hash', + message: '"fileMd5Hash" must be a valid base64 string', + }, + }), + ) + }) + + it('should return 400 when creating presigned POST URL object errors', async () => { + // Arrange + // Mock error. + jest + .spyOn(aws.s3, 'createPresignedPost') + // @ts-ignore + .mockImplementationOnce((_opts, cb) => + cb(new Error('something went wrong')), + ) + const form = await EncryptFormModel.create({ + title: 'form', + admin: defaultUser._id, + publicKey: 'does not matter again', + }) + + // Act + const response = await request + .post(`/admin/forms/${form._id}/logos/presign`) + .send(DEFAULT_POST_PARAMS) + + // Assert + expect(response.status).toEqual(400) + expect(response.body).toEqual({ + message: 'Error occurred whilst uploading file', + }) + }) + + it('should return 404 when form to upload logo to cannot be found', async () => { + // Arrange + const invalidFormId = new ObjectId().toHexString() + + // Act + const response = await request + .post(`/admin/forms/${invalidFormId}/logos/presign`) + .send(DEFAULT_POST_PARAMS) + + // Assert + expect(response.status).toEqual(404) + expect(response.body).toEqual({ message: 'Form not found' }) + }) + + it('should return 410 when form to upload logo to is already archived', async () => { + // Arrange + const archivedForm = await EncryptFormModel.create({ + title: 'archived form', + status: Status.Archived, + responseMode: ResponseMode.Encrypt, + publicKey: 'does not matter', + admin: defaultUser._id, + }) + + // Act + const response = await request + .post(`/admin/forms/${archivedForm._id}/logos/presign`) + .send(DEFAULT_POST_PARAMS) + + // Assert + expect(response.status).toEqual(410) + expect(response.body).toEqual({ message: 'Form has been archived' }) + }) + + it('should return 422 when user in session cannot be retrieved from the database', async () => { + // Arrange + // Clear user collection + await dbHandler.clearCollection(UserModel.collection.name) + + // Act + const response = await request + .post(`/admin/forms/${new ObjectId()}/logos/presign`) + .send(DEFAULT_POST_PARAMS) + + // Assert + expect(response.status).toEqual(422) + expect(response.body).toEqual({ message: 'User not found' }) + }) + }) +}) diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.presign.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.presign.routes.ts new file mode 100644 index 0000000000..3565d96eb2 --- /dev/null +++ b/src/app/routes/api/v3/admin/forms/admin-forms.presign.routes.ts @@ -0,0 +1,45 @@ +import { Router } from 'express' + +import * as AdminFormController from '../../../../../modules/form/admin-form/admin-form.controller' + +export const AdminFormsPresignRouter = Router() + +// Validators + +/** + * Upload images + * @route POST /api/v3/admin/forms/:formId/images/presign + * @security session + * + * @returns 200 with presigned POST URL object + * @returns 400 when error occurs whilst creating presigned POST URL object + * @returns 400 when Joi validation fails + * @returns 401 when user does not exist in session + * @returns 403 when user does not have write permissions for form + * @returns 404 when form cannot be found + * @returns 410 when form is archived + * @returns 422 when user in session cannot be retrieved from the database + */ +AdminFormsPresignRouter.post( + '/:formId([a-fA-F0-9]{24})/images/presign', + AdminFormController.handleCreatePresignedPostUrlForImages, +) + +/** + * Upload logos + * @route POST /api/v3/admin/forms/:formId/logos/presign + * @security session + * + * @returns 200 with presigned POST URL object + * @returns 400 when error occurs whilst creating presigned POST URL object + * @returns 400 when Joi validation fails + * @returns 401 when user does not exist in session + * @returns 403 when user does not have write permissions for form + * @returns 404 when form cannot be found + * @returns 410 when form is archived + * @returns 422 when user in session cannot be retrieved from the database + */ +AdminFormsPresignRouter.post( + '/:formId([a-fA-F0-9]{24})/logos/presign', + AdminFormController.handleCreatePresignedPostUrlForLogos, +) diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts index 0d7f4a6884..8818b03b32 100644 --- a/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts +++ b/src/app/routes/api/v3/admin/forms/admin-forms.routes.ts @@ -6,6 +6,7 @@ import { handleDeleteLogic } from '../../../../../modules/form/admin-form/admin- import { AdminFormsFeedbackRouter } from './admin-forms.feedback.routes' import { AdminFormsFormRouter } from './admin-forms.form.routes' import { AdminFormsLogicRouter } from './admin-forms.logic.routes' +import { AdminFormsPresignRouter } from './admin-forms.presign.routes' import { AdminFormsPreviewRouter } from './admin-forms.preview.routes' import { AdminFormsSettingsRouter } from './admin-forms.settings.routes' import { AdminFormsSubmissionsRouter } from './admin-forms.submissions.routes' @@ -20,6 +21,7 @@ AdminFormsRouter.use(AdminFormsFeedbackRouter) AdminFormsRouter.use(AdminFormsFormRouter) AdminFormsRouter.use(AdminFormsSubmissionsRouter) AdminFormsRouter.use(AdminFormsPreviewRouter) +AdminFormsRouter.use(AdminFormsPresignRouter) AdminFormsRouter.use(AdminFormsLogicRouter) /** diff --git a/src/public/services/FileHandlerService.ts b/src/public/services/FileHandlerService.ts index 719158dac5..e0e6e9a1f2 100644 --- a/src/public/services/FileHandlerService.ts +++ b/src/public/services/FileHandlerService.ts @@ -151,7 +151,7 @@ export const uploadImage = async ({ const fileId = `${formId}-${Date.now()}-${image.name.toLowerCase()}` return uploadFile({ - url: `/${formId}/adminform/images`, + url: `/api/v3/admin/forms/${formId}/images/presign`, file: image, fileId, cancelToken, @@ -172,7 +172,7 @@ export const uploadLogo = async ({ const fileId = `${Date.now()}-${image.name.toLowerCase()}` return uploadFile({ - url: `/${formId}/adminform/logos`, + url: `/api/v3/admin/forms/${formId}/logos/presign`, file: image, fileId, cancelToken, diff --git a/src/public/services/__tests__/FileHandlerService.test.ts b/src/public/services/__tests__/FileHandlerService.test.ts index 62bdff556c..db3532d4d3 100644 --- a/src/public/services/__tests__/FileHandlerService.test.ts +++ b/src/public/services/__tests__/FileHandlerService.test.ts @@ -40,7 +40,7 @@ describe('FileHandlerService', () => { // Assert expect(actual).toEqual(expected) expect(uploadSpy).toHaveBeenCalledWith({ - url: `/${mockFormId}/adminform/images`, + url: `/api/v3/admin/forms/${mockFormId}/images/presign`, file: mockImage, fileId: expectedFileId, }) @@ -80,7 +80,7 @@ describe('FileHandlerService', () => { // Assert expect(actual).toEqual(expected) expect(uploadSpy).toHaveBeenCalledWith({ - url: `/${mockFormId}/adminform/logos`, + url: `/api/v3/admin/forms/${mockFormId}/logos/presign`, file: mockLogo, fileId: expectedFileId, }) From dfdac895a030a52f7240641979180266ac4be7b9 Mon Sep 17 00:00:00 2001 From: Kar Rui Lau Date: Thu, 22 Apr 2021 14:17:31 +0800 Subject: [PATCH 19/51] fix: sync email field state between hasAllowedEmailDomains and allowedEmailDomains (#1697) * fix: sync client hasAllowedEmailDomains and allowedEmailDomains states * fix: watch field.isVerifiable to show relevant tooltip * feat: add logger to track deprecated updateForm endpoint calls * fix: sync inconsistent email field states in the backend * fix: sync email field states in getUpdatedFormFields fn instead states may desync in the create or update actions * test(adminFormUtils): add extra tests for syncing of email fields --- .../__tests__/admin-form.utils.spec.ts | 74 +++++++++++++++++++ .../form/admin-form/admin-form.service.ts | 17 +++++ .../form/admin-form/admin-form.utils.ts | 15 ++++ .../field-validation.guards.ts | 16 +++- .../edit-fields-modal.client.controller.js | 23 +++++- ...s-verifiable-save-interceptor.directive.js | 3 +- .../admin/views/edit-fields.client.modal.html | 1 + 7 files changed, 145 insertions(+), 4 deletions(-) diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.utils.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.utils.spec.ts index d575a0b5b7..6c8f30068c 100644 --- a/src/app/modules/form/admin-form/__tests__/admin-form.utils.spec.ts +++ b/src/app/modules/form/admin-form/__tests__/admin-form.utils.spec.ts @@ -4,6 +4,7 @@ import { cloneDeep, omit, tail } from 'lodash' import { EditFieldActions } from 'src/shared/constants' import { BasicField, + IEmailFieldSchema, IFieldSchema, IPopulatedForm, IPopulatedUser, @@ -443,6 +444,79 @@ describe('admin-form.utils', () => { ]) }) + it('should return synced email field when updating with desynced email field', async () => { + // Arrange + const initialField = generateDefaultField(BasicField.Email, { + title: 'some old title', + }) + const desyncedEmailField = ({ + ...initialField, + title: 'new title', + hasAllowedEmailDomains: true, + // true but empty array + allowedEmailDomains: [], + } as unknown) as IEmailFieldSchema + + const updateFieldParams: EditFormFieldParams = { + action: { + name: EditFieldActions.Update, + }, + field: desyncedEmailField, + } + + // Act + const actualResult = getUpdatedFormFields( + [initialField], + updateFieldParams, + ) + + // Assert + // Email field should be updated but synced + expect(actualResult._unsafeUnwrap()).toEqual([ + // hasAllowedEmailDomains should be false since allowedEmailDomains is empty + { + ...desyncedEmailField, + hasAllowedEmailDomains: false, + allowedEmailDomains: [], + }, + ]) + }) + + it('should return synced email field when creating with desynced email field', async () => { + // Arrange + const desyncedEmailField = ({ + ...generateDefaultField(BasicField.Email), + hasAllowedEmailDomains: false, + // False but contains domains. + allowedEmailDomains: ['@example.com'], + } as unknown) as IEmailFieldSchema + + const createFieldParams: EditFormFieldParams = { + action: { + name: EditFieldActions.Create, + }, + field: desyncedEmailField, + } + + // Act + const actualResult = getUpdatedFormFields( + INITIAL_FIELDS, + createFieldParams, + ) + + // Assert + // Email field should be updated but synced + expect(actualResult._unsafeUnwrap()).toEqual([ + ...INITIAL_FIELDS, + // allowedEmailDomains should be empty since hasAllowedEmailDomains is false + { + ...desyncedEmailField, + hasAllowedEmailDomains: false, + allowedEmailDomains: [], + }, + ]) + }) + it('should return EditFieldError when field to be created already exists', async () => { // Arrange const existingField = cloneDeep(INITIAL_FIELDS[0]) diff --git a/src/app/modules/form/admin-form/admin-form.service.ts b/src/app/modules/form/admin-form/admin-form.service.ts index 0fdb0e33f5..3731a4fbae 100644 --- a/src/app/modules/form/admin-form/admin-form.service.ts +++ b/src/app/modules/form/admin-form/admin-form.service.ts @@ -5,6 +5,7 @@ import { errAsync, okAsync, ResultAsync } from 'neverthrow' import { Except, Merge } from 'type-fest' import { + EditFieldActions, MAX_UPLOAD_FILE_SIZE, VALID_UPLOAD_FILE_TYPES, } from '../../../../shared/constants' @@ -516,6 +517,22 @@ export const editFormFields = ( IPopulatedForm, EditFieldError | ReturnType > => { + // TODO(#1210): Remove this function when no longer being called. + if ( + [EditFieldActions.Create, EditFieldActions.Update].includes( + editFormFieldParams.action.name, + ) + ) { + logger.info({ + message: 'deprecated editFormFields functions are still being used', + meta: { + action: 'editFormFields', + fieldAction: editFormFieldParams.action.name, + field: editFormFieldParams.field, + }, + }) + } + // TODO(#815): Split out this function into their own separate service functions depending on the update type. return getUpdatedFormFields( originalForm.form_fields, diff --git a/src/app/modules/form/admin-form/admin-form.utils.ts b/src/app/modules/form/admin-form/admin-form.utils.ts index 06593dcf67..a6f8e619b6 100644 --- a/src/app/modules/form/admin-form/admin-form.utils.ts +++ b/src/app/modules/form/admin-form/admin-form.utils.ts @@ -9,6 +9,7 @@ import { Status, } from '../../../../types' import { createLoggerWithLabel } from '../../../config/logger' +import { isPossibleEmailFieldSchema } from '../../../utils/field-validation/field-validation.guards' import { reorder, replaceAt } from '../../../utils/immutable-array-fns' import { ApplicationError, @@ -358,6 +359,20 @@ export const getUpdatedFormFields = ( ): EditFormFieldResult => { const { field: fieldToUpdate, action } = editFieldParams + // TODO(#1210): Remove this function when no longer being called. + // Sync states for backwards compatibility with old clients send inconsistent + // email fields + if (isPossibleEmailFieldSchema(fieldToUpdate)) { + if (fieldToUpdate.hasAllowedEmailDomains === false) { + fieldToUpdate.allowedEmailDomains = [] + } else { + fieldToUpdate.hasAllowedEmailDomains = fieldToUpdate.allowedEmailDomains + ?.length + ? fieldToUpdate.allowedEmailDomains.length > 0 + : false + } + } + switch (action.name) { // Duplicate is just an alias of create for the use case. case EditFieldActions.Create: diff --git a/src/app/utils/field-validation/field-validation.guards.ts b/src/app/utils/field-validation/field-validation.guards.ts index fa3a96163b..221f1bd92c 100644 --- a/src/app/utils/field-validation/field-validation.guards.ts +++ b/src/app/utils/field-validation/field-validation.guards.ts @@ -1,5 +1,7 @@ +import { get } from 'lodash' + import { types as basicTypes } from '../../../shared/resources/basic' -import { BasicField, ITableRow } from '../../../types' +import { BasicField, IEmailFieldSchema, ITableRow } from '../../../types' import { ColumnResponse, ProcessedAttachmentResponse, @@ -74,3 +76,15 @@ export const isProcessedAttachmentResponse = ( // Hence hidden attachment fields - which still return empty response - will not have response.filename property ) } + +/** + * Utility to check if the given field is a possible IEmailFieldSchema object. + * Can be used to assign IEmailFieldSchema variables safely. + * @param field the field to check + * @returns true if given field's fieldType is BasicField.Email. + */ +export const isPossibleEmailFieldSchema = ( + field: unknown, +): field is Partial => { + return get(field, 'fieldType') === BasicField.Email +} diff --git a/src/public/modules/forms/admin/controllers/edit-fields-modal.client.controller.js b/src/public/modules/forms/admin/controllers/edit-fields-modal.client.controller.js index 3901bb5406..614ff7f856 100644 --- a/src/public/modules/forms/admin/controllers/edit-fields-modal.client.controller.js +++ b/src/public/modules/forms/admin/controllers/edit-fields-modal.client.controller.js @@ -74,13 +74,23 @@ function EditFieldsModalController( vm.user = Auth.getUser() || $state.go('signin') if (vm.field.fieldType === 'email') { const userEmailDomain = '@' + vm.user.email.split('@').pop() + + // Backwards compatibility and inconsistency fix. + // Set allowedEmailDomains array to empty if allow domains toggle is off. + if (vm.field.hasAllowedEmailDomains === false) { + vm.field.allowedEmailDomains = [] + } else { + // hasAllowedEmailDomains is true, set "true" state based on length of allowedEmailDomains. + vm.field.hasAllowedEmailDomains = vm.field.allowedEmailDomains.length > 0 + } + vm.field.allowedEmailDomainsPlaceholder = `${userEmailDomain}\n@agency.gov.sg` - if (vm.field.allowedEmailDomains.length > 0) { + if (vm.field.hasAllowedEmailDomains) { vm.field.allowedEmailDomainsFromText = vm.field.allowedEmailDomains.join( '\n', ) } - $scope.$watch('vm.field.allowedEmailDomainsFromText', (newValue) => { + $scope.$watch('vm.field.isVerifiable', (newValue) => { if (newValue) { vm.tooltipHtml = 'e.g. @mom.gov.sg, @moe.gov.sg' } else { @@ -145,6 +155,14 @@ function EditFieldsModalController( } } + vm.handleRestrictEmailDomainsToggle = function () { + const field = vm.field + if (field.hasAllowedEmailDomains === false) { + // Reset email domains. + field.allowedEmailDomainsFromText = '' + } + } + vm.ratingSteps = Rating.steps vm.ratingShapes = Rating.shapes @@ -475,6 +493,7 @@ function EditFieldsModalController( .map((s) => s.trim()) .filter((s) => s) } + field.hasAllowedEmailDomains = field.allowedEmailDomains.length > 0 delete field.allowedEmailDomainsFromText delete field.allowedEmailDomainsPlaceholder } diff --git a/src/public/modules/forms/admin/directives/is-verifiable-save-interceptor.directive.js b/src/public/modules/forms/admin/directives/is-verifiable-save-interceptor.directive.js index 013c49b77c..79e81adbe6 100644 --- a/src/public/modules/forms/admin/directives/is-verifiable-save-interceptor.directive.js +++ b/src/public/modules/forms/admin/directives/is-verifiable-save-interceptor.directive.js @@ -16,8 +16,9 @@ function isVerifiableSaveInterceptor(Toastr) { Toastr.error( 'Turn on OTP verification again if you wish to restrict email domains.', ) + scope.vm.field.hasAllowedEmailDomains = false + scope.vm.field.allowedEmailDomainsFromText = '' } - scope.vm.field.hasAllowedEmailDomains = false return inputValue }) }, diff --git a/src/public/modules/forms/admin/views/edit-fields.client.modal.html b/src/public/modules/forms/admin/views/edit-fields.client.modal.html index 488f66e67e..9ad900d6bf 100644 --- a/src/public/modules/forms/admin/views/edit-fields.client.modal.html +++ b/src/public/modules/forms/admin/views/edit-fields.client.modal.html @@ -622,6 +622,7 @@