From e5bd2cda8bb741f24f9b1693b18ca3e6abf43713 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Wed, 17 Mar 2021 14:51:26 +0800 Subject: [PATCH 01/86] refactor(modules/spcp): shifts get spcp session out into service --- src/app/modules/spcp/spcp.factory.ts | 2 ++ src/app/modules/spcp/spcp.service.ts | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/app/modules/spcp/spcp.factory.ts b/src/app/modules/spcp/spcp.factory.ts index d7db1f322d..68ae6a34c9 100644 --- a/src/app/modules/spcp/spcp.factory.ts +++ b/src/app/modules/spcp/spcp.factory.ts @@ -19,6 +19,7 @@ interface ISpcpFactory { createJWT: SpcpService['createJWT'] createJWTPayload: SpcpService['createJWTPayload'] getCookieSettings: SpcpService['getCookieSettings'] + getSpcpSession: SpcpService['getSpcpSession'] } export const createSpcpFactory = ({ @@ -38,6 +39,7 @@ export const createSpcpFactory = ({ createJWT: () => err(error), createJWTPayload: () => err(error), getCookieSettings: () => ({}), + getSpcpSession: () => errAsync(error), } } return new SpcpService(props) diff --git a/src/app/modules/spcp/spcp.service.ts b/src/app/modules/spcp/spcp.service.ts index aae6ae8b9e..7bafb719ce 100644 --- a/src/app/modules/spcp/spcp.service.ts +++ b/src/app/modules/spcp/spcp.service.ts @@ -10,6 +10,7 @@ import { AuthType } from '../../../types' import { ApplicationError } from '../core/core.errors' import { + AuthTypeMismatchError, CreateRedirectUrlError, FetchLoginPageError, InvalidJwtError, @@ -394,4 +395,23 @@ export class SpcpService { const spcpCookieDomain = this.#spcpProps.spcpCookieDomain return spcpCookieDomain ? { domain: spcpCookieDomain, path: '/' } : {} } + + /** + * Gets the spcp session info from the auth and the cookies + * @param authType The authentication type of the user + * @param cookies The spcp cookies set by the redirect + * @return okAsync(jwtPayload) if successful + * @return errAsync(error) the kind of error encountered + */ + getSpcpSession( + authType: AuthType, + cookies: SpcpCookies, + ): ResultAsync { + if (authType === AuthType.SP || authType === AuthType.CP) { + return this.extractJwt(cookies, authType).asyncAndThen((jwtResult) => + this.extractJwtPayload(jwtResult, authType), + ) + } + return errAsync(new AuthTypeMismatchError(authType)) + } } From f40eee1dfa2b34a4b2a707af3a209585c5e79d04 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Wed, 17 Mar 2021 14:52:15 +0800 Subject: [PATCH 02/86] refactor(modules/form): adds utility methods and refactored checkFormSubmission --- src/app/modules/form/form.service.ts | 97 +++++++++++++++++++++++----- 1 file changed, 81 insertions(+), 16 deletions(-) diff --git a/src/app/modules/form/form.service.ts b/src/app/modules/form/form.service.ts index be0041a491..edbdf91708 100644 --- a/src/app/modules/form/form.service.ts +++ b/src/app/modules/form/form.service.ts @@ -15,8 +15,15 @@ import getFormModel, { getEncryptedFormModel, } from '../../models/form.server.model' import getSubmissionModel from '../../models/submission.server.model' -import { getMongoErrorMessage } from '../../utils/handle-mongo-error' -import { ApplicationError, DatabaseError } from '../core/core.errors' +import { + getMongoErrorMessage, + transformMongoError, +} from '../../utils/handle-mongo-error' +import { + ApplicationError, + DatabaseError, + DatabaseValidationError, +} from '../core/core.errors' import { FormDeletedError, @@ -30,10 +37,28 @@ const EmailFormModel = getEmailFormModel(mongoose) const EncryptedFormModel = getEncryptedFormModel(mongoose) const SubmissionModel = getSubmissionModel(mongoose) +/** + * Intentionally caught instead of neverthrown to prevent blocking because the result of the call is not important. + * @param formId the id of the form to deactivate + * @returns Promise(IFormSchema) the db object of the form if the form is successfully deactivated + * @returns null if an error is thrown while deactivating + */ export const deactivateForm = async ( formId: string, ): Promise => { - return FormModel.deactivateById(formId) + try { + return FormModel.deactivateById(formId) + } catch (error) { + logger.error({ + message: 'Error deactivating form by id', + meta: { + action: 'deactivateForm', + form: formId, + }, + error, + }) + return null + } } /** @@ -135,17 +160,35 @@ export const isFormPublic = ( /** * Method to check whether a form has reached submission limits, and deactivate the form if necessary * @param form the form to check - * @returns ok(true) if submission is allowed because the form has not reached limits - * @returns ok(false) if submission is not allowed because the form has reached limits + * @returns okAsync(form) if submission is allowed because the form has not reached limits + * @returns errAsync(error) if submission is not allowed because the form has reached limits or if an error occurs while counting the documents */ -export const checkFormSubmissionLimitAndDeactivateForm = async ( +export const checkFormSubmissionLimitAndDeactivateForm = ( form: IPopulatedForm, -): Promise> => { - if (form.submissionLimit !== null) { - const currentCount = await SubmissionModel.countDocuments({ - form: form._id, - }).exec() +): ResultAsync< + IPopulatedForm, + DatabaseError | DatabaseValidationError | PrivateFormError +> => { + if (!form.submissionLimit) { + return okAsync(form) + } + return ResultAsync.fromPromise( + SubmissionModel.countDocuments({ + form: form._id, + }).exec(), + (error) => { + logger.error({ + message: 'Error counting documents', + meta: { + action: 'checkFormSubmissionLimitAndDeactivateForm', + form: form._id, + }, + error, + }) + return transformMongoError(error) + }, + ).andThen((currentCount) => { if (currentCount >= form.submissionLimit) { logger.info({ message: 'Form reached maximum submission count, deactivating.', @@ -154,14 +197,36 @@ export const checkFormSubmissionLimitAndDeactivateForm = async ( action: 'checkFormSubmissionLimitAndDeactivate', }, }) + await deactivateForm(form._id) - return err(new PrivateFormError(form.inactiveMessage, form.title)) + return errAsync(new PrivateFormError(form.inactiveMessage, form.title)) } else { - return ok(true) + return okAsync(form) } - } else { - return ok(true) - } + }) +} + +/** + * Method to retrieve a fully populated form that is public + * @param formId the id of the form to retrieve + * @returns okAsync(form) if the form was retrieved successfully + * @returns errAsync(error) the kind of error resulting from unsuccessful retrieval + */ +export const retrievePublicFormById = ( + formId: string, +): ResultAsync< + IPopulatedForm, + | FormNotFoundError + | DatabaseError + | FormDeletedError + | PrivateFormError + | ApplicationError +> => { + return retrieveFullFormById(formId).andThen((form) => + isFormPublic(form) + .map(() => form) + .mapErr((error) => error), + ) } export const getFormModelByResponseMode = ( From beada89a954d19a7fd74347c5197b24be59c793a Mon Sep 17 00:00:00 2001 From: seaerchin Date: Wed, 17 Mar 2021 14:59:26 +0800 Subject: [PATCH 03/86] refactor(modules/form): refactors GET public forms end point from js to ts --- .../public-form/public-form.controller.ts | 92 +++++++++++++++++++ .../form/public-form/public-form.routes.ts | 27 ++++++ 2 files changed, 119 insertions(+) create mode 100644 src/app/modules/form/public-form/public-form.routes.ts diff --git a/src/app/modules/form/public-form/public-form.controller.ts b/src/app/modules/form/public-form/public-form.controller.ts index 474058a54c..fff52d1208 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -1,9 +1,22 @@ import { RequestHandler } from 'express' import { StatusCodes } from 'http-status-codes' +import { err, errAsync, ok, okAsync, Result } from 'neverthrow' import querystring from 'querystring' import { createLoggerWithLabel } from '../../../../config/logger' +import { AuthType } from '../../../../types' import { createReqMeta } from '../../../utils/request' +import { MyInfoFactory } from '../../myinfo/myinfo.factory' +import { + MyInfoCookiePayload, + MyInfoCookieState, +} from '../../myinfo/myinfo.types' +import { + extractMyInfoCookie, + validateMyInfoForm, +} from '../../myinfo/myinfo.util' +import { AuthTypeMismatchError } from '../../spcp/spcp.errors' +import { SpcpFactory } from '../../spcp/spcp.factory' import { PrivateFormError } from '../form.errors' import * as FormService from '../form.service' @@ -161,3 +174,82 @@ export const handleRedirect: RequestHandler< redirectPath, }) } + +const extractCookieInfo = ( + cookiePayload: MyInfoCookiePayload, + authType: AuthType, +): Result => + authType === AuthType.MyInfo + ? ok(cookiePayload) + : err(new AuthTypeMismatchError(AuthType.MyInfo)) + +export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( + req, + res, +) => { + const { formId } = req.params + // eslint-disable-next-line typesafe/no-await-without-trycatch + const formData = await FormService.retrievePublicFormById(formId) + .andThen((form) => + FormService.checkFormSubmissionLimitAndDeactivateForm(form), + ) + .andThen((form) => { + const { authType } = form + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const jwtPayload = SpcpFactory.getSpcpSession( + authType, + req.cookies, + ).mapErr((error) => { + logger.error({ + message: 'Failed to verify JWT with auth client', + meta: { + action: 'addSpcpSessionInfo', + ...createReqMeta(req), + }, + error, + }) + return error + }) + + const myInfoCookie: Result< + MyInfoCookiePayload, + AuthTypeMismatchError + > = extractMyInfoCookie(req.cookies).andThen((cookiePayload) => + extractCookieInfo(cookiePayload, authType), + ) + + const myInfoError = myInfoCookie + .andThen(({ state }) => ok(state !== MyInfoCookieState.Success)) + .unwrapOr(true) + + const requestedAttributes = form.getUniqueMyInfoAttrs() + const spcpSession = myInfoCookie.asyncAndThen((cookiePayload) => { + return validateMyInfoForm(form).asyncAndThen((form) => + cookiePayload.state === MyInfoCookieState.Success + ? MyInfoFactory.fetchMyInfoPersonData( + cookiePayload.accessToken, + requestedAttributes, + form.esrvcId, + ).andThen((myinfoData) => + okAsync({ userName: myinfoData.getUinFin() }), + ) + : errAsync('cookie payload has wrong state'), + ) + }) + + return { + form: form.getPublicView(), + spcpSession, + myInfoError, + } + + // .map() => return form and spcpSession + // .mapErr() => return form and myInfoError + }) + + return res.json(formData) +} + +// SpcpController.addSpcpSessionInfo, +// MyInfoMiddleware.addMyInfo, +// forms.read(forms.REQUEST_TYPE.PUBLIC), diff --git a/src/app/modules/form/public-form/public-form.routes.ts b/src/app/modules/form/public-form/public-form.routes.ts new file mode 100644 index 0000000000..0d4fd71cc0 --- /dev/null +++ b/src/app/modules/form/public-form/public-form.routes.ts @@ -0,0 +1,27 @@ +import { Router } from 'express' + +import * as PublicFormController from './public-form.controller' + +export const PublicFormRouter = Router() + +/** + * Returns the specified form to the user, along with any + * identity information obtained from SingPass/CorpPass, + * and MyInfo details, if any. + * + * 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}/publicform + * @group forms - endpoints to serve forms + * @param {string} formId.path.required - the form id + * @consumes application/json + * @produces application/json + * @returns {string} 404 - form is not made public + * @returns {PublicForm.model} 200 - the form, and other information + */ +PublicFormRouter.get( + '/:formId([a-fA-F0-9]{24})/publicform', + PublicFormController.handleGetPublicForm, +) From e6a1455faa886e0bc9846220ceca30a004e8e25b Mon Sep 17 00:00:00 2001 From: seaerchin Date: Wed, 17 Mar 2021 19:59:35 +0800 Subject: [PATCH 04/86] refactor(utils): adds utility type for all possible db errors --- src/app/utils/handle-mongo-error.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/app/utils/handle-mongo-error.ts b/src/app/utils/handle-mongo-error.ts index cf3033402c..c2ca6ca243 100644 --- a/src/app/utils/handle-mongo-error.ts +++ b/src/app/utils/handle-mongo-error.ts @@ -8,6 +8,12 @@ import { DatabaseValidationError, } from '../modules/core/core.errors' +export type PossibleDatabaseError = + | DatabaseError + | DatabaseValidationError + | DatabaseConflictError + | DatabasePayloadSizeError + /** * Exported for testing. * Format error recovery message to be returned to client. @@ -66,13 +72,7 @@ export const getMongoErrorMessage = ( * @param error the error thrown by database operations * @returns errors that extend from ApplicationError class */ -export const transformMongoError = ( - error: unknown, -): - | DatabaseError - | DatabaseValidationError - | DatabaseConflictError - | DatabasePayloadSizeError => { +export const transformMongoError = (error: unknown): PossibleDatabaseError => { const errorMessage = getMongoErrorMessage(error) if (!(error instanceof Error)) { return new DatabaseError(errorMessage) From b5e57181d7e857d5f9b1954d550c673f385888a2 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Wed, 17 Mar 2021 20:00:54 +0800 Subject: [PATCH 05/86] fix(types/form): fixed the type of submissionLimit to allow for null --- src/types/form.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/types/form.ts b/src/types/form.ts index c2928fb977..44263ed400 100644 --- a/src/types/form.ts +++ b/src/types/form.ts @@ -129,7 +129,7 @@ export interface IForm { status?: Status inactiveMessage?: string - submissionLimit?: number + submissionLimit?: number | null isListed?: boolean esrvcId?: string webhook?: Webhook @@ -208,7 +208,7 @@ export interface IFormDocument extends IFormSchema { authType: NonNullable status: NonNullable inactiveMessage: NonNullable - submissionLimit: NonNullable + submissionLimit: Exclude isListed: NonNullable form_fields: NonNullable startPage: SetRequired, 'colorTheme'> From 7b25c8820311b472876c559761a9e5117ea71d2b Mon Sep 17 00:00:00 2001 From: seaerchin Date: Wed, 17 Mar 2021 20:04:41 +0800 Subject: [PATCH 06/86] refactor(modules/form): updated deactiveForm so that it uses never throw and adds logging --- src/app/modules/form/form.service.ts | 31 ++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/src/app/modules/form/form.service.ts b/src/app/modules/form/form.service.ts index edbdf91708..d25dc2d63a 100644 --- a/src/app/modules/form/form.service.ts +++ b/src/app/modules/form/form.service.ts @@ -38,17 +38,15 @@ const EncryptedFormModel = getEncryptedFormModel(mongoose) const SubmissionModel = getSubmissionModel(mongoose) /** - * Intentionally caught instead of neverthrown to prevent blocking because the result of the call is not important. + * Deactivates a given form by its id * @param formId the id of the form to deactivate - * @returns Promise(IFormSchema) the db object of the form if the form is successfully deactivated + * @returns Promise the db object of the form if the form is successfully deactivated * @returns null if an error is thrown while deactivating */ -export const deactivateForm = async ( +export const deactivateForm = ( formId: string, -): Promise => { - try { - return FormModel.deactivateById(formId) - } catch (error) { +): ResultAsync => { + return ResultAsync.fromPromise(FormModel.deactivateById(formId), (error) => { logger.error({ message: 'Error deactivating form by id', meta: { @@ -57,8 +55,23 @@ export const deactivateForm = async ( }, error, }) - return null - } + + return transformMongoError(error) + }).andThen((deactivatedForm) => { + if (!deactivatedForm) { + logger.error({ + message: + 'Attempted to deactivate form that cannot be found in the database', + meta: { + action: 'deactivateForm', + form: formId, + }, + }) + return errAsync(new FormNotFoundError()) + } + // Successfully deactivated. + return okAsync(true) + }) } /** From de6e0a2dcb5c98c24c3580086c5dc2a621783d15 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Wed, 17 Mar 2021 20:06:51 +0800 Subject: [PATCH 07/86] refactor(modules/form): updated form limit checking to account for submission limit being null --- src/app/modules/form/form.service.ts | 66 +++++++++++++--------------- 1 file changed, 30 insertions(+), 36 deletions(-) diff --git a/src/app/modules/form/form.service.ts b/src/app/modules/form/form.service.ts index d25dc2d63a..a753a08436 100644 --- a/src/app/modules/form/form.service.ts +++ b/src/app/modules/form/form.service.ts @@ -17,13 +17,10 @@ import getFormModel, { import getSubmissionModel from '../../models/submission.server.model' import { getMongoErrorMessage, + PossibleDatabaseError, transformMongoError, } from '../../utils/handle-mongo-error' -import { - ApplicationError, - DatabaseError, - DatabaseValidationError, -} from '../core/core.errors' +import { DatabaseError } from '../core/core.errors' import { FormDeletedError, @@ -86,7 +83,7 @@ export const retrieveFullFormById = ( ): ResultAsync => { if (!mongoose.Types.ObjectId.isValid(formId)) { return errAsync(new FormNotFoundError()) - } + } return ResultAsync.fromPromise(FormModel.getFullFormById(formId), (error) => { logger.error({ @@ -151,15 +148,10 @@ export const retrieveFormById = ( * @returns ok(true) if form is public * @returns err(FormDeletedError) if form has been deleted * @returns err(PrivateFormError) if form is private, the message will be the form inactive message - * @returns err(ApplicationError) if form has an invalid state */ export const isFormPublic = ( form: IPopulatedForm, -): Result => { - if (!form.status) { - return err(new ApplicationError()) - } - +): Result => { switch (form.status) { case Status.Public: return ok(true) @@ -180,42 +172,50 @@ export const checkFormSubmissionLimitAndDeactivateForm = ( form: IPopulatedForm, ): ResultAsync< IPopulatedForm, - DatabaseError | DatabaseValidationError | PrivateFormError + PossibleDatabaseError | PrivateFormError | FormNotFoundError > => { - if (!form.submissionLimit) { + const { submissionLimit } = form + const formId = String(form._id) + // Not using falsey check as submissionLimit === 0 can result in incorrectly + // returning form without any actions. + if (submissionLimit === null) { return okAsync(form) } return ResultAsync.fromPromise( SubmissionModel.countDocuments({ - form: form._id, + form: formId, }).exec(), (error) => { logger.error({ message: 'Error counting documents', meta: { action: 'checkFormSubmissionLimitAndDeactivateForm', - form: form._id, + form: formId, }, error, }) return transformMongoError(error) }, ).andThen((currentCount) => { - if (currentCount >= form.submissionLimit) { - logger.info({ - message: 'Form reached maximum submission count, deactivating.', - meta: { - form: form._id, - action: 'checkFormSubmissionLimitAndDeactivate', - }, - }) - - await deactivateForm(form._id) - return errAsync(new PrivateFormError(form.inactiveMessage, form.title)) - } else { + // Limit has not been hit yet, passthrough. + if (currentCount < submissionLimit) { return okAsync(form) } + + logger.info({ + message: 'Form reached maximum submission count, deactivating.', + meta: { + form: formId, + action: 'checkFormSubmissionLimitAndDeactivate', + }, + }) + + // Map success case back into error to display to client as form has been + // deactivated. + return deactivateForm(formId).andThen(() => + errAsync(new PrivateFormError(form.inactiveMessage, form.title)), + ) }) } @@ -229,16 +229,10 @@ export const retrievePublicFormById = ( formId: string, ): ResultAsync< IPopulatedForm, - | FormNotFoundError - | DatabaseError - | FormDeletedError - | PrivateFormError - | ApplicationError + FormNotFoundError | DatabaseError | FormDeletedError | PrivateFormError > => { return retrieveFullFormById(formId).andThen((form) => - isFormPublic(form) - .map(() => form) - .mapErr((error) => error), + isFormPublic(form).map(() => form), ) } From c0ccea57a155d16d040a113a4fdc14888e58476a Mon Sep 17 00:00:00 2001 From: seaerchin Date: Wed, 17 Mar 2021 20:11:19 +0800 Subject: [PATCH 08/86] refactor(modules/form): adds logging to getSpcpSession --- src/app/modules/spcp/spcp.service.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/app/modules/spcp/spcp.service.ts b/src/app/modules/spcp/spcp.service.ts index 7bafb719ce..a9f03b24a8 100644 --- a/src/app/modules/spcp/spcp.service.ts +++ b/src/app/modules/spcp/spcp.service.ts @@ -397,7 +397,7 @@ export class SpcpService { } /** - * Gets the spcp session info from the auth and the cookies + * Gets the spcp session info from the auth, cookies * @param authType The authentication type of the user * @param cookies The spcp cookies set by the redirect * @return okAsync(jwtPayload) if successful @@ -412,6 +412,19 @@ export class SpcpService { this.extractJwtPayload(jwtResult, authType), ) } - return errAsync(new AuthTypeMismatchError(authType)) + + const error = new AuthTypeMismatchError(authType) + const logMeta = { + action: 'getSpcpSession', + authType, + } + + logger.error({ + message: 'Failed to obtain spcp session info from cookies', + meta: logMeta, + error, + }) + + return errAsync(error) } } From f167eb433dcc468e82bfb07aa0f8053963d36557 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Wed, 17 Mar 2021 20:13:04 +0800 Subject: [PATCH 09/86] refactor(modules/form): refactored handleGetPublicForm for clarity and code cleanliness --- src/app/modules/form/form.service.ts | 2 +- .../public-form/public-form.controller.ts | 179 +++++++++++------- 2 files changed, 109 insertions(+), 72 deletions(-) diff --git a/src/app/modules/form/form.service.ts b/src/app/modules/form/form.service.ts index a753a08436..1854e9837c 100644 --- a/src/app/modules/form/form.service.ts +++ b/src/app/modules/form/form.service.ts @@ -83,7 +83,7 @@ export const retrieveFullFormById = ( ): ResultAsync => { if (!mongoose.Types.ObjectId.isValid(formId)) { return errAsync(new FormNotFoundError()) - } + } return ResultAsync.fromPromise(FormModel.getFullFormById(formId), (error) => { logger.error({ diff --git a/src/app/modules/form/public-form/public-form.controller.ts b/src/app/modules/form/public-form/public-form.controller.ts index fff52d1208..8739c43c8b 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -1,21 +1,18 @@ import { RequestHandler } from 'express' import { StatusCodes } from 'http-status-codes' -import { err, errAsync, ok, okAsync, Result } from 'neverthrow' +import _ from 'lodash' import querystring from 'querystring' import { createLoggerWithLabel } from '../../../../config/logger' import { AuthType } from '../../../../types' import { createReqMeta } from '../../../utils/request' +import { getFormIfPublic } from '../../auth/auth.service' import { MyInfoFactory } from '../../myinfo/myinfo.factory' -import { - MyInfoCookiePayload, - MyInfoCookieState, -} from '../../myinfo/myinfo.types' +import { MyInfoCookieState } from '../../myinfo/myinfo.types' import { extractMyInfoCookie, validateMyInfoForm, } from '../../myinfo/myinfo.util' -import { AuthTypeMismatchError } from '../../spcp/spcp.errors' import { SpcpFactory } from '../../spcp/spcp.factory' import { PrivateFormError } from '../form.errors' import * as FormService from '../form.service' @@ -175,81 +172,121 @@ export const handleRedirect: RequestHandler< }) } -const extractCookieInfo = ( - cookiePayload: MyInfoCookiePayload, - authType: AuthType, -): Result => - authType === AuthType.MyInfo - ? ok(cookiePayload) - : err(new AuthTypeMismatchError(AuthType.MyInfo)) - export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( req, res, ) => { const { formId } = req.params - // eslint-disable-next-line typesafe/no-await-without-trycatch - const formData = await FormService.retrievePublicFormById(formId) - .andThen((form) => - FormService.checkFormSubmissionLimitAndDeactivateForm(form), - ) - .andThen((form) => { - const { authType } = form - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const jwtPayload = SpcpFactory.getSpcpSession( - authType, - req.cookies, - ).mapErr((error) => { - logger.error({ - message: 'Failed to verify JWT with auth client', - meta: { - action: 'addSpcpSessionInfo', - ...createReqMeta(req), - }, - error, - }) - return error - }) - const myInfoCookie: Result< - MyInfoCookiePayload, - AuthTypeMismatchError - > = extractMyInfoCookie(req.cookies).andThen((cookiePayload) => - extractCookieInfo(cookiePayload, authType), - ) + const formResult = await getFormIfPublic(formId).andThen((form) => + FormService.checkFormSubmissionLimitAndDeactivateForm(form), + ) - const myInfoError = myInfoCookie - .andThen(({ state }) => ok(state !== MyInfoCookieState.Success)) - .unwrapOr(true) - - const requestedAttributes = form.getUniqueMyInfoAttrs() - const spcpSession = myInfoCookie.asyncAndThen((cookiePayload) => { - return validateMyInfoForm(form).asyncAndThen((form) => - cookiePayload.state === MyInfoCookieState.Success - ? MyInfoFactory.fetchMyInfoPersonData( - cookiePayload.accessToken, - requestedAttributes, - form.esrvcId, - ).andThen((myinfoData) => - okAsync({ userName: myinfoData.getUinFin() }), - ) - : errAsync('cookie payload has wrong state'), - ) - }) + // Early return if form is not public or any error occurred. + if (formResult.isErr()) { + const { error } = formResult + logger.error({ + message: 'Error retrieving public form', + meta: { + action: 'handleGetPublicForm', + ...createReqMeta(req), + formId, + }, + error, + }) + const { errorMessage, statusCode } = mapRouteError(error) + return res.status(statusCode).json({ message: errorMessage }) + } + + const form = formResult.value + const { authType } = form + + // Form is valid, check for SPCP/MyInfo data. + const spcpSessionResult = ( + await SpcpFactory.getSpcpSession(authType, req.cookies) + ).map(({ userName }) => ({ userName })) - return { + const validatedMyInfoForm = await validateMyInfoForm(form) + + // NOTE: This function is called only when MyInfo verification fails. + // Hence, myInfoError is awlays true when there is a spcp session result + const generateSPCPResponse = () => { + if (spcpSessionResult.isOk()) { + const spcpSession = spcpSessionResult.value + return res.json({ form: form.getPublicView(), spcpSession, - myInfoError, - } + myInfoError: true, + }) + } else { + const { error } = spcpSessionResult + logger.error({ + message: 'Error getting public form', + meta: { + action: 'handleGetPublicForm', + ...createReqMeta(req), + formId, + }, + error, + }) + return res.json({ + form: form.getPublicView(), + }) + } + } - // .map() => return form and spcpSession - // .mapErr() => return form and myInfoError - }) + const myInfoCookie = extractMyInfoCookie(req.cookies) - return res.json(formData) -} + if ( + authType !== AuthType.MyInfo || + myInfoCookie.isErr() || + validatedMyInfoForm.isErr() + ) { + return generateSPCPResponse() + } -// SpcpController.addSpcpSessionInfo, -// MyInfoMiddleware.addMyInfo, -// forms.read(forms.REQUEST_TYPE.PUBLIC), + const cookiePayload = myInfoCookie.value + const requestedAttributes = form.getUniqueMyInfoAttrs() + + if (cookiePayload.state === MyInfoCookieState.Success) { + // eslint-disable-next-line typesafe/no-await-without-trycatch + const formResponse = await validateMyInfoForm(form) + .asyncAndThen((form) => + MyInfoFactory.fetchMyInfoPersonData( + cookiePayload.accessToken, + requestedAttributes, + form.esrvcId, + ), + ) + .andThen((myInfoData) => + MyInfoFactory.prefillMyInfoFields( + myInfoData, + form.toJSON().form_fields, + ).map((formFields) => ({ + formFields, + spcpSession: { userName: myInfoData.getUinFin() }, + })), + ) + .map(async (form) => { + // eslint-disable-next-line typesafe/no-await-without-trycatch + await MyInfoFactory.saveMyInfoHashes( + form.spcpSession.userName, + formId, + form.formFields, + ) + return form + }) + .map(({ spcpSession, formFields }) => + res.json({ + form: _.set(form, 'form_fields', formFields), + spcpSession, + }), + ) + + if (formResponse.isOk()) { + return formResponse.value + } + return generateSPCPResponse() + } + return generateSPCPResponse() +} From ce212d7eb5af91572f1f0730121bcacb2e774413 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Thu, 18 Mar 2021 15:11:15 +0800 Subject: [PATCH 10/86] refactor(publicformctrl): refactored handler for GET public forms to be cleaner and easier to read --- .../public-form/public-form.controller.ts | 167 ++++++++++-------- 1 file changed, 93 insertions(+), 74 deletions(-) diff --git a/src/app/modules/form/public-form/public-form.controller.ts b/src/app/modules/form/public-form/public-form.controller.ts index 8739c43c8b..93bd108f1c 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -172,6 +172,13 @@ export const handleRedirect: RequestHandler< }) } +/** + * Handler for GET /:formId/publicform endpoint + * @returns 200 if the form exists + * @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 or if the type of error is unknown + */ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( req, res, @@ -201,92 +208,104 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( const form = formResult.value const { authType } = form - // Form is valid, check for SPCP/MyInfo data. - const spcpSessionResult = ( - await SpcpFactory.getSpcpSession(authType, req.cookies) - ).map(({ userName }) => ({ userName })) + // Shows the client a form based on how they are authorized + // If the client is MyInfo, we have to prefill the form + switch (authType) { + case AuthType.SP: + case AuthType.CP: { + // Form is valid, check for SPCP/MyInfo data. + const spcpSessionResult = ( + await SpcpFactory.getSpcpSession(authType, req.cookies) + ).map(({ userName }) => ({ userName })) - const validatedMyInfoForm = await validateMyInfoForm(form) + if (spcpSessionResult.isErr()) { + const { error } = spcpSessionResult + logger.error({ + message: 'Error getting public form', + meta: { + action: 'handleGetPublicForm', + ...createReqMeta(req), + formId, + }, + error, + }) + return res.json({ + form: form.getPublicView(), + }) + } - // NOTE: This function is called only when MyInfo verification fails. - // Hence, myInfoError is awlays true when there is a spcp session result - const generateSPCPResponse = () => { - if (spcpSessionResult.isOk()) { const spcpSession = spcpSessionResult.value + return res.json({ form: form.getPublicView(), spcpSession, - myInfoError: true, - }) - } else { - const { error } = spcpSessionResult - logger.error({ - message: 'Error getting public form', - meta: { - action: 'handleGetPublicForm', - ...createReqMeta(req), - formId, - }, - error, - }) - return res.json({ - form: form.getPublicView(), }) } - } - const myInfoCookie = extractMyInfoCookie(req.cookies) + case AuthType.MyInfo: { + const validatedMyInfoForm = await validateMyInfoForm(form) + const myInfoCookie = extractMyInfoCookie(req.cookies) + const requestedAttributes = form.getUniqueMyInfoAttrs() - if ( - authType !== AuthType.MyInfo || - myInfoCookie.isErr() || - validatedMyInfoForm.isErr() - ) { - return generateSPCPResponse() - } + if (myInfoCookie.isErr() || validatedMyInfoForm.isErr()) { + return res.json({ + form: form.getPublicView(), + myInfoError: true, + }) + } - const cookiePayload = myInfoCookie.value - const requestedAttributes = form.getUniqueMyInfoAttrs() - - if (cookiePayload.state === MyInfoCookieState.Success) { - // eslint-disable-next-line typesafe/no-await-without-trycatch - const formResponse = await validateMyInfoForm(form) - .asyncAndThen((form) => - MyInfoFactory.fetchMyInfoPersonData( - cookiePayload.accessToken, - requestedAttributes, - form.esrvcId, - ), - ) - .andThen((myInfoData) => - MyInfoFactory.prefillMyInfoFields( - myInfoData, - form.toJSON().form_fields, - ).map((formFields) => ({ - formFields, - spcpSession: { userName: myInfoData.getUinFin() }, - })), - ) - .map(async (form) => { - // eslint-disable-next-line typesafe/no-await-without-trycatch - await MyInfoFactory.saveMyInfoHashes( - form.spcpSession.userName, - formId, - form.formFields, - ) - return form + const errorResponse = res.json({ + form: form.getPublicView(), + myInfoError: true, }) - .map(({ spcpSession, formFields }) => - res.json({ - form: _.set(form, 'form_fields', formFields), - spcpSession, - }), - ) - - if (formResponse.isOk()) { - return formResponse.value + + const cookiePayload = myInfoCookie.value + + if (cookiePayload.state !== MyInfoCookieState.Success) { + return errorResponse + } + // eslint-disable-next-line typesafe/no-await-without-trycatch + const formResponse = await validateMyInfoForm(form) + .asyncAndThen((form) => + MyInfoFactory.fetchMyInfoPersonData( + cookiePayload.accessToken, + requestedAttributes, + form.esrvcId, + ), + ) + .andThen((myInfoData) => + MyInfoFactory.prefillMyInfoFields( + myInfoData, + form.toJSON().form_fields, + ).map((formFields) => ({ + formFields, + spcpSession: { userName: myInfoData.getUinFin() }, + })), + ) + .map(async (form) => { + // eslint-disable-next-line typesafe/no-await-without-trycatch + await MyInfoFactory.saveMyInfoHashes( + form.spcpSession.userName, + formId, + form.formFields, + ) + return form + }) + .map(({ spcpSession, formFields }) => + res.json({ + form: _.set(form, 'form_fields', formFields), + spcpSession, + }), + ) + + if (formResponse.isOk()) { + return formResponse.value + } + return errorResponse } - return generateSPCPResponse() + default: + return res.json({ + form: form.getPublicView(), + }) } - return generateSPCPResponse() } From 5dab71d2d595ec559f4a97507fec827af5bf00dd Mon Sep 17 00:00:00 2001 From: seaerchin Date: Thu, 18 Mar 2021 15:27:43 +0800 Subject: [PATCH 11/86] refactor(spcpsvc): added logging to extract JWT; updated typings for getSpcpSession --- src/app/modules/spcp/spcp.service.ts | 40 +++++++++++++--------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/src/app/modules/spcp/spcp.service.ts b/src/app/modules/spcp/spcp.service.ts index a9f03b24a8..11f430b5e6 100644 --- a/src/app/modules/spcp/spcp.service.ts +++ b/src/app/modules/spcp/spcp.service.ts @@ -10,7 +10,6 @@ import { AuthType } from '../../../types' import { ApplicationError } from '../core/core.errors' import { - AuthTypeMismatchError, CreateRedirectUrlError, FetchLoginPageError, InvalidJwtError, @@ -202,9 +201,22 @@ export class SpcpService { ): Result { const jwtName = authType === AuthType.SP ? JwtName.SP : JwtName.CP const cookie = cookies[jwtName] + if (!cookie) { - return err(new MissingJwtError()) + const error = new MissingJwtError() + const logMeta = { + action: 'extractJWT', + authType, + cookies, + } + logger.error({ + message: 'Failed to extract SPCP jwt cookie', + meta: logMeta, + error, + }) + return err(error) } + return ok(cookie) } @@ -404,27 +416,11 @@ export class SpcpService { * @return errAsync(error) the kind of error encountered */ getSpcpSession( - authType: AuthType, + authType: AuthType.SP | AuthType.CP, cookies: SpcpCookies, ): ResultAsync { - if (authType === AuthType.SP || authType === AuthType.CP) { - return this.extractJwt(cookies, authType).asyncAndThen((jwtResult) => - this.extractJwtPayload(jwtResult, authType), - ) - } - - const error = new AuthTypeMismatchError(authType) - const logMeta = { - action: 'getSpcpSession', - authType, - } - - logger.error({ - message: 'Failed to obtain spcp session info from cookies', - meta: logMeta, - error, - }) - - return errAsync(error) + return this.extractJwt(cookies, authType).asyncAndThen((jwtResult) => + this.extractJwtPayload(jwtResult, authType), + ) } } From 3aeb40d5ee536a43e4626aca759d09019e89198a Mon Sep 17 00:00:00 2001 From: seaerchin Date: Thu, 18 Mar 2021 15:30:08 +0800 Subject: [PATCH 12/86] revert(formsvc): removes retrievePublicForm (will be done in another PR) to limit scope --- src/app/modules/form/form.service.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/app/modules/form/form.service.ts b/src/app/modules/form/form.service.ts index 1854e9837c..93bfb96b95 100644 --- a/src/app/modules/form/form.service.ts +++ b/src/app/modules/form/form.service.ts @@ -219,23 +219,6 @@ export const checkFormSubmissionLimitAndDeactivateForm = ( }) } -/** - * Method to retrieve a fully populated form that is public - * @param formId the id of the form to retrieve - * @returns okAsync(form) if the form was retrieved successfully - * @returns errAsync(error) the kind of error resulting from unsuccessful retrieval - */ -export const retrievePublicFormById = ( - formId: string, -): ResultAsync< - IPopulatedForm, - FormNotFoundError | DatabaseError | FormDeletedError | PrivateFormError -> => { - return retrieveFullFormById(formId).andThen((form) => - isFormPublic(form).map(() => form), - ) -} - export const getFormModelByResponseMode = ( responseMode: ResponseMode, ): IEmailFormModel | IEncryptedFormModel => { From 65e650567ba17e4cedd826fd2c0bf652a9a75bd8 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Thu, 18 Mar 2021 15:48:31 +0800 Subject: [PATCH 13/86] style(public-form/controller): updated object to use variables --- .../form/public-form/public-form.controller.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/app/modules/form/public-form/public-form.controller.ts b/src/app/modules/form/public-form/public-form.controller.ts index 93bd108f1c..5613779cec 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -247,18 +247,15 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( const myInfoCookie = extractMyInfoCookie(req.cookies) const requestedAttributes = form.getUniqueMyInfoAttrs() - if (myInfoCookie.isErr() || validatedMyInfoForm.isErr()) { - return res.json({ - form: form.getPublicView(), - myInfoError: true, - }) - } - const errorResponse = res.json({ form: form.getPublicView(), myInfoError: true, }) + if (myInfoCookie.isErr() || validatedMyInfoForm.isErr()) { + return errorResponse + } + const cookiePayload = myInfoCookie.value if (cookiePayload.state !== MyInfoCookieState.Success) { From 6e9e470ec331e5ffd3509dc7c3a627e881d4f747 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Thu, 18 Mar 2021 18:07:20 +0800 Subject: [PATCH 14/86] style(services/types): updated documentation for functions and removed unneeded logging --- src/app/modules/form/form.service.ts | 9 ++++++--- src/app/modules/spcp/spcp.service.ts | 13 ++++++++----- src/types/form.ts | 2 ++ 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/app/modules/form/form.service.ts b/src/app/modules/form/form.service.ts index 93bfb96b95..b73e9af217 100644 --- a/src/app/modules/form/form.service.ts +++ b/src/app/modules/form/form.service.ts @@ -37,8 +37,9 @@ const SubmissionModel = getSubmissionModel(mongoose) /** * Deactivates a given form by its id * @param formId the id of the form to deactivate - * @returns Promise the db object of the form if the form is successfully deactivated - * @returns null if an error is thrown while deactivating + * @returns ok(true) if the form has been deactivated successfully + * @returns err(PossibleDatabaseError) if an error occurred while trying to deactivate the form + * @returns err(FormNotFoundError) if there is no form with the given formId */ export const deactivateForm = ( formId: string, @@ -166,7 +167,9 @@ export const isFormPublic = ( * Method to check whether a form has reached submission limits, and deactivate the form if necessary * @param form the form to check * @returns okAsync(form) if submission is allowed because the form has not reached limits - * @returns errAsync(error) if submission is not allowed because the form has reached limits or if an error occurs while counting the documents + * @returns errAsync(PossibleDatabaseError) if an error occurred while querying the database for the specified form + * @returns errAsync(FormNotFoundError) if the form has exceeded the submission limits but could not be found and deactivated + * @returns errAsync(PrivateFormError) if the count of the form has been exceeded and the form has been deactivated */ export const checkFormSubmissionLimitAndDeactivateForm = ( form: IPopulatedForm, diff --git a/src/app/modules/spcp/spcp.service.ts b/src/app/modules/spcp/spcp.service.ts index 11f430b5e6..69a889e4be 100644 --- a/src/app/modules/spcp/spcp.service.ts +++ b/src/app/modules/spcp/spcp.service.ts @@ -203,7 +203,6 @@ export class SpcpService { const cookie = cookies[jwtName] if (!cookie) { - const error = new MissingJwtError() const logMeta = { action: 'extractJWT', authType, @@ -212,9 +211,8 @@ export class SpcpService { logger.error({ message: 'Failed to extract SPCP jwt cookie', meta: logMeta, - error, }) - return err(error) + return err(new MissingJwtError()) } return ok(cookie) @@ -413,12 +411,17 @@ export class SpcpService { * @param authType The authentication type of the user * @param cookies The spcp cookies set by the redirect * @return okAsync(jwtPayload) if successful - * @return errAsync(error) the kind of error encountered + * @return errAsync(MissingJwtError) if the specified cookie for the authType (spcp) does not exist + * @return errAsync(VerifyJwtError) if the jwt exists but could not be authenticated + * @return errAsync(InvalidJwtError) if the jwt exists but the payload is invalid */ getSpcpSession( authType: AuthType.SP | AuthType.CP, cookies: SpcpCookies, - ): ResultAsync { + ): ResultAsync< + JwtPayload, + VerifyJwtError | InvalidJwtError | MissingJwtError + > { return this.extractJwt(cookies, authType).asyncAndThen((jwtResult) => this.extractJwtPayload(jwtResult, authType), ) diff --git a/src/types/form.ts b/src/types/form.ts index 44263ed400..e8964e443a 100644 --- a/src/types/form.ts +++ b/src/types/form.ts @@ -208,6 +208,8 @@ export interface IFormDocument extends IFormSchema { authType: NonNullable status: NonNullable inactiveMessage: NonNullable + // NOTE: Due to the way creating a form works, creating a form without specifying submissionLimit will throw an error. + // Hence, using Exclude here over NonNullable. submissionLimit: Exclude isListed: NonNullable form_fields: NonNullable From 2ac213a7aa03d6b41efccd6d6b3c819b8da7cec8 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Thu, 18 Mar 2021 18:08:28 +0800 Subject: [PATCH 15/86] refactor(public-form/controller): refactored spcp flow so it's clearer --- .../public-form/public-form.controller.ts | 56 ++++++++++--------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/src/app/modules/form/public-form/public-form.controller.ts b/src/app/modules/form/public-form/public-form.controller.ts index 5613779cec..82691da84b 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -206,6 +206,7 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( } const form = formResult.value + const publicFormView = form.getPublicView() const { authType } = form // Shows the client a form based on how they are authorized @@ -214,32 +215,35 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( case AuthType.SP: case AuthType.CP: { // Form is valid, check for SPCP/MyInfo data. - const spcpSessionResult = ( - await SpcpFactory.getSpcpSession(authType, req.cookies) - ).map(({ userName }) => ({ userName })) - - if (spcpSessionResult.isErr()) { - const { error } = spcpSessionResult - logger.error({ - message: 'Error getting public form', - meta: { - action: 'handleGetPublicForm', - ...createReqMeta(req), - formId, - }, - error, - }) - return res.json({ - form: form.getPublicView(), + // eslint-disable-next-line typesafe/no-await-without-trycatch + const spcpResponseResult = await SpcpFactory.getSpcpSession( + authType, + req.cookies, + ) + .map(({ userName }) => + res.json({ + form: publicFormView, + spcpSession: { userName }, + }), + ) + .mapErr((error) => { + logger.error({ + message: 'Error getting public form', + meta: { + action: 'handleGetPublicForm', + ...createReqMeta(req), + formId, + }, + error, + }) + return res.json({ + form: publicFormView, + }) }) - } - - const spcpSession = spcpSessionResult.value - return res.json({ - form: form.getPublicView(), - spcpSession, - }) + return spcpResponseResult.isOk() + ? spcpResponseResult.value + : spcpResponseResult.error } case AuthType.MyInfo: { @@ -248,7 +252,7 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( const requestedAttributes = form.getUniqueMyInfoAttrs() const errorResponse = res.json({ - form: form.getPublicView(), + form: publicFormView, myInfoError: true, }) @@ -302,7 +306,7 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( } default: return res.json({ - form: form.getPublicView(), + form: publicFormView, }) } } From 42c4a37ef11466a54b1627e5f7cd75cc1766878e Mon Sep 17 00:00:00 2001 From: seaerchin Date: Thu, 18 Mar 2021 18:35:07 +0800 Subject: [PATCH 16/86] refactor(public-form/controller): wip for myInfo --- .../public-form/public-form.controller.ts | 49 +++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/src/app/modules/form/public-form/public-form.controller.ts b/src/app/modules/form/public-form/public-form.controller.ts index 82691da84b..e3de79ca8d 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -1,12 +1,14 @@ import { RequestHandler } from 'express' import { StatusCodes } from 'http-status-codes' import _ from 'lodash' +// import { err, ok } from 'neverthrow' import querystring from 'querystring' import { createLoggerWithLabel } from '../../../../config/logger' import { AuthType } from '../../../../types' import { createReqMeta } from '../../../utils/request' import { getFormIfPublic } from '../../auth/auth.service' +// import { MyInfoCookieStateError } from '../../myinfo/myinfo.errors' import { MyInfoFactory } from '../../myinfo/myinfo.factory' import { MyInfoCookieState } from '../../myinfo/myinfo.types' import { @@ -247,7 +249,7 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( } case AuthType.MyInfo: { - const validatedMyInfoForm = await validateMyInfoForm(form) + const validatedMyInfoForm = validateMyInfoForm(form) const myInfoCookie = extractMyInfoCookie(req.cookies) const requestedAttributes = form.getUniqueMyInfoAttrs() @@ -259,14 +261,55 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( if (myInfoCookie.isErr() || validatedMyInfoForm.isErr()) { return errorResponse } - const cookiePayload = myInfoCookie.value if (cookiePayload.state !== MyInfoCookieState.Success) { return errorResponse } + + // const result = extractMyInfoCookie(req.cookies) + // .map((cookiePayload) => + // cookiePayload.state === MyInfoCookieState.Success + // ? cookiePayload + // : new MyInfoCookieStateError(), + // ) + // .asyncAndThen((cookiePayload) => + // validateMyInfoForm(form) + // .asyncAndThen((form) => + // MyInfoFactory.fetchMyInfoPersonData( + // cookiePayload.accessToken, + // requestedAttributes, + // form.esrvcId, + // ), + // ) + // .andThen((myInfoData) => + // MyInfoFactory.prefillMyInfoFields( + // myInfoData, + // form.toJSON().form_fields, + // ).map((formFields) => ({ + // formFields, + // spcpSession: { userName: myInfoData.getUinFin() }, + // })), + // ) + // .map(async (form) => { + // // eslint-disable-next-line typesafe/no-await-without-trycatch + // await MyInfoFactory.saveMyInfoHashes( + // form.spcpSession.userName, + // formId, + // form.formFields, + // ) + // return form + // }) + // .map(({ spcpSession, formFields }) => + // res.json({ + // form: _.set(form, 'form_fields', formFields), + // spcpSession, + // }), + // ), + // ) + // eslint-disable-next-line typesafe/no-await-without-trycatch - const formResponse = await validateMyInfoForm(form) + const formResponse = validateMyInfoForm(form) .asyncAndThen((form) => MyInfoFactory.fetchMyInfoPersonData( cookiePayload.accessToken, From 44f6059660f4a35f5d95c9128438d81355cf0997 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Fri, 19 Mar 2021 12:31:52 +0800 Subject: [PATCH 17/86] refactor(myinfo): extracts myInfoCookiePayload to 2 types and adds utility function to differentiate --- src/app/modules/myinfo/myinfo.types.ts | 20 ++++++++++++-------- src/app/modules/myinfo/myinfo.util.ts | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/app/modules/myinfo/myinfo.types.ts b/src/app/modules/myinfo/myinfo.types.ts index d5d91c8a9f..f14fc231a6 100644 --- a/src/app/modules/myinfo/myinfo.types.ts +++ b/src/app/modules/myinfo/myinfo.types.ts @@ -44,15 +44,19 @@ export enum MyInfoCookieState { Error = 'error', } +export type MyInfoSuccessfulCookiePayload = { + accessToken: string + usedCount: number + state: MyInfoCookieState.Success +} + +export type MyInfoErroredCookiePayload = { + state: Exclude +} + export type MyInfoCookiePayload = - | { - accessToken: string - usedCount: number - state: MyInfoCookieState.Success - } - | { - state: Exclude - } + | MyInfoSuccessfulCookiePayload + | MyInfoErroredCookiePayload /** * The stringified properties included in the state sent to MyInfo. diff --git a/src/app/modules/myinfo/myinfo.util.ts b/src/app/modules/myinfo/myinfo.util.ts index cfc4c88814..807955063d 100644 --- a/src/app/modules/myinfo/myinfo.util.ts +++ b/src/app/modules/myinfo/myinfo.util.ts @@ -41,8 +41,10 @@ import { MyInfoComparePromises, MyInfoCookiePayload, MyInfoCookieState, + MyInfoErroredCookiePayload, MyInfoHashPromises, MyInfoRelayState, + MyInfoSuccessfulCookiePayload, VisibleMyInfoResponse, } from './myinfo.types' @@ -359,6 +361,18 @@ export const extractMyInfoCookie = ( return err(new MyInfoMissingAccessTokenError()) } +/** + * Checks if myInfoCookie is successful and returns the result. + * This function acts as a discriminator so that the type of the cookie is encoded in its type + * @param cookie the cookie to + * @returns ok(cookie) the successful myInfoCookie + * @returns err(cookie) the errored cookie + */ +export const extractSuccessfulCookie = ( + cookie: MyInfoCookiePayload, +): Result => + cookie.state === MyInfoCookieState.Success ? ok(cookie) : err(cookie) + /** * Extracts access token from a MyInfo cookie * @param cookie Cookie from which access token should be extracted From 4549ca8416a2115e069c66ef4e5036d1b8b9a905 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Fri, 19 Mar 2021 12:34:24 +0800 Subject: [PATCH 18/86] refactor(public-form/controller): refactored myinfo chunk so it's neater --- .../public-form/public-form.controller.ts | 134 +++++++----------- 1 file changed, 49 insertions(+), 85 deletions(-) diff --git a/src/app/modules/form/public-form/public-form.controller.ts b/src/app/modules/form/public-form/public-form.controller.ts index e3de79ca8d..06e31d298b 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -1,18 +1,21 @@ import { RequestHandler } from 'express' import { StatusCodes } from 'http-status-codes' import _ from 'lodash' -// import { err, ok } from 'neverthrow' import querystring from 'querystring' import { createLoggerWithLabel } from '../../../../config/logger' import { AuthType } from '../../../../types' import { createReqMeta } from '../../../utils/request' import { getFormIfPublic } from '../../auth/auth.service' -// import { MyInfoCookieStateError } from '../../myinfo/myinfo.errors' +import { + MYINFO_COOKIE_NAME, + MYINFO_COOKIE_OPTIONS, +} from '../../myinfo/myinfo.constants' +import { MyInfoCookieStateError } from '../../myinfo/myinfo.errors' import { MyInfoFactory } from '../../myinfo/myinfo.factory' -import { MyInfoCookieState } from '../../myinfo/myinfo.types' import { extractMyInfoCookie, + extractSuccessfulCookie, validateMyInfoForm, } from '../../myinfo/myinfo.util' import { SpcpFactory } from '../../spcp/spcp.factory' @@ -217,11 +220,7 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( case AuthType.SP: case AuthType.CP: { // Form is valid, check for SPCP/MyInfo data. - // eslint-disable-next-line typesafe/no-await-without-trycatch - const spcpResponseResult = await SpcpFactory.getSpcpSession( - authType, - req.cookies, - ) + return SpcpFactory.getSpcpSession(authType, req.cookies) .map(({ userName }) => res.json({ form: publicFormView, @@ -242,79 +241,31 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( form: publicFormView, }) }) - - return spcpResponseResult.isOk() - ? spcpResponseResult.value - : spcpResponseResult.error } case AuthType.MyInfo: { - const validatedMyInfoForm = validateMyInfoForm(form) - const myInfoCookie = extractMyInfoCookie(req.cookies) + // TODO: shift this out so that the call is FormService.getUniqueMyInfoAttrs(form) const requestedAttributes = form.getUniqueMyInfoAttrs() - const errorResponse = res.json({ - form: publicFormView, - myInfoError: true, - }) - - if (myInfoCookie.isErr() || validatedMyInfoForm.isErr()) { - return errorResponse - } - const cookiePayload = myInfoCookie.value - - if (cookiePayload.state !== MyInfoCookieState.Success) { - return errorResponse - } - - // const result = extractMyInfoCookie(req.cookies) - // .map((cookiePayload) => - // cookiePayload.state === MyInfoCookieState.Success - // ? cookiePayload - // : new MyInfoCookieStateError(), - // ) - // .asyncAndThen((cookiePayload) => - // validateMyInfoForm(form) - // .asyncAndThen((form) => - // MyInfoFactory.fetchMyInfoPersonData( - // cookiePayload.accessToken, - // requestedAttributes, - // form.esrvcId, - // ), - // ) - // .andThen((myInfoData) => - // MyInfoFactory.prefillMyInfoFields( - // myInfoData, - // form.toJSON().form_fields, - // ).map((formFields) => ({ - // formFields, - // spcpSession: { userName: myInfoData.getUinFin() }, - // })), - // ) - // .map(async (form) => { - // // eslint-disable-next-line typesafe/no-await-without-trycatch - // await MyInfoFactory.saveMyInfoHashes( - // form.spcpSession.userName, - // formId, - // form.formFields, - // ) - // return form - // }) - // .map(({ spcpSession, formFields }) => - // res.json({ - // form: _.set(form, 'form_fields', formFields), - // spcpSession, - // }), - // ), - // ) - + // 1. Validate the cookie and myInfo form + // 2. Fetch myInfo data and fill the form based on the result + // 3. Hash and save to database + // 4. Return result if successful otherwise, clear cookies and return default response // eslint-disable-next-line typesafe/no-await-without-trycatch - const formResponse = validateMyInfoForm(form) - .asyncAndThen((form) => - MyInfoFactory.fetchMyInfoPersonData( - cookiePayload.accessToken, - requestedAttributes, - form.esrvcId, + return extractMyInfoCookie(req.cookies) + .andThen((cookiePayload) => + // Transform into an error because no meaningful work can be done on a errored cookie + extractSuccessfulCookie(cookiePayload).mapErr( + () => new MyInfoCookieStateError(), + ), + ) + .asyncAndThen((cookiePayload) => + validateMyInfoForm(form).asyncAndThen((form) => + MyInfoFactory.fetchMyInfoPersonData( + cookiePayload.accessToken, + requestedAttributes, + form.esrvcId, + ), ), ) .andThen((myInfoData) => @@ -326,26 +277,39 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( spcpSession: { userName: myInfoData.getUinFin() }, })), ) - .map(async (form) => { + .andThen((form) => // eslint-disable-next-line typesafe/no-await-without-trycatch - await MyInfoFactory.saveMyInfoHashes( + MyInfoFactory.saveMyInfoHashes( form.spcpSession.userName, formId, form.formFields, - ) - return form - }) + ).map(() => form), + ) .map(({ spcpSession, formFields }) => res.json({ form: _.set(form, 'form_fields', formFields), spcpSession, }), ) - - if (formResponse.isOk()) { - return formResponse.value - } - return errorResponse + .mapErr((error) => { + logger.error({ + message: error.message, + meta: { + action: 'handlePublicForm', + ...createReqMeta(req), + formId: formId, + esrvcId: form.esrvcId, + requestedAttributes, + }, + error, + }) + return res + .clearCookie(MYINFO_COOKIE_NAME, MYINFO_COOKIE_OPTIONS) + .json({ + form: publicFormView, + myInfoError: true, + }) + }) } default: return res.json({ From 4d02a8fa5f4136d84097c1a978cfe86f8818390b Mon Sep 17 00:00:00 2001 From: seaerchin Date: Fri, 19 Mar 2021 14:38:03 +0800 Subject: [PATCH 19/86] test(form/service): fixes failing tests due to checkFormSubmissionLimitAndDeactivateForm --- src/app/modules/form/__tests__/form.service.spec.ts | 6 +++--- src/app/modules/form/form.service.ts | 8 ++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/app/modules/form/__tests__/form.service.spec.ts b/src/app/modules/form/__tests__/form.service.spec.ts index e6774d33b8..0363ff0435 100644 --- a/src/app/modules/form/__tests__/form.service.spec.ts +++ b/src/app/modules/form/__tests__/form.service.spec.ts @@ -242,7 +242,7 @@ describe('FormService', () => { ) // Assert - expect(actual._unsafeUnwrap()).toEqual(true) + expect(actual._unsafeUnwrap()).toEqual(form) }) it('should let requests through when form has not reached submission limit', async () => { @@ -268,11 +268,11 @@ describe('FormService', () => { // Act const actual = await FormService.checkFormSubmissionLimitAndDeactivateForm( - form, + form as IPopulatedForm, ) // Assert - expect(actual._unsafeUnwrap()).toEqual(true) + expect(actual._unsafeUnwrap()).toEqual(validForm) }) it('should not let requests through and deactivate form when form has reached submission limit', async () => { diff --git a/src/app/modules/form/form.service.ts b/src/app/modules/form/form.service.ts index b73e9af217..f950b8187f 100644 --- a/src/app/modules/form/form.service.ts +++ b/src/app/modules/form/form.service.ts @@ -20,7 +20,7 @@ import { PossibleDatabaseError, transformMongoError, } from '../../utils/handle-mongo-error' -import { DatabaseError } from '../core/core.errors' +import { ApplicationError, DatabaseError } from '../core/core.errors' import { FormDeletedError, @@ -147,12 +147,16 @@ export const retrieveFormById = ( * Method to ensure given form is available to the public. * @param form the form to check * @returns ok(true) if form is public + * @returns err(ApplicationError) if form has an invalid state * @returns err(FormDeletedError) if form has been deleted * @returns err(PrivateFormError) if form is private, the message will be the form inactive message */ export const isFormPublic = ( form: IPopulatedForm, -): Result => { +): Result => { + if (!form.status) { + return err(new ApplicationError()) + } switch (form.status) { case Status.Public: return ok(true) From 10cfafd3772ca1a25361dacb5b0d3d6a0b2698df Mon Sep 17 00:00:00 2001 From: seaerchin Date: Fri, 19 Mar 2021 15:41:35 +0800 Subject: [PATCH 20/86] refactor(myinfo): removed unneeded cookie type and updated extractSuccessfulCookie to reflect this --- src/app/modules/myinfo/myinfo.types.ts | 6 +----- src/app/modules/myinfo/myinfo.util.ts | 7 ++++--- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/app/modules/myinfo/myinfo.types.ts b/src/app/modules/myinfo/myinfo.types.ts index f14fc231a6..2be487cad8 100644 --- a/src/app/modules/myinfo/myinfo.types.ts +++ b/src/app/modules/myinfo/myinfo.types.ts @@ -50,13 +50,9 @@ export type MyInfoSuccessfulCookiePayload = { state: MyInfoCookieState.Success } -export type MyInfoErroredCookiePayload = { - state: Exclude -} - export type MyInfoCookiePayload = | MyInfoSuccessfulCookiePayload - | MyInfoErroredCookiePayload + | { state: Exclude } /** * The stringified properties included in the state sent to MyInfo. diff --git a/src/app/modules/myinfo/myinfo.util.ts b/src/app/modules/myinfo/myinfo.util.ts index 807955063d..32d4faf1a8 100644 --- a/src/app/modules/myinfo/myinfo.util.ts +++ b/src/app/modules/myinfo/myinfo.util.ts @@ -41,7 +41,6 @@ import { MyInfoComparePromises, MyInfoCookiePayload, MyInfoCookieState, - MyInfoErroredCookiePayload, MyInfoHashPromises, MyInfoRelayState, MyInfoSuccessfulCookiePayload, @@ -370,8 +369,10 @@ export const extractMyInfoCookie = ( */ export const extractSuccessfulCookie = ( cookie: MyInfoCookiePayload, -): Result => - cookie.state === MyInfoCookieState.Success ? ok(cookie) : err(cookie) +): Result => + cookie.state === MyInfoCookieState.Success + ? ok(cookie) + : err(new MyInfoCookieStateError()) /** * Extracts access token from a MyInfo cookie From a78ae1712945db3f211fe69fa85f6b502d9e43d4 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Fri, 19 Mar 2021 15:45:20 +0800 Subject: [PATCH 21/86] docs(public-form/controller): adds docs to confusing sections of handlGetPublicForm --- .../public-form/public-form.controller.ts | 153 +++++++++--------- 1 file changed, 80 insertions(+), 73 deletions(-) diff --git a/src/app/modules/form/public-form/public-form.controller.ts b/src/app/modules/form/public-form/public-form.controller.ts index 06e31d298b..d61ac3fcac 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -11,13 +11,13 @@ import { MYINFO_COOKIE_NAME, MYINFO_COOKIE_OPTIONS, } from '../../myinfo/myinfo.constants' -import { MyInfoCookieStateError } from '../../myinfo/myinfo.errors' import { MyInfoFactory } from '../../myinfo/myinfo.factory' import { extractMyInfoCookie, extractSuccessfulCookie, validateMyInfoForm, } from '../../myinfo/myinfo.util' +import { MissingJwtError } from '../../spcp/spcp.errors' import { SpcpFactory } from '../../spcp/spcp.factory' import { PrivateFormError } from '../form.errors' import * as FormService from '../form.service' @@ -214,8 +214,9 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( const publicFormView = form.getPublicView() const { authType } = form - // Shows the client a form based on how they are authorized - // If the client is MyInfo, we have to prefill the form + // NOTE: Once there is a valid form retrieved from the database, + // the client should always get a 200 response with the form's public view. + // Additional errors should be tagged onto the response object like myInfoError. switch (authType) { case AuthType.SP: case AuthType.CP: { @@ -228,15 +229,19 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( }), ) .mapErr((error) => { - logger.error({ - message: 'Error getting public form', - meta: { - action: 'handleGetPublicForm', - ...createReqMeta(req), - formId, - }, - error, - }) + // NOTE: Only log if there is no jwt present on the request. + // This is because clients can be members of the pubilc and hence, have no jwt. + if (!(error instanceof MissingJwtError)) { + logger.error({ + message: 'Error getting public form', + meta: { + action: 'handleGetPublicForm', + ...createReqMeta(req), + formId, + }, + error, + }) + } return res.json({ form: publicFormView, }) @@ -244,74 +249,76 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( } case AuthType.MyInfo: { - // TODO: shift this out so that the call is FormService.getUniqueMyInfoAttrs(form) const requestedAttributes = form.getUniqueMyInfoAttrs() // 1. Validate the cookie and myInfo form - // 2. Fetch myInfo data and fill the form based on the result - // 3. Hash and save to database - // 4. Return result if successful otherwise, clear cookies and return default response - // eslint-disable-next-line typesafe/no-await-without-trycatch - return extractMyInfoCookie(req.cookies) - .andThen((cookiePayload) => - // Transform into an error because no meaningful work can be done on a errored cookie - extractSuccessfulCookie(cookiePayload).mapErr( - () => new MyInfoCookieStateError(), - ), - ) - .asyncAndThen((cookiePayload) => - validateMyInfoForm(form).asyncAndThen((form) => - MyInfoFactory.fetchMyInfoPersonData( - cookiePayload.accessToken, - requestedAttributes, - form.esrvcId, + return ( + extractMyInfoCookie(req.cookies) + .andThen((cookiePayload) => extractSuccessfulCookie(cookiePayload)) + .asyncAndThen((cookiePayload) => + validateMyInfoForm(form).asyncAndThen((form) => + MyInfoFactory.fetchMyInfoPersonData( + cookiePayload.accessToken, + requestedAttributes, + form.esrvcId, + ), ), - ), - ) - .andThen((myInfoData) => - MyInfoFactory.prefillMyInfoFields( - myInfoData, - form.toJSON().form_fields, - ).map((formFields) => ({ - formFields, - spcpSession: { userName: myInfoData.getUinFin() }, - })), - ) - .andThen((form) => - // eslint-disable-next-line typesafe/no-await-without-trycatch - MyInfoFactory.saveMyInfoHashes( - form.spcpSession.userName, - formId, - form.formFields, - ).map(() => form), - ) - .map(({ spcpSession, formFields }) => - res.json({ - form: _.set(form, 'form_fields', formFields), - spcpSession, - }), - ) - .mapErr((error) => { - logger.error({ - message: error.message, - meta: { - action: 'handlePublicForm', - ...createReqMeta(req), - formId: formId, - esrvcId: form.esrvcId, - requestedAttributes, - }, - error, - }) - return res - .clearCookie(MYINFO_COOKIE_NAME, MYINFO_COOKIE_OPTIONS) - .json({ - form: publicFormView, - myInfoError: true, + ) + // 2. Fetch myInfo data and fill the form based on the result + .andThen((myInfoData) => + MyInfoFactory.prefillMyInfoFields( + myInfoData, + form.toJSON().form_fields, + ).map((formFields) => ({ + formFields, + spcpSession: { userName: myInfoData.getUinFin() }, + })), + ) + // 3. Hash and save to database + .andThen((form) => + MyInfoFactory.saveMyInfoHashes( + form.spcpSession.userName, + formId, + form.formFields, + ).map( + // NOTE: Passthrough as form is needed in the pipeline + () => form, + ), + ) + // 4. Return result if successful otherwise, clear cookies and return default response + .map(({ spcpSession, formFields }) => + res.json({ + form: _.set(form, 'form_fields', formFields), + spcpSession, + }), + ) + .mapErr((error) => { + logger.error({ + message: error.message, + meta: { + action: 'handlePublicForm', + ...createReqMeta(req), + formId: formId, + esrvcId: form.esrvcId, + requestedAttributes, + }, + error, }) - }) + return ( + res + // NOTE: No need for cookie if data could not be retrieved + .clearCookie(MYINFO_COOKIE_NAME, MYINFO_COOKIE_OPTIONS) + .json({ + form: publicFormView, + myInfoError: true, + }) + ) + }) + ) } default: + // NOTE: Client did not choose any form of authentication. + // Only return the public form view back to the client return res.json({ form: publicFormView, }) From 1440851f0f06a51d6bc1cd796cbbaecf6426de34 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Fri, 19 Mar 2021 18:27:17 +0800 Subject: [PATCH 22/86] test(public-form/controller/test): add test for database error when GET /getPublicForm --- .../__tests__/public-form.controller.spec.ts | 53 ++++++++++++++++++- .../public-form/public-form.controller.ts | 2 +- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts index cc3cf172fc..fdf00b79fa 100644 --- a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts +++ b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts @@ -11,6 +11,7 @@ import { IPopulatedForm } from 'src/types' import expressHandler from 'tests/unit/backend/helpers/jest-express' +import * as AuthService from '../../../auth/auth.service' import { FormDeletedError, FormNotFoundError, @@ -21,10 +22,13 @@ import * as PublicFormController from '../public-form.controller' import * as PublicFormService from '../public-form.service' import { Metatags } from '../public-form.types' -jest.mock('../../form.service') +// Mocking services that tests are dependent on jest.mock('../public-form.service') +jest.mock('../../form.service') +jest.mock('../../../auth/auth.service') const MockFormService = mocked(FormService) const MockPublicFormService = mocked(PublicFormService) +const MockAuthService = mocked(AuthService) const FormFeedbackModel = getFormFeedbackModel(mongoose) @@ -389,4 +393,51 @@ describe('public-form.controller', () => { expect(mockRes.redirect).toHaveBeenCalledWith(expectedRedirectPath) }) }) + + describe('handleGetPublicForm', () => { + // Arrange variables that are shared throughout the test suite + const MOCK_FORM_ID = new ObjectId().toHexString() + // const MOCK_SUCCESSFUL_FORM = { + // _id: MOCK_FORM_ID, + // title: 'mock form title', + // inactiveMessage: 'This mock form is mock closed.', + // } as IPopulatedForm + + // Success goes here + + // Failed errors + // TODO: this should be grouped together under the same class of errors + it('should return 500 if the form does not exist', async () => { + // Arrange + // 1. Mock the request + const MOCK_REQ = expressHandler.mockRequest({ + params: { + formId: MOCK_FORM_ID, + }, + }) + + const MOCK_RES = expressHandler.mockResponse() + + // 2. Mock the call to retrieve the form + MockAuthService.getFormIfPublic.mockReturnValueOnce( + errAsync(new DatabaseError()), + ) + + // Act + await PublicFormController.handleGetPublicForm( + MOCK_REQ, + MOCK_RES, + jest.fn(), + ) + // Assert + + // 1. Check args of mocked services + expect(MockAuthService.getFormIfPublic).toHaveBeenCalledWith(MOCK_FORM_ID) + // 2. Check that error is correct + expect( + MockFormService.checkFormSubmissionLimitAndDeactivateForm, + ).not.toHaveBeenCalled() + expect(MOCK_RES.status).toHaveBeenCalledWith(500) + }) + }) }) diff --git a/src/app/modules/form/public-form/public-form.controller.ts b/src/app/modules/form/public-form/public-form.controller.ts index d61ac3fcac..87e9e1a04d 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -298,7 +298,7 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( meta: { action: 'handlePublicForm', ...createReqMeta(req), - formId: formId, + formId, esrvcId: form.esrvcId, requestedAttributes, }, From be961d080d7289aee77f80060e84948ffa2fb042 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Mon, 22 Mar 2021 18:28:18 +0800 Subject: [PATCH 23/86] test(public-form/controller/tests): add more tests --- .../__tests__/public-form.controller.spec.ts | 306 +++++++++++++++--- 1 file changed, 270 insertions(+), 36 deletions(-) diff --git a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts index fdf00b79fa..3980824d30 100644 --- a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts +++ b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts @@ -1,17 +1,33 @@ import { ObjectId } from 'bson-ext' -import { merge } from 'lodash' +import { MyInfoNoESrvcIdError } from 'dist/backend/app/modules/myinfo/myinfo.errors' +import _, { merge } from 'lodash' import mongoose from 'mongoose' import { err, errAsync, ok, okAsync } from 'neverthrow' import querystring from 'querystring' +import { MockedObject } from 'ts-jest/dist/utils/testing' import { mocked } from 'ts-jest/utils' import getFormFeedbackModel from 'src/app/models/form_feedback.server.model' import { DatabaseError } from 'src/app/modules/core/core.errors' -import { IPopulatedForm } from 'src/types' +import { MyInfoCookieState } from 'src/app/modules/myinfo/myinfo.types' +import * as myInfoUtils from 'src/app/modules/myinfo/myinfo.util' +import { + AuthType, + IPopulatedForm, + IPopulatedUser, + MyInfoAttribute, + PublicForm, +} from 'src/types' import expressHandler from 'tests/unit/backend/helpers/jest-express' import * as AuthService from '../../../auth/auth.service' +import { + MyInfoCookieStateError, + MyInfoMissingAccessTokenError, +} from '../../../myinfo/myinfo.errors' +import { MissingJwtError } from '../../../spcp/spcp.errors' +import { SpcpFactory } from '../../../spcp/spcp.factory' import { FormDeletedError, FormNotFoundError, @@ -26,9 +42,14 @@ import { Metatags } from '../public-form.types' jest.mock('../public-form.service') jest.mock('../../form.service') jest.mock('../../../auth/auth.service') +jest.mock('../../../spcp/spcp.factory') +jest.mock('src/app/modules/myinfo/myinfo.util') + const MockFormService = mocked(FormService) const MockPublicFormService = mocked(PublicFormService) const MockAuthService = mocked(AuthService) +const MockSpcpFactory = mocked(SpcpFactory) +const MockMyInfoUtils = mocked(myInfoUtils) const FormFeedbackModel = getFormFeedbackModel(mongoose) @@ -395,49 +416,262 @@ describe('public-form.controller', () => { }) describe('handleGetPublicForm', () => { + // TODO: add tests for logging for spcp to ensure it's only called once + // TODO: ensure that saveMyInfoHashes is being called // Arrange variables that are shared throughout the test suite const MOCK_FORM_ID = new ObjectId().toHexString() - // const MOCK_SUCCESSFUL_FORM = { - // _id: MOCK_FORM_ID, - // title: 'mock form title', - // inactiveMessage: 'This mock form is mock closed.', - // } as IPopulatedForm + const MOCK_USER_ID = new ObjectId().toHexString() + const MOCK_USER = { + _id: MOCK_USER_ID, + email: 'randomrandomtest@example.com', + } as IPopulatedUser + + const MOCK_SCRUBBED_FORM = ({ + _id: MOCK_FORM_ID, + title: 'mock title', + admin: { _id: MOCK_USER_ID }, + } as unknown) as PublicForm - // Success goes here + const MOCK_FORM = (mocked({ + admin: MOCK_USER, + _id: MOCK_FORM_ID, + title: 'Mock', + getPublicView: jest.fn().mockResolvedValue(MOCK_SCRUBBED_FORM), + }) as unknown) as MockedObject - // Failed errors - // TODO: this should be grouped together under the same class of errors - it('should return 500 if the form does not exist', async () => { - // Arrange - // 1. Mock the request - const MOCK_REQ = expressHandler.mockRequest({ - params: { - formId: MOCK_FORM_ID, - }, + const MOCK_REQ = expressHandler.mockRequest({ + params: { + formId: MOCK_FORM_ID, + }, + }) + + // Success + + // Errors + describe('errors in myInfo', () => { + // Setup because this gets invoked at the start of the controller to decide which branch to take + const MOCK_MYINFO_FORM = (_(MOCK_FORM) + .set('authType', AuthType.MyInfo) + .set( + 'getUniqueMyInfoAttrs', + jest.fn().mockReturnValue([MyInfoAttribute.Name]), + ) + .value() as unknown) as MockedObject + + MockAuthService.getFormIfPublic.mockReturnValue(okAsync(MOCK_MYINFO_FORM)) + MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValue( + okAsync(MOCK_MYINFO_FORM), + ) + + it('should return 200 but the response should have cookies cleared and myInfoError if the request has no cookie', async () => { + // Arrange + // 1. Mock the response and calls + const MOCK_RES = expressHandler.mockResponse() + const MOCK_CLEAR_COOKIE = jest.fn().mockReturnValueOnce(MOCK_RES) + // NOTE: This is done because the calls to .json and .clearCookie are chained + MOCK_RES.clearCookie = MOCK_CLEAR_COOKIE + MockMyInfoUtils.extractMyInfoCookie.mockReturnValueOnce( + err(new MyInfoMissingAccessTokenError()), + ) + + // Act + await PublicFormController.handleGetPublicForm( + _.set(MOCK_REQ, 'cookies', {}), + MOCK_RES, + jest.fn(), + ) + + // Assert + expect(MOCK_RES.clearCookie).toHaveBeenCalled() + expect(MOCK_RES.json).toHaveBeenCalledWith({ + form: MOCK_FORM.getPublicView(), + myInfoError: true, + }) }) - const MOCK_RES = expressHandler.mockResponse() + it('should return 200 but the response should have cookies cleared and myInfoError if the cookie cannot be validated', async () => { + // Arrange + // 1. Mock the response and calls + const MOCK_RES = expressHandler.mockResponse() + const MOCK_CLEAR_COOKIE = jest.fn().mockReturnValueOnce(MOCK_RES) + MOCK_RES.clearCookie = MOCK_CLEAR_COOKIE + MockMyInfoUtils.extractMyInfoCookie.mockReturnValueOnce( + ok({ state: MyInfoCookieState.Error }), + ) + MockMyInfoUtils.extractSuccessfulCookie.mockReturnValueOnce( + err(new MyInfoCookieStateError()), + ) + + // Act + await PublicFormController.handleGetPublicForm( + _.set(MOCK_REQ, 'cookies', { accessToken: 'cookie monster?' }), + MOCK_RES, + jest.fn(), + ) + + // Assert + expect(MOCK_RES.clearCookie).toHaveBeenCalled() + expect(MOCK_RES.json).toHaveBeenCalledWith({ + form: MOCK_FORM.getPublicView(), + myInfoError: true, + }) + }) - // 2. Mock the call to retrieve the form - MockAuthService.getFormIfPublic.mockReturnValueOnce( - errAsync(new DatabaseError()), - ) + it('should return 200 but the response should have cookies cleared and myInfoError if the form cannot be validated', async () => { + // Arrange + // 1. Mock the response and calls + const MOCK_RES = expressHandler.mockResponse() + const MOCK_CLEAR_COOKIE = jest.fn().mockReturnValueOnce(MOCK_RES) + MOCK_RES.clearCookie = MOCK_CLEAR_COOKIE + MockMyInfoUtils.extractMyInfoCookie.mockReturnValueOnce( + ok({ state: MyInfoCookieState.Error }), + ) + MockMyInfoUtils.extractSuccessfulCookie.mockReturnValueOnce( + err(new MyInfoCookieStateError()), + ) + MockMyInfoUtils.validateMyInfoForm.mockReturnValueOnce( + err(new MyInfoNoESrvcIdError()), + ) + + // Act + await PublicFormController.handleGetPublicForm( + _.set(MOCK_REQ, 'cookies', { accessToken: 'cookie monster?' }), + MOCK_RES, + jest.fn(), + ) + + // Assert + expect(MOCK_RES.clearCookie).toHaveBeenCalled() + expect(MOCK_RES.json).toHaveBeenCalledWith({ + form: MOCK_FORM.getPublicView(), + myInfoError: true, + }) + }) + }) - // Act - await PublicFormController.handleGetPublicForm( - MOCK_REQ, - MOCK_RES, - jest.fn(), - ) - // Assert + describe('errors in spcp', () => { + it('should return 200 with the form but without a spcpSession', async () => { + // Arrange + // 1. Mock the response and calls + const MOCK_RES = expressHandler.mockResponse() + const MOCK_SPCP_FORM = _.set(MOCK_FORM, 'authType', AuthType.SP) + + MockAuthService.getFormIfPublic.mockReturnValueOnce( + okAsync(MOCK_SPCP_FORM), + ) + MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( + okAsync(MOCK_SPCP_FORM), + ) + MockSpcpFactory.getSpcpSession.mockReturnValueOnce( + errAsync(new MissingJwtError()), + ) + + // Act + // 2. GET the endpoint + await PublicFormController.handleGetPublicForm( + MOCK_REQ, + MOCK_RES, + jest.fn(), + ) + + // Assert + // Status should be 200 + // json object should only have form property + expect(MOCK_RES.json).toHaveBeenCalledWith({ + form: MOCK_SPCP_FORM.getPublicView(), + }) + }) + }) + + describe('errors in form retrieval', () => { + it('should return 500 if a database error occurs', async () => { + // Arrange + // 1. Mock the response + const MOCK_RES = expressHandler.mockResponse() + + // 2. Mock the call to retrieve the form + MockAuthService.getFormIfPublic.mockReturnValueOnce( + errAsync(new DatabaseError()), + ) + + // Act + await PublicFormController.handleGetPublicForm( + MOCK_REQ, + MOCK_RES, + jest.fn(), + ) + + // Assert + // 1. Check args of mocked services + expect(MockAuthService.getFormIfPublic).toHaveBeenCalledWith( + MOCK_FORM_ID, + ) + // 2. Check that error is correct + expect( + MockFormService.checkFormSubmissionLimitAndDeactivateForm, + ).not.toHaveBeenCalled() + expect(MOCK_RES.status).toHaveBeenCalledWith(500) + }) - // 1. Check args of mocked services - expect(MockAuthService.getFormIfPublic).toHaveBeenCalledWith(MOCK_FORM_ID) - // 2. Check that error is correct - expect( - MockFormService.checkFormSubmissionLimitAndDeactivateForm, - ).not.toHaveBeenCalled() - expect(MOCK_RES.status).toHaveBeenCalledWith(500) + it('should return 404 if the form is not found', async () => { + // Arrange + // 1. Mock the response + const MOCK_RES = expressHandler.mockResponse() + + // 2. Mock the call to retrieve the form + MockAuthService.getFormIfPublic.mockReturnValueOnce( + errAsync(new FormNotFoundError()), + ) + + // Act + await PublicFormController.handleGetPublicForm( + MOCK_REQ, + MOCK_RES, + jest.fn(), + ) + + // Assert + // 1. Check args of mocked services + expect(MockAuthService.getFormIfPublic).toHaveBeenCalledWith( + MOCK_FORM_ID, + ) + // 2. Check that error is correct + expect( + MockFormService.checkFormSubmissionLimitAndDeactivateForm, + ).not.toHaveBeenCalled() + expect(MOCK_RES.status).toHaveBeenCalledWith(404) + }) + + it('should return 404 if the form is private and not accessible by the public', async () => { + // Arrange + // 1. Mock the response + const MOCK_RES = expressHandler.mockResponse() + + // 2. Mock the call to retrieve the form + MockAuthService.getFormIfPublic.mockReturnValueOnce( + errAsync( + new PrivateFormError('teehee this form is private', 'private form'), + ), + ) + + // Act + await PublicFormController.handleGetPublicForm( + MOCK_REQ, + MOCK_RES, + jest.fn(), + ) + + // Assert + // 1. Check args of mocked services + expect(MockAuthService.getFormIfPublic).toHaveBeenCalledWith( + MOCK_FORM_ID, + ) + // 2. Check that error is correct + expect( + MockFormService.checkFormSubmissionLimitAndDeactivateForm, + ).not.toHaveBeenCalled() + expect(MOCK_RES.status).toHaveBeenCalledWith(404) + }) }) }) }) From d707ead66325ca2d0af66e63452d5277923a80e9 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Tue, 23 Mar 2021 10:51:49 +0800 Subject: [PATCH 24/86] test(public-form/controller/test): adds test for success cases --- .../__tests__/public-form.controller.spec.ts | 244 +++++++++++++++--- 1 file changed, 212 insertions(+), 32 deletions(-) diff --git a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts index 3980824d30..5ab6091e12 100644 --- a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts +++ b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts @@ -1,5 +1,4 @@ import { ObjectId } from 'bson-ext' -import { MyInfoNoESrvcIdError } from 'dist/backend/app/modules/myinfo/myinfo.errors' import _, { merge } from 'lodash' import mongoose from 'mongoose' import { err, errAsync, ok, okAsync } from 'neverthrow' @@ -9,8 +8,12 @@ import { mocked } from 'ts-jest/utils' import getFormFeedbackModel from 'src/app/models/form_feedback.server.model' import { DatabaseError } from 'src/app/modules/core/core.errors' -import { MyInfoCookieState } from 'src/app/modules/myinfo/myinfo.types' +import { + MyInfoCookieState, + MyInfoSuccessfulCookiePayload, +} from 'src/app/modules/myinfo/myinfo.types' import * as myInfoUtils from 'src/app/modules/myinfo/myinfo.util' +import { JwtPayload } from 'src/app/modules/spcp/spcp.types' import { AuthType, IPopulatedForm, @@ -25,6 +28,7 @@ import * as AuthService from '../../../auth/auth.service' import { MyInfoCookieStateError, MyInfoMissingAccessTokenError, + MyInfoNoESrvcIdError, } from '../../../myinfo/myinfo.errors' import { MissingJwtError } from '../../../spcp/spcp.errors' import { SpcpFactory } from '../../../spcp/spcp.factory' @@ -416,9 +420,7 @@ describe('public-form.controller', () => { }) describe('handleGetPublicForm', () => { - // TODO: add tests for logging for spcp to ensure it's only called once // TODO: ensure that saveMyInfoHashes is being called - // Arrange variables that are shared throughout the test suite const MOCK_FORM_ID = new ObjectId().toHexString() const MOCK_USER_ID = new ObjectId().toHexString() const MOCK_USER = { @@ -432,12 +434,13 @@ describe('public-form.controller', () => { admin: { _id: MOCK_USER_ID }, } as unknown) as PublicForm - const MOCK_FORM = (mocked({ + const BASE_FORM = { admin: MOCK_USER, _id: MOCK_FORM_ID, title: 'Mock', - getPublicView: jest.fn().mockResolvedValue(MOCK_SCRUBBED_FORM), - }) as unknown) as MockedObject + getUniqueMyInfoAttrs: jest.fn().mockReturnValue([MyInfoAttribute.Name]), + getPublicView: jest.fn().mockReturnValue(MOCK_SCRUBBED_FORM), + } const MOCK_REQ = expressHandler.mockRequest({ params: { @@ -446,21 +449,172 @@ describe('public-form.controller', () => { }) // Success + describe('valid form id', () => { + const MOCK_JWT_PAYLOAD: JwtPayload = { + userName: 'mock', + rememberMe: false, + } + + it('should return 200 when there is no AuthType on the request', async () => { + // Arrange + const MOCK_NIL_AUTH_FORM = (mocked({ + ...BASE_FORM, + authType: AuthType.NIL, + }) as unknown) as MockedObject + const MOCK_RES = expressHandler.mockResponse() + + MockAuthService.getFormIfPublic.mockReturnValueOnce( + okAsync(MOCK_NIL_AUTH_FORM), + ) + MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( + okAsync(MOCK_NIL_AUTH_FORM), + ) + + // Act + await PublicFormController.handleGetPublicForm( + MOCK_REQ, + MOCK_RES, + jest.fn(), + ) + + // Assert + expect(MOCK_RES.json).toHaveBeenCalledWith({ + form: MOCK_NIL_AUTH_FORM.getPublicView(), + }) + }) + + it('should return 200 when client authenticates using SP', async () => { + // Arrange + const MOCK_SP_AUTH_FORM = (mocked({ + ...BASE_FORM, + authType: AuthType.SP, + }) as unknown) as MockedObject + const MOCK_RES = expressHandler.mockResponse() + + MockSpcpFactory.getSpcpSession.mockReturnValueOnce( + okAsync(MOCK_JWT_PAYLOAD), + ) + MockAuthService.getFormIfPublic.mockReturnValueOnce( + okAsync(MOCK_SP_AUTH_FORM), + ) + MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( + okAsync(MOCK_SP_AUTH_FORM), + ) + + // Act + await PublicFormController.handleGetPublicForm( + MOCK_REQ, + MOCK_RES, + jest.fn(), + ) + + // Assert + expect(MOCK_RES.json).toHaveBeenCalledWith({ + form: MOCK_SP_AUTH_FORM.getPublicView(), + spcpSession: { + userName: MOCK_JWT_PAYLOAD.userName, + }, + }) + }) + + it('should return 200 when client authenticates using CP', async () => { + // Arrange + const MOCK_CP_AUTH_FORM = (mocked({ + ...BASE_FORM, + authType: AuthType.CP, + }) as unknown) as MockedObject + const MOCK_RES = expressHandler.mockResponse() + + MockSpcpFactory.getSpcpSession.mockReturnValueOnce( + okAsync(MOCK_JWT_PAYLOAD), + ) + MockAuthService.getFormIfPublic.mockReturnValueOnce( + okAsync(MOCK_CP_AUTH_FORM), + ) + MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( + okAsync(MOCK_CP_AUTH_FORM), + ) + + // Act + await PublicFormController.handleGetPublicForm( + MOCK_REQ, + MOCK_RES, + jest.fn(), + ) + + // Assert + expect(MOCK_RES.json).toHaveBeenCalledWith({ + form: MOCK_CP_AUTH_FORM.getPublicView(), + spcpSession: { + userName: MOCK_JWT_PAYLOAD.userName, + }, + }) + }) + + it('should return 200 when client authenticates using MyInfo', async () => { + // Arrange + const MOCK_MYINFO_AUTH_FORM = (mocked({ + ...BASE_FORM, + esrvcId: 'thing', + authType: AuthType.MyInfo, + }) as unknown) as MockedObject + const MOCK_RES = expressHandler.mockResponse() + const MOCK_MYINFO_COOKIE = { + accessToken: 'cookie', + usedCount: 0, + state: MyInfoCookieState.Success, + } + const MOCK_REQ_WITH_COOKIES = expressHandler.mockRequest({ + params: { + formId: MOCK_FORM_ID, + }, + others: { cookies: { MyInfoCookie: MOCK_MYINFO_COOKIE } }, + }) + + MockAuthService.getFormIfPublic.mockReturnValueOnce( + okAsync(MOCK_MYINFO_AUTH_FORM), + ) + MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( + okAsync(MOCK_MYINFO_AUTH_FORM), + ) + MockMyInfoUtils.extractMyInfoCookie.mockReturnValueOnce( + ok(MOCK_MYINFO_COOKIE), + ) + MockMyInfoUtils.extractSuccessfulCookie.mockReturnValueOnce( + ok(MOCK_MYINFO_COOKIE as MyInfoSuccessfulCookiePayload), + ) + + // Act + await PublicFormController.handleGetPublicForm( + MOCK_REQ_WITH_COOKIES, + MOCK_RES, + jest.fn(), + ) + + // Assert + expect(MOCK_RES.json).toHaveBeenCalledWith({ + form: MOCK_MYINFO_AUTH_FORM.getPublicView(), + }) + }) + }) // Errors describe('errors in myInfo', () => { - // Setup because this gets invoked at the start of the controller to decide which branch to take - const MOCK_MYINFO_FORM = (_(MOCK_FORM) - .set('authType', AuthType.MyInfo) - .set( - 'getUniqueMyInfoAttrs', - jest.fn().mockReturnValue([MyInfoAttribute.Name]), - ) - .value() as unknown) as MockedObject + const MOCK_MYINFO_FORM = (mocked({ + ...BASE_FORM, + authType: AuthType.MyInfo, + }) as unknown) as IPopulatedForm - MockAuthService.getFormIfPublic.mockReturnValue(okAsync(MOCK_MYINFO_FORM)) - MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValue( - okAsync(MOCK_MYINFO_FORM), + // Setup because this gets invoked at the start of the controller to decide which branch to take + beforeAll(() => + MockAuthService.getFormIfPublic.mockReturnValue( + okAsync(MOCK_MYINFO_FORM), + ), + ) + beforeAll(() => + MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValue( + okAsync(MOCK_MYINFO_FORM), + ), ) it('should return 200 but the response should have cookies cleared and myInfoError if the request has no cookie', async () => { @@ -468,8 +622,18 @@ describe('public-form.controller', () => { // 1. Mock the response and calls const MOCK_RES = expressHandler.mockResponse() const MOCK_CLEAR_COOKIE = jest.fn().mockReturnValueOnce(MOCK_RES) - // NOTE: This is done because the calls to .json and .clearCookie are chained - MOCK_RES.clearCookie = MOCK_CLEAR_COOKIE + const MOCK_INITIAL_RES = _.set( + MOCK_RES, + 'clearCookie', + MOCK_CLEAR_COOKIE, + ) + + MockAuthService.getFormIfPublic.mockReturnValue( + okAsync(MOCK_MYINFO_FORM), + ) + MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValue( + okAsync(MOCK_MYINFO_FORM), + ) MockMyInfoUtils.extractMyInfoCookie.mockReturnValueOnce( err(new MyInfoMissingAccessTokenError()), ) @@ -477,14 +641,15 @@ describe('public-form.controller', () => { // Act await PublicFormController.handleGetPublicForm( _.set(MOCK_REQ, 'cookies', {}), - MOCK_RES, + // NOTE: This is done because the calls to .json and .clearCookie are chained + MOCK_INITIAL_RES, jest.fn(), ) // Assert - expect(MOCK_RES.clearCookie).toHaveBeenCalled() + expect(MOCK_INITIAL_RES.clearCookie).toHaveBeenCalled() expect(MOCK_RES.json).toHaveBeenCalledWith({ - form: MOCK_FORM.getPublicView(), + form: BASE_FORM.getPublicView(), myInfoError: true, }) }) @@ -494,7 +659,12 @@ describe('public-form.controller', () => { // 1. Mock the response and calls const MOCK_RES = expressHandler.mockResponse() const MOCK_CLEAR_COOKIE = jest.fn().mockReturnValueOnce(MOCK_RES) - MOCK_RES.clearCookie = MOCK_CLEAR_COOKIE + const MOCK_INITIAL_RES = _.set( + MOCK_RES, + 'clearCookie', + MOCK_CLEAR_COOKIE, + ) + MockMyInfoUtils.extractMyInfoCookie.mockReturnValueOnce( ok({ state: MyInfoCookieState.Error }), ) @@ -505,14 +675,15 @@ describe('public-form.controller', () => { // Act await PublicFormController.handleGetPublicForm( _.set(MOCK_REQ, 'cookies', { accessToken: 'cookie monster?' }), - MOCK_RES, + // NOTE: This is done because the calls to .json and .clearCookie are chained + MOCK_INITIAL_RES, jest.fn(), ) // Assert - expect(MOCK_RES.clearCookie).toHaveBeenCalled() + expect(MOCK_INITIAL_RES.clearCookie).toHaveBeenCalled() expect(MOCK_RES.json).toHaveBeenCalledWith({ - form: MOCK_FORM.getPublicView(), + form: BASE_FORM.getPublicView(), myInfoError: true, }) }) @@ -522,7 +693,12 @@ describe('public-form.controller', () => { // 1. Mock the response and calls const MOCK_RES = expressHandler.mockResponse() const MOCK_CLEAR_COOKIE = jest.fn().mockReturnValueOnce(MOCK_RES) - MOCK_RES.clearCookie = MOCK_CLEAR_COOKIE + const MOCK_INITIAL_RES = _.set( + MOCK_RES, + 'clearCookie', + MOCK_CLEAR_COOKIE, + ) + MockMyInfoUtils.extractMyInfoCookie.mockReturnValueOnce( ok({ state: MyInfoCookieState.Error }), ) @@ -536,25 +712,29 @@ describe('public-form.controller', () => { // Act await PublicFormController.handleGetPublicForm( _.set(MOCK_REQ, 'cookies', { accessToken: 'cookie monster?' }), - MOCK_RES, + // NOTE: This is done because the calls to .json and .clearCookie are chained + _.set(MOCK_INITIAL_RES, 'clearCookie', MOCK_CLEAR_COOKIE), jest.fn(), ) // Assert - expect(MOCK_RES.clearCookie).toHaveBeenCalled() + expect(MOCK_INITIAL_RES.clearCookie).toHaveBeenCalled() expect(MOCK_RES.json).toHaveBeenCalledWith({ - form: MOCK_FORM.getPublicView(), + form: BASE_FORM.getPublicView(), myInfoError: true, }) }) }) describe('errors in spcp', () => { + const MOCK_SPCP_FORM = (mocked({ + ...BASE_FORM, + authType: AuthType.SP, + }) as unknown) as MockedObject it('should return 200 with the form but without a spcpSession', async () => { // Arrange // 1. Mock the response and calls const MOCK_RES = expressHandler.mockResponse() - const MOCK_SPCP_FORM = _.set(MOCK_FORM, 'authType', AuthType.SP) MockAuthService.getFormIfPublic.mockReturnValueOnce( okAsync(MOCK_SPCP_FORM), From bf51474959c0ed87d64aa96bf7b40f7859dd95f2 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Tue, 23 Mar 2021 17:34:10 +0800 Subject: [PATCH 25/86] refactor(myinfo): extracts out chunks form public-form controller into myinfo service --- .../myinfo/__tests__/myinfo.service.spec.ts | 44 +++++++++++++++++ .../myinfo/__tests__/myinfo.test.constants.ts | 16 ++++-- src/app/modules/myinfo/myinfo.factory.ts | 20 ++++++++ src/app/modules/myinfo/myinfo.service.ts | 49 ++++++++++++++++++- src/app/modules/myinfo/myinfo.util.ts | 17 +++++++ 5 files changed, 142 insertions(+), 4 deletions(-) diff --git a/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts b/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts index b433e47025..9252b576a5 100644 --- a/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts +++ b/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts @@ -10,6 +10,7 @@ import { IFieldSchema, IHashes, IMyInfoHashSchema, + IPopulatedForm, MyInfoAttribute, } from 'src/types' @@ -21,6 +22,7 @@ import { MyInfoCircuitBreakerError, MyInfoFetchError, MyInfoInvalidAccessTokenError, + MyInfoMissingAccessTokenError, MyInfoParseRelayStateError, } from '../myinfo.errors' import { IPossiblyPrefilledField, MyInfoRelayState } from '../myinfo.types' @@ -35,11 +37,13 @@ import { MOCK_HASHED_FIELD_IDS, MOCK_HASHES, MOCK_MYINFO_DATA, + MOCK_MYINFO_FORM, MOCK_POPULATED_FORM_FIELDS, MOCK_REDIRECT_URL, MOCK_REQUESTED_ATTRS, MOCK_RESPONSES, MOCK_SERVICE_PARAMS, + MOCK_SUCCESSFUL_COOKIE, MOCK_UINFIN, } from './myinfo.test.constants' @@ -435,4 +439,44 @@ describe('MyInfoService', () => { ) }) }) + + describe('validateAndFillFormWithMyInfoData', () => { + // NOTE: Mocks the underlying circuit breaker implementation to avoid network calls + beforeEach(() => { + myInfoService = new MyInfoService(MOCK_SERVICE_PARAMS) + }) + + it('should return myInfo data when the provided form and cookie is valid', async () => { + // Arrange + const mockReturnedParams = { + uinFin: MOCK_UINFIN, + data: MOCK_MYINFO_DATA, + } + + mockGetPerson.mockResolvedValueOnce(mockReturnedParams) + + // Act + const result = await myInfoService.extractMyInfoData( + MOCK_MYINFO_FORM as IPopulatedForm, + { MyInfoCookie: MOCK_SUCCESSFUL_COOKIE }, + ) + + // Assert + expect(result._unsafeUnwrap()).toEqual(new MyInfoData(mockReturnedParams)) + }) + + it('should not validate the form if the cookie does not exist', async () => { + // Arrange + const expected = new MyInfoMissingAccessTokenError() + + // Act + const result = await myInfoService.extractMyInfoData( + MOCK_MYINFO_FORM as IPopulatedForm, + {}, + ) + + // Assert + expect(result._unsafeUnwrapErr()).toEqual(expected) + }) + }) }) diff --git a/src/app/modules/myinfo/__tests__/myinfo.test.constants.ts b/src/app/modules/myinfo/__tests__/myinfo.test.constants.ts index 3ac62d4c4b..675b0d0cd5 100644 --- a/src/app/modules/myinfo/__tests__/myinfo.test.constants.ts +++ b/src/app/modules/myinfo/__tests__/myinfo.test.constants.ts @@ -8,9 +8,13 @@ import { ObjectId } from 'bson' import { merge, zipWith } from 'lodash' import { ISpcpMyInfo } from 'src/config/feature-manager' -import { Environment, IFormSchema, MyInfoAttribute } from 'src/types' +import { AuthType, Environment, IFormSchema, MyInfoAttribute } from 'src/types' -import { IMyInfoServiceConfig } from '../myinfo.types' +import { + IMyInfoServiceConfig, + MyInfoCookieState, + MyInfoSuccessfulCookiePayload, +} from '../myinfo.types' export const MOCK_MYINFO_DATA = { name: { @@ -144,6 +148,12 @@ export const MOCK_SERVICE_PARAMS: IMyInfoServiceConfig = { export const MOCK_MYINFO_FORM = ({ _id: MOCK_FORM_ID, esrvcId: MOCK_ESRVC_ID, - authType: 'MyInfo', + authType: AuthType.MyInfo, getUniqueMyInfoAttrs: () => MOCK_REQUESTED_ATTRS, } as unknown) as IFormSchema + +export const MOCK_SUCCESSFUL_COOKIE: MyInfoSuccessfulCookiePayload = { + accessToken: MOCK_ACCESS_TOKEN, + usedCount: 0, + state: MyInfoCookieState.Success, +} diff --git a/src/app/modules/myinfo/myinfo.factory.ts b/src/app/modules/myinfo/myinfo.factory.ts index 9327f04d5f..c061df6a44 100644 --- a/src/app/modules/myinfo/myinfo.factory.ts +++ b/src/app/modules/myinfo/myinfo.factory.ts @@ -10,6 +10,7 @@ import { IFieldSchema, IHashes, IMyInfoHashSchema, + IPopulatedForm, MyInfoAttribute, } from '../../../types' import { DatabaseError, MissingFeatureError } from '../core/core.errors' @@ -17,12 +18,16 @@ import { ProcessedFieldResponse } from '../submission/submission.types' import { MyInfoData } from './myinfo.adapter' import { + MyInfoAuthTypeError, MyInfoCircuitBreakerError, + MyInfoCookieStateError, MyInfoFetchError, MyInfoHashDidNotMatchError, MyInfoHashingError, MyInfoInvalidAccessTokenError, + MyInfoMissingAccessTokenError, MyInfoMissingHashError, + MyInfoNoESrvcIdError, MyInfoParseRelayStateError, } from './myinfo.errors' import { MyInfoService } from './myinfo.service' @@ -82,6 +87,20 @@ interface IMyInfoFactory { extractUinFin: ( accessToken: string, ) => Result + + extractMyInfoData: ( + form: IPopulatedForm, + cookies: Record, + ) => ResultAsync< + MyInfoData, + | MyInfoMissingAccessTokenError + | MyInfoCookieStateError + | MyInfoNoESrvcIdError + | MyInfoAuthTypeError + | MyInfoCircuitBreakerError + | MyInfoFetchError + | MissingFeatureError + > } export const createMyInfoFactory = ({ @@ -100,6 +119,7 @@ export const createMyInfoFactory = ({ createRedirectURL: () => err(error), parseMyInfoRelayState: () => err(error), extractUinFin: () => err(error), + extractMyInfoData: () => errAsync(error), } } return new MyInfoService({ diff --git a/src/app/modules/myinfo/myinfo.service.ts b/src/app/modules/myinfo/myinfo.service.ts index 92291c1b23..a2e8021e26 100644 --- a/src/app/modules/myinfo/myinfo.service.ts +++ b/src/app/modules/myinfo/myinfo.service.ts @@ -16,9 +16,10 @@ import { IFieldSchema, IHashes, IMyInfoHashSchema, + IPopulatedForm, MyInfoAttribute, } from '../../../types' -import { DatabaseError } from '../core/core.errors' +import { DatabaseError, MissingFeatureError } from '../core/core.errors' import { ProcessedFieldResponse } from '../submission/submission.types' import { internalAttrListToScopes, MyInfoData } from './myinfo.adapter' @@ -28,12 +29,16 @@ import { MYINFO_ROUTER_PREFIX, } from './myinfo.constants' import { + MyInfoAuthTypeError, MyInfoCircuitBreakerError, + MyInfoCookieStateError, MyInfoFetchError, MyInfoHashDidNotMatchError, MyInfoHashingError, MyInfoInvalidAccessTokenError, + MyInfoMissingAccessTokenError, MyInfoMissingHashError, + MyInfoNoESrvcIdError, MyInfoParseRelayStateError, } from './myinfo.errors' import { @@ -45,8 +50,10 @@ import { import { compareHashedValues, createRelayState, + extractSuccessfulMyInfoCookie, hashFieldValues, isMyInfoRelayState, + validateMyInfoForm, } from './myinfo.util' import getMyInfoHashModel from './myinfo_hash.model' @@ -451,4 +458,44 @@ export class MyInfoService { }, )() } + + /** + * Extracts myInfo data using the provided form and the cookies of the request + * @param form the form to validate + * @param cookies cookies of the request + * @returns ok(MyInfoData) if the form has been validated successfully + * @returns err(MyInfoMissingAccessTokenError) if no myInfoCookie was found on the request + * @returns err(MyInfoCookieStateError) if cookie was not successful + * @returns err(MyInfoNoESrvcIdError) if form has no eserviceId + * @returns err(MyInfoAuthTypeError) if the client was not authenticated using MyInfo + * @returns err(MyInfoCircuitBreakerError) if circuit breaker was active + * @returns err(MyInfoFetchError) if validated but the data could not be retrieved + * @returns err(MissingFeatureError) if using an outdated version that does not support myInfo + */ + extractMyInfoData( + form: IPopulatedForm, + cookies: Record, + ): ResultAsync< + MyInfoData, + | MyInfoMissingAccessTokenError + | MyInfoCookieStateError + | MyInfoNoESrvcIdError + | MyInfoAuthTypeError + | MyInfoCircuitBreakerError + | MyInfoFetchError + | MissingFeatureError + > { + const requestedAttributes = form.getUniqueMyInfoAttrs() + return extractSuccessfulMyInfoCookie( + cookies, + ).asyncAndThen((cookiePayload) => + validateMyInfoForm(form).asyncAndThen((form) => + this.fetchMyInfoPersonData( + cookiePayload.accessToken, + requestedAttributes, + form.esrvcId, + ), + ), + ) + } } diff --git a/src/app/modules/myinfo/myinfo.util.ts b/src/app/modules/myinfo/myinfo.util.ts index 32d4faf1a8..5c0ee8a3dd 100644 --- a/src/app/modules/myinfo/myinfo.util.ts +++ b/src/app/modules/myinfo/myinfo.util.ts @@ -374,6 +374,23 @@ export const extractSuccessfulCookie = ( ? ok(cookie) : err(new MyInfoCookieStateError()) +/** + * Extracts a successful myInfoCookie from a request's cookies + * @param cookies Cookies in a request + * @return ok(cookie) the successful myInfoCookie + * @return err(MyInfoMissingAccessTokenError) if myInfoCookie is not present on the request + * @return err(MyInfoCookieStateError) if the extracted myInfoCookie was in an error state + */ +export const extractSuccessfulMyInfoCookie = ( + cookies: Record, +): Result< + MyInfoSuccessfulCookiePayload, + MyInfoCookieStateError | MyInfoMissingAccessTokenError +> => + extractMyInfoCookie(cookies).andThen((cookiePayload) => + extractSuccessfulCookie(cookiePayload), + ) + /** * Extracts access token from a MyInfo cookie * @param cookie Cookie from which access token should be extracted From 77117a71202414be8d29093eb2ad477a8ee8f8d1 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Tue, 23 Mar 2021 18:40:31 +0800 Subject: [PATCH 26/86] refactor(public-form/controller): updated controller to use MyInfoFactory method --- .../public-form/public-form.controller.ts | 21 +++---------------- 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/src/app/modules/form/public-form/public-form.controller.ts b/src/app/modules/form/public-form/public-form.controller.ts index 87e9e1a04d..d2dd7dbe22 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -12,11 +12,6 @@ import { MYINFO_COOKIE_OPTIONS, } from '../../myinfo/myinfo.constants' import { MyInfoFactory } from '../../myinfo/myinfo.factory' -import { - extractMyInfoCookie, - extractSuccessfulCookie, - validateMyInfoForm, -} from '../../myinfo/myinfo.util' import { MissingJwtError } from '../../spcp/spcp.errors' import { SpcpFactory } from '../../spcp/spcp.factory' import { PrivateFormError } from '../form.errors' @@ -251,20 +246,10 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( case AuthType.MyInfo: { const requestedAttributes = form.getUniqueMyInfoAttrs() - // 1. Validate the cookie and myInfo form return ( - extractMyInfoCookie(req.cookies) - .andThen((cookiePayload) => extractSuccessfulCookie(cookiePayload)) - .asyncAndThen((cookiePayload) => - validateMyInfoForm(form).asyncAndThen((form) => - MyInfoFactory.fetchMyInfoPersonData( - cookiePayload.accessToken, - requestedAttributes, - form.esrvcId, - ), - ), - ) - // 2. Fetch myInfo data and fill the form based on the result + // 1. Validate form and extract myInfoData + MyInfoFactory.extractMyInfoData(form, req.cookies) + // 2. Fill the form based on the result .andThen((myInfoData) => MyInfoFactory.prefillMyInfoFields( myInfoData, From e30b482931742776be42fcaa463eecfdf1a10c37 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Tue, 23 Mar 2021 18:41:09 +0800 Subject: [PATCH 27/86] test(public-form/controller/test): updated tests to use mocks --- .../__tests__/public-form.controller.spec.ts | 104 ++++++++---------- 1 file changed, 43 insertions(+), 61 deletions(-) diff --git a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts index 5ab6091e12..f0d03431e8 100644 --- a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts +++ b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts @@ -1,3 +1,4 @@ +import { IPersonResponse } from '@opengovsg/myinfo-gov-client' import { ObjectId } from 'bson-ext' import _, { merge } from 'lodash' import mongoose from 'mongoose' @@ -8,14 +9,12 @@ import { mocked } from 'ts-jest/utils' import getFormFeedbackModel from 'src/app/models/form_feedback.server.model' import { DatabaseError } from 'src/app/modules/core/core.errors' -import { - MyInfoCookieState, - MyInfoSuccessfulCookiePayload, -} from 'src/app/modules/myinfo/myinfo.types' -import * as myInfoUtils from 'src/app/modules/myinfo/myinfo.util' +import { MyInfoData } from 'src/app/modules/myinfo/myinfo.adapter' +import { MyInfoCookieState } from 'src/app/modules/myinfo/myinfo.types' import { JwtPayload } from 'src/app/modules/spcp/spcp.types' import { AuthType, + IMyInfoHashSchema, IPopulatedForm, IPopulatedUser, MyInfoAttribute, @@ -25,11 +24,7 @@ import { import expressHandler from 'tests/unit/backend/helpers/jest-express' import * as AuthService from '../../../auth/auth.service' -import { - MyInfoCookieStateError, - MyInfoMissingAccessTokenError, - MyInfoNoESrvcIdError, -} from '../../../myinfo/myinfo.errors' +import { MyInfoFactory } from '../../../myinfo/myinfo.factory' import { MissingJwtError } from '../../../spcp/spcp.errors' import { SpcpFactory } from '../../../spcp/spcp.factory' import { @@ -47,13 +42,13 @@ jest.mock('../public-form.service') jest.mock('../../form.service') jest.mock('../../../auth/auth.service') jest.mock('../../../spcp/spcp.factory') -jest.mock('src/app/modules/myinfo/myinfo.util') +jest.mock('../../../myinfo/myinfo.factory') const MockFormService = mocked(FormService) const MockPublicFormService = mocked(PublicFormService) const MockAuthService = mocked(AuthService) const MockSpcpFactory = mocked(SpcpFactory) -const MockMyInfoUtils = mocked(myInfoUtils) +const MockMyInfoFactory = mocked(MyInfoFactory) const FormFeedbackModel = getFormFeedbackModel(mongoose) @@ -437,7 +432,8 @@ describe('public-form.controller', () => { const BASE_FORM = { admin: MOCK_USER, _id: MOCK_FORM_ID, - title: 'Mock', + title: MOCK_SCRUBBED_FORM.title, + toJSON: jest.fn().mockReturnValue(MOCK_SCRUBBED_FORM), getUniqueMyInfoAttrs: jest.fn().mockReturnValue([MyInfoAttribute.Name]), getPublicView: jest.fn().mockReturnValue(MOCK_SCRUBBED_FORM), } @@ -448,6 +444,19 @@ describe('public-form.controller', () => { }, }) + const MOCK_MYINFO_COOKIE = { + accessToken: 'cookie', + usedCount: 0, + state: MyInfoCookieState.Success, + } + + const MOCK_REQ_WITH_COOKIES = expressHandler.mockRequest({ + params: { + formId: MOCK_FORM_ID, + }, + others: { cookies: { MyInfoCookie: MOCK_MYINFO_COOKIE } }, + }) + // Success describe('valid form id', () => { const MOCK_JWT_PAYLOAD: JwtPayload = { @@ -559,17 +568,12 @@ describe('public-form.controller', () => { authType: AuthType.MyInfo, }) as unknown) as MockedObject const MOCK_RES = expressHandler.mockResponse() - const MOCK_MYINFO_COOKIE = { - accessToken: 'cookie', - usedCount: 0, - state: MyInfoCookieState.Success, - } - const MOCK_REQ_WITH_COOKIES = expressHandler.mockRequest({ - params: { - formId: MOCK_FORM_ID, - }, - others: { cookies: { MyInfoCookie: MOCK_MYINFO_COOKIE } }, - }) + const MOCK_CLEAR_COOKIE = jest.fn().mockReturnValueOnce(MOCK_RES) + const MOCK_INITIAL_RES = _.set( + MOCK_RES, + 'clearCookie', + MOCK_CLEAR_COOKIE, + ) MockAuthService.getFormIfPublic.mockReturnValueOnce( okAsync(MOCK_MYINFO_AUTH_FORM), @@ -577,23 +581,28 @@ describe('public-form.controller', () => { MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( okAsync(MOCK_MYINFO_AUTH_FORM), ) - MockMyInfoUtils.extractMyInfoCookie.mockReturnValueOnce( - ok(MOCK_MYINFO_COOKIE), - ) - MockMyInfoUtils.extractSuccessfulCookie.mockReturnValueOnce( - ok(MOCK_MYINFO_COOKIE as MyInfoSuccessfulCookiePayload), - ) + MockMyInfoFactory.prefillMyInfoFields.mockReturnValueOnce(ok([])) + MockMyInfoFactory.extractMyInfoData.mockReturnValueOnce( + okAsync(new MyInfoData({ uinFin: 'i am a fish' } as IPersonResponse)), + ), + MockMyInfoFactory.saveMyInfoHashes.mockReturnValueOnce( + okAsync({ + uinFin: 'hello world', + } as IMyInfoHashSchema), + ) // Act await PublicFormController.handleGetPublicForm( MOCK_REQ_WITH_COOKIES, - MOCK_RES, + MOCK_INITIAL_RES, jest.fn(), ) // Assert + expect(MockMyInfoFactory.saveMyInfoHashes).toHaveBeenCalled() expect(MOCK_RES.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_AUTH_FORM.getPublicView(), + spcpSession: { userName: 'i am a fish' }, }) }) }) @@ -628,19 +637,9 @@ describe('public-form.controller', () => { MOCK_CLEAR_COOKIE, ) - MockAuthService.getFormIfPublic.mockReturnValue( - okAsync(MOCK_MYINFO_FORM), - ) - MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValue( - okAsync(MOCK_MYINFO_FORM), - ) - MockMyInfoUtils.extractMyInfoCookie.mockReturnValueOnce( - err(new MyInfoMissingAccessTokenError()), - ) - // Act await PublicFormController.handleGetPublicForm( - _.set(MOCK_REQ, 'cookies', {}), + MOCK_REQ_WITH_COOKIES, // NOTE: This is done because the calls to .json and .clearCookie are chained MOCK_INITIAL_RES, jest.fn(), @@ -665,16 +664,9 @@ describe('public-form.controller', () => { MOCK_CLEAR_COOKIE, ) - MockMyInfoUtils.extractMyInfoCookie.mockReturnValueOnce( - ok({ state: MyInfoCookieState.Error }), - ) - MockMyInfoUtils.extractSuccessfulCookie.mockReturnValueOnce( - err(new MyInfoCookieStateError()), - ) - // Act await PublicFormController.handleGetPublicForm( - _.set(MOCK_REQ, 'cookies', { accessToken: 'cookie monster?' }), + MOCK_REQ_WITH_COOKIES, // NOTE: This is done because the calls to .json and .clearCookie are chained MOCK_INITIAL_RES, jest.fn(), @@ -699,19 +691,9 @@ describe('public-form.controller', () => { MOCK_CLEAR_COOKIE, ) - MockMyInfoUtils.extractMyInfoCookie.mockReturnValueOnce( - ok({ state: MyInfoCookieState.Error }), - ) - MockMyInfoUtils.extractSuccessfulCookie.mockReturnValueOnce( - err(new MyInfoCookieStateError()), - ) - MockMyInfoUtils.validateMyInfoForm.mockReturnValueOnce( - err(new MyInfoNoESrvcIdError()), - ) - // Act await PublicFormController.handleGetPublicForm( - _.set(MOCK_REQ, 'cookies', { accessToken: 'cookie monster?' }), + MOCK_REQ_WITH_COOKIES, // NOTE: This is done because the calls to .json and .clearCookie are chained _.set(MOCK_INITIAL_RES, 'clearCookie', MOCK_CLEAR_COOKIE), jest.fn(), From 2eab1d6545a4200d0420a69533423ca6f6919d20 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Wed, 24 Mar 2021 11:50:36 +0800 Subject: [PATCH 28/86] fix(public-form/controller): fixed succesful 200 when auth using myInfo returning a private view --- src/app/modules/form/public-form/public-form.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/modules/form/public-form/public-form.controller.ts b/src/app/modules/form/public-form/public-form.controller.ts index d2dd7dbe22..ba158e1b32 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -273,7 +273,7 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( // 4. Return result if successful otherwise, clear cookies and return default response .map(({ spcpSession, formFields }) => res.json({ - form: _.set(form, 'form_fields', formFields), + form: _.set(publicFormView, 'form_fields', formFields), spcpSession, }), ) From c2fe4c9ef79d3dd372434d703794a3605f0fa9e1 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Wed, 24 Mar 2021 11:52:19 +0800 Subject: [PATCH 29/86] test(public-form/controller/test): adds remaining tests for myInfo --- .../__tests__/public-form.controller.spec.ts | 152 ++++++++++++++++-- 1 file changed, 143 insertions(+), 9 deletions(-) diff --git a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts index f0d03431e8..0650feb7aa 100644 --- a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts +++ b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts @@ -8,10 +8,19 @@ import { MockedObject } from 'ts-jest/dist/utils/testing' import { mocked } from 'ts-jest/utils' import getFormFeedbackModel from 'src/app/models/form_feedback.server.model' -import { DatabaseError } from 'src/app/modules/core/core.errors' +import { + DatabaseError, + MissingFeatureError, +} from 'src/app/modules/core/core.errors' import { MyInfoData } from 'src/app/modules/myinfo/myinfo.adapter' +import { + MyInfoAuthTypeError, + MyInfoMissingAccessTokenError, + MyInfoNoESrvcIdError, +} from 'src/app/modules/myinfo/myinfo.errors' import { MyInfoCookieState } from 'src/app/modules/myinfo/myinfo.types' import { JwtPayload } from 'src/app/modules/spcp/spcp.types' +import { FeatureNames } from 'src/config/feature-manager/types' import { AuthType, IMyInfoHashSchema, @@ -24,6 +33,7 @@ import { import expressHandler from 'tests/unit/backend/helpers/jest-express' import * as AuthService from '../../../auth/auth.service' +import { MyInfoCookieStateError } from '../../../myinfo/myinfo.errors' import { MyInfoFactory } from '../../../myinfo/myinfo.factory' import { MissingJwtError } from '../../../spcp/spcp.errors' import { SpcpFactory } from '../../../spcp/spcp.factory' @@ -415,7 +425,6 @@ describe('public-form.controller', () => { }) describe('handleGetPublicForm', () => { - // TODO: ensure that saveMyInfoHashes is being called const MOCK_FORM_ID = new ObjectId().toHexString() const MOCK_USER_ID = new ObjectId().toHexString() const MOCK_USER = { @@ -433,7 +442,6 @@ describe('public-form.controller', () => { admin: MOCK_USER, _id: MOCK_FORM_ID, title: MOCK_SCRUBBED_FORM.title, - toJSON: jest.fn().mockReturnValue(MOCK_SCRUBBED_FORM), getUniqueMyInfoAttrs: jest.fn().mockReturnValue([MyInfoAttribute.Name]), getPublicView: jest.fn().mockReturnValue(MOCK_SCRUBBED_FORM), } @@ -566,6 +574,7 @@ describe('public-form.controller', () => { ...BASE_FORM, esrvcId: 'thing', authType: AuthType.MyInfo, + toJSON: jest.fn().mockReturnValue(BASE_FORM), }) as unknown) as MockedObject const MOCK_RES = expressHandler.mockResponse() const MOCK_CLEAR_COOKIE = jest.fn().mockReturnValueOnce(MOCK_RES) @@ -574,6 +583,9 @@ describe('public-form.controller', () => { 'clearCookie', MOCK_CLEAR_COOKIE, ) + const MOCK_MYINFO_DATA = new MyInfoData({ + uinFin: 'i am a fish', + } as IPersonResponse) MockAuthService.getFormIfPublic.mockReturnValueOnce( okAsync(MOCK_MYINFO_AUTH_FORM), @@ -583,7 +595,7 @@ describe('public-form.controller', () => { ) MockMyInfoFactory.prefillMyInfoFields.mockReturnValueOnce(ok([])) MockMyInfoFactory.extractMyInfoData.mockReturnValueOnce( - okAsync(new MyInfoData({ uinFin: 'i am a fish' } as IPersonResponse)), + okAsync(MOCK_MYINFO_DATA), ), MockMyInfoFactory.saveMyInfoHashes.mockReturnValueOnce( okAsync({ @@ -602,7 +614,7 @@ describe('public-form.controller', () => { expect(MockMyInfoFactory.saveMyInfoHashes).toHaveBeenCalled() expect(MOCK_RES.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_AUTH_FORM.getPublicView(), - spcpSession: { userName: 'i am a fish' }, + spcpSession: { userName: MOCK_MYINFO_DATA.getUinFin() }, }) }) }) @@ -612,6 +624,7 @@ describe('public-form.controller', () => { const MOCK_MYINFO_FORM = (mocked({ ...BASE_FORM, authType: AuthType.MyInfo, + toJSON: jest.fn().mockReturnValue(BASE_FORM), }) as unknown) as IPopulatedForm // Setup because this gets invoked at the start of the controller to decide which branch to take @@ -636,10 +649,13 @@ describe('public-form.controller', () => { 'clearCookie', MOCK_CLEAR_COOKIE, ) + MockMyInfoFactory.extractMyInfoData.mockReturnValueOnce( + errAsync(new MyInfoMissingAccessTokenError()), + ) // Act await PublicFormController.handleGetPublicForm( - MOCK_REQ_WITH_COOKIES, + MOCK_REQ, // NOTE: This is done because the calls to .json and .clearCookie are chained MOCK_INITIAL_RES, jest.fn(), @@ -647,8 +663,9 @@ describe('public-form.controller', () => { // Assert expect(MOCK_INITIAL_RES.clearCookie).toHaveBeenCalled() + expect(MockMyInfoFactory.saveMyInfoHashes).not.toHaveBeenCalled() expect(MOCK_RES.json).toHaveBeenCalledWith({ - form: BASE_FORM.getPublicView(), + form: MOCK_MYINFO_FORM.getPublicView(), myInfoError: true, }) }) @@ -663,6 +680,9 @@ describe('public-form.controller', () => { 'clearCookie', MOCK_CLEAR_COOKIE, ) + MockMyInfoFactory.extractMyInfoData.mockReturnValueOnce( + errAsync(new MyInfoCookieStateError()), + ) // Act await PublicFormController.handleGetPublicForm( @@ -674,8 +694,9 @@ describe('public-form.controller', () => { // Assert expect(MOCK_INITIAL_RES.clearCookie).toHaveBeenCalled() + expect(MockMyInfoFactory.saveMyInfoHashes).not.toHaveBeenCalled() expect(MOCK_RES.json).toHaveBeenCalledWith({ - form: BASE_FORM.getPublicView(), + form: MOCK_MYINFO_FORM.getPublicView(), myInfoError: true, }) }) @@ -690,6 +711,119 @@ describe('public-form.controller', () => { 'clearCookie', MOCK_CLEAR_COOKIE, ) + MockMyInfoFactory.extractMyInfoData.mockReturnValueOnce( + errAsync(new MyInfoAuthTypeError()), + ) + + // Act + await PublicFormController.handleGetPublicForm( + MOCK_REQ_WITH_COOKIES, + // NOTE: This is done because the calls to .json and .clearCookie are chained + _.set(MOCK_INITIAL_RES, 'clearCookie', MOCK_CLEAR_COOKIE), + jest.fn(), + ) + + // Assert + expect(MOCK_INITIAL_RES.clearCookie).toHaveBeenCalled() + expect(MockMyInfoFactory.saveMyInfoHashes).not.toHaveBeenCalled() + expect(MOCK_RES.json).toHaveBeenCalledWith({ + form: MOCK_MYINFO_FORM.getPublicView(), + myInfoError: true, + }) + }) + + it('should return 200 but the response should have cookies cleared and myInfoError if the form has no eservcId', async () => { + // Arrange + // 1. Mock the response and calls + const MOCK_RES = expressHandler.mockResponse() + const MOCK_CLEAR_COOKIE = jest.fn().mockReturnValueOnce(MOCK_RES) + const MOCK_INITIAL_RES = _.set( + MOCK_RES, + 'clearCookie', + MOCK_CLEAR_COOKIE, + ) + MockMyInfoFactory.extractMyInfoData.mockReturnValueOnce( + errAsync(new MyInfoNoESrvcIdError()), + ) + + // Act + await PublicFormController.handleGetPublicForm( + MOCK_REQ_WITH_COOKIES, + // NOTE: This is done because the calls to .json and .clearCookie are chained + _.set(MOCK_INITIAL_RES, 'clearCookie', MOCK_CLEAR_COOKIE), + jest.fn(), + ) + + // Assert + expect(MOCK_INITIAL_RES.clearCookie).toHaveBeenCalled() + expect(MockMyInfoFactory.saveMyInfoHashes).not.toHaveBeenCalled() + expect(MOCK_RES.json).toHaveBeenCalledWith({ + form: MOCK_MYINFO_FORM.getPublicView(), + myInfoError: true, + }) + }) + + it('should return 200 but the response should have cookies cleared and myInfoError if the form could not be filled', async () => { + // Arrange + // 1. Mock the response and calls + const MOCK_RES = expressHandler.mockResponse() + const MOCK_CLEAR_COOKIE = jest.fn().mockReturnValueOnce(MOCK_RES) + const MOCK_INITIAL_RES = _.set( + MOCK_RES, + 'clearCookie', + MOCK_CLEAR_COOKIE, + ) + MockMyInfoFactory.extractMyInfoData.mockReturnValueOnce( + okAsync({} as MyInfoData), + ) + MockMyInfoFactory.prefillMyInfoFields.mockReturnValueOnce( + err( + new MissingFeatureError( + 'testing is the missing feature' as FeatureNames, + ), + ), + ) + + // Act + await PublicFormController.handleGetPublicForm( + MOCK_REQ_WITH_COOKIES, + // NOTE: This is done because the calls to .json and .clearCookie are chained + _.set(MOCK_INITIAL_RES, 'clearCookie', MOCK_CLEAR_COOKIE), + jest.fn(), + ) + + // Assert + expect(MOCK_INITIAL_RES.clearCookie).toHaveBeenCalled() + expect(MockMyInfoFactory.saveMyInfoHashes).not.toHaveBeenCalled() + expect(MOCK_RES.json).toHaveBeenCalledWith({ + form: MOCK_MYINFO_FORM.getPublicView(), + myInfoError: true, + }) + }) + + it('should return 200 but the response should have cookies cleared and myInfoError if a database error occurs while saving hashes', async () => { + // Arrange + // 1. Mock the response and calls + const MOCK_RES = expressHandler.mockResponse() + const MOCK_CLEAR_COOKIE = jest.fn().mockReturnValueOnce(MOCK_RES) + const MOCK_INITIAL_RES = _.set( + MOCK_RES, + 'clearCookie', + MOCK_CLEAR_COOKIE, + ) + MockAuthService.getFormIfPublic.mockReturnValueOnce( + okAsync(MOCK_MYINFO_FORM), + ) + MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( + okAsync(MOCK_MYINFO_FORM), + ) + MockMyInfoFactory.prefillMyInfoFields.mockReturnValueOnce(ok([])) + MockMyInfoFactory.extractMyInfoData.mockReturnValueOnce( + okAsync(({ getUinFin: jest.fn() } as unknown) as MyInfoData), + ), + MockMyInfoFactory.saveMyInfoHashes.mockReturnValueOnce( + errAsync(new DatabaseError()), + ) // Act await PublicFormController.handleGetPublicForm( @@ -702,7 +836,7 @@ describe('public-form.controller', () => { // Assert expect(MOCK_INITIAL_RES.clearCookie).toHaveBeenCalled() expect(MOCK_RES.json).toHaveBeenCalledWith({ - form: BASE_FORM.getPublicView(), + form: MOCK_MYINFO_FORM.getPublicView(), myInfoError: true, }) }) From 78e142d947c80134445c652427d451fa5e79648e Mon Sep 17 00:00:00 2001 From: seaerchin Date: Thu, 25 Mar 2021 17:47:06 +0800 Subject: [PATCH 30/86] refactor(spcp): combined controller calls to spcp services into a single spcp call --- src/app/modules/spcp/spcp.errors.ts | 12 +++++++++ src/app/modules/spcp/spcp.factory.ts | 2 ++ src/app/modules/spcp/spcp.service.ts | 38 ++++++++++++++++++++++++---- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/app/modules/spcp/spcp.errors.ts b/src/app/modules/spcp/spcp.errors.ts index 7a523de4db..cede9f50ec 100644 --- a/src/app/modules/spcp/spcp.errors.ts +++ b/src/app/modules/spcp/spcp.errors.ts @@ -66,6 +66,18 @@ export class AuthTypeMismatchError extends ApplicationError { } } +/** + * Attempt to perform a SPCP-related operation on a form without SPCP + * authentication enabled. + */ +export class SpcpAuthTypeError extends ApplicationError { + constructor( + message = 'Spcp function called on form without Spcp authentication type', + ) { + super(message) + } +} + /** * Attributes given by SP/CP did not contain NRIC or entity ID/UID. */ diff --git a/src/app/modules/spcp/spcp.factory.ts b/src/app/modules/spcp/spcp.factory.ts index 68ae6a34c9..b3dd48ee6b 100644 --- a/src/app/modules/spcp/spcp.factory.ts +++ b/src/app/modules/spcp/spcp.factory.ts @@ -20,6 +20,7 @@ interface ISpcpFactory { createJWTPayload: SpcpService['createJWTPayload'] getCookieSettings: SpcpService['getCookieSettings'] getSpcpSession: SpcpService['getSpcpSession'] + createFormWithSpcpSession: SpcpService['createFormWithSpcpSession'] } export const createSpcpFactory = ({ @@ -40,6 +41,7 @@ export const createSpcpFactory = ({ createJWTPayload: () => err(error), getCookieSettings: () => ({}), getSpcpSession: () => errAsync(error), + createFormWithSpcpSession: () => errAsync(error), } } return new SpcpService(props) diff --git a/src/app/modules/spcp/spcp.service.ts b/src/app/modules/spcp/spcp.service.ts index 69a889e4be..ad9802fb62 100644 --- a/src/app/modules/spcp/spcp.service.ts +++ b/src/app/modules/spcp/spcp.service.ts @@ -6,8 +6,9 @@ import { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow' import { ISpcpMyInfo } from '../../../config/feature-manager' import { createLoggerWithLabel } from '../../../config/logger' -import { AuthType } from '../../../types' +import { AuthType, IPopulatedForm } from '../../../types' import { ApplicationError } from '../core/core.errors' +import { IPublicFormView } from '../form/public-form/public-form.types' import { CreateRedirectUrlError, @@ -18,6 +19,7 @@ import { MissingAttributesError, MissingJwtError, RetrieveAttributesError, + SpcpAuthTypeError, VerifyJwtError, } from './spcp.errors' import { @@ -410,10 +412,10 @@ export class SpcpService { * Gets the spcp session info from the auth, cookies * @param authType The authentication type of the user * @param cookies The spcp cookies set by the redirect - * @return okAsync(jwtPayload) if successful - * @return errAsync(MissingJwtError) if the specified cookie for the authType (spcp) does not exist - * @return errAsync(VerifyJwtError) if the jwt exists but could not be authenticated - * @return errAsync(InvalidJwtError) if the jwt exists but the payload is invalid + * @return ok(jwtPayload) if successful + * @return err(MissingJwtError) if the specified cookie for the authType (spcp) does not exist + * @return err(VerifyJwtError) if the jwt exists but could not be authenticated + * @return err(InvalidJwtError) if the jwt exists but the payload is invalid */ getSpcpSession( authType: AuthType.SP | AuthType.CP, @@ -426,4 +428,30 @@ export class SpcpService { this.extractJwtPayload(jwtResult, authType), ) } + + /** + * Validates and creates the public form view from the cookies and form of a request + * @param form The public form view + * @param authType Possible authentication types of the request (SP or CP) + * @param cookies Cookies of the request + * @return ok(IPublicFormView) The public view of the form with associated session info + * @return err(MissingJwtError) if the specified cookie for the authType (spcp) does not exist + * @return err(VerifyJwtError) if the jwt exists but could not be authenticated + * @return err(InvalidJwtError) if the jwt exists but the payload is invalid + * @return err(AuthTypeMismatchError) if the client did not authenticate using SPCP + */ + createFormWithSpcpSession( + form: IPopulatedForm, + cookies: Record, + ): ResultAsync< + IPublicFormView, + MissingJwtError | VerifyJwtError | InvalidJwtError | SpcpAuthTypeError + > { + return form.authType === AuthType.CP || form.authType === AuthType.SP + ? this.getSpcpSession(form.authType, cookies).map(({ userName }) => ({ + form: form.getPublicView(), + spcpSession: { userName }, + })) + : errAsync(new SpcpAuthTypeError()) + } } From e09d22794b89feacc6caa405a66a35aadb770947 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Thu, 25 Mar 2021 17:49:34 +0800 Subject: [PATCH 31/86] refactor(myinfo): refactored multiple controller calls to myInfo into a single call --- src/app/modules/myinfo/myinfo.errors.ts | 9 +++ src/app/modules/myinfo/myinfo.factory.ts | 17 ++++++ src/app/modules/myinfo/myinfo.service.ts | 70 +++++++++++++++++++++--- src/app/modules/myinfo/myinfo.util.ts | 14 +++++ 4 files changed, 101 insertions(+), 9 deletions(-) diff --git a/src/app/modules/myinfo/myinfo.errors.ts b/src/app/modules/myinfo/myinfo.errors.ts index 29f25c8c04..f149653498 100644 --- a/src/app/modules/myinfo/myinfo.errors.ts +++ b/src/app/modules/myinfo/myinfo.errors.ts @@ -103,3 +103,12 @@ export class MyInfoCookieStateError extends ApplicationError { super(message) } } + +/** + * MyInfo cookie has been used more than once + */ +export class MyInfoCookieAccessError extends ApplicationError { + constructor(message = 'MyInfo cookie has already been used') { + super(message) + } +} diff --git a/src/app/modules/myinfo/myinfo.factory.ts b/src/app/modules/myinfo/myinfo.factory.ts index c061df6a44..6d010bd1a0 100644 --- a/src/app/modules/myinfo/myinfo.factory.ts +++ b/src/app/modules/myinfo/myinfo.factory.ts @@ -14,6 +14,7 @@ import { MyInfoAttribute, } from '../../../types' import { DatabaseError, MissingFeatureError } from '../core/core.errors' +import { IPublicFormView } from '../form/public-form/public-form.types' import { ProcessedFieldResponse } from '../submission/submission.types' import { MyInfoData } from './myinfo.adapter' @@ -101,6 +102,21 @@ interface IMyInfoFactory { | MyInfoFetchError | MissingFeatureError > + + createFormWithMyInfo: ( + form: IPopulatedForm, + cookies: Record, + ) => ResultAsync< + IPublicFormView, + | MissingFeatureError + | MyInfoFetchError + | MyInfoAuthTypeError + | MyInfoCircuitBreakerError + | DatabaseError + | MyInfoMissingAccessTokenError + | MyInfoCookieStateError + | MyInfoNoESrvcIdError + > } export const createMyInfoFactory = ({ @@ -120,6 +136,7 @@ export const createMyInfoFactory = ({ parseMyInfoRelayState: () => err(error), extractUinFin: () => err(error), extractMyInfoData: () => errAsync(error), + createFormWithMyInfo: () => errAsync(error), } } return new MyInfoService({ diff --git a/src/app/modules/myinfo/myinfo.service.ts b/src/app/modules/myinfo/myinfo.service.ts index a2e8021e26..d9576712c9 100644 --- a/src/app/modules/myinfo/myinfo.service.ts +++ b/src/app/modules/myinfo/myinfo.service.ts @@ -20,6 +20,7 @@ import { MyInfoAttribute, } from '../../../types' import { DatabaseError, MissingFeatureError } from '../core/core.errors' +import { IPublicFormView } from '../form/public-form/public-form.types' import { ProcessedFieldResponse } from '../submission/submission.types' import { internalAttrListToScopes, MyInfoData } from './myinfo.adapter' @@ -31,6 +32,7 @@ import { import { MyInfoAuthTypeError, MyInfoCircuitBreakerError, + MyInfoCookieAccessError, MyInfoCookieStateError, MyInfoFetchError, MyInfoHashDidNotMatchError, @@ -48,6 +50,7 @@ import { MyInfoParsedRelayState, } from './myinfo.types' import { + checkMyInfoCookieUsedCount, compareHashedValues, createRelayState, extractSuccessfulMyInfoCookie, @@ -466,6 +469,7 @@ export class MyInfoService { * @returns ok(MyInfoData) if the form has been validated successfully * @returns err(MyInfoMissingAccessTokenError) if no myInfoCookie was found on the request * @returns err(MyInfoCookieStateError) if cookie was not successful + * @returns err(MyInfoCookieAccessError) if the cookie has already been used before * @returns err(MyInfoNoESrvcIdError) if form has no eserviceId * @returns err(MyInfoAuthTypeError) if the client was not authenticated using MyInfo * @returns err(MyInfoCircuitBreakerError) if circuit breaker was active @@ -484,18 +488,66 @@ export class MyInfoService { | MyInfoCircuitBreakerError | MyInfoFetchError | MissingFeatureError + | MyInfoCookieAccessError > { const requestedAttributes = form.getUniqueMyInfoAttrs() - return extractSuccessfulMyInfoCookie( - cookies, - ).asyncAndThen((cookiePayload) => - validateMyInfoForm(form).asyncAndThen((form) => - this.fetchMyInfoPersonData( - cookiePayload.accessToken, - requestedAttributes, - form.esrvcId, + return extractSuccessfulMyInfoCookie(cookies) + .andThen((myInfoCookie) => checkMyInfoCookieUsedCount(myInfoCookie)) + .asyncAndThen((cookiePayload) => + validateMyInfoForm(form).asyncAndThen((form) => + this.fetchMyInfoPersonData( + cookiePayload.accessToken, + requestedAttributes, + form.esrvcId, + ), ), - ), + ) + } + + /** + * creates a form view with myInfo fields prefilled onto the form + * @param form The form to validate and fill + * @param cookies The cookies on the request + * @returns + */ + createFormWithMyInfo( + form: IPopulatedForm, + cookies: Record, + ): ResultAsync< + IPublicFormView, + | MyInfoCookieAccessError + | MissingFeatureError + | MyInfoFetchError + | MyInfoAuthTypeError + | MyInfoCircuitBreakerError + | DatabaseError + | MyInfoMissingAccessTokenError + | MyInfoCookieStateError + | MyInfoNoESrvcIdError + > { + return ( + // 1. Validate form and extract myInfoData + this.extractMyInfoData(form, cookies) + // 2. Fill the form based on the result + .andThen((myInfoData) => + this.prefillMyInfoFields(myInfoData, form.toJSON().form_fields).map( + (formFields) => ({ + form: { + ...form.getPublicView(), + form_fields: formFields, + }, + spcpSession: { userName: myInfoData.getUinFin() }, + }), + ), + ) + // 3. Hash and save to database + .andThen((prefilledForm) => + this.saveMyInfoHashes( + prefilledForm.spcpSession.userName, + form._id, + prefilledForm.form.form_fields, + ).map(() => prefilledForm), + ) ) } } diff --git a/src/app/modules/myinfo/myinfo.util.ts b/src/app/modules/myinfo/myinfo.util.ts index 5c0ee8a3dd..938ae4deb8 100644 --- a/src/app/modules/myinfo/myinfo.util.ts +++ b/src/app/modules/myinfo/myinfo.util.ts @@ -28,6 +28,7 @@ import { ProcessedFieldResponse } from '../submission/submission.types' import { MYINFO_COOKIE_NAME } from './myinfo.constants' import { MyInfoAuthTypeError, + MyInfoCookieAccessError, MyInfoCookieStateError, MyInfoHashDidNotMatchError, MyInfoHashingError, @@ -391,6 +392,19 @@ export const extractSuccessfulMyInfoCookie = ( extractSuccessfulCookie(cookiePayload), ) +/** + * checks if a MyInfo cookie has been used + * @param myInfoCookie + * @returns ok(myInfoCookie) if the cookie has not been used before + * @returns err(MyInfoCookieAccessError) if the cookie has been used before + */ +export const checkMyInfoCookieUsedCount = ( + myInfoCookie: MyInfoSuccessfulCookiePayload, +): Result => + myInfoCookie.usedCount <= 0 + ? ok(myInfoCookie) + : err(new MyInfoCookieAccessError()) + /** * Extracts access token from a MyInfo cookie * @param cookie Cookie from which access token should be extracted From c4637a17e8bef3efa2024a63c4d3e116facd23a8 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Thu, 25 Mar 2021 17:50:26 +0800 Subject: [PATCH 32/86] refactor(public-form): updated typings and refactored getPublicForm to be cleaner --- .../public-form/public-form.controller.ts | 147 ++++++------------ .../form/public-form/public-form.types.ts | 16 ++ src/types/form.ts | 17 ++ 3 files changed, 84 insertions(+), 96 deletions(-) diff --git a/src/app/modules/form/public-form/public-form.controller.ts b/src/app/modules/form/public-form/public-form.controller.ts index ba158e1b32..5f80b27cad 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -1,10 +1,10 @@ import { RequestHandler } from 'express' import { StatusCodes } from 'http-status-codes' -import _ from 'lodash' +import { okAsync } from 'neverthrow' import querystring from 'querystring' import { createLoggerWithLabel } from '../../../../config/logger' -import { AuthType } from '../../../../types' +import { AuthType, FormController } from '../../../../types' import { createReqMeta } from '../../../utils/request' import { getFormIfPublic } from '../../auth/auth.service' import { @@ -12,6 +12,8 @@ import { MYINFO_COOKIE_OPTIONS, } from '../../myinfo/myinfo.constants' import { MyInfoFactory } from '../../myinfo/myinfo.factory' +import { MyInfoCookiePayload } from '../../myinfo/myinfo.types' +import { extractSuccessfulCookie } from '../../myinfo/myinfo.util' import { MissingJwtError } from '../../spcp/spcp.errors' import { SpcpFactory } from '../../spcp/spcp.factory' import { PrivateFormError } from '../form.errors' @@ -206,106 +208,59 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( } const form = formResult.value - const publicFormView = form.getPublicView() + const publicForm = form.getPublicView() const { authType } = form + // NOTE: Creating a variable to ensure that TS will enforce the type and ensure all keys in AuthType are covered. + const formController: FormController< + | ReturnType + | ReturnType + > = { + [AuthType.SP]: () => + SpcpFactory.createFormWithSpcpSession(form, req.cookies), + [AuthType.CP]: () => + SpcpFactory.createFormWithSpcpSession(form, req.cookies), + [AuthType.MyInfo]: () => { + return MyInfoFactory.createFormWithMyInfo(form, req.cookies) + .andThen((publicForm) => { + return extractSuccessfulCookie(req.cookies).map((myInfoCookie) => { + const cookiePayload: MyInfoCookiePayload = { + ...myInfoCookie, + usedCount: myInfoCookie.usedCount + 1, + } + // NOTE: This is a side effect to set the cookie on the result after it has been successfully prefilled. + res.cookie(MYINFO_COOKIE_NAME, cookiePayload, MYINFO_COOKIE_OPTIONS) + return publicForm + }) + }) + .mapErr((error) => { + // NOTE: This is a side-effect as there is no need for cookie if data could not be retrieved + res.clearCookie(MYINFO_COOKIE_NAME, MYINFO_COOKIE_OPTIONS) + return error + }) + }, + [AuthType.NIL]: () => okAsync({ form: form.getPublicView() }), + } + // NOTE: Once there is a valid form retrieved from the database, // the client should always get a 200 response with the form's public view. // Additional errors should be tagged onto the response object like myInfoError. - switch (authType) { - case AuthType.SP: - case AuthType.CP: { - // Form is valid, check for SPCP/MyInfo data. - return SpcpFactory.getSpcpSession(authType, req.cookies) - .map(({ userName }) => - res.json({ - form: publicFormView, - spcpSession: { userName }, - }), - ) - .mapErr((error) => { - // NOTE: Only log if there is no jwt present on the request. - // This is because clients can be members of the pubilc and hence, have no jwt. - if (!(error instanceof MissingJwtError)) { - logger.error({ - message: 'Error getting public form', - meta: { - action: 'handleGetPublicForm', - ...createReqMeta(req), - formId, - }, - error, - }) - } - return res.json({ - form: publicFormView, - }) + return formController[authType]() + .map((publicFormView) => res.json(publicFormView)) + .mapErr((error) => { + if (!(error instanceof MissingJwtError)) { + logger.error({ + message: 'Error getting public form', + meta: { + action: 'handleGetPublicForm', + ...createReqMeta(req), + formId, + }, + error, }) - } - - case AuthType.MyInfo: { - const requestedAttributes = form.getUniqueMyInfoAttrs() - - return ( - // 1. Validate form and extract myInfoData - MyInfoFactory.extractMyInfoData(form, req.cookies) - // 2. Fill the form based on the result - .andThen((myInfoData) => - MyInfoFactory.prefillMyInfoFields( - myInfoData, - form.toJSON().form_fields, - ).map((formFields) => ({ - formFields, - spcpSession: { userName: myInfoData.getUinFin() }, - })), - ) - // 3. Hash and save to database - .andThen((form) => - MyInfoFactory.saveMyInfoHashes( - form.spcpSession.userName, - formId, - form.formFields, - ).map( - // NOTE: Passthrough as form is needed in the pipeline - () => form, - ), - ) - // 4. Return result if successful otherwise, clear cookies and return default response - .map(({ spcpSession, formFields }) => - res.json({ - form: _.set(publicFormView, 'form_fields', formFields), - spcpSession, - }), - ) - .mapErr((error) => { - logger.error({ - message: error.message, - meta: { - action: 'handlePublicForm', - ...createReqMeta(req), - formId, - esrvcId: form.esrvcId, - requestedAttributes, - }, - error, - }) - return ( - res - // NOTE: No need for cookie if data could not be retrieved - .clearCookie(MYINFO_COOKIE_NAME, MYINFO_COOKIE_OPTIONS) - .json({ - form: publicFormView, - myInfoError: true, - }) - ) - }) - ) - } - default: - // NOTE: Client did not choose any form of authentication. - // Only return the public form view back to the client + } return res.json({ - form: publicFormView, + form: publicForm, }) - } + }) } diff --git a/src/app/modules/form/public-form/public-form.types.ts b/src/app/modules/form/public-form/public-form.types.ts index 543318873c..dbbd2eedaf 100644 --- a/src/app/modules/form/public-form/public-form.types.ts +++ b/src/app/modules/form/public-form/public-form.types.ts @@ -1,5 +1,9 @@ import { ParamsDictionary } from 'express-serve-static-core' +import { IFieldSchema, PublicForm } from 'src/types' + +import { IPossiblyPrefilledField } from '../../myinfo/myinfo.types' + export type Metatags = { title: string description?: string @@ -13,3 +17,15 @@ export type RedirectParams = ParamsDictionary & { // TODO(#144): Rename Id to formId after all routes have been updated. Id: string } + +// NOTE: This is needed because PublicForm inherits from IFormDocument (where form_fields has type of IFieldSchema). +// However, the form returned back to the client has form_field of two possible types +interface PossiblyPrefilledPublicForm extends Omit { + form_fields: IPossiblyPrefilledField[] | IFieldSchema[] +} + +export interface IPublicFormView { + form: PossiblyPrefilledPublicForm + spcpSession?: { userName: string } + myInfoError?: boolean +} diff --git a/src/types/form.ts b/src/types/form.ts index e8964e443a..532da21041 100644 --- a/src/types/form.ts +++ b/src/types/form.ts @@ -266,3 +266,20 @@ export type FormMetaView = Pick< > & { admin: IPopulatedUser } + +/** + * Type guard for whether a populated form is email mode + * @param form Form document to check + */ +export const isEmailModeForm = ( + form: IPopulatedForm, +): form is IPopulatedEmailForm => { + return form.responseMode === ResponseMode.Email +} + +/** + * Mapping type between authType into an output function to ensure that the indexed type has transformations for all forms + */ +export type FormController = { + [K in keyof typeof AuthType]: () => F +} From 62d40e41759d3b18719e8ff1860a594f69dc7671 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Mon, 29 Mar 2021 12:25:00 +0800 Subject: [PATCH 33/86] style(public-form/controller/test): updated comments in tests to use when --- .../__tests__/public-form.controller.spec.ts | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts index 0650feb7aa..1012bc6acf 100644 --- a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts +++ b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts @@ -628,18 +628,17 @@ describe('public-form.controller', () => { }) as unknown) as IPopulatedForm // Setup because this gets invoked at the start of the controller to decide which branch to take - beforeAll(() => + beforeAll(() => { MockAuthService.getFormIfPublic.mockReturnValue( okAsync(MOCK_MYINFO_FORM), - ), - ) - beforeAll(() => + ) + MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValue( okAsync(MOCK_MYINFO_FORM), - ), - ) + ) + }) - it('should return 200 but the response should have cookies cleared and myInfoError if the request has no cookie', async () => { + it('should return 200 but the response should have cookies cleared and myInfoError when the request has no cookie', async () => { // Arrange // 1. Mock the response and calls const MOCK_RES = expressHandler.mockResponse() @@ -670,7 +669,7 @@ describe('public-form.controller', () => { }) }) - it('should return 200 but the response should have cookies cleared and myInfoError if the cookie cannot be validated', async () => { + it('should return 200 but the response should have cookies cleared and myInfoError when the cookie cannot be validated', async () => { // Arrange // 1. Mock the response and calls const MOCK_RES = expressHandler.mockResponse() @@ -732,7 +731,7 @@ describe('public-form.controller', () => { }) }) - it('should return 200 but the response should have cookies cleared and myInfoError if the form has no eservcId', async () => { + it('should return 200 but the response should have cookies cleared and myInfoError when the form has no eservcId', async () => { // Arrange // 1. Mock the response and calls const MOCK_RES = expressHandler.mockResponse() @@ -763,7 +762,7 @@ describe('public-form.controller', () => { }) }) - it('should return 200 but the response should have cookies cleared and myInfoError if the form could not be filled', async () => { + it('should return 200 but the response should have cookies cleared and myInfoError when the form could not be filled', async () => { // Arrange // 1. Mock the response and calls const MOCK_RES = expressHandler.mockResponse() @@ -847,7 +846,7 @@ describe('public-form.controller', () => { ...BASE_FORM, authType: AuthType.SP, }) as unknown) as MockedObject - it('should return 200 with the form but without a spcpSession', async () => { + it('should return 200 with the form but without a spcpSession when the JWT token could not be found', async () => { // Arrange // 1. Mock the response and calls const MOCK_RES = expressHandler.mockResponse() @@ -880,7 +879,7 @@ describe('public-form.controller', () => { }) describe('errors in form retrieval', () => { - it('should return 500 if a database error occurs', async () => { + it('should return 500 when a database error occurs', async () => { // Arrange // 1. Mock the response const MOCK_RES = expressHandler.mockResponse() @@ -909,7 +908,7 @@ describe('public-form.controller', () => { expect(MOCK_RES.status).toHaveBeenCalledWith(500) }) - it('should return 404 if the form is not found', async () => { + it('should return 404 when the form is not found', async () => { // Arrange // 1. Mock the response const MOCK_RES = expressHandler.mockResponse() @@ -938,7 +937,7 @@ describe('public-form.controller', () => { expect(MOCK_RES.status).toHaveBeenCalledWith(404) }) - it('should return 404 if the form is private and not accessible by the public', async () => { + it('should return 404 when the form is private and not accessible by the public', async () => { // Arrange // 1. Mock the response const MOCK_RES = expressHandler.mockResponse() From b05846539d4a1111f4488d73b70efa71a30eb022 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Mon, 29 Mar 2021 12:26:24 +0800 Subject: [PATCH 34/86] refactor(form/service): updated deactiveForm to return the form itself; updated tests --- .../modules/form/__tests__/form.service.spec.ts | 2 +- src/app/modules/form/form.service.ts | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/app/modules/form/__tests__/form.service.spec.ts b/src/app/modules/form/__tests__/form.service.spec.ts index 0363ff0435..eceb853bde 100644 --- a/src/app/modules/form/__tests__/form.service.spec.ts +++ b/src/app/modules/form/__tests__/form.service.spec.ts @@ -245,7 +245,7 @@ describe('FormService', () => { expect(actual._unsafeUnwrap()).toEqual(form) }) - it('should let requests through when form has not reached submission limit', async () => { + it('should return the form when the submission limit is not reached', async () => { // Arrange const formParams = merge({}, MOCK_ENCRYPTED_FORM_PARAMS, { status: Status.Public, diff --git a/src/app/modules/form/form.service.ts b/src/app/modules/form/form.service.ts index f950b8187f..c8ba88fa1d 100644 --- a/src/app/modules/form/form.service.ts +++ b/src/app/modules/form/form.service.ts @@ -43,7 +43,7 @@ const SubmissionModel = getSubmissionModel(mongoose) */ export const deactivateForm = ( formId: string, -): ResultAsync => { +): ResultAsync => { return ResultAsync.fromPromise(FormModel.deactivateById(formId), (error) => { logger.error({ message: 'Error deactivating form by id', @@ -68,7 +68,7 @@ export const deactivateForm = ( return errAsync(new FormNotFoundError()) } // Successfully deactivated. - return okAsync(true) + return okAsync(deactivatedForm) }) } @@ -170,10 +170,10 @@ export const isFormPublic = ( /** * Method to check whether a form has reached submission limits, and deactivate the form if necessary * @param form the form to check - * @returns okAsync(form) if submission is allowed because the form has not reached limits - * @returns errAsync(PossibleDatabaseError) if an error occurred while querying the database for the specified form - * @returns errAsync(FormNotFoundError) if the form has exceeded the submission limits but could not be found and deactivated - * @returns errAsync(PrivateFormError) if the count of the form has been exceeded and the form has been deactivated + * @returns ok(form) if submission is allowed because the form has not reached limits + * @returns err(PossibleDatabaseError) if an error occurred while querying the database for the specified form + * @returns err(FormNotFoundError) if the form has exceeded the submission limits but could not be found and deactivated + * @returns err(PrivateFormError) if the count of the form has been exceeded and the form has been deactivated */ export const checkFormSubmissionLimitAndDeactivateForm = ( form: IPopulatedForm, @@ -198,7 +198,7 @@ export const checkFormSubmissionLimitAndDeactivateForm = ( message: 'Error counting documents', meta: { action: 'checkFormSubmissionLimitAndDeactivateForm', - form: formId, + formId, }, error, }) From 31d4d5e3a561a8f25309636d1c931c49b856a082 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Mon, 29 Mar 2021 17:02:01 +0800 Subject: [PATCH 35/86] style(myinfo): extractMyInfoData renamed to fetchMyInfoData --- src/app/modules/myinfo/myinfo.factory.ts | 4 ++-- src/app/modules/myinfo/myinfo.service.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/modules/myinfo/myinfo.factory.ts b/src/app/modules/myinfo/myinfo.factory.ts index 6d010bd1a0..ed70f144e0 100644 --- a/src/app/modules/myinfo/myinfo.factory.ts +++ b/src/app/modules/myinfo/myinfo.factory.ts @@ -89,7 +89,7 @@ interface IMyInfoFactory { accessToken: string, ) => Result - extractMyInfoData: ( + fetchMyInfoData: ( form: IPopulatedForm, cookies: Record, ) => ResultAsync< @@ -135,7 +135,7 @@ export const createMyInfoFactory = ({ createRedirectURL: () => err(error), parseMyInfoRelayState: () => err(error), extractUinFin: () => err(error), - extractMyInfoData: () => errAsync(error), + fetchMyInfoData: () => errAsync(error), createFormWithMyInfo: () => errAsync(error), } } diff --git a/src/app/modules/myinfo/myinfo.service.ts b/src/app/modules/myinfo/myinfo.service.ts index d9576712c9..4d0876cf91 100644 --- a/src/app/modules/myinfo/myinfo.service.ts +++ b/src/app/modules/myinfo/myinfo.service.ts @@ -476,7 +476,7 @@ export class MyInfoService { * @returns err(MyInfoFetchError) if validated but the data could not be retrieved * @returns err(MissingFeatureError) if using an outdated version that does not support myInfo */ - extractMyInfoData( + fetchMyInfoData( form: IPopulatedForm, cookies: Record, ): ResultAsync< @@ -527,7 +527,7 @@ export class MyInfoService { > { return ( // 1. Validate form and extract myInfoData - this.extractMyInfoData(form, cookies) + this.fetchMyInfoData(form, cookies) // 2. Fill the form based on the result .andThen((myInfoData) => this.prefillMyInfoFields(myInfoData, form.toJSON().form_fields).map( From 8775701d486fc5023ca3ed95ded3e58fbc1cfdf0 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Mon, 29 Mar 2021 17:53:38 +0800 Subject: [PATCH 36/86] fix(public-form/controller): fixed bug where wrong extract cookie method is being called --- .../__tests__/public-form.controller.spec.ts | 194 ++++++++---------- .../public-form/public-form.controller.ts | 38 ++-- 2 files changed, 110 insertions(+), 122 deletions(-) diff --git a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts index 1012bc6acf..267be29cb3 100644 --- a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts +++ b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts @@ -1,6 +1,6 @@ import { IPersonResponse } from '@opengovsg/myinfo-gov-client' import { ObjectId } from 'bson-ext' -import _, { merge } from 'lodash' +import { merge } from 'lodash' import mongoose from 'mongoose' import { err, errAsync, ok, okAsync } from 'neverthrow' import querystring from 'querystring' @@ -23,7 +23,6 @@ import { JwtPayload } from 'src/app/modules/spcp/spcp.types' import { FeatureNames } from 'src/config/feature-manager/types' import { AuthType, - IMyInfoHashSchema, IPopulatedForm, IPopulatedUser, MyInfoAttribute, @@ -47,7 +46,6 @@ import * as PublicFormController from '../public-form.controller' import * as PublicFormService from '../public-form.service' import { Metatags } from '../public-form.types' -// Mocking services that tests are dependent on jest.mock('../public-form.service') jest.mock('../../form.service') jest.mock('../../../auth/auth.service') @@ -57,8 +55,8 @@ jest.mock('../../../myinfo/myinfo.factory') const MockFormService = mocked(FormService) const MockPublicFormService = mocked(PublicFormService) const MockAuthService = mocked(AuthService) -const MockSpcpFactory = mocked(SpcpFactory) -const MockMyInfoFactory = mocked(MyInfoFactory) +const MockSpcpFactory = mocked(SpcpFactory, true) +const MockMyInfoFactory = mocked(MyInfoFactory, true) const FormFeedbackModel = getFormFeedbackModel(mongoose) @@ -458,11 +456,15 @@ describe('public-form.controller', () => { state: MyInfoCookieState.Success, } - const MOCK_REQ_WITH_COOKIES = expressHandler.mockRequest({ - params: { - formId: MOCK_FORM_ID, - }, - others: { cookies: { MyInfoCookie: MOCK_MYINFO_COOKIE } }, + let MOCK_REQ_WITH_COOKIES: ReturnType + + beforeEach(() => { + MOCK_REQ_WITH_COOKIES = expressHandler.mockRequest({ + params: { + formId: MOCK_FORM_ID, + }, + others: { cookies: { MyInfoCookie: MOCK_MYINFO_COOKIE } }, + }) }) // Success @@ -508,6 +510,13 @@ describe('public-form.controller', () => { }) as unknown) as MockedObject const MOCK_RES = expressHandler.mockResponse() + MockSpcpFactory.createFormWithSpcpSession.mockReturnValueOnce( + okAsync({ + form: MOCK_SP_AUTH_FORM.getPublicView(), + spcpSession: { userName: MOCK_JWT_PAYLOAD.userName }, + }), + ) + MockSpcpFactory.getSpcpSession.mockReturnValueOnce( okAsync(MOCK_JWT_PAYLOAD), ) @@ -542,6 +551,13 @@ describe('public-form.controller', () => { }) as unknown) as MockedObject const MOCK_RES = expressHandler.mockResponse() + MockSpcpFactory.createFormWithSpcpSession.mockReturnValueOnce( + okAsync({ + form: MOCK_CP_AUTH_FORM.getPublicView(), + spcpSession: { userName: MOCK_JWT_PAYLOAD.userName }, + }), + ) + MockSpcpFactory.getSpcpSession.mockReturnValueOnce( okAsync(MOCK_JWT_PAYLOAD), ) @@ -576,13 +592,10 @@ describe('public-form.controller', () => { authType: AuthType.MyInfo, toJSON: jest.fn().mockReturnValue(BASE_FORM), }) as unknown) as MockedObject - const MOCK_RES = expressHandler.mockResponse() - const MOCK_CLEAR_COOKIE = jest.fn().mockReturnValueOnce(MOCK_RES) - const MOCK_INITIAL_RES = _.set( - MOCK_RES, - 'clearCookie', - MOCK_CLEAR_COOKIE, - ) + const MOCK_RES = expressHandler.mockResponse({ + clearCookie: jest.fn().mockReturnThis(), + }) + const MOCK_MYINFO_DATA = new MyInfoData({ uinFin: 'i am a fish', } as IPersonResponse) @@ -593,25 +606,22 @@ describe('public-form.controller', () => { MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( okAsync(MOCK_MYINFO_AUTH_FORM), ) - MockMyInfoFactory.prefillMyInfoFields.mockReturnValueOnce(ok([])) - MockMyInfoFactory.extractMyInfoData.mockReturnValueOnce( - okAsync(MOCK_MYINFO_DATA), - ), - MockMyInfoFactory.saveMyInfoHashes.mockReturnValueOnce( - okAsync({ - uinFin: 'hello world', - } as IMyInfoHashSchema), - ) + MockMyInfoFactory.createFormWithMyInfo.mockReturnValueOnce( + okAsync({ + form: MOCK_MYINFO_AUTH_FORM.getPublicView(), + spcpSession: { userName: MOCK_MYINFO_DATA.getUinFin() }, + }), + ) // Act await PublicFormController.handleGetPublicForm( MOCK_REQ_WITH_COOKIES, - MOCK_INITIAL_RES, + MOCK_RES, jest.fn(), ) // Assert - expect(MockMyInfoFactory.saveMyInfoHashes).toHaveBeenCalled() + expect(MOCK_RES.clearCookie).not.toHaveBeenCalled() expect(MOCK_RES.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_AUTH_FORM.getPublicView(), spcpSession: { userName: MOCK_MYINFO_DATA.getUinFin() }, @@ -641,28 +651,23 @@ describe('public-form.controller', () => { it('should return 200 but the response should have cookies cleared and myInfoError when the request has no cookie', async () => { // Arrange // 1. Mock the response and calls - const MOCK_RES = expressHandler.mockResponse() - const MOCK_CLEAR_COOKIE = jest.fn().mockReturnValueOnce(MOCK_RES) - const MOCK_INITIAL_RES = _.set( - MOCK_RES, - 'clearCookie', - MOCK_CLEAR_COOKIE, - ) - MockMyInfoFactory.extractMyInfoData.mockReturnValueOnce( + const MOCK_RES = expressHandler.mockResponse({ + clearCookie: jest.fn().mockReturnThis(), + }) + + MockMyInfoFactory.createFormWithMyInfo.mockReturnValueOnce( errAsync(new MyInfoMissingAccessTokenError()), ) // Act await PublicFormController.handleGetPublicForm( MOCK_REQ, - // NOTE: This is done because the calls to .json and .clearCookie are chained - MOCK_INITIAL_RES, + MOCK_RES, jest.fn(), ) // Assert - expect(MOCK_INITIAL_RES.clearCookie).toHaveBeenCalled() - expect(MockMyInfoFactory.saveMyInfoHashes).not.toHaveBeenCalled() + expect(MOCK_RES.clearCookie).toHaveBeenCalled() expect(MOCK_RES.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_FORM.getPublicView(), myInfoError: true, @@ -672,28 +677,23 @@ describe('public-form.controller', () => { it('should return 200 but the response should have cookies cleared and myInfoError when the cookie cannot be validated', async () => { // Arrange // 1. Mock the response and calls - const MOCK_RES = expressHandler.mockResponse() - const MOCK_CLEAR_COOKIE = jest.fn().mockReturnValueOnce(MOCK_RES) - const MOCK_INITIAL_RES = _.set( - MOCK_RES, - 'clearCookie', - MOCK_CLEAR_COOKIE, - ) - MockMyInfoFactory.extractMyInfoData.mockReturnValueOnce( + const MOCK_RES = expressHandler.mockResponse({ + clearCookie: jest.fn().mockReturnThis(), + }) + + MockMyInfoFactory.createFormWithMyInfo.mockReturnValueOnce( errAsync(new MyInfoCookieStateError()), ) // Act await PublicFormController.handleGetPublicForm( MOCK_REQ_WITH_COOKIES, - // NOTE: This is done because the calls to .json and .clearCookie are chained - MOCK_INITIAL_RES, + MOCK_RES, jest.fn(), ) // Assert - expect(MOCK_INITIAL_RES.clearCookie).toHaveBeenCalled() - expect(MockMyInfoFactory.saveMyInfoHashes).not.toHaveBeenCalled() + expect(MOCK_RES.clearCookie).toHaveBeenCalled() expect(MOCK_RES.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_FORM.getPublicView(), myInfoError: true, @@ -703,28 +703,23 @@ describe('public-form.controller', () => { it('should return 200 but the response should have cookies cleared and myInfoError if the form cannot be validated', async () => { // Arrange // 1. Mock the response and calls - const MOCK_RES = expressHandler.mockResponse() - const MOCK_CLEAR_COOKIE = jest.fn().mockReturnValueOnce(MOCK_RES) - const MOCK_INITIAL_RES = _.set( - MOCK_RES, - 'clearCookie', - MOCK_CLEAR_COOKIE, - ) - MockMyInfoFactory.extractMyInfoData.mockReturnValueOnce( + const MOCK_RES = expressHandler.mockResponse({ + clearCookie: jest.fn().mockReturnThis(), + }) + + MockMyInfoFactory.createFormWithMyInfo.mockReturnValueOnce( errAsync(new MyInfoAuthTypeError()), ) // Act await PublicFormController.handleGetPublicForm( MOCK_REQ_WITH_COOKIES, - // NOTE: This is done because the calls to .json and .clearCookie are chained - _.set(MOCK_INITIAL_RES, 'clearCookie', MOCK_CLEAR_COOKIE), + MOCK_RES, jest.fn(), ) // Assert - expect(MOCK_INITIAL_RES.clearCookie).toHaveBeenCalled() - expect(MockMyInfoFactory.saveMyInfoHashes).not.toHaveBeenCalled() + expect(MOCK_RES.clearCookie).toHaveBeenCalled() expect(MOCK_RES.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_FORM.getPublicView(), myInfoError: true, @@ -734,28 +729,23 @@ describe('public-form.controller', () => { it('should return 200 but the response should have cookies cleared and myInfoError when the form has no eservcId', async () => { // Arrange // 1. Mock the response and calls - const MOCK_RES = expressHandler.mockResponse() - const MOCK_CLEAR_COOKIE = jest.fn().mockReturnValueOnce(MOCK_RES) - const MOCK_INITIAL_RES = _.set( - MOCK_RES, - 'clearCookie', - MOCK_CLEAR_COOKIE, - ) - MockMyInfoFactory.extractMyInfoData.mockReturnValueOnce( + const MOCK_RES = expressHandler.mockResponse({ + clearCookie: jest.fn().mockReturnThis(), + }) + + MockMyInfoFactory.createFormWithMyInfo.mockReturnValueOnce( errAsync(new MyInfoNoESrvcIdError()), ) // Act await PublicFormController.handleGetPublicForm( MOCK_REQ_WITH_COOKIES, - // NOTE: This is done because the calls to .json and .clearCookie are chained - _.set(MOCK_INITIAL_RES, 'clearCookie', MOCK_CLEAR_COOKIE), + MOCK_RES, jest.fn(), ) // Assert - expect(MOCK_INITIAL_RES.clearCookie).toHaveBeenCalled() - expect(MockMyInfoFactory.saveMyInfoHashes).not.toHaveBeenCalled() + expect(MOCK_RES.clearCookie).toHaveBeenCalled() expect(MOCK_RES.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_FORM.getPublicView(), myInfoError: true, @@ -765,18 +755,12 @@ describe('public-form.controller', () => { it('should return 200 but the response should have cookies cleared and myInfoError when the form could not be filled', async () => { // Arrange // 1. Mock the response and calls - const MOCK_RES = expressHandler.mockResponse() - const MOCK_CLEAR_COOKIE = jest.fn().mockReturnValueOnce(MOCK_RES) - const MOCK_INITIAL_RES = _.set( - MOCK_RES, - 'clearCookie', - MOCK_CLEAR_COOKIE, - ) - MockMyInfoFactory.extractMyInfoData.mockReturnValueOnce( - okAsync({} as MyInfoData), - ) - MockMyInfoFactory.prefillMyInfoFields.mockReturnValueOnce( - err( + const MOCK_RES = expressHandler.mockResponse({ + clearCookie: jest.fn().mockReturnThis(), + }) + + MockMyInfoFactory.createFormWithMyInfo.mockReturnValueOnce( + errAsync( new MissingFeatureError( 'testing is the missing feature' as FeatureNames, ), @@ -786,14 +770,12 @@ describe('public-form.controller', () => { // Act await PublicFormController.handleGetPublicForm( MOCK_REQ_WITH_COOKIES, - // NOTE: This is done because the calls to .json and .clearCookie are chained - _.set(MOCK_INITIAL_RES, 'clearCookie', MOCK_CLEAR_COOKIE), + MOCK_RES, jest.fn(), ) // Assert - expect(MOCK_INITIAL_RES.clearCookie).toHaveBeenCalled() - expect(MockMyInfoFactory.saveMyInfoHashes).not.toHaveBeenCalled() + expect(MOCK_RES.clearCookie).toHaveBeenCalled() expect(MOCK_RES.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_FORM.getPublicView(), myInfoError: true, @@ -803,37 +785,29 @@ describe('public-form.controller', () => { it('should return 200 but the response should have cookies cleared and myInfoError if a database error occurs while saving hashes', async () => { // Arrange // 1. Mock the response and calls - const MOCK_RES = expressHandler.mockResponse() - const MOCK_CLEAR_COOKIE = jest.fn().mockReturnValueOnce(MOCK_RES) - const MOCK_INITIAL_RES = _.set( - MOCK_RES, - 'clearCookie', - MOCK_CLEAR_COOKIE, - ) + const MOCK_RES = expressHandler.mockResponse({ + clearCookie: jest.fn().mockReturnThis(), + }) + MockAuthService.getFormIfPublic.mockReturnValueOnce( okAsync(MOCK_MYINFO_FORM), ) MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( okAsync(MOCK_MYINFO_FORM), ) - MockMyInfoFactory.prefillMyInfoFields.mockReturnValueOnce(ok([])) - MockMyInfoFactory.extractMyInfoData.mockReturnValueOnce( - okAsync(({ getUinFin: jest.fn() } as unknown) as MyInfoData), - ), - MockMyInfoFactory.saveMyInfoHashes.mockReturnValueOnce( - errAsync(new DatabaseError()), - ) + MockMyInfoFactory.createFormWithMyInfo.mockReturnValueOnce( + errAsync(new DatabaseError()), + ) // Act await PublicFormController.handleGetPublicForm( MOCK_REQ_WITH_COOKIES, - // NOTE: This is done because the calls to .json and .clearCookie are chained - _.set(MOCK_INITIAL_RES, 'clearCookie', MOCK_CLEAR_COOKIE), + MOCK_RES, jest.fn(), ) // Assert - expect(MOCK_INITIAL_RES.clearCookie).toHaveBeenCalled() + expect(MOCK_RES.clearCookie).toHaveBeenCalled() expect(MOCK_RES.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_FORM.getPublicView(), myInfoError: true, @@ -857,7 +831,7 @@ describe('public-form.controller', () => { MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( okAsync(MOCK_SPCP_FORM), ) - MockSpcpFactory.getSpcpSession.mockReturnValueOnce( + MockSpcpFactory.createFormWithSpcpSession.mockReturnValueOnce( errAsync(new MissingJwtError()), ) diff --git a/src/app/modules/form/public-form/public-form.controller.ts b/src/app/modules/form/public-form/public-form.controller.ts index 5f80b27cad..e9e2d11d4e 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -1,3 +1,7 @@ +import { + InvalidJwtError, + VerifyJwtError, +} from 'dist/backend/app/modules/spcp/spcp.errors' import { RequestHandler } from 'express' import { StatusCodes } from 'http-status-codes' import { okAsync } from 'neverthrow' @@ -13,8 +17,7 @@ import { } from '../../myinfo/myinfo.constants' import { MyInfoFactory } from '../../myinfo/myinfo.factory' import { MyInfoCookiePayload } from '../../myinfo/myinfo.types' -import { extractSuccessfulCookie } from '../../myinfo/myinfo.util' -import { MissingJwtError } from '../../spcp/spcp.errors' +import { extractSuccessfulMyInfoCookie } from '../../myinfo/myinfo.util' import { SpcpFactory } from '../../spcp/spcp.factory' import { PrivateFormError } from '../form.errors' import * as FormService from '../form.service' @@ -210,6 +213,7 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( const form = formResult.value const publicForm = form.getPublicView() const { authType } = form + let myInfoError: boolean // NOTE: Creating a variable to ensure that TS will enforce the type and ensure all keys in AuthType are covered. const formController: FormController< @@ -223,19 +227,28 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( [AuthType.MyInfo]: () => { return MyInfoFactory.createFormWithMyInfo(form, req.cookies) .andThen((publicForm) => { - return extractSuccessfulCookie(req.cookies).map((myInfoCookie) => { - const cookiePayload: MyInfoCookiePayload = { - ...myInfoCookie, - usedCount: myInfoCookie.usedCount + 1, - } - // NOTE: This is a side effect to set the cookie on the result after it has been successfully prefilled. - res.cookie(MYINFO_COOKIE_NAME, cookiePayload, MYINFO_COOKIE_OPTIONS) - return publicForm - }) + return extractSuccessfulMyInfoCookie(req.cookies).map( + (myInfoCookie) => { + const cookiePayload: MyInfoCookiePayload = { + ...myInfoCookie, + usedCount: myInfoCookie.usedCount + 1, + } + // NOTE: This is a side effect to set the cookie on the result after it has been successfully prefilled. + res.cookie( + MYINFO_COOKIE_NAME, + cookiePayload, + MYINFO_COOKIE_OPTIONS, + ) + return publicForm + }, + ) }) .mapErr((error) => { // NOTE: This is a side-effect as there is no need for cookie if data could not be retrieved res.clearCookie(MYINFO_COOKIE_NAME, MYINFO_COOKIE_OPTIONS) + // NOTE: This is done as a workaround because the type of FormController enforces a uniform return value + // but we are required to signal if myInfoError in the event of an error + myInfoError = true return error }) }, @@ -248,7 +261,7 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( return formController[authType]() .map((publicFormView) => res.json(publicFormView)) .mapErr((error) => { - if (!(error instanceof MissingJwtError)) { + if (error instanceof VerifyJwtError || error instanceof InvalidJwtError) { logger.error({ message: 'Error getting public form', meta: { @@ -261,6 +274,7 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( } return res.json({ form: publicForm, + ...(myInfoError && { myInfoError }), }) }) } From 6aff7e91fb9fe247c0315c2aab67f9a133f06e03 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Mon, 29 Mar 2021 17:54:50 +0800 Subject: [PATCH 37/86] refactor(spcp/myinfo): removed extra logging in spcp; updated names in myinfo --- .../myinfo/__tests__/myinfo.service.spec.ts | 4 ++-- src/app/modules/spcp/spcp.service.ts | 16 +--------------- 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts b/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts index 9252b576a5..472904e955 100644 --- a/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts +++ b/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts @@ -456,7 +456,7 @@ describe('MyInfoService', () => { mockGetPerson.mockResolvedValueOnce(mockReturnedParams) // Act - const result = await myInfoService.extractMyInfoData( + const result = await myInfoService.fetchMyInfoData( MOCK_MYINFO_FORM as IPopulatedForm, { MyInfoCookie: MOCK_SUCCESSFUL_COOKIE }, ) @@ -470,7 +470,7 @@ describe('MyInfoService', () => { const expected = new MyInfoMissingAccessTokenError() // Act - const result = await myInfoService.extractMyInfoData( + const result = await myInfoService.fetchMyInfoData( MOCK_MYINFO_FORM as IPopulatedForm, {}, ) diff --git a/src/app/modules/spcp/spcp.service.ts b/src/app/modules/spcp/spcp.service.ts index ad9802fb62..860103b1b4 100644 --- a/src/app/modules/spcp/spcp.service.ts +++ b/src/app/modules/spcp/spcp.service.ts @@ -203,21 +203,7 @@ export class SpcpService { ): Result { const jwtName = authType === AuthType.SP ? JwtName.SP : JwtName.CP const cookie = cookies[jwtName] - - if (!cookie) { - const logMeta = { - action: 'extractJWT', - authType, - cookies, - } - logger.error({ - message: 'Failed to extract SPCP jwt cookie', - meta: logMeta, - }) - return err(new MissingJwtError()) - } - - return ok(cookie) + return cookie ? ok(cookie) : err(new MissingJwtError()) } /** From ee320777ce502f8ea414e68ef9afa1c8b086a741 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Mon, 29 Mar 2021 18:39:03 +0800 Subject: [PATCH 38/86] test(spcp/service/test): adds service tests --- .../modules/spcp/__tests__/spcp.service.spec.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/app/modules/spcp/__tests__/spcp.service.spec.ts b/src/app/modules/spcp/__tests__/spcp.service.spec.ts index 3a96990ed0..5c7235e68f 100644 --- a/src/app/modules/spcp/__tests__/spcp.service.spec.ts +++ b/src/app/modules/spcp/__tests__/spcp.service.spec.ts @@ -768,4 +768,20 @@ describe('spcp.service', () => { expect(result._unsafeUnwrapErr()).toEqual(new MissingJwtError()) }) }) + + describe('getSpcpSession', () => { + it('should return 200 when there is a valid JWT in the request', async () => { + // Arrange + const spcpService = new SpcpService(MOCK_PARAMS) + + // Act + await spcpService.getSpcpSession(AuthType.SP, MOCK_COOKIES) + + // Assert + expect(spcpService.extractJwt).toBeCalled() + expect(spcpService.extractJwtPayload).toBeCalled() + }) + }) + + // describe('createFormWithSpcpSession', () => {}) }) From 3918298d59983e99ffeb4100f3f21c34e56e5b84 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Tue, 30 Mar 2021 13:27:19 +0800 Subject: [PATCH 39/86] test(spcp/service/test): adds unit tests for createFormWithSpcpSession --- .../spcp/__tests__/spcp.service.spec.ts | 220 +++++++++++++++++- .../spcp/__tests__/spcp.test.constants.ts | 13 ++ 2 files changed, 225 insertions(+), 8 deletions(-) diff --git a/src/app/modules/spcp/__tests__/spcp.service.spec.ts b/src/app/modules/spcp/__tests__/spcp.service.spec.ts index 5c7235e68f..09706f8243 100644 --- a/src/app/modules/spcp/__tests__/spcp.service.spec.ts +++ b/src/app/modules/spcp/__tests__/spcp.service.spec.ts @@ -1,10 +1,14 @@ import SPCPAuthClient from '@opengovsg/spcp-auth-client' import axios from 'axios' +import { InvalidJwtError } from 'dist/backend/app/modules/spcp/spcp.errors' import fs from 'fs' import { omit } from 'lodash' import { mocked } from 'ts-jest/utils' -import { MOCK_COOKIE_AGE } from 'src/app/modules/myinfo/__tests__/myinfo.test.constants' +import { + MOCK_COOKIE_AGE, + MOCK_MYINFO_FORM, +} from 'src/app/modules/myinfo/__tests__/myinfo.test.constants' import { ISpcpMyInfo } from 'src/config/feature-manager' import { AuthType } from 'src/types' @@ -18,6 +22,7 @@ import { MissingAttributesError, MissingJwtError, RetrieveAttributesError, + SpcpAuthTypeError, VerifyJwtError, } from '../spcp.errors' import { SpcpService } from '../spcp.service' @@ -25,6 +30,7 @@ import { JwtName } from '../spcp.types' import { MOCK_COOKIES, + MOCK_CP_FORM, MOCK_CP_JWT_PAYLOAD, MOCK_CP_SAML, MOCK_DESTINATION, @@ -36,6 +42,7 @@ import { MOCK_LOGIN_HTML, MOCK_REDIRECT_URL, MOCK_SERVICE_PARAMS as MOCK_PARAMS, + MOCK_SP_FORM, MOCK_SP_JWT_PAYLOAD, MOCK_SP_SAML, MOCK_SP_SAML_WRONG_HASH, @@ -226,7 +233,7 @@ describe('spcp.service', () => { expect(result._unsafeUnwrap()).toEqual(MOCK_SP_JWT_PAYLOAD) }) - it('should return VerifyJwtError when SingPass JWT is invalid', async () => { + it('should return VerifyJwtError when SingPass JWT could not be verified', async () => { const spcpService = new SpcpService(MOCK_PARAMS) // Assumes that SP auth client was instantiated first const mockClient = mocked(MockAuthClient.mock.instances[0], true) @@ -237,6 +244,21 @@ describe('spcp.service', () => { expect(result._unsafeUnwrapErr()).toEqual(new VerifyJwtError()) }) + it('should return InvalidJWTError when SP JWT has invalid shape', async () => { + // Arrange + const spcpService = new SpcpService(MOCK_PARAMS) + // Assumes that SP auth client was instantiated first + const mockClient = mocked(MockAuthClient.mock.instances[0], true) + mockClient.verifyJWT.mockImplementationOnce((jwt, cb) => cb(null, {})) + const expected = new InvalidJwtError() + + // Act + const result = await spcpService.extractJwtPayload(MOCK_JWT, AuthType.SP) + + // Assert + expect(result._unsafeUnwrapErr()).toEqual(expected) + }) + it('should return the correct payload for Corppass when JWT is valid', async () => { const spcpService = new SpcpService(MOCK_PARAMS) // Assumes that SP auth client was instantiated first @@ -248,7 +270,7 @@ describe('spcp.service', () => { expect(result._unsafeUnwrap()).toEqual(MOCK_CP_JWT_PAYLOAD) }) - it('should return VerifyJwtError when CorpPass JWT is invalid', async () => { + it('should return VerifyJwtError when CorpPass JWT could not be verified', async () => { const spcpService = new SpcpService(MOCK_PARAMS) // Assumes that SP auth client was instantiated first const mockClient = mocked(MockAuthClient.mock.instances[1], true) @@ -258,6 +280,21 @@ describe('spcp.service', () => { const result = await spcpService.extractJwtPayload(MOCK_JWT, AuthType.CP) expect(result._unsafeUnwrapErr()).toEqual(new VerifyJwtError()) }) + + it('should return InvalidJWTError when CorpPass JWT has invalid shape', async () => { + // Arrange + const spcpService = new SpcpService(MOCK_PARAMS) + // Assumes that SP auth client was instantiated first + const mockClient = mocked(MockAuthClient.mock.instances[1], true) + mockClient.verifyJWT.mockImplementationOnce((jwt, cb) => cb(null, {})) + const expected = new InvalidJwtError() + + // Act + const result = await spcpService.extractJwtPayload(MOCK_JWT, AuthType.CP) + + // Assert + expect(result._unsafeUnwrapErr()).toEqual(expected) + }) }) describe('parseOOBParams', () => { @@ -770,18 +807,185 @@ describe('spcp.service', () => { }) describe('getSpcpSession', () => { - it('should return 200 when there is a valid JWT in the request', async () => { + it('should return a SP JWT payload when there is a valid JWT in the request', async () => { // Arrange const spcpService = new SpcpService(MOCK_PARAMS) + // Assumes that SP auth client was instantiated first + const mockClient = mocked(MockAuthClient.mock.instances[0], true) + mockClient.verifyJWT.mockImplementationOnce((jwt, cb) => + cb(null, MOCK_SP_JWT_PAYLOAD), + ) + + // Act + const result = await spcpService.getSpcpSession(AuthType.SP, MOCK_COOKIES) + + // Assert + expect(result._unsafeUnwrap()).toBe(MOCK_SP_JWT_PAYLOAD) + }) + + it('should return a CP JWT payload when there is a valid JWT in the request', async () => { + // Arrange + const spcpService = new SpcpService(MOCK_PARAMS) + // Assumes that CP auth client was instantiated first + const mockClient = mocked(MockAuthClient.mock.instances[1], true) + mockClient.verifyJWT.mockImplementationOnce((jwt, cb) => + cb(null, MOCK_CP_JWT_PAYLOAD), + ) + + // Act + const result = await spcpService.getSpcpSession(AuthType.CP, MOCK_COOKIES) + + // Assert + expect(result._unsafeUnwrap()).toBe(MOCK_CP_JWT_PAYLOAD) + }) + + it('should return MissingJwtError if there is no JWT when client authenticates using SP', async () => { + // Arrange + const spcpService = new SpcpService(MOCK_PARAMS) + const expected = new MissingJwtError() + + // Act + const result = await spcpService.getSpcpSession(AuthType.SP, {}) + + // Assert + expect(result._unsafeUnwrapErr()).toEqual(expected) + }) + + it('should return MissingJwtError when client authenticates using CP and there is no JWT', async () => { + // Arrange + const spcpService = new SpcpService(MOCK_PARAMS) + const expected = new MissingJwtError() + + // Act + const result = await spcpService.getSpcpSession(AuthType.CP, {}) + + // Assert + expect(result._unsafeUnwrapErr()).toEqual(expected) + }) + + it('should return InvalidJWTError when the client authenticates using SP and the JWT has wrong shape', async () => { + // Arrange + const spcpService = new SpcpService(MOCK_PARAMS) + // Assumes that SP auth client was instantiated first + const mockClient = mocked(MockAuthClient.mock.instances[0], true) + mockClient.verifyJWT.mockImplementationOnce((jwt, cb) => + cb(new Error(), null), + ) + const expected = new VerifyJwtError() // Act - await spcpService.getSpcpSession(AuthType.SP, MOCK_COOKIES) + const result = await spcpService.getSpcpSession(AuthType.SP, MOCK_COOKIES) // Assert - expect(spcpService.extractJwt).toBeCalled() - expect(spcpService.extractJwtPayload).toBeCalled() + expect(result._unsafeUnwrapErr()).toEqual(expected) + }) + + it('should return VerifyJWTError when the client authenticates using CP and the JWT has wrong shape', async () => { + // Arrange + const spcpService = new SpcpService(MOCK_PARAMS) + // Assumes that SP auth client was instantiated first + const mockClient = mocked(MockAuthClient.mock.instances[1], true) + mockClient.verifyJWT.mockImplementationOnce((jwt, cb) => + cb(new Error(), null), + ) + const expected = new VerifyJwtError() + + // Act + const result = await spcpService.getSpcpSession(AuthType.CP, MOCK_COOKIES) + + // Assert + expect(result._unsafeUnwrapErr()).toEqual(expected) + }) + it('should return InvalidJWTError when the client authenticates using SP and the JWT has invalid shape', async () => { + // Arrange + const spcpService = new SpcpService(MOCK_PARAMS) + // Assumes that SP auth client was instantiated first + const mockClient = mocked(MockAuthClient.mock.instances[0], true) + mockClient.verifyJWT.mockImplementationOnce((jwt, cb) => cb(null, {})) + const expected = new InvalidJwtError() + + // Act + const result = await spcpService.getSpcpSession(AuthType.SP, MOCK_COOKIES) + + // Assert + expect(result._unsafeUnwrapErr()).toEqual(expected) + }) + + it('should return InvalidJWTError when the client authenticates using CP and the JWT has invalid shape', async () => { + // Arrange + const spcpService = new SpcpService(MOCK_PARAMS) + // Assumes that SP auth client was instantiated first + const mockClient = mocked(MockAuthClient.mock.instances[1], true) + mockClient.verifyJWT.mockImplementationOnce((jwt, cb) => cb(null, {})) + const expected = new InvalidJwtError() + + // Act + const result = await spcpService.getSpcpSession(AuthType.CP, MOCK_COOKIES) + + // Assert + expect(result._unsafeUnwrapErr()).toEqual(expected) }) }) - // describe('createFormWithSpcpSession', () => {}) + describe('createFormWithSpcpSession', () => { + it('should return the public form view when clients authenticate using SP', async () => { + // Arrange + const spcpService = new SpcpService(MOCK_PARAMS) + // Assumes that SP auth client was instantiated first + const mockClient = mocked(MockAuthClient.mock.instances[0], true) + mockClient.verifyJWT.mockImplementationOnce((jwt, cb) => + cb(null, MOCK_SP_JWT_PAYLOAD), + ) + const expected = { + form: MOCK_SP_FORM.getPublicView(), + spcpSession: { userName: MOCK_SP_JWT_PAYLOAD.userName }, + } + + // Act + const result = spcpService.createFormWithSpcpSession( + MOCK_SP_FORM, + MOCK_COOKIES, + ) + + // Assert + expect((await result)._unsafeUnwrap()).toEqual(expected) + }) + + it('should return the public form view when clients authenticate using CP', async () => { + // Arrange + const spcpService = new SpcpService(MOCK_PARAMS) + // Assumes that SP auth client was instantiated first + const mockClient = mocked(MockAuthClient.mock.instances[1], true) + mockClient.verifyJWT.mockImplementationOnce((jwt, cb) => + cb(null, MOCK_CP_JWT_PAYLOAD), + ) + const expected = { + form: MOCK_CP_FORM.getPublicView(), + spcpSession: { userName: MOCK_CP_JWT_PAYLOAD.userName }, + } + + // Act + const result = spcpService.createFormWithSpcpSession( + MOCK_CP_FORM, + MOCK_COOKIES, + ) + + // Assert + expect((await result)._unsafeUnwrap()).toEqual(expected) + }) + it('should return SpcpAuthTypeError when auth type is not SP or CP', async () => { + // Arrange + const spcpService = new SpcpService(MOCK_PARAMS) + const expected = new SpcpAuthTypeError() + + // Act + const result = await spcpService.createFormWithSpcpSession( + MOCK_MYINFO_FORM, + MOCK_COOKIES, + ) + + // Assert + expect(result._unsafeUnwrapErr()).toEqual(expected) + }) + }) }) diff --git a/src/app/modules/spcp/__tests__/spcp.test.constants.ts b/src/app/modules/spcp/__tests__/spcp.test.constants.ts index 6a059fc214..2a036f4a12 100644 --- a/src/app/modules/spcp/__tests__/spcp.test.constants.ts +++ b/src/app/modules/spcp/__tests__/spcp.test.constants.ts @@ -1,6 +1,7 @@ import { MyInfoMode } from '@opengovsg/myinfo-gov-client' import { ObjectId } from 'bson' import crypto from 'crypto' +import _ from 'lodash' import { ISpcpMyInfo } from 'src/config/feature-manager' import { ILoginSchema, IPopulatedForm } from 'src/types' @@ -114,6 +115,7 @@ export const MOCK_SP_FORM = ({ _id: new ObjectId().toHexString(), agency: new ObjectId().toHexString(), }, + getPublicView: () => _.omit(this, 'admin'), } as unknown) as IPopulatedForm export const MOCK_CP_FORM = ({ @@ -124,6 +126,17 @@ export const MOCK_CP_FORM = ({ _id: new ObjectId().toHexString(), agency: new ObjectId().toHexString(), }, + getPublicView: () => _.omit(this, 'admin'), +} as unknown) as IPopulatedForm + +export const MOCK_MYINFO_FORM = ({ + authType: 'MyInfo', + title: 'Mock MyInfo form', + _id: new ObjectId().toHexString(), + admin: { + _id: new ObjectId().toHexString(), + agency: new ObjectId().toHexString(), + }, } as unknown) as IPopulatedForm export const MOCK_LOGIN_DOC = { From c61184390835e9972a2558a9c2b6a9a862344d49 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Tue, 30 Mar 2021 13:58:08 +0800 Subject: [PATCH 40/86] test(myinfo.service): adds tests for createFormWithMyInfo --- .../public-form/public-form.controller.ts | 5 +- .../myinfo/__tests__/myinfo.service.spec.ts | 93 ++++++++++++++++++- .../myinfo/__tests__/myinfo.test.constants.ts | 13 ++- src/app/modules/myinfo/myinfo.service.ts | 3 +- 4 files changed, 106 insertions(+), 8 deletions(-) diff --git a/src/app/modules/form/public-form/public-form.controller.ts b/src/app/modules/form/public-form/public-form.controller.ts index e9e2d11d4e..f9b1259802 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -1,7 +1,3 @@ -import { - InvalidJwtError, - VerifyJwtError, -} from 'dist/backend/app/modules/spcp/spcp.errors' import { RequestHandler } from 'express' import { StatusCodes } from 'http-status-codes' import { okAsync } from 'neverthrow' @@ -18,6 +14,7 @@ import { import { MyInfoFactory } from '../../myinfo/myinfo.factory' import { MyInfoCookiePayload } from '../../myinfo/myinfo.types' import { extractSuccessfulMyInfoCookie } from '../../myinfo/myinfo.util' +import { InvalidJwtError, VerifyJwtError } from '../../spcp/spcp.errors' import { SpcpFactory } from '../../spcp/spcp.factory' import { PrivateFormError } from '../form.errors' import * as FormService from '../form.service' diff --git a/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts b/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts index 472904e955..fbbce0e684 100644 --- a/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts +++ b/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts @@ -3,6 +3,7 @@ import mongoose from 'mongoose' import { mocked } from 'ts-jest/utils' import { v4 as uuidv4 } from 'uuid' +import { DatabaseError } from 'src/app/modules/core/core.errors' import { MyInfoService } from 'src/app/modules/myinfo/myinfo.service' import getMyInfoHashModel from 'src/app/modules/myinfo/myinfo_hash.model' import { ProcessedFieldResponse } from 'src/app/modules/submission/submission.types' @@ -17,7 +18,10 @@ import { import dbHandler from 'tests/unit/backend/helpers/jest-db' import { MyInfoData } from '../myinfo.adapter' -import { MYINFO_CONSENT_PAGE_PURPOSE } from '../myinfo.constants' +import { + MYINFO_CONSENT_PAGE_PURPOSE, + MYINFO_COOKIE_NAME, +} from '../myinfo.constants' import { MyInfoCircuitBreakerError, MyInfoFetchError, @@ -440,7 +444,7 @@ describe('MyInfoService', () => { }) }) - describe('validateAndFillFormWithMyInfoData', () => { + describe('fetchMyInfoData', () => { // NOTE: Mocks the underlying circuit breaker implementation to avoid network calls beforeEach(() => { myInfoService = new MyInfoService(MOCK_SERVICE_PARAMS) @@ -479,4 +483,89 @@ describe('MyInfoService', () => { expect(result._unsafeUnwrapErr()).toEqual(expected) }) }) + + describe('createFormWithMyInfo', () => { + // NOTE: Mocks the underlying circuit breaker implementation to avoid network calls + beforeEach(() => { + myInfoService = new MyInfoService(MOCK_SERVICE_PARAMS) + }) + + it('should return the filled form when the form and cookies are valid', async () => { + // Arrange + const mockReturnedParams = { + uinFin: MOCK_UINFIN, + data: MOCK_MYINFO_DATA, + } + mockGetPerson.mockResolvedValueOnce(mockReturnedParams) + + const expected = { + form: MOCK_MYINFO_FORM.getPublicView(), + spcpSession: { userName: MOCK_UINFIN }, + } + + // Spies to ensure that submethods have been called + const fetchMyInfoSpy = jest.spyOn(myInfoService, 'fetchMyInfoData') + const prefillMyInfoSpy = jest.spyOn(myInfoService, 'prefillMyInfoFields') + const saveMyInfoSpy = jest.spyOn(myInfoService, 'saveMyInfoHashes') + + // Act + const result = await myInfoService.createFormWithMyInfo( + MOCK_MYINFO_FORM as IPopulatedForm, + { [MYINFO_COOKIE_NAME]: MOCK_SUCCESSFUL_COOKIE }, + ) + + // Assert + expect(fetchMyInfoSpy).toHaveBeenCalled() + expect(prefillMyInfoSpy).toHaveBeenCalled() + expect(saveMyInfoSpy).toHaveBeenCalled() + expect(result._unsafeUnwrap()).toEqual(expected) + }) + + it('should return MyInfoMissingAccessTokenError when there are no cookies in the request', async () => { + // Arrange + const expected = new MyInfoMissingAccessTokenError() + const prefillMyInfoSpy = jest.spyOn(myInfoService, 'prefillMyInfoFields') + const saveMyInfoSpy = jest.spyOn(myInfoService, 'saveMyInfoHashes') + + // Act + const result = await myInfoService.createFormWithMyInfo( + MOCK_MYINFO_FORM as IPopulatedForm, + {}, + ) + + // Assert + expect(prefillMyInfoSpy).not.toHaveBeenCalled() + expect(saveMyInfoSpy).not.toHaveBeenCalled() + expect(result._unsafeUnwrapErr()).toEqual(expected) + }) + + it('should return DatabaseError when the form could not be saved to the database', async () => { + // Arrange + const expected = new DatabaseError( + 'Failed to save MyInfo hashes to database', + ) + const prefillMyInfoSpy = jest.spyOn(myInfoService, 'prefillMyInfoFields') + const saveMyInfoSpy = jest.spyOn(myInfoService, 'saveMyInfoHashes') + const mockReturnedParams = { + uinFin: MOCK_UINFIN, + data: MOCK_MYINFO_DATA, + } + mockGetPerson.mockResolvedValueOnce(mockReturnedParams) + + jest + .spyOn(MyInfoHash, 'updateHashes') + .mockRejectedValueOnce(new DatabaseError()) + + // Act + const result = await myInfoService.createFormWithMyInfo( + MOCK_MYINFO_FORM as IPopulatedForm, + { [MYINFO_COOKIE_NAME]: MOCK_SUCCESSFUL_COOKIE }, + ) + + // Assert + expect(prefillMyInfoSpy).toHaveBeenCalled() + expect(saveMyInfoSpy).toHaveBeenCalled() + expect(result._unsafeUnwrapErr()).toEqual(expected) + }) + }) }) diff --git a/src/app/modules/myinfo/__tests__/myinfo.test.constants.ts b/src/app/modules/myinfo/__tests__/myinfo.test.constants.ts index 675b0d0cd5..033b77af50 100644 --- a/src/app/modules/myinfo/__tests__/myinfo.test.constants.ts +++ b/src/app/modules/myinfo/__tests__/myinfo.test.constants.ts @@ -5,7 +5,7 @@ import { MyInfoSource, } from '@opengovsg/myinfo-gov-client' import { ObjectId } from 'bson' -import { merge, zipWith } from 'lodash' +import { merge, omit, zipWith } from 'lodash' import { ISpcpMyInfo } from 'src/config/feature-manager' import { AuthType, Environment, IFormSchema, MyInfoAttribute } from 'src/types' @@ -149,7 +149,18 @@ export const MOCK_MYINFO_FORM = ({ _id: MOCK_FORM_ID, esrvcId: MOCK_ESRVC_ID, authType: AuthType.MyInfo, + admin: { + _id: new ObjectId().toHexString(), + agency: new ObjectId().toHexString(), + }, getUniqueMyInfoAttrs: () => MOCK_REQUESTED_ATTRS, + getPublicView: function () { + return omit(this, 'admin') + }, + toJSON: function () { + return this + }, + form_fields: [], } as unknown) as IFormSchema export const MOCK_SUCCESSFUL_COOKIE: MyInfoSuccessfulCookiePayload = { diff --git a/src/app/modules/myinfo/myinfo.service.ts b/src/app/modules/myinfo/myinfo.service.ts index 4d0876cf91..f0b0116429 100644 --- a/src/app/modules/myinfo/myinfo.service.ts +++ b/src/app/modules/myinfo/myinfo.service.ts @@ -286,6 +286,7 @@ export class MyInfoService { myInfoData: MyInfoData, currFormFields: LeanDocument, ): Result { + console.log(currFormFields) const prefilledFields = currFormFields.map((field) => { if (!field.myInfo?.attr) return field @@ -505,7 +506,7 @@ export class MyInfoService { } /** - * creates a form view with myInfo fields prefilled onto the form + * Creates a form view with myInfo fields prefilled onto the form * @param form The form to validate and fill * @param cookies The cookies on the request * @returns From 55b075ccb9c2d8098ac88608bbac30fda482e218 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Tue, 30 Mar 2021 18:38:15 +0800 Subject: [PATCH 41/86] feat(form): adds new errors and utility methods --- src/app/modules/form/form.errors.ts | 9 +++++++++ src/app/modules/form/form.service.ts | 24 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/app/modules/form/form.errors.ts b/src/app/modules/form/form.errors.ts index 65ea70cfeb..60e8046b3f 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 a Spcp/MyInfo forms is accessed from intranet + */ +export class IntranetAccessError extends ApplicationError { + constructor(formId: string) { + super(`Form with id: ${formId} was accessed from intranet`) + } +} diff --git a/src/app/modules/form/form.service.ts b/src/app/modules/form/form.service.ts index c8ba88fa1d..750c44d385 100644 --- a/src/app/modules/form/form.service.ts +++ b/src/app/modules/form/form.service.ts @@ -1,12 +1,16 @@ import mongoose from 'mongoose' import { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow' +import { IntranetFactory } from 'src/app/services/intranet/intranet.factory' + import { createLoggerWithLabel } from '../../../config/logger' import { + AuthType, IEmailFormModel, IEncryptedFormModel, IFormSchema, IPopulatedForm, + PublicForm, ResponseMode, Status, } from '../../../types' @@ -25,6 +29,7 @@ import { ApplicationError, DatabaseError } from '../core/core.errors' import { FormDeletedError, FormNotFoundError, + IntranetAccessError, PrivateFormError, } from './form.errors' @@ -236,3 +241,22 @@ export const getFormModelByResponseMode = ( return EncryptedFormModel } } + +/** + * Checks whether a given form submission is made from within intranet + * @param ip The ip of the request + * @param publicForm The form to check + * @returns ok(PublicForm) if the form is accessed from the internet + * @returns err(IntranetAccessError) if the form is accessed from within intranet + */ +export const isFormSubmissionFromIntranet = ( + ip: string, + publicForm: PublicForm, +): Result => { + return IntranetFactory.isIntranetIp(ip).andThen((isIntranetUser) => { + return isIntranetUser && + [AuthType.SP, AuthType.CP, AuthType.MyInfo].includes(publicForm.authType) + ? err(new IntranetAccessError(publicForm._id)) + : ok(publicForm) + }) +} From 11804b9fe3684d23b5c327f42b63f9344edf182f Mon Sep 17 00:00:00 2001 From: seaerchin Date: Tue, 30 Mar 2021 18:41:24 +0800 Subject: [PATCH 42/86] refactor(public-form): refactor to account for intarnet --- .../__tests__/public-form.controller.spec.ts | 18 +++++ .../public-form/public-form.controller.ts | 72 +++++++++++++------ .../form/public-form/public-form.types.ts | 1 - src/app/modules/myinfo/myinfo.service.ts | 1 - 4 files changed, 70 insertions(+), 22 deletions(-) diff --git a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts index 267be29cb3..9bb28475f1 100644 --- a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts +++ b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts @@ -474,6 +474,12 @@ describe('public-form.controller', () => { rememberMe: false, } + beforeEach(() => + MockFormService.isFormSubmissionFromIntranet.mockImplementationOnce( + (_, publicForm) => ok(publicForm), + ), + ) + it('should return 200 when there is no AuthType on the request', async () => { // Arrange const MOCK_NIL_AUTH_FORM = (mocked({ @@ -499,6 +505,8 @@ describe('public-form.controller', () => { // Assert expect(MOCK_RES.json).toHaveBeenCalledWith({ form: MOCK_NIL_AUTH_FORM.getPublicView(), + isIntranetUser: false, + myInfoError: false, }) }) @@ -540,6 +548,8 @@ describe('public-form.controller', () => { spcpSession: { userName: MOCK_JWT_PAYLOAD.userName, }, + isIntranetUser: false, + myInfoError: false, }) }) @@ -581,6 +591,8 @@ describe('public-form.controller', () => { spcpSession: { userName: MOCK_JWT_PAYLOAD.userName, }, + isIntranetUser: false, + myInfoError: false, }) }) @@ -625,6 +637,8 @@ describe('public-form.controller', () => { expect(MOCK_RES.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_AUTH_FORM.getPublicView(), spcpSession: { userName: MOCK_MYINFO_DATA.getUinFin() }, + isIntranetUser: false, + myInfoError: false, }) }) }) @@ -646,6 +660,10 @@ describe('public-form.controller', () => { MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValue( okAsync(MOCK_MYINFO_FORM), ) + + MockFormService.isFormSubmissionFromIntranet.mockImplementationOnce( + (_, publicForm) => ok(publicForm), + ) }) it('should return 200 but the response should have cookies cleared and myInfoError when the request has no cookie', async () => { diff --git a/src/app/modules/form/public-form/public-form.controller.ts b/src/app/modules/form/public-form/public-form.controller.ts index f9b1259802..7ba2a991fd 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -5,7 +5,7 @@ import querystring from 'querystring' import { createLoggerWithLabel } from '../../../../config/logger' import { AuthType, FormController } from '../../../../types' -import { createReqMeta } from '../../../utils/request' +import { createReqMeta, getRequestIp } from '../../../utils/request' import { getFormIfPublic } from '../../auth/auth.service' import { MYINFO_COOKIE_NAME, @@ -16,7 +16,7 @@ import { MyInfoCookiePayload } from '../../myinfo/myinfo.types' import { extractSuccessfulMyInfoCookie } from '../../myinfo/myinfo.util' import { InvalidJwtError, VerifyJwtError } from '../../spcp/spcp.errors' import { SpcpFactory } from '../../spcp/spcp.factory' -import { PrivateFormError } from '../form.errors' +import { IntranetAccessError, PrivateFormError } from '../form.errors' import * as FormService from '../form.service' import * as PublicFormService from './public-form.service' @@ -210,7 +210,7 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( const form = formResult.value const publicForm = form.getPublicView() const { authType } = form - let myInfoError: boolean + let myInfoError = false // NOTE: Creating a variable to ensure that TS will enforce the type and ensure all keys in AuthType are covered. const formController: FormController< @@ -255,23 +255,55 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( // NOTE: Once there is a valid form retrieved from the database, // the client should always get a 200 response with the form's public view. // Additional errors should be tagged onto the response object like myInfoError. - return formController[authType]() - .map((publicFormView) => res.json(publicFormView)) - .mapErr((error) => { - if (error instanceof VerifyJwtError || error instanceof InvalidJwtError) { - logger.error({ - message: 'Error getting public form', - meta: { - action: 'handleGetPublicForm', - ...createReqMeta(req), - formId, - }, - error, + return ( + formController[authType]() + // inject isIntranetUser here + .andThen((publicFormView) => + // Checks if a form submission is made over intranet and passes through if it is + FormService.isFormSubmissionFromIntranet( + getRequestIp(req), + publicForm, + ).map(() => ({ ...publicFormView, isIntranetUser: false })), + ) + .map((publicFormView) => + res.json({ + ...publicFormView, + myInfoError: false, + }), + ) + .mapErr((error) => { + if ( + error instanceof VerifyJwtError || + error instanceof InvalidJwtError + ) { + logger.error({ + message: 'Error getting public form', + meta: { + action: 'handleGetPublicForm', + ...createReqMeta(req), + formId, + }, + error, + }) + } + const isIntranetUser = error instanceof IntranetAccessError + if (isIntranetUser) { + logger.warn({ + message: + 'Attempting to access SingPass, CorpPass or MyInfo form from intranet', + meta: { + action: 'read', + formId, + }, + error, + }) + } + + return res.json({ + form: publicForm, + myInfoError, + isIntranetUser, }) - } - return res.json({ - form: publicForm, - ...(myInfoError && { myInfoError }), }) - }) + ) } diff --git a/src/app/modules/form/public-form/public-form.types.ts b/src/app/modules/form/public-form/public-form.types.ts index dbbd2eedaf..cf31b320ab 100644 --- a/src/app/modules/form/public-form/public-form.types.ts +++ b/src/app/modules/form/public-form/public-form.types.ts @@ -27,5 +27,4 @@ interface PossiblyPrefilledPublicForm extends Omit { export interface IPublicFormView { form: PossiblyPrefilledPublicForm spcpSession?: { userName: string } - myInfoError?: boolean } diff --git a/src/app/modules/myinfo/myinfo.service.ts b/src/app/modules/myinfo/myinfo.service.ts index f0b0116429..9da9653952 100644 --- a/src/app/modules/myinfo/myinfo.service.ts +++ b/src/app/modules/myinfo/myinfo.service.ts @@ -286,7 +286,6 @@ export class MyInfoService { myInfoData: MyInfoData, currFormFields: LeanDocument, ): Result { - console.log(currFormFields) const prefilledFields = currFormFields.map((field) => { if (!field.myInfo?.attr) return field From 0ba608a028512440b3b1eee4b2d376a748ac9209 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Wed, 31 Mar 2021 02:23:42 +0800 Subject: [PATCH 43/86] refactor(public-form/controller): added compatability for checking intranet access --- src/app/modules/form/form.errors.ts | 9 --- src/app/modules/form/form.service.ts | 57 ++++++++++---- .../public-form/public-form.controller.ts | 76 +++++++------------ .../form/public-form/public-form.types.ts | 2 + .../spcp/__tests__/spcp.service.spec.ts | 2 +- 5 files changed, 71 insertions(+), 75 deletions(-) diff --git a/src/app/modules/form/form.errors.ts b/src/app/modules/form/form.errors.ts index 60e8046b3f..65ea70cfeb 100644 --- a/src/app/modules/form/form.errors.ts +++ b/src/app/modules/form/form.errors.ts @@ -51,12 +51,3 @@ export class TransferOwnershipError extends ApplicationError { super(message) } } - -/** - * Error to be returned when a Spcp/MyInfo forms is accessed from intranet - */ -export class IntranetAccessError extends ApplicationError { - constructor(formId: string) { - super(`Form with id: ${formId} was accessed from intranet`) - } -} diff --git a/src/app/modules/form/form.service.ts b/src/app/modules/form/form.service.ts index 750c44d385..b3aa750479 100644 --- a/src/app/modules/form/form.service.ts +++ b/src/app/modules/form/form.service.ts @@ -1,5 +1,6 @@ import mongoose from 'mongoose' import { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow' +import { SetRequired } from 'type-fest' import { IntranetFactory } from 'src/app/services/intranet/intranet.factory' @@ -10,7 +11,6 @@ import { IEncryptedFormModel, IFormSchema, IPopulatedForm, - PublicForm, ResponseMode, Status, } from '../../../types' @@ -26,10 +26,10 @@ import { } from '../../utils/handle-mongo-error' import { ApplicationError, DatabaseError } from '../core/core.errors' +import { IPublicFormView } from './public-form/public-form.types' import { FormDeletedError, FormNotFoundError, - IntranetAccessError, PrivateFormError, } from './form.errors' @@ -243,20 +243,47 @@ export const getFormModelByResponseMode = ( } /** - * Checks whether a given form submission is made from within intranet + * Checks if a form is accessed from within intranet and sets the property accordingly * @param ip The ip of the request - * @param publicForm The form to check - * @returns ok(PublicForm) if the form is accessed from the internet - * @returns err(IntranetAccessError) if the form is accessed from within intranet + * @param publicFormView The form to check + * @returns ok(PublicFormView) if the form is accessed from the internet + * @returns err(ApplicationError) if an error occured while checking if the ip of the request is from the intranet */ -export const isFormSubmissionFromIntranet = ( +export const setIsIntranetFormAccess = ( ip: string, - publicForm: PublicForm, -): Result => { - return IntranetFactory.isIntranetIp(ip).andThen((isIntranetUser) => { - return isIntranetUser && - [AuthType.SP, AuthType.CP, AuthType.MyInfo].includes(publicForm.authType) - ? err(new IntranetAccessError(publicForm._id)) - : ok(publicForm) + publicFormView: IPublicFormView, +): Result, ApplicationError> => + IntranetFactory.isIntranetIp(ip).andThen((isIntranetIp) => { + const isIntranetUser = + isIntranetIp && + [AuthType.SP, AuthType.CP, AuthType.MyInfo].includes( + publicFormView.form.authType, + ) + if (isIntranetUser) { + logger.warn({ + message: + 'Attempting to access SingPass, CorpPass or MyInfo form from intranet', + meta: { + action: 'read', + formId: publicFormView.form._id, + }, + }) + } + return ok({ ...publicFormView, isIntranetUser }) + }) + +/** + * Utility method to signify to downstream consumers that the myInfoError property has been set + * @param publicFormView The form view to set + * @param hasMyInfoError Whether there is a myInfoError + * @returns ok(publicFormView) The form with the myInfoError property set + * @returns err(never) Should never happen + */ +export const setMyInfoError = ( + publicFormView: IPublicFormView, + hasMyInfoError: boolean, +): Result, never> => + ok({ + ...publicFormView, + myInfoError: hasMyInfoError, }) -} diff --git a/src/app/modules/form/public-form/public-form.controller.ts b/src/app/modules/form/public-form/public-form.controller.ts index 7ba2a991fd..c0ea05d9e5 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -16,7 +16,7 @@ import { MyInfoCookiePayload } from '../../myinfo/myinfo.types' import { extractSuccessfulMyInfoCookie } from '../../myinfo/myinfo.util' import { InvalidJwtError, VerifyJwtError } from '../../spcp/spcp.errors' import { SpcpFactory } from '../../spcp/spcp.factory' -import { IntranetAccessError, PrivateFormError } from '../form.errors' +import { PrivateFormError } from '../form.errors' import * as FormService from '../form.service' import * as PublicFormService from './public-form.service' @@ -255,55 +255,31 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( // NOTE: Once there is a valid form retrieved from the database, // the client should always get a 200 response with the form's public view. // Additional errors should be tagged onto the response object like myInfoError. - return ( - formController[authType]() - // inject isIntranetUser here - .andThen((publicFormView) => - // Checks if a form submission is made over intranet and passes through if it is - FormService.isFormSubmissionFromIntranet( - getRequestIp(req), - publicForm, - ).map(() => ({ ...publicFormView, isIntranetUser: false })), - ) - .map((publicFormView) => - res.json({ - ...publicFormView, - myInfoError: false, - }), - ) - .mapErr((error) => { - if ( - error instanceof VerifyJwtError || - error instanceof InvalidJwtError - ) { - logger.error({ - message: 'Error getting public form', - meta: { - action: 'handleGetPublicForm', - ...createReqMeta(req), - formId, - }, - error, - }) - } - const isIntranetUser = error instanceof IntranetAccessError - if (isIntranetUser) { - logger.warn({ - message: - 'Attempting to access SingPass, CorpPass or MyInfo form from intranet', - meta: { - action: 'read', - formId, - }, - error, - }) - } - - return res.json({ - form: publicForm, - myInfoError, - isIntranetUser, + return formController[authType]() + .andThen((publicFormView) => + FormService.setIsIntranetFormAccess(getRequestIp(req), publicFormView), + ) + .andThen((publicFormView) => + FormService.setMyInfoError(publicFormView, myInfoError), + ) + .map((publicFormView) => res.json(publicFormView)) + .mapErr((error) => { + if (error instanceof VerifyJwtError || error instanceof InvalidJwtError) { + logger.error({ + message: 'Error getting public form', + meta: { + action: 'handleGetPublicForm', + ...createReqMeta(req), + formId, + }, + error, }) + } + + return res.json({ + form: publicForm, + myInfoError, + isIntranetUser: false, }) - ) + }) } diff --git a/src/app/modules/form/public-form/public-form.types.ts b/src/app/modules/form/public-form/public-form.types.ts index cf31b320ab..0d82a96163 100644 --- a/src/app/modules/form/public-form/public-form.types.ts +++ b/src/app/modules/form/public-form/public-form.types.ts @@ -27,4 +27,6 @@ interface PossiblyPrefilledPublicForm extends Omit { export interface IPublicFormView { form: PossiblyPrefilledPublicForm spcpSession?: { userName: string } + isIntranetUser?: boolean + myInfoError?: boolean } diff --git a/src/app/modules/spcp/__tests__/spcp.service.spec.ts b/src/app/modules/spcp/__tests__/spcp.service.spec.ts index 09706f8243..611c8c4a63 100644 --- a/src/app/modules/spcp/__tests__/spcp.service.spec.ts +++ b/src/app/modules/spcp/__tests__/spcp.service.spec.ts @@ -1,6 +1,5 @@ import SPCPAuthClient from '@opengovsg/spcp-auth-client' import axios from 'axios' -import { InvalidJwtError } from 'dist/backend/app/modules/spcp/spcp.errors' import fs from 'fs' import { omit } from 'lodash' import { mocked } from 'ts-jest/utils' @@ -17,6 +16,7 @@ import dbHandler from 'tests/unit/backend/helpers/jest-db' import { CreateRedirectUrlError, FetchLoginPageError, + InvalidJwtError, InvalidOOBParamsError, LoginPageValidationError, MissingAttributesError, From a05c8997e8f967f9f073e741c04109b6f53a8d2b Mon Sep 17 00:00:00 2001 From: seaerchin Date: Wed, 31 Mar 2021 02:26:18 +0800 Subject: [PATCH 44/86] test(public-form/controller): adds tests for checking intranet; fixes old tests due to this addition --- src/app/modules/form/form.service.ts | 3 +- .../__tests__/public-form.controller.spec.ts | 217 +++++++++++++++++- 2 files changed, 208 insertions(+), 12 deletions(-) diff --git a/src/app/modules/form/form.service.ts b/src/app/modules/form/form.service.ts index b3aa750479..bfd8527665 100644 --- a/src/app/modules/form/form.service.ts +++ b/src/app/modules/form/form.service.ts @@ -2,8 +2,6 @@ import mongoose from 'mongoose' import { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow' import { SetRequired } from 'type-fest' -import { IntranetFactory } from 'src/app/services/intranet/intranet.factory' - import { createLoggerWithLabel } from '../../../config/logger' import { AuthType, @@ -19,6 +17,7 @@ import getFormModel, { getEncryptedFormModel, } from '../../models/form.server.model' import getSubmissionModel from '../../models/submission.server.model' +import { IntranetFactory } from '../../services/intranet/intranet.factory' import { getMongoErrorMessage, PossibleDatabaseError, diff --git a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts index 9bb28475f1..a0926b932a 100644 --- a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts +++ b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts @@ -474,11 +474,15 @@ describe('public-form.controller', () => { rememberMe: false, } - beforeEach(() => - MockFormService.isFormSubmissionFromIntranet.mockImplementationOnce( - (_, publicForm) => ok(publicForm), - ), - ) + beforeAll(() => { + MockFormService.setIsIntranetFormAccess.mockImplementation( + (_, publicForm) => ok({ ...publicForm, isIntranetUser: false }), + ) + + MockFormService.setMyInfoError.mockImplementation((publicForm) => + ok({ ...publicForm, myInfoError: false }), + ) + }) it('should return 200 when there is no AuthType on the request', async () => { // Arrange @@ -648,7 +652,6 @@ describe('public-form.controller', () => { const MOCK_MYINFO_FORM = (mocked({ ...BASE_FORM, authType: AuthType.MyInfo, - toJSON: jest.fn().mockReturnValue(BASE_FORM), }) as unknown) as IPopulatedForm // Setup because this gets invoked at the start of the controller to decide which branch to take @@ -660,10 +663,6 @@ describe('public-form.controller', () => { MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValue( okAsync(MOCK_MYINFO_FORM), ) - - MockFormService.isFormSubmissionFromIntranet.mockImplementationOnce( - (_, publicForm) => ok(publicForm), - ) }) it('should return 200 but the response should have cookies cleared and myInfoError when the request has no cookie', async () => { @@ -689,6 +688,7 @@ describe('public-form.controller', () => { expect(MOCK_RES.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_FORM.getPublicView(), myInfoError: true, + isIntranetUser: false, }) }) @@ -714,6 +714,7 @@ describe('public-form.controller', () => { expect(MOCK_RES.clearCookie).toHaveBeenCalled() expect(MOCK_RES.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_FORM.getPublicView(), + isIntranetUser: false, myInfoError: true, }) }) @@ -740,6 +741,7 @@ describe('public-form.controller', () => { expect(MOCK_RES.clearCookie).toHaveBeenCalled() expect(MOCK_RES.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_FORM.getPublicView(), + isIntranetUser: false, myInfoError: true, }) }) @@ -766,6 +768,7 @@ describe('public-form.controller', () => { expect(MOCK_RES.clearCookie).toHaveBeenCalled() expect(MOCK_RES.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_FORM.getPublicView(), + isIntranetUser: false, myInfoError: true, }) }) @@ -796,6 +799,7 @@ describe('public-form.controller', () => { expect(MOCK_RES.clearCookie).toHaveBeenCalled() expect(MOCK_RES.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_FORM.getPublicView(), + isIntranetUser: false, myInfoError: true, }) }) @@ -828,6 +832,7 @@ describe('public-form.controller', () => { expect(MOCK_RES.clearCookie).toHaveBeenCalled() expect(MOCK_RES.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_FORM.getPublicView(), + isIntranetUser: false, myInfoError: true, }) }) @@ -866,6 +871,8 @@ describe('public-form.controller', () => { // json object should only have form property expect(MOCK_RES.json).toHaveBeenCalledWith({ form: MOCK_SPCP_FORM.getPublicView(), + isIntranetUser: false, + myInfoError: false, }) }) }) @@ -960,5 +967,195 @@ describe('public-form.controller', () => { expect(MOCK_RES.status).toHaveBeenCalledWith(404) }) }) + + describe('errors in form access', () => { + const MOCK_JWT_PAYLOAD: JwtPayload = { + userName: 'mock', + rememberMe: false, + } + + beforeAll(() => { + MockFormService.setMyInfoError.mockImplementation((publicForm) => + ok({ ...publicForm, myInfoError: false }), + ) + }) + + it('should return 200 with isIntranetUser set to false when a user accesses the form with no authType set', async () => { + // Arrange + const MOCK_NIL_AUTH_FORM = (mocked({ + ...BASE_FORM, + authType: AuthType.NIL, + }) as unknown) as MockedObject + const MOCK_RES = expressHandler.mockResponse() + + MockAuthService.getFormIfPublic.mockReturnValueOnce( + okAsync(MOCK_NIL_AUTH_FORM), + ) + MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( + okAsync(MOCK_NIL_AUTH_FORM), + ) + + MockFormService.setIsIntranetFormAccess.mockImplementation( + (_, publicForm) => ok({ ...publicForm, isIntranetUser: false }), + ) + + // Act + await PublicFormController.handleGetPublicForm( + MOCK_REQ, + MOCK_RES, + jest.fn(), + ) + + // Assert + expect(MOCK_RES.json).toHaveBeenCalledWith({ + form: MOCK_NIL_AUTH_FORM.getPublicView(), + isIntranetUser: false, + myInfoError: false, + }) + }) + + it('should return 200 with isIntranetUser set to true when a user accesses the form using SP', async () => { + // Arrange + const MOCK_SP_AUTH_FORM = (mocked({ + ...BASE_FORM, + authType: AuthType.SP, + }) as unknown) as MockedObject + const MOCK_RES = expressHandler.mockResponse() + + MockSpcpFactory.createFormWithSpcpSession.mockReturnValueOnce( + okAsync({ + form: MOCK_SP_AUTH_FORM.getPublicView(), + spcpSession: { userName: MOCK_JWT_PAYLOAD.userName }, + }), + ) + + MockFormService.setIsIntranetFormAccess.mockImplementation( + (_, publicForm) => ok({ ...publicForm, isIntranetUser: true }), + ) + + MockSpcpFactory.getSpcpSession.mockReturnValueOnce( + okAsync(MOCK_JWT_PAYLOAD), + ) + MockAuthService.getFormIfPublic.mockReturnValueOnce( + okAsync(MOCK_SP_AUTH_FORM), + ) + MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( + okAsync(MOCK_SP_AUTH_FORM), + ) + + // Act + await PublicFormController.handleGetPublicForm( + MOCK_REQ, + MOCK_RES, + jest.fn(), + ) + + // Assert + expect(MOCK_RES.json).toHaveBeenCalledWith({ + form: MOCK_SP_AUTH_FORM.getPublicView(), + spcpSession: { + userName: MOCK_JWT_PAYLOAD.userName, + }, + isIntranetUser: true, + myInfoError: false, + }) + }) + + it('should return 200 with isIntranetUser set to true when a user accesses the form using CP', async () => { + // Arrange + const MOCK_CP_AUTH_FORM = (mocked({ + ...BASE_FORM, + authType: AuthType.CP, + }) as unknown) as MockedObject + const MOCK_RES = expressHandler.mockResponse() + + MockSpcpFactory.createFormWithSpcpSession.mockReturnValueOnce( + okAsync({ + form: MOCK_CP_AUTH_FORM.getPublicView(), + spcpSession: { userName: MOCK_JWT_PAYLOAD.userName }, + }), + ) + + MockFormService.setIsIntranetFormAccess.mockImplementation( + (_, publicForm) => ok({ ...publicForm, isIntranetUser: true }), + ) + + MockSpcpFactory.getSpcpSession.mockReturnValueOnce( + okAsync(MOCK_JWT_PAYLOAD), + ) + MockAuthService.getFormIfPublic.mockReturnValueOnce( + okAsync(MOCK_CP_AUTH_FORM), + ) + MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( + okAsync(MOCK_CP_AUTH_FORM), + ) + + // Act + await PublicFormController.handleGetPublicForm( + MOCK_REQ, + MOCK_RES, + jest.fn(), + ) + + // Assert + expect(MOCK_RES.json).toHaveBeenCalledWith({ + form: MOCK_CP_AUTH_FORM.getPublicView(), + spcpSession: { + userName: MOCK_JWT_PAYLOAD.userName, + }, + isIntranetUser: true, + myInfoError: false, + }) + }) + + it('should return 200 with isIntranetUser set to true when a user accesses the form using MyInfo', async () => { + // Arrange + const MOCK_MYINFO_AUTH_FORM = (mocked({ + ...BASE_FORM, + esrvcId: 'thing', + authType: AuthType.MyInfo, + toJSON: jest.fn().mockReturnValue(BASE_FORM), + }) as unknown) as MockedObject + const MOCK_RES = expressHandler.mockResponse({ + clearCookie: jest.fn().mockReturnThis(), + }) + + const MOCK_MYINFO_DATA = new MyInfoData({ + uinFin: 'i am a fish', + } as IPersonResponse) + + MockAuthService.getFormIfPublic.mockReturnValueOnce( + okAsync(MOCK_MYINFO_AUTH_FORM), + ) + MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( + okAsync(MOCK_MYINFO_AUTH_FORM), + ) + MockMyInfoFactory.createFormWithMyInfo.mockReturnValueOnce( + okAsync({ + form: MOCK_MYINFO_AUTH_FORM.getPublicView(), + spcpSession: { userName: MOCK_MYINFO_DATA.getUinFin() }, + }), + ) + MockFormService.setIsIntranetFormAccess.mockImplementation( + (_, publicForm) => ok({ ...publicForm, isIntranetUser: true }), + ) + + // Act + await PublicFormController.handleGetPublicForm( + MOCK_REQ_WITH_COOKIES, + MOCK_RES, + jest.fn(), + ) + + // Assert + expect(MOCK_RES.clearCookie).not.toHaveBeenCalled() + expect(MOCK_RES.json).toHaveBeenCalledWith({ + form: MOCK_MYINFO_AUTH_FORM.getPublicView(), + spcpSession: { userName: MOCK_MYINFO_DATA.getUinFin() }, + isIntranetUser: true, + myInfoError: false, + }) + }) + }) }) }) From 2a54c1cb5960e284bbd5554b96a09862092796ff Mon Sep 17 00:00:00 2001 From: seaerchin Date: Wed, 31 Mar 2021 19:00:57 +0800 Subject: [PATCH 45/86] style(spcp/service/test): changed naming to camelCase for variables --- src/app/modules/spcp/__tests__/spcp.service.spec.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/modules/spcp/__tests__/spcp.service.spec.ts b/src/app/modules/spcp/__tests__/spcp.service.spec.ts index 611c8c4a63..498ac282d3 100644 --- a/src/app/modules/spcp/__tests__/spcp.service.spec.ts +++ b/src/app/modules/spcp/__tests__/spcp.service.spec.ts @@ -244,7 +244,7 @@ describe('spcp.service', () => { expect(result._unsafeUnwrapErr()).toEqual(new VerifyJwtError()) }) - it('should return InvalidJWTError when SP JWT has invalid shape', async () => { + it('should return InvalidJwtError when SP JWT has invalid shape', async () => { // Arrange const spcpService = new SpcpService(MOCK_PARAMS) // Assumes that SP auth client was instantiated first @@ -281,7 +281,7 @@ describe('spcp.service', () => { expect(result._unsafeUnwrapErr()).toEqual(new VerifyJwtError()) }) - it('should return InvalidJWTError when CorpPass JWT has invalid shape', async () => { + it('should return InvalidJwtError when CorpPass JWT has invalid shape', async () => { // Arrange const spcpService = new SpcpService(MOCK_PARAMS) // Assumes that SP auth client was instantiated first @@ -863,7 +863,7 @@ describe('spcp.service', () => { expect(result._unsafeUnwrapErr()).toEqual(expected) }) - it('should return InvalidJWTError when the client authenticates using SP and the JWT has wrong shape', async () => { + it('should return InvalidJwtError when the client authenticates using SP and the JWT has wrong shape', async () => { // Arrange const spcpService = new SpcpService(MOCK_PARAMS) // Assumes that SP auth client was instantiated first @@ -880,7 +880,7 @@ describe('spcp.service', () => { expect(result._unsafeUnwrapErr()).toEqual(expected) }) - it('should return VerifyJWTError when the client authenticates using CP and the JWT has wrong shape', async () => { + it('should return VerifyJwtError when the client authenticates using CP and the JWT has wrong shape', async () => { // Arrange const spcpService = new SpcpService(MOCK_PARAMS) // Assumes that SP auth client was instantiated first @@ -896,7 +896,7 @@ describe('spcp.service', () => { // Assert expect(result._unsafeUnwrapErr()).toEqual(expected) }) - it('should return InvalidJWTError when the client authenticates using SP and the JWT has invalid shape', async () => { + it('should return InvalidJwtError when the client authenticates using SP and the JWT has invalid shape', async () => { // Arrange const spcpService = new SpcpService(MOCK_PARAMS) // Assumes that SP auth client was instantiated first @@ -911,7 +911,7 @@ describe('spcp.service', () => { expect(result._unsafeUnwrapErr()).toEqual(expected) }) - it('should return InvalidJWTError when the client authenticates using CP and the JWT has invalid shape', async () => { + it('should return InvalidJwtError when the client authenticates using CP and the JWT has invalid shape', async () => { // Arrange const spcpService = new SpcpService(MOCK_PARAMS) // Assumes that SP auth client was instantiated first From 0341f2490e4779bb3264d478785ef6726b85c92b Mon Sep 17 00:00:00 2001 From: seaerchin Date: Wed, 31 Mar 2021 19:05:28 +0800 Subject: [PATCH 46/86] fix(form.service): fixed intranet ip checking --- src/app/modules/form/form.service.ts | 29 ++++++++-------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/src/app/modules/form/form.service.ts b/src/app/modules/form/form.service.ts index bfd8527665..6ae5190a1f 100644 --- a/src/app/modules/form/form.service.ts +++ b/src/app/modules/form/form.service.ts @@ -251,14 +251,16 @@ export const getFormModelByResponseMode = ( export const setIsIntranetFormAccess = ( ip: string, publicFormView: IPublicFormView, -): Result, ApplicationError> => - IntranetFactory.isIntranetIp(ip).andThen((isIntranetIp) => { - const isIntranetUser = - isIntranetIp && +): Result, ApplicationError> => { + return IntranetFactory.isIntranetIp(ip).andThen((isIntranetUser) => { + // Warn if form is being accessed from within intranet + // and the form has authentication set + if ( + isIntranetUser && [AuthType.SP, AuthType.CP, AuthType.MyInfo].includes( publicFormView.form.authType, ) - if (isIntranetUser) { + ) { logger.warn({ message: 'Attempting to access SingPass, CorpPass or MyInfo form from intranet', @@ -270,19 +272,4 @@ export const setIsIntranetFormAccess = ( } return ok({ ...publicFormView, isIntranetUser }) }) - -/** - * Utility method to signify to downstream consumers that the myInfoError property has been set - * @param publicFormView The form view to set - * @param hasMyInfoError Whether there is a myInfoError - * @returns ok(publicFormView) The form with the myInfoError property set - * @returns err(never) Should never happen - */ -export const setMyInfoError = ( - publicFormView: IPublicFormView, - hasMyInfoError: boolean, -): Result, never> => - ok({ - ...publicFormView, - myInfoError: hasMyInfoError, - }) +} From 938e129f1214a18888ca52af10dda03927879a65 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Wed, 31 Mar 2021 23:09:49 +0800 Subject: [PATCH 47/86] refactor(public-form): shifts utility method out into public form service --- .../public-form/public-form.controller.ts | 147 +++++++++--------- .../form/public-form/public-form.service.ts | 30 +++- .../form/public-form/public-form.types.ts | 2 +- src/app/utils/handle-mongo-error.ts | 12 ++ src/types/form.ts | 15 +- 5 files changed, 115 insertions(+), 91 deletions(-) diff --git a/src/app/modules/form/public-form/public-form.controller.ts b/src/app/modules/form/public-form/public-form.controller.ts index c0ea05d9e5..77f80c9f47 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -1,21 +1,20 @@ import { RequestHandler } from 'express' import { StatusCodes } from 'http-status-codes' -import { okAsync } from 'neverthrow' +import { ok } from 'neverthrow' import querystring from 'querystring' import { createLoggerWithLabel } from '../../../../config/logger' -import { AuthType, FormController } from '../../../../types' +import { AuthType } from '../../../../types' +import { isMongoError } from '../../../utils/handle-mongo-error' import { createReqMeta, getRequestIp } from '../../../utils/request' import { getFormIfPublic } from '../../auth/auth.service' import { MYINFO_COOKIE_NAME, MYINFO_COOKIE_OPTIONS, } from '../../myinfo/myinfo.constants' -import { MyInfoFactory } from '../../myinfo/myinfo.factory' import { MyInfoCookiePayload } from '../../myinfo/myinfo.types' import { extractSuccessfulMyInfoCookie } from '../../myinfo/myinfo.util' import { InvalidJwtError, VerifyJwtError } from '../../spcp/spcp.errors' -import { SpcpFactory } from '../../spcp/spcp.factory' import { PrivateFormError } from '../form.errors' import * as FormService from '../form.service' @@ -194,15 +193,19 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( // Early return if form is not public or any error occurred. if (formResult.isErr()) { const { error } = formResult - logger.error({ - message: 'Error retrieving public form', - meta: { - action: 'handleGetPublicForm', - ...createReqMeta(req), - formId, - }, - error, - }) + // NOTE: Only log on possible database errors. + // This is because the other kinds of errors are expected errors and are not truly exceptional + if (isMongoError(error)) { + logger.error({ + message: 'Error retrieving public form', + meta: { + action: 'handleGetPublicForm', + ...createReqMeta(req), + formId, + }, + error, + }) + } const { errorMessage, statusCode } = mapRouteError(error) return res.status(statusCode).json({ message: errorMessage }) } @@ -210,76 +213,66 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( const form = formResult.value const publicForm = form.getPublicView() const { authType } = form - let myInfoError = false - // NOTE: Creating a variable to ensure that TS will enforce the type and ensure all keys in AuthType are covered. - const formController: FormController< - | ReturnType - | ReturnType - > = { - [AuthType.SP]: () => - SpcpFactory.createFormWithSpcpSession(form, req.cookies), - [AuthType.CP]: () => - SpcpFactory.createFormWithSpcpSession(form, req.cookies), - [AuthType.MyInfo]: () => { - return MyInfoFactory.createFormWithMyInfo(form, req.cookies) - .andThen((publicForm) => { - return extractSuccessfulMyInfoCookie(req.cookies).map( - (myInfoCookie) => { - const cookiePayload: MyInfoCookiePayload = { - ...myInfoCookie, - usedCount: myInfoCookie.usedCount + 1, - } - // NOTE: This is a side effect to set the cookie on the result after it has been successfully prefilled. - res.cookie( - MYINFO_COOKIE_NAME, - cookiePayload, - MYINFO_COOKIE_OPTIONS, - ) - return publicForm - }, - ) - }) - .mapErr((error) => { - // NOTE: This is a side-effect as there is no need for cookie if data could not be retrieved - res.clearCookie(MYINFO_COOKIE_NAME, MYINFO_COOKIE_OPTIONS) - // NOTE: This is done as a workaround because the type of FormController enforces a uniform return value - // but we are required to signal if myInfoError in the event of an error - myInfoError = true - return error - }) - }, - [AuthType.NIL]: () => okAsync({ form: form.getPublicView() }), + // Step 1: Call the appropriate handler to generate the public form view for authType of form + let publicFormViewResult = await PublicFormService.getAuthTypeHandlerForForm( + authType, + )(form, req.cookies) + + // Step 2: Check if form is MyInfo and set/clear cookies accordingly + // NOTE: This is done to preserve type information on the errors + if (authType === AuthType.MyInfo) { + publicFormViewResult = publicFormViewResult + .andThen((publicFormView) => { + return extractSuccessfulMyInfoCookie(req.cookies).map( + (myInfoCookie) => { + const cookiePayload: MyInfoCookiePayload = { + ...myInfoCookie, + usedCount: myInfoCookie.usedCount + 1, + } + // NOTE: This is a side effect to set the cookie on the result after it has been successfully prefilled. + res.cookie(MYINFO_COOKIE_NAME, cookiePayload, MYINFO_COOKIE_OPTIONS) + return publicFormView + }, + ) + }) + .orElse(() => { + // NOTE: This is a side-effect as there is no need for cookie if data could not be retrieved. + res.clearCookie(MYINFO_COOKIE_NAME, MYINFO_COOKIE_OPTIONS) + return ok({ form: publicForm, myInfoError: true }) + }) } // NOTE: Once there is a valid form retrieved from the database, // the client should always get a 200 response with the form's public view. // Additional errors should be tagged onto the response object like myInfoError. - return formController[authType]() - .andThen((publicFormView) => - FormService.setIsIntranetFormAccess(getRequestIp(req), publicFormView), - ) - .andThen((publicFormView) => - FormService.setMyInfoError(publicFormView, myInfoError), - ) - .map((publicFormView) => res.json(publicFormView)) - .mapErr((error) => { - if (error instanceof VerifyJwtError || error instanceof InvalidJwtError) { - logger.error({ - message: 'Error getting public form', - meta: { - action: 'handleGetPublicForm', - ...createReqMeta(req), - formId, - }, - error, - }) - } + return ( + publicFormViewResult + // Step 3: Check and set whether form is accessed from intranet + .andThen((publicFormView) => + FormService.setIsIntranetFormAccess(getRequestIp(req), publicFormView), + ) + // Step 4: Return the public view + .map((publicFormView) => res.json(publicFormView)) + .mapErr((error) => { + if ( + error instanceof VerifyJwtError || + error instanceof InvalidJwtError + ) { + logger.error({ + message: 'Error getting public form', + meta: { + action: 'handleGetPublicForm', + ...createReqMeta(req), + formId, + }, + error, + }) + } - return res.json({ - form: publicForm, - myInfoError, - isIntranetUser: false, + return res.json({ + form: publicForm, + }) }) - }) + ) } diff --git a/src/app/modules/form/public-form/public-form.service.ts b/src/app/modules/form/public-form/public-form.service.ts index 6d924cea1d..731185556e 100644 --- a/src/app/modules/form/public-form/public-form.service.ts +++ b/src/app/modules/form/public-form/public-form.service.ts @@ -2,10 +2,17 @@ import mongoose from 'mongoose' import { errAsync, okAsync, ResultAsync } from 'neverthrow' import { createLoggerWithLabel } from '../../../../config/logger' -import { IFormFeedbackSchema } from '../../../../types' +import { + AuthType, + FormController, + IFormFeedbackSchema, + IPopulatedForm, +} from '../../../../types' import getFormModel from '../../../models/form.server.model' import getFormFeedbackModel from '../../../models/form_feedback.server.model' import { DatabaseError } from '../../core/core.errors' +import { MyInfoFactory } from '../../myinfo/myinfo.factory' +import { SpcpFactory } from '../../spcp/spcp.factory' import { FormNotFoundError } from '../form.errors' import { Metatags } from './public-form.types' @@ -102,3 +109,24 @@ export const createMetatags = ({ return okAsync(metatags) }) } + +export const getAuthTypeHandlerForForm = (authType: AuthType) => ( + form: IPopulatedForm, + cookies: Record, +): + | ReturnType + | ReturnType => { + // NOTE: This object is created to ensure that all cases are checked + const AuthTypeHandler: FormController< + | typeof SpcpFactory.createFormWithSpcpSession + | typeof MyInfoFactory.createFormWithMyInfo + > = { + [AuthType.SP]: SpcpFactory.createFormWithSpcpSession, + [AuthType.CP]: SpcpFactory.createFormWithSpcpSession, + [AuthType.MyInfo]: MyInfoFactory.createFormWithMyInfo, + [AuthType.NIL]: (form: IPopulatedForm) => + okAsync({ form: form.getPublicView() }), + } + + return AuthTypeHandler[authType](form, cookies) +} diff --git a/src/app/modules/form/public-form/public-form.types.ts b/src/app/modules/form/public-form/public-form.types.ts index 0d82a96163..38b9d09d53 100644 --- a/src/app/modules/form/public-form/public-form.types.ts +++ b/src/app/modules/form/public-form/public-form.types.ts @@ -28,5 +28,5 @@ export interface IPublicFormView { form: PossiblyPrefilledPublicForm spcpSession?: { userName: string } isIntranetUser?: boolean - myInfoError?: boolean + myInfoError?: true } diff --git a/src/app/utils/handle-mongo-error.ts b/src/app/utils/handle-mongo-error.ts index c2ca6ca243..2950872ab7 100644 --- a/src/app/utils/handle-mongo-error.ts +++ b/src/app/utils/handle-mongo-error.ts @@ -99,3 +99,15 @@ export const transformMongoError = (error: unknown): PossibleDatabaseError => { return new DatabaseError(errorMessage) } + +export const isMongoError = (error: Error): boolean => { + switch (error.constructor) { + case DatabaseConflictError: + case DatabaseError: + case DatabasePayloadSizeError: + case DatabaseValidationError: + return true + default: + return false + } +} diff --git a/src/types/form.ts b/src/types/form.ts index 532da21041..06fd729b18 100644 --- a/src/types/form.ts +++ b/src/types/form.ts @@ -268,18 +268,9 @@ export type FormMetaView = Pick< } /** - * Type guard for whether a populated form is email mode - * @param form Form document to check - */ -export const isEmailModeForm = ( - form: IPopulatedForm, -): form is IPopulatedEmailForm => { - return form.responseMode === ResponseMode.Email -} - -/** - * Mapping type between authType into an output function to ensure that the indexed type has transformations for all forms + * Mapping type between authType into an output function to ensure that the indexed type has transformations for all forms. + * F is a function type that maps authType into the output */ export type FormController = { - [K in keyof typeof AuthType]: () => F + [K in keyof typeof AuthType]: F } From 8b1f740c0a92a39fe595372f1e9cbe3b9ef10e5b Mon Sep 17 00:00:00 2001 From: seaerchin Date: Wed, 31 Mar 2021 23:10:38 +0800 Subject: [PATCH 48/86] test(public-form/controller): updates tests --- .../__tests__/public-form.controller.spec.ts | 386 +++++++++--------- 1 file changed, 199 insertions(+), 187 deletions(-) diff --git a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts index a0926b932a..e0cf25ddd0 100644 --- a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts +++ b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts @@ -4,7 +4,6 @@ import { merge } from 'lodash' import mongoose from 'mongoose' import { err, errAsync, ok, okAsync } from 'neverthrow' import querystring from 'querystring' -import { MockedObject } from 'ts-jest/dist/utils/testing' import { mocked } from 'ts-jest/utils' import getFormFeedbackModel from 'src/app/models/form_feedback.server.model' @@ -217,7 +216,7 @@ describe('public-form.controller', () => { expect(mockRes.json).toHaveBeenCalledWith({ message: 'Gone' }) }) - it('should return 500 when databse errors occur', async () => { + it('should return 500 when database errors occur', async () => { // Arrange const mockRes = expressHandler.mockResponse() const mockErrorString = 'Form feedback could not be created' @@ -456,10 +455,10 @@ describe('public-form.controller', () => { state: MyInfoCookieState.Success, } - let MOCK_REQ_WITH_COOKIES: ReturnType + let mockReqWithCookies: ReturnType beforeEach(() => { - MOCK_REQ_WITH_COOKIES = expressHandler.mockRequest({ + mockReqWithCookies = expressHandler.mockRequest({ params: { formId: MOCK_FORM_ID, }, @@ -478,19 +477,15 @@ describe('public-form.controller', () => { MockFormService.setIsIntranetFormAccess.mockImplementation( (_, publicForm) => ok({ ...publicForm, isIntranetUser: false }), ) - - MockFormService.setMyInfoError.mockImplementation((publicForm) => - ok({ ...publicForm, myInfoError: false }), - ) }) it('should return 200 when there is no AuthType on the request', async () => { // Arrange - const MOCK_NIL_AUTH_FORM = (mocked({ + const MOCK_NIL_AUTH_FORM = ({ ...BASE_FORM, authType: AuthType.NIL, - }) as unknown) as MockedObject - const MOCK_RES = expressHandler.mockResponse() + } as unknown) as IPopulatedForm + const mockRes = expressHandler.mockResponse() MockAuthService.getFormIfPublic.mockReturnValueOnce( okAsync(MOCK_NIL_AUTH_FORM), @@ -498,123 +493,117 @@ describe('public-form.controller', () => { MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( okAsync(MOCK_NIL_AUTH_FORM), ) + MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( + () => + okAsync({ + form: MOCK_NIL_AUTH_FORM.getPublicView(), + }), + ) // Act await PublicFormController.handleGetPublicForm( MOCK_REQ, - MOCK_RES, + mockRes, jest.fn(), ) // Assert - expect(MOCK_RES.json).toHaveBeenCalledWith({ + expect(mockRes.json).toHaveBeenCalledWith({ form: MOCK_NIL_AUTH_FORM.getPublicView(), isIntranetUser: false, - myInfoError: false, }) }) it('should return 200 when client authenticates using SP', async () => { // Arrange - const MOCK_SP_AUTH_FORM = (mocked({ + const MOCK_SP_AUTH_FORM = ({ ...BASE_FORM, authType: AuthType.SP, - }) as unknown) as MockedObject - const MOCK_RES = expressHandler.mockResponse() - - MockSpcpFactory.createFormWithSpcpSession.mockReturnValueOnce( - okAsync({ - form: MOCK_SP_AUTH_FORM.getPublicView(), - spcpSession: { userName: MOCK_JWT_PAYLOAD.userName }, - }), - ) + } as unknown) as IPopulatedForm + const MOCK_PUBLIC_SP_FORM_VIEW = { + form: MOCK_SP_AUTH_FORM.getPublicView(), + spcpSession: { userName: MOCK_JWT_PAYLOAD.userName }, + } + const mockRes = expressHandler.mockResponse() - MockSpcpFactory.getSpcpSession.mockReturnValueOnce( - okAsync(MOCK_JWT_PAYLOAD), - ) MockAuthService.getFormIfPublic.mockReturnValueOnce( okAsync(MOCK_SP_AUTH_FORM), ) MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( okAsync(MOCK_SP_AUTH_FORM), ) + MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( + () => okAsync(MOCK_PUBLIC_SP_FORM_VIEW), + ) // Act await PublicFormController.handleGetPublicForm( MOCK_REQ, - MOCK_RES, + mockRes, jest.fn(), ) // Assert - expect(MOCK_RES.json).toHaveBeenCalledWith({ - form: MOCK_SP_AUTH_FORM.getPublicView(), - spcpSession: { - userName: MOCK_JWT_PAYLOAD.userName, - }, + expect(mockRes.json).toHaveBeenCalledWith({ + ...MOCK_PUBLIC_SP_FORM_VIEW, isIntranetUser: false, - myInfoError: false, }) }) it('should return 200 when client authenticates using CP', async () => { // Arrange - const MOCK_CP_AUTH_FORM = (mocked({ + const MOCK_CP_AUTH_FORM = ({ ...BASE_FORM, authType: AuthType.CP, - }) as unknown) as MockedObject - const MOCK_RES = expressHandler.mockResponse() - - MockSpcpFactory.createFormWithSpcpSession.mockReturnValueOnce( - okAsync({ - form: MOCK_CP_AUTH_FORM.getPublicView(), - spcpSession: { userName: MOCK_JWT_PAYLOAD.userName }, - }), - ) + } as unknown) as IPopulatedForm + const MOCK_PUBLIC_CP_FORM_VIEW = { + form: MOCK_CP_AUTH_FORM.getPublicView(), + spcpSession: { userName: MOCK_JWT_PAYLOAD.userName }, + } + const mockRes = expressHandler.mockResponse() - MockSpcpFactory.getSpcpSession.mockReturnValueOnce( - okAsync(MOCK_JWT_PAYLOAD), - ) MockAuthService.getFormIfPublic.mockReturnValueOnce( okAsync(MOCK_CP_AUTH_FORM), ) MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( okAsync(MOCK_CP_AUTH_FORM), ) + MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( + () => okAsync(MOCK_PUBLIC_CP_FORM_VIEW), + ) // Act await PublicFormController.handleGetPublicForm( MOCK_REQ, - MOCK_RES, + mockRes, jest.fn(), ) // Assert - expect(MOCK_RES.json).toHaveBeenCalledWith({ - form: MOCK_CP_AUTH_FORM.getPublicView(), - spcpSession: { - userName: MOCK_JWT_PAYLOAD.userName, - }, + expect(mockRes.json).toHaveBeenCalledWith({ + ...MOCK_PUBLIC_CP_FORM_VIEW, isIntranetUser: false, - myInfoError: false, }) }) it('should return 200 when client authenticates using MyInfo', async () => { // Arrange - const MOCK_MYINFO_AUTH_FORM = (mocked({ + const MOCK_MYINFO_AUTH_FORM = ({ ...BASE_FORM, esrvcId: 'thing', authType: AuthType.MyInfo, toJSON: jest.fn().mockReturnValue(BASE_FORM), - }) as unknown) as MockedObject - const MOCK_RES = expressHandler.mockResponse({ - clearCookie: jest.fn().mockReturnThis(), - }) - + } as unknown) as IPopulatedForm const MOCK_MYINFO_DATA = new MyInfoData({ uinFin: 'i am a fish', } as IPersonResponse) + const MOCK_PUBLIC_MYINFO_FORM_VIEW = { + form: MOCK_MYINFO_AUTH_FORM.getPublicView(), + spcpSession: { userName: MOCK_MYINFO_DATA.getUinFin() }, + } + const mockRes = expressHandler.mockResponse({ + clearCookie: jest.fn().mockReturnThis(), + }) MockAuthService.getFormIfPublic.mockReturnValueOnce( okAsync(MOCK_MYINFO_AUTH_FORM), @@ -622,37 +611,33 @@ describe('public-form.controller', () => { MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( okAsync(MOCK_MYINFO_AUTH_FORM), ) - MockMyInfoFactory.createFormWithMyInfo.mockReturnValueOnce( - okAsync({ - form: MOCK_MYINFO_AUTH_FORM.getPublicView(), - spcpSession: { userName: MOCK_MYINFO_DATA.getUinFin() }, - }), + + MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( + () => okAsync(MOCK_PUBLIC_MYINFO_FORM_VIEW), ) // Act await PublicFormController.handleGetPublicForm( - MOCK_REQ_WITH_COOKIES, - MOCK_RES, + mockReqWithCookies, + mockRes, jest.fn(), ) // Assert - expect(MOCK_RES.clearCookie).not.toHaveBeenCalled() - expect(MOCK_RES.json).toHaveBeenCalledWith({ - form: MOCK_MYINFO_AUTH_FORM.getPublicView(), - spcpSession: { userName: MOCK_MYINFO_DATA.getUinFin() }, + expect(mockRes.clearCookie).not.toHaveBeenCalled() + expect(mockRes.json).toHaveBeenCalledWith({ + ...MOCK_PUBLIC_MYINFO_FORM_VIEW, isIntranetUser: false, - myInfoError: false, }) }) }) // Errors describe('errors in myInfo', () => { - const MOCK_MYINFO_FORM = (mocked({ + const MOCK_MYINFO_FORM = ({ ...BASE_FORM, authType: AuthType.MyInfo, - }) as unknown) as IPopulatedForm + } as unknown) as IPopulatedForm // Setup because this gets invoked at the start of the controller to decide which branch to take beforeAll(() => { @@ -660,6 +645,10 @@ describe('public-form.controller', () => { okAsync(MOCK_MYINFO_FORM), ) + MockFormService.setIsIntranetFormAccess.mockImplementation( + (ip, publicForm) => ok({ ...publicForm, isIntranetUser: false }), + ) + MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValue( okAsync(MOCK_MYINFO_FORM), ) @@ -668,51 +657,51 @@ describe('public-form.controller', () => { it('should return 200 but the response should have cookies cleared and myInfoError when the request has no cookie', async () => { // Arrange // 1. Mock the response and calls - const MOCK_RES = expressHandler.mockResponse({ + const mockRes = expressHandler.mockResponse({ clearCookie: jest.fn().mockReturnThis(), }) - MockMyInfoFactory.createFormWithMyInfo.mockReturnValueOnce( - errAsync(new MyInfoMissingAccessTokenError()), + MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( + () => errAsync(new MyInfoMissingAccessTokenError()), ) // Act await PublicFormController.handleGetPublicForm( MOCK_REQ, - MOCK_RES, + mockRes, jest.fn(), ) // Assert - expect(MOCK_RES.clearCookie).toHaveBeenCalled() - expect(MOCK_RES.json).toHaveBeenCalledWith({ + expect(mockRes.clearCookie).toHaveBeenCalled() + expect(mockRes.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_FORM.getPublicView(), - myInfoError: true, isIntranetUser: false, + myInfoError: true, }) }) it('should return 200 but the response should have cookies cleared and myInfoError when the cookie cannot be validated', async () => { // Arrange // 1. Mock the response and calls - const MOCK_RES = expressHandler.mockResponse({ + const mockRes = expressHandler.mockResponse({ clearCookie: jest.fn().mockReturnThis(), }) - MockMyInfoFactory.createFormWithMyInfo.mockReturnValueOnce( - errAsync(new MyInfoCookieStateError()), + MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( + () => errAsync(new MyInfoCookieStateError()), ) // Act await PublicFormController.handleGetPublicForm( - MOCK_REQ_WITH_COOKIES, - MOCK_RES, + mockReqWithCookies, + mockRes, jest.fn(), ) // Assert - expect(MOCK_RES.clearCookie).toHaveBeenCalled() - expect(MOCK_RES.json).toHaveBeenCalledWith({ + expect(mockRes.clearCookie).toHaveBeenCalled() + expect(mockRes.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_FORM.getPublicView(), isIntranetUser: false, myInfoError: true, @@ -722,24 +711,24 @@ describe('public-form.controller', () => { it('should return 200 but the response should have cookies cleared and myInfoError if the form cannot be validated', async () => { // Arrange // 1. Mock the response and calls - const MOCK_RES = expressHandler.mockResponse({ + const mockRes = expressHandler.mockResponse({ clearCookie: jest.fn().mockReturnThis(), }) - MockMyInfoFactory.createFormWithMyInfo.mockReturnValueOnce( - errAsync(new MyInfoAuthTypeError()), + MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( + () => errAsync(new MyInfoAuthTypeError()), ) // Act await PublicFormController.handleGetPublicForm( - MOCK_REQ_WITH_COOKIES, - MOCK_RES, + mockReqWithCookies, + mockRes, jest.fn(), ) // Assert - expect(MOCK_RES.clearCookie).toHaveBeenCalled() - expect(MOCK_RES.json).toHaveBeenCalledWith({ + expect(mockRes.clearCookie).toHaveBeenCalled() + expect(mockRes.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_FORM.getPublicView(), isIntranetUser: false, myInfoError: true, @@ -749,24 +738,24 @@ describe('public-form.controller', () => { it('should return 200 but the response should have cookies cleared and myInfoError when the form has no eservcId', async () => { // Arrange // 1. Mock the response and calls - const MOCK_RES = expressHandler.mockResponse({ + const mockRes = expressHandler.mockResponse({ clearCookie: jest.fn().mockReturnThis(), }) - MockMyInfoFactory.createFormWithMyInfo.mockReturnValueOnce( - errAsync(new MyInfoNoESrvcIdError()), + MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( + () => errAsync(new MyInfoNoESrvcIdError()), ) // Act await PublicFormController.handleGetPublicForm( - MOCK_REQ_WITH_COOKIES, - MOCK_RES, + mockReqWithCookies, + mockRes, jest.fn(), ) // Assert - expect(MOCK_RES.clearCookie).toHaveBeenCalled() - expect(MOCK_RES.json).toHaveBeenCalledWith({ + expect(mockRes.clearCookie).toHaveBeenCalled() + expect(mockRes.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_FORM.getPublicView(), isIntranetUser: false, myInfoError: true, @@ -776,28 +765,29 @@ describe('public-form.controller', () => { it('should return 200 but the response should have cookies cleared and myInfoError when the form could not be filled', async () => { // Arrange // 1. Mock the response and calls - const MOCK_RES = expressHandler.mockResponse({ + const mockRes = expressHandler.mockResponse({ clearCookie: jest.fn().mockReturnThis(), }) - MockMyInfoFactory.createFormWithMyInfo.mockReturnValueOnce( - errAsync( - new MissingFeatureError( - 'testing is the missing feature' as FeatureNames, + MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( + () => + errAsync( + new MissingFeatureError( + 'testing is the missing feature' as FeatureNames, + ), ), - ), ) // Act await PublicFormController.handleGetPublicForm( - MOCK_REQ_WITH_COOKIES, - MOCK_RES, + mockReqWithCookies, + mockRes, jest.fn(), ) // Assert - expect(MOCK_RES.clearCookie).toHaveBeenCalled() - expect(MOCK_RES.json).toHaveBeenCalledWith({ + expect(mockRes.clearCookie).toHaveBeenCalled() + expect(mockRes.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_FORM.getPublicView(), isIntranetUser: false, myInfoError: true, @@ -807,30 +797,24 @@ describe('public-form.controller', () => { it('should return 200 but the response should have cookies cleared and myInfoError if a database error occurs while saving hashes', async () => { // Arrange // 1. Mock the response and calls - const MOCK_RES = expressHandler.mockResponse({ + const mockRes = expressHandler.mockResponse({ clearCookie: jest.fn().mockReturnThis(), }) - MockAuthService.getFormIfPublic.mockReturnValueOnce( - okAsync(MOCK_MYINFO_FORM), - ) - MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( - okAsync(MOCK_MYINFO_FORM), - ) - MockMyInfoFactory.createFormWithMyInfo.mockReturnValueOnce( - errAsync(new DatabaseError()), + MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( + () => errAsync(new DatabaseError()), ) // Act await PublicFormController.handleGetPublicForm( - MOCK_REQ_WITH_COOKIES, - MOCK_RES, + mockReqWithCookies, + mockRes, jest.fn(), ) // Assert - expect(MOCK_RES.clearCookie).toHaveBeenCalled() - expect(MOCK_RES.json).toHaveBeenCalledWith({ + expect(mockRes.clearCookie).toHaveBeenCalled() + expect(mockRes.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_FORM.getPublicView(), isIntranetUser: false, myInfoError: true, @@ -839,14 +823,14 @@ describe('public-form.controller', () => { }) describe('errors in spcp', () => { - const MOCK_SPCP_FORM = (mocked({ + const MOCK_SPCP_FORM = ({ ...BASE_FORM, authType: AuthType.SP, - }) as unknown) as MockedObject + } as unknown) as IPopulatedForm it('should return 200 with the form but without a spcpSession when the JWT token could not be found', async () => { // Arrange // 1. Mock the response and calls - const MOCK_RES = expressHandler.mockResponse() + const mockRes = expressHandler.mockResponse() MockAuthService.getFormIfPublic.mockReturnValueOnce( okAsync(MOCK_SPCP_FORM), @@ -854,44 +838,44 @@ describe('public-form.controller', () => { MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( okAsync(MOCK_SPCP_FORM), ) - MockSpcpFactory.createFormWithSpcpSession.mockReturnValueOnce( - errAsync(new MissingJwtError()), + MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( + () => errAsync(new MissingJwtError()), ) // Act // 2. GET the endpoint await PublicFormController.handleGetPublicForm( MOCK_REQ, - MOCK_RES, + mockRes, jest.fn(), ) // Assert // Status should be 200 // json object should only have form property - expect(MOCK_RES.json).toHaveBeenCalledWith({ + expect(mockRes.json).toHaveBeenCalledWith({ form: MOCK_SPCP_FORM.getPublicView(), - isIntranetUser: false, - myInfoError: false, }) }) }) describe('errors in form retrieval', () => { + const MOCK_ERROR_STRING = 'mockingbird' + it('should return 500 when a database error occurs', async () => { // Arrange // 1. Mock the response - const MOCK_RES = expressHandler.mockResponse() + const mockRes = expressHandler.mockResponse() // 2. Mock the call to retrieve the form MockAuthService.getFormIfPublic.mockReturnValueOnce( - errAsync(new DatabaseError()), + errAsync(new DatabaseError(MOCK_ERROR_STRING)), ) // Act await PublicFormController.handleGetPublicForm( MOCK_REQ, - MOCK_RES, + mockRes, jest.fn(), ) @@ -904,23 +888,27 @@ describe('public-form.controller', () => { expect( MockFormService.checkFormSubmissionLimitAndDeactivateForm, ).not.toHaveBeenCalled() - expect(MOCK_RES.status).toHaveBeenCalledWith(500) + expect(mockRes.status).toHaveBeenCalledWith(500) + expect(mockRes.json).toHaveBeenCalledWith({ + message: MOCK_ERROR_STRING, + }) }) it('should return 404 when the form is not found', async () => { // Arrange // 1. Mock the response - const MOCK_RES = expressHandler.mockResponse() + const mockRes = expressHandler.mockResponse() + const MOCK_ERROR_STRING = 'Your form was eaten up by a monster' // 2. Mock the call to retrieve the form MockAuthService.getFormIfPublic.mockReturnValueOnce( - errAsync(new FormNotFoundError()), + errAsync(new FormNotFoundError(MOCK_ERROR_STRING)), ) // Act await PublicFormController.handleGetPublicForm( MOCK_REQ, - MOCK_RES, + mockRes, jest.fn(), ) @@ -933,25 +921,26 @@ describe('public-form.controller', () => { expect( MockFormService.checkFormSubmissionLimitAndDeactivateForm, ).not.toHaveBeenCalled() - expect(MOCK_RES.status).toHaveBeenCalledWith(404) + expect(mockRes.status).toHaveBeenCalledWith(404) + expect(mockRes.json).toHaveBeenCalledWith({ + message: MOCK_ERROR_STRING, + }) }) it('should return 404 when the form is private and not accessible by the public', async () => { // Arrange // 1. Mock the response - const MOCK_RES = expressHandler.mockResponse() + const mockRes = expressHandler.mockResponse() // 2. Mock the call to retrieve the form MockAuthService.getFormIfPublic.mockReturnValueOnce( - errAsync( - new PrivateFormError('teehee this form is private', 'private form'), - ), + errAsync(new PrivateFormError(MOCK_ERROR_STRING, 'private form')), ) // Act await PublicFormController.handleGetPublicForm( MOCK_REQ, - MOCK_RES, + mockRes, jest.fn(), ) @@ -964,7 +953,10 @@ describe('public-form.controller', () => { expect( MockFormService.checkFormSubmissionLimitAndDeactivateForm, ).not.toHaveBeenCalled() - expect(MOCK_RES.status).toHaveBeenCalledWith(404) + expect(mockRes.status).toHaveBeenCalledWith(404) + expect(mockRes.json).toHaveBeenCalledWith({ + message: MOCK_ERROR_STRING, + }) }) }) @@ -974,19 +966,13 @@ describe('public-form.controller', () => { rememberMe: false, } - beforeAll(() => { - MockFormService.setMyInfoError.mockImplementation((publicForm) => - ok({ ...publicForm, myInfoError: false }), - ) - }) - - it('should return 200 with isIntranetUser set to false when a user accesses the form with no authType set', async () => { + it('should return 200 with isIntranetUser set to false when a user accesses a form from outside intranet', async () => { // Arrange - const MOCK_NIL_AUTH_FORM = (mocked({ + const MOCK_NIL_AUTH_FORM = ({ ...BASE_FORM, authType: AuthType.NIL, - }) as unknown) as MockedObject - const MOCK_RES = expressHandler.mockResponse() + } as unknown) as IPopulatedForm + const mockRes = expressHandler.mockResponse() MockAuthService.getFormIfPublic.mockReturnValueOnce( okAsync(MOCK_NIL_AUTH_FORM), @@ -994,33 +980,37 @@ describe('public-form.controller', () => { MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( okAsync(MOCK_NIL_AUTH_FORM), ) - MockFormService.setIsIntranetFormAccess.mockImplementation( (_, publicForm) => ok({ ...publicForm, isIntranetUser: false }), ) + MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( + () => + okAsync({ + form: MOCK_NIL_AUTH_FORM.getPublicView(), + }), + ) // Act await PublicFormController.handleGetPublicForm( MOCK_REQ, - MOCK_RES, + mockRes, jest.fn(), ) // Assert - expect(MOCK_RES.json).toHaveBeenCalledWith({ + expect(mockRes.json).toHaveBeenCalledWith({ form: MOCK_NIL_AUTH_FORM.getPublicView(), isIntranetUser: false, - myInfoError: false, }) }) - it('should return 200 with isIntranetUser set to true when a user accesses the form using SP', async () => { + it('should return 200 with isIntranetUser set to true when a intranet user accesses an AuthType.SP form', async () => { // Arrange - const MOCK_SP_AUTH_FORM = (mocked({ + const MOCK_SP_AUTH_FORM = ({ ...BASE_FORM, authType: AuthType.SP, - }) as unknown) as MockedObject - const MOCK_RES = expressHandler.mockResponse() + } as unknown) as IPopulatedForm + const mockRes = expressHandler.mockResponse() MockSpcpFactory.createFormWithSpcpSession.mockReturnValueOnce( okAsync({ @@ -1028,11 +1018,9 @@ describe('public-form.controller', () => { spcpSession: { userName: MOCK_JWT_PAYLOAD.userName }, }), ) - MockFormService.setIsIntranetFormAccess.mockImplementation( (_, publicForm) => ok({ ...publicForm, isIntranetUser: true }), ) - MockSpcpFactory.getSpcpSession.mockReturnValueOnce( okAsync(MOCK_JWT_PAYLOAD), ) @@ -1042,32 +1030,40 @@ describe('public-form.controller', () => { MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( okAsync(MOCK_SP_AUTH_FORM), ) + MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( + () => + okAsync({ + form: MOCK_SP_AUTH_FORM.getPublicView(), + spcpSession: { + userName: MOCK_JWT_PAYLOAD.userName, + }, + }), + ) // Act await PublicFormController.handleGetPublicForm( MOCK_REQ, - MOCK_RES, + mockRes, jest.fn(), ) // Assert - expect(MOCK_RES.json).toHaveBeenCalledWith({ + expect(mockRes.json).toHaveBeenCalledWith({ form: MOCK_SP_AUTH_FORM.getPublicView(), spcpSession: { userName: MOCK_JWT_PAYLOAD.userName, }, isIntranetUser: true, - myInfoError: false, }) }) - it('should return 200 with isIntranetUser set to true when a user accesses the form using CP', async () => { + it('should return 200 with isIntranetUser set to true when a intranet user accesses an AuthType.CP form', async () => { // Arrange - const MOCK_CP_AUTH_FORM = (mocked({ + const MOCK_CP_AUTH_FORM = ({ ...BASE_FORM, authType: AuthType.CP, - }) as unknown) as MockedObject - const MOCK_RES = expressHandler.mockResponse() + } as unknown) as IPopulatedForm + const mockRes = expressHandler.mockResponse() MockSpcpFactory.createFormWithSpcpSession.mockReturnValueOnce( okAsync({ @@ -1089,34 +1085,42 @@ describe('public-form.controller', () => { MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( okAsync(MOCK_CP_AUTH_FORM), ) + MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( + () => + okAsync({ + form: MOCK_CP_AUTH_FORM.getPublicView(), + spcpSession: { + userName: MOCK_JWT_PAYLOAD.userName, + }, + }), + ) // Act await PublicFormController.handleGetPublicForm( MOCK_REQ, - MOCK_RES, + mockRes, jest.fn(), ) // Assert - expect(MOCK_RES.json).toHaveBeenCalledWith({ + expect(mockRes.json).toHaveBeenCalledWith({ form: MOCK_CP_AUTH_FORM.getPublicView(), spcpSession: { userName: MOCK_JWT_PAYLOAD.userName, }, isIntranetUser: true, - myInfoError: false, }) }) - it('should return 200 with isIntranetUser set to true when a user accesses the form using MyInfo', async () => { + it('should return 200 with isIntranetUser set to true when a intranet user accesses an AuthType.MyInfo form', async () => { // Arrange - const MOCK_MYINFO_AUTH_FORM = (mocked({ + const MOCK_MYINFO_AUTH_FORM = ({ ...BASE_FORM, esrvcId: 'thing', authType: AuthType.MyInfo, toJSON: jest.fn().mockReturnValue(BASE_FORM), - }) as unknown) as MockedObject - const MOCK_RES = expressHandler.mockResponse({ + } as unknown) as IPopulatedForm + const mockRes = expressHandler.mockResponse({ clearCookie: jest.fn().mockReturnThis(), }) @@ -1139,21 +1143,29 @@ describe('public-form.controller', () => { MockFormService.setIsIntranetFormAccess.mockImplementation( (_, publicForm) => ok({ ...publicForm, isIntranetUser: true }), ) + MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( + () => + okAsync({ + form: MOCK_MYINFO_AUTH_FORM.getPublicView(), + spcpSession: { + userName: MOCK_MYINFO_DATA.getUinFin(), + }, + }), + ) // Act await PublicFormController.handleGetPublicForm( - MOCK_REQ_WITH_COOKIES, - MOCK_RES, + mockReqWithCookies, + mockRes, jest.fn(), ) // Assert - expect(MOCK_RES.clearCookie).not.toHaveBeenCalled() - expect(MOCK_RES.json).toHaveBeenCalledWith({ + expect(mockRes.clearCookie).not.toHaveBeenCalled() + expect(mockRes.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_AUTH_FORM.getPublicView(), spcpSession: { userName: MOCK_MYINFO_DATA.getUinFin() }, isIntranetUser: true, - myInfoError: false, }) }) }) From 18157cb978dfb25779f57358007f6afc89bbf9ef Mon Sep 17 00:00:00 2001 From: seaerchin Date: Wed, 31 Mar 2021 23:13:35 +0800 Subject: [PATCH 49/86] refactor(public-forms/server/routes): swaps to new controller for express middleware --- .../modules/form/public-form/public-form.service.ts | 6 +++--- src/app/routes/public-forms.server.routes.js | 12 +----------- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/src/app/modules/form/public-form/public-form.service.ts b/src/app/modules/form/public-form/public-form.service.ts index 731185556e..87e1bba040 100644 --- a/src/app/modules/form/public-form/public-form.service.ts +++ b/src/app/modules/form/public-form/public-form.service.ts @@ -121,9 +121,9 @@ export const getAuthTypeHandlerForForm = (authType: AuthType) => ( | typeof SpcpFactory.createFormWithSpcpSession | typeof MyInfoFactory.createFormWithMyInfo > = { - [AuthType.SP]: SpcpFactory.createFormWithSpcpSession, - [AuthType.CP]: SpcpFactory.createFormWithSpcpSession, - [AuthType.MyInfo]: MyInfoFactory.createFormWithMyInfo, + [AuthType.SP]: () => SpcpFactory.createFormWithSpcpSession(form, cookies), + [AuthType.CP]: () => SpcpFactory.createFormWithSpcpSession(form, cookies), + [AuthType.MyInfo]: () => MyInfoFactory.createFormWithMyInfo(form, cookies), [AuthType.NIL]: (form: IPopulatedForm) => okAsync({ form: form.getPublicView() }), } diff --git a/src/app/routes/public-forms.server.routes.js b/src/app/routes/public-forms.server.routes.js index 15b27cd048..57d5c80083 100644 --- a/src/app/routes/public-forms.server.routes.js +++ b/src/app/routes/public-forms.server.routes.js @@ -4,14 +4,11 @@ * Module dependencies. */ const forms = require('../../app/controllers/forms.server.controller') -const publicForms = require('../modules/form/public-form/public-form.middlewares') -const MyInfoMiddleware = require('../modules/myinfo/myinfo.middleware') const { celebrate, Joi, Segments } = require('celebrate') const { CaptchaFactory } = require('../services/captcha/captcha.factory') const { limitRate } = require('../utils/limit-rate') const { rateLimitConfig } = require('../../config/config') const PublicFormController = require('../modules/form/public-form/public-form.controller') -const SpcpController = require('../modules/spcp/spcp.controller') const { BasicField } = require('../../types') const EncryptSubmissionController = require('../modules/submission/encrypt-submission/encrypt-submission.controller') @@ -127,14 +124,7 @@ module.exports = function (app) { */ app .route('/:formId([a-fA-F0-9]{24})/publicform') - .get( - forms.formById, - publicForms.isFormPublicCheck, - publicForms.checkFormSubmissionLimitAndDeactivate, - SpcpController.addSpcpSessionInfo, - MyInfoMiddleware.addMyInfo, - forms.read(forms.REQUEST_TYPE.PUBLIC), - ) + .get(PublicFormController.handleGetPublicForm) /** * On preview, submit a form response, and stores the encrypted contents. Optionally, an autoreply From 7fc19ac0e080a56cc797c2688ac1ae439e98509e Mon Sep 17 00:00:00 2001 From: seaerchin Date: Thu, 1 Apr 2021 14:16:32 +0800 Subject: [PATCH 50/86] fix(myinfo/util): added cookie access check --- src/app/modules/myinfo/myinfo.util.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/app/modules/myinfo/myinfo.util.ts b/src/app/modules/myinfo/myinfo.util.ts index 938ae4deb8..b6541556ec 100644 --- a/src/app/modules/myinfo/myinfo.util.ts +++ b/src/app/modules/myinfo/myinfo.util.ts @@ -381,16 +381,19 @@ export const extractSuccessfulCookie = ( * @return ok(cookie) the successful myInfoCookie * @return err(MyInfoMissingAccessTokenError) if myInfoCookie is not present on the request * @return err(MyInfoCookieStateError) if the extracted myInfoCookie was in an error state + * @return err(MyInfoCookieAccessError) if the cookie has been accessed before */ export const extractSuccessfulMyInfoCookie = ( cookies: Record, ): Result< MyInfoSuccessfulCookiePayload, - MyInfoCookieStateError | MyInfoMissingAccessTokenError + | MyInfoCookieStateError + | MyInfoMissingAccessTokenError + | MyInfoCookieAccessError > => - extractMyInfoCookie(cookies).andThen((cookiePayload) => - extractSuccessfulCookie(cookiePayload), - ) + extractMyInfoCookie(cookies) + .andThen((cookiePayload) => extractSuccessfulCookie(cookiePayload)) + .andThen((cookiePayload) => checkMyInfoCookieUsedCount(cookiePayload)) /** * checks if a MyInfo cookie has been used From cf306b6a30d033abbc75c85cff79cb642b8eedc7 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Thu, 1 Apr 2021 14:20:20 +0800 Subject: [PATCH 51/86] fix(form/service): adds error recovery for missing feature error when checking intranet access --- src/app/modules/form/form.service.ts | 56 +++++++++++++++++----------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/src/app/modules/form/form.service.ts b/src/app/modules/form/form.service.ts index 6ae5190a1f..95c2a8a1c8 100644 --- a/src/app/modules/form/form.service.ts +++ b/src/app/modules/form/form.service.ts @@ -1,6 +1,5 @@ import mongoose from 'mongoose' import { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow' -import { SetRequired } from 'type-fest' import { createLoggerWithLabel } from '../../../config/logger' import { @@ -23,7 +22,11 @@ import { PossibleDatabaseError, transformMongoError, } from '../../utils/handle-mongo-error' -import { ApplicationError, DatabaseError } from '../core/core.errors' +import { + ApplicationError, + DatabaseError, + MissingFeatureError, +} from '../core/core.errors' import { IPublicFormView } from './public-form/public-form.types' import { @@ -251,25 +254,34 @@ export const getFormModelByResponseMode = ( export const setIsIntranetFormAccess = ( ip: string, publicFormView: IPublicFormView, -): Result, ApplicationError> => { - return IntranetFactory.isIntranetIp(ip).andThen((isIntranetUser) => { - // Warn if form is being accessed from within intranet - // and the form has authentication set - if ( - isIntranetUser && - [AuthType.SP, AuthType.CP, AuthType.MyInfo].includes( - publicFormView.form.authType, - ) - ) { - logger.warn({ - message: - 'Attempting to access SingPass, CorpPass or MyInfo form from intranet', - meta: { - action: 'read', - formId: publicFormView.form._id, +): Result => { + return ( + IntranetFactory.isIntranetIp(ip) + // NOTE: Need to annotate types because initializing the factory can cause MissingFeatureError + .andThen( + (isIntranetUser) => { + // Warn if form is being accessed from within intranet + // and the form has authentication set + if ( + isIntranetUser && + [AuthType.SP, AuthType.CP, AuthType.MyInfo].includes( + publicFormView.form.authType, + ) + ) { + logger.warn({ + message: + 'Attempting to access SingPass, CorpPass or MyInfo form from intranet', + meta: { + action: 'read', + formId: publicFormView.form._id, + }, + }) + } + return ok({ ...publicFormView, isIntranetUser }) }, - }) - } - return ok({ ...publicFormView, isIntranetUser }) - }) + ) + .orElse((error) => + error instanceof MissingFeatureError ? ok(publicFormView) : err(error), + ) + ) } From 98aca7041417a5b3cd1da22c90774775d96f8dd0 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Thu, 1 Apr 2021 14:21:05 +0800 Subject: [PATCH 52/86] refactor(public-form/controller): tightened logic for myinfo error --- .../__tests__/public-form.controller.spec.ts | 32 ++++++++++++++++--- .../public-form/public-form.controller.ts | 15 +++++++-- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts index e0cf25ddd0..aa33208c97 100644 --- a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts +++ b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts @@ -14,6 +14,7 @@ import { import { MyInfoData } from 'src/app/modules/myinfo/myinfo.adapter' import { MyInfoAuthTypeError, + MyInfoCookieAccessError, MyInfoMissingAccessTokenError, MyInfoNoESrvcIdError, } from 'src/app/modules/myinfo/myinfo.errors' @@ -654,7 +655,7 @@ describe('public-form.controller', () => { ) }) - it('should return 200 but the response should have cookies cleared and myInfoError when the request has no cookie', async () => { + it('should return 200 but the response should have cookies cleared without myInfoError when the request has no cookie', async () => { // Arrange // 1. Mock the response and calls const mockRes = expressHandler.mockResponse({ @@ -676,8 +677,31 @@ describe('public-form.controller', () => { expect(mockRes.clearCookie).toHaveBeenCalled() expect(mockRes.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_FORM.getPublicView(), - isIntranetUser: false, - myInfoError: true, + }) + }) + + it('should return 200 but the response should have cookies cleared without myInfoError when the cookie has been used before', async () => { + // Arrange + // 1. Mock the response and calls + const mockRes = expressHandler.mockResponse({ + clearCookie: jest.fn().mockReturnThis(), + }) + + MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( + () => errAsync(new MyInfoCookieAccessError()), + ) + + // Act + await PublicFormController.handleGetPublicForm( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.clearCookie).toHaveBeenCalled() + expect(mockRes.json).toHaveBeenCalledWith({ + form: MOCK_MYINFO_FORM.getPublicView(), }) }) @@ -730,8 +754,8 @@ describe('public-form.controller', () => { expect(mockRes.clearCookie).toHaveBeenCalled() expect(mockRes.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_FORM.getPublicView(), - isIntranetUser: false, myInfoError: true, + isIntranetUser: false, }) }) diff --git a/src/app/modules/form/public-form/public-form.controller.ts b/src/app/modules/form/public-form/public-form.controller.ts index 77f80c9f47..1d525a05f3 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -1,6 +1,6 @@ import { RequestHandler } from 'express' import { StatusCodes } from 'http-status-codes' -import { ok } from 'neverthrow' +import { err, ok } from 'neverthrow' import querystring from 'querystring' import { createLoggerWithLabel } from '../../../../config/logger' @@ -12,6 +12,10 @@ import { MYINFO_COOKIE_NAME, MYINFO_COOKIE_OPTIONS, } from '../../myinfo/myinfo.constants' +import { + MyInfoCookieAccessError, + MyInfoMissingAccessTokenError, +} from '../../myinfo/myinfo.errors' import { MyInfoCookiePayload } from '../../myinfo/myinfo.types' import { extractSuccessfulMyInfoCookie } from '../../myinfo/myinfo.util' import { InvalidJwtError, VerifyJwtError } from '../../spcp/spcp.errors' @@ -236,13 +240,18 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( }, ) }) - .orElse(() => { + .orElse((error) => { // NOTE: This is a side-effect as there is no need for cookie if data could not be retrieved. res.clearCookie(MYINFO_COOKIE_NAME, MYINFO_COOKIE_OPTIONS) + if ( + error instanceof MyInfoMissingAccessTokenError || + error instanceof MyInfoCookieAccessError + ) { + return err(error) + } return ok({ form: publicForm, myInfoError: true }) }) } - // NOTE: Once there is a valid form retrieved from the database, // the client should always get a 200 response with the form's public view. // Additional errors should be tagged onto the response object like myInfoError. From f38ed9570764c791645e35255798c44b5fd6ce05 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Tue, 6 Apr 2021 10:35:55 +0800 Subject: [PATCH 53/86] build(package.json): added ts-essential for UnreachableCaseException --- package-lock.json | 10 +++++++++- package.json | 1 + 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 585ab0e477..ce0458f91f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17234,6 +17234,7 @@ "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.6.3.tgz", "integrity": "sha512-rOZuR0QkodZiM+UbQE5kDsJykBqWi0CL4Ec2i1nrGrUI3KO11r6Fbxskqmq3JK2NH7aW4dcccBuUujAP0ERl5w==", "dev": true, + "optional": true, "requires": { "bl": "^2.2.1", "bson": "^1.1.4", @@ -17247,7 +17248,8 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/bson/-/bson-1.1.5.tgz", "integrity": "sha512-kDuEzldR21lHciPQAIulLs1LZlCXdLziXI6Mb/TDkwXhb//UORJNPXgcRs2CuO4H0DcMkpfT3/ySsP3unoZjBg==", - "dev": true + "dev": true, + "optional": true } } }, @@ -24036,6 +24038,12 @@ "utf8-byte-length": "^1.0.1" } }, + "ts-essentials": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-7.0.1.tgz", + "integrity": "sha512-8lwh3QJtIc1UWhkQtr9XuksXu3O0YQdEE5g79guDfhCaU1FWTDIEDZ1ZSx4HTHUmlJZ8L812j3BZQ4a0aOUkSA==", + "dev": true + }, "ts-jest": { "version": "26.5.4", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-26.5.4.tgz", diff --git a/package.json b/package.json index 4959819c61..4fc14909b0 100644 --- a/package.json +++ b/package.json @@ -248,6 +248,7 @@ "supertest-session": "^4.1.0", "terser-webpack-plugin": "^1.2.3", "testcafe": "^1.13.0", + "ts-essentials": "^7.0.1", "ts-jest": "^26.5.4", "ts-loader": "^7.0.5", "ts-mock-imports": "^1.3.3", From 933b9c90a1667669f690b8302f434cd0e584507c Mon Sep 17 00:00:00 2001 From: seaerchin Date: Tue, 6 Apr 2021 10:37:15 +0800 Subject: [PATCH 54/86] refactor(forms): added new type for intranet form --- src/app/modules/form/form.service.ts | 18 ++++++++---------- .../form/public-form/public-form.types.ts | 7 ++++++- src/types/form.ts | 8 ++++++++ 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/app/modules/form/form.service.ts b/src/app/modules/form/form.service.ts index 95c2a8a1c8..57d2e016ab 100644 --- a/src/app/modules/form/form.service.ts +++ b/src/app/modules/form/form.service.ts @@ -28,7 +28,7 @@ import { MissingFeatureError, } from '../core/core.errors' -import { IPublicFormView } from './public-form/public-form.types' +import { IIntranetForm } from './public-form/public-form.types' import { FormDeletedError, FormNotFoundError, @@ -253,35 +253,33 @@ export const getFormModelByResponseMode = ( */ export const setIsIntranetFormAccess = ( ip: string, - publicFormView: IPublicFormView, -): Result => { + form: IPopulatedForm, +): Result => { return ( IntranetFactory.isIntranetIp(ip) // NOTE: Need to annotate types because initializing the factory can cause MissingFeatureError - .andThen( + .andThen( (isIntranetUser) => { // Warn if form is being accessed from within intranet // and the form has authentication set if ( isIntranetUser && - [AuthType.SP, AuthType.CP, AuthType.MyInfo].includes( - publicFormView.form.authType, - ) + [AuthType.SP, AuthType.CP, AuthType.MyInfo].includes(form.authType) ) { logger.warn({ message: 'Attempting to access SingPass, CorpPass or MyInfo form from intranet', meta: { action: 'read', - formId: publicFormView.form._id, + formId: form._id, }, }) } - return ok({ ...publicFormView, isIntranetUser }) + return ok({ form, isIntranetUser }) }, ) .orElse((error) => - error instanceof MissingFeatureError ? ok(publicFormView) : err(error), + error instanceof MissingFeatureError ? ok({ form }) : err(error), ) ) } diff --git a/src/app/modules/form/public-form/public-form.types.ts b/src/app/modules/form/public-form/public-form.types.ts index 38b9d09d53..bdbc5f8eed 100644 --- a/src/app/modules/form/public-form/public-form.types.ts +++ b/src/app/modules/form/public-form/public-form.types.ts @@ -1,6 +1,6 @@ import { ParamsDictionary } from 'express-serve-static-core' -import { IFieldSchema, PublicForm } from 'src/types' +import { IFieldSchema, IPopulatedForm, PublicForm } from 'src/types' import { IPossiblyPrefilledField } from '../../myinfo/myinfo.types' @@ -30,3 +30,8 @@ export interface IPublicFormView { isIntranetUser?: boolean myInfoError?: true } + +export interface IIntranetForm { + isIntranetUser?: boolean + form: IPopulatedForm +} diff --git a/src/types/form.ts b/src/types/form.ts index 06fd729b18..f4e8d1fdc3 100644 --- a/src/types/form.ts +++ b/src/types/form.ts @@ -7,6 +7,7 @@ import { PublicView } from './database' import { IFieldSchema, MyInfoAttribute } from './field' import { ILogicSchema } from './form_logic' import { FormLogoState, IFormLogo } from './form_logo' +import { SpcpSession } from './spcp' import { IPopulatedUser, IUserSchema, PublicUser } from './user' export enum AuthType { @@ -274,3 +275,10 @@ export type FormMetaView = Pick< export type FormController = { [K in keyof typeof AuthType]: F } + +/** + * The current session of a user who is logged in + */ +export type UserSession = { + spcpSession: SpcpSession +} From fc7da6321209ed2bbbe9efb23983442f5032499a Mon Sep 17 00:00:00 2001 From: seaerchin Date: Tue, 6 Apr 2021 10:41:28 +0800 Subject: [PATCH 55/86] refactor(spcp/service): updated service methods --- src/app/modules/spcp/spcp.factory.ts | 2 -- src/app/modules/spcp/spcp.service.ts | 36 ++++------------------------ 2 files changed, 5 insertions(+), 33 deletions(-) diff --git a/src/app/modules/spcp/spcp.factory.ts b/src/app/modules/spcp/spcp.factory.ts index b3dd48ee6b..68ae6a34c9 100644 --- a/src/app/modules/spcp/spcp.factory.ts +++ b/src/app/modules/spcp/spcp.factory.ts @@ -20,7 +20,6 @@ interface ISpcpFactory { createJWTPayload: SpcpService['createJWTPayload'] getCookieSettings: SpcpService['getCookieSettings'] getSpcpSession: SpcpService['getSpcpSession'] - createFormWithSpcpSession: SpcpService['createFormWithSpcpSession'] } export const createSpcpFactory = ({ @@ -41,7 +40,6 @@ export const createSpcpFactory = ({ createJWTPayload: () => err(error), getCookieSettings: () => ({}), getSpcpSession: () => errAsync(error), - createFormWithSpcpSession: () => errAsync(error), } } return new SpcpService(props) diff --git a/src/app/modules/spcp/spcp.service.ts b/src/app/modules/spcp/spcp.service.ts index 860103b1b4..ffa27c379e 100644 --- a/src/app/modules/spcp/spcp.service.ts +++ b/src/app/modules/spcp/spcp.service.ts @@ -6,9 +6,8 @@ import { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow' import { ISpcpMyInfo } from '../../../config/feature-manager' import { createLoggerWithLabel } from '../../../config/logger' -import { AuthType, IPopulatedForm } from '../../../types' +import { AuthType, SpcpSession } from '../../../types' import { ApplicationError } from '../core/core.errors' -import { IPublicFormView } from '../form/public-form/public-form.types' import { CreateRedirectUrlError, @@ -19,7 +18,6 @@ import { MissingAttributesError, MissingJwtError, RetrieveAttributesError, - SpcpAuthTypeError, VerifyJwtError, } from './spcp.errors' import { @@ -407,37 +405,13 @@ export class SpcpService { authType: AuthType.SP | AuthType.CP, cookies: SpcpCookies, ): ResultAsync< - JwtPayload, + SpcpSession, VerifyJwtError | InvalidJwtError | MissingJwtError > { return this.extractJwt(cookies, authType).asyncAndThen((jwtResult) => - this.extractJwtPayload(jwtResult, authType), + this.extractJwtPayload(jwtResult, authType).map(({ userName }) => ({ + userName, + })), ) } - - /** - * Validates and creates the public form view from the cookies and form of a request - * @param form The public form view - * @param authType Possible authentication types of the request (SP or CP) - * @param cookies Cookies of the request - * @return ok(IPublicFormView) The public view of the form with associated session info - * @return err(MissingJwtError) if the specified cookie for the authType (spcp) does not exist - * @return err(VerifyJwtError) if the jwt exists but could not be authenticated - * @return err(InvalidJwtError) if the jwt exists but the payload is invalid - * @return err(AuthTypeMismatchError) if the client did not authenticate using SPCP - */ - createFormWithSpcpSession( - form: IPopulatedForm, - cookies: Record, - ): ResultAsync< - IPublicFormView, - MissingJwtError | VerifyJwtError | InvalidJwtError | SpcpAuthTypeError - > { - return form.authType === AuthType.CP || form.authType === AuthType.SP - ? this.getSpcpSession(form.authType, cookies).map(({ userName }) => ({ - form: form.getPublicView(), - spcpSession: { userName }, - })) - : errAsync(new SpcpAuthTypeError()) - } } From aa2a687dc90e57ed1687372da306b6217884a731 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Tue, 6 Apr 2021 10:41:57 +0800 Subject: [PATCH 56/86] refactor(intranet/factory): updated isIntranetIp and factory signature typings --- src/app/services/intranet/intranet.factory.ts | 9 ++++++--- src/app/services/intranet/intranet.service.ts | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/app/services/intranet/intranet.factory.ts b/src/app/services/intranet/intranet.factory.ts index 6b15b317a6..32860b2eea 100644 --- a/src/app/services/intranet/intranet.factory.ts +++ b/src/app/services/intranet/intranet.factory.ts @@ -1,4 +1,4 @@ -import { err } from 'neverthrow' +import { err, ok, Result } from 'neverthrow' import FeatureManager, { FeatureNames, @@ -9,7 +9,7 @@ import { MissingFeatureError } from '../../modules/core/core.errors' import { IntranetService } from './intranet.service' interface IIntranetFactory { - isIntranetIp: IntranetService['isIntranetIp'] + isIntranetIp: (ip: string) => Result } export const createIntranetFactory = ({ @@ -17,7 +17,10 @@ export const createIntranetFactory = ({ props, }: RegisteredFeature): IIntranetFactory => { if (isEnabled && props?.intranetIpListPath) { - return new IntranetService(props) + const intranetService = new IntranetService(props) + return { + isIntranetIp: (ip: string) => ok(intranetService.isIntranetIp(ip)), + } } const error = new MissingFeatureError(FeatureNames.Intranet) diff --git a/src/app/services/intranet/intranet.service.ts b/src/app/services/intranet/intranet.service.ts index 56c1f00b97..662e3d966d 100644 --- a/src/app/services/intranet/intranet.service.ts +++ b/src/app/services/intranet/intranet.service.ts @@ -42,7 +42,7 @@ export class IntranetService { * @param ip IP address to check * @returns Whether the IP address originated from the intranet */ - isIntranetIp(ip: string): Result { - return ok(this.intranetIps.includes(ip)) + isIntranetIp(ip: string): boolean { + return this.intranetIps.includes(ip) } } From 479702dd77cf2d39d11aea779489eb942dd3eb60 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Tue, 6 Apr 2021 10:42:48 +0800 Subject: [PATCH 57/86] refactor(myinfo): updated typings and factory methods to remove responsibility from service --- src/app/modules/myinfo/myinfo.factory.ts | 23 +++++----- src/app/modules/myinfo/myinfo.service.ts | 57 +++++++++--------------- src/app/modules/myinfo/myinfo.types.ts | 4 ++ src/app/modules/myinfo/myinfo.util.ts | 10 ++--- 4 files changed, 41 insertions(+), 53 deletions(-) diff --git a/src/app/modules/myinfo/myinfo.factory.ts b/src/app/modules/myinfo/myinfo.factory.ts index ed70f144e0..85628531d6 100644 --- a/src/app/modules/myinfo/myinfo.factory.ts +++ b/src/app/modules/myinfo/myinfo.factory.ts @@ -1,5 +1,6 @@ -import { LeanDocument } from 'mongoose' +import mongoose, { LeanDocument } from 'mongoose' import { err, errAsync, Result, ResultAsync } from 'neverthrow' +import { Merge } from 'type-fest' import config from '../../../config/config' import FeatureManager, { @@ -12,6 +13,7 @@ import { IMyInfoHashSchema, IPopulatedForm, MyInfoAttribute, + UserSession, } from '../../../types' import { DatabaseError, MissingFeatureError } from '../core/core.errors' import { IPublicFormView } from '../form/public-form/public-form.types' @@ -21,7 +23,9 @@ import { MyInfoData } from './myinfo.adapter' import { MyInfoAuthTypeError, MyInfoCircuitBreakerError, + MyInfoCookieAccessError, MyInfoCookieStateError, + MyInfoError, MyInfoFetchError, MyInfoHashDidNotMatchError, MyInfoHashingError, @@ -35,6 +39,7 @@ import { MyInfoService } from './myinfo.service' import { IMyInfoRedirectURLArgs, IPossiblyPrefilledField, + IPossiblyPrefilledFieldArray, MyInfoParsedRelayState, } from './myinfo.types' @@ -104,18 +109,12 @@ interface IMyInfoFactory { > createFormWithMyInfo: ( - form: IPopulatedForm, - cookies: Record, + formFields: mongoose.LeanDocument[], + myInfoData: MyInfoData, + formId: string, ) => ResultAsync< - IPublicFormView, - | MissingFeatureError - | MyInfoFetchError - | MyInfoAuthTypeError - | MyInfoCircuitBreakerError - | DatabaseError - | MyInfoMissingAccessTokenError - | MyInfoCookieStateError - | MyInfoNoESrvcIdError + Merge, + MyInfoError | MyInfoMissingAccessTokenError | MyInfoCookieAccessError > } diff --git a/src/app/modules/myinfo/myinfo.service.ts b/src/app/modules/myinfo/myinfo.service.ts index 9da9653952..913553e85f 100644 --- a/src/app/modules/myinfo/myinfo.service.ts +++ b/src/app/modules/myinfo/myinfo.service.ts @@ -9,6 +9,7 @@ import { cloneDeep } from 'lodash' import mongoose, { LeanDocument } from 'mongoose' import { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow' import CircuitBreaker from 'opossum' +import { Merge } from 'type-fest' import { createLoggerWithLabel } from '../../../config/logger' import { @@ -18,6 +19,7 @@ import { IMyInfoHashSchema, IPopulatedForm, MyInfoAttribute, + UserSession, } from '../../../types' import { DatabaseError, MissingFeatureError } from '../core/core.errors' import { IPublicFormView } from '../form/public-form/public-form.types' @@ -34,6 +36,7 @@ import { MyInfoCircuitBreakerError, MyInfoCookieAccessError, MyInfoCookieStateError, + MyInfoError, MyInfoFetchError, MyInfoHashDidNotMatchError, MyInfoHashingError, @@ -47,13 +50,14 @@ import { IMyInfoRedirectURLArgs, IMyInfoServiceConfig, IPossiblyPrefilledField, + IPossiblyPrefilledFieldArray, MyInfoParsedRelayState, } from './myinfo.types' import { - checkMyInfoCookieUsedCount, + assertMyInfoCookieUnused, compareHashedValues, createRelayState, - extractSuccessfulMyInfoCookie, + extractAndAssertMyInfoCookieValidity, hashFieldValues, isMyInfoRelayState, validateMyInfoForm, @@ -491,8 +495,8 @@ export class MyInfoService { | MyInfoCookieAccessError > { const requestedAttributes = form.getUniqueMyInfoAttrs() - return extractSuccessfulMyInfoCookie(cookies) - .andThen((myInfoCookie) => checkMyInfoCookieUsedCount(myInfoCookie)) + return extractAndAssertMyInfoCookieValidity(cookies) + .andThen((myInfoCookie) => assertMyInfoCookieUnused(myInfoCookie)) .asyncAndThen((cookiePayload) => validateMyInfoForm(form).asyncAndThen((form) => this.fetchMyInfoPersonData( @@ -511,42 +515,23 @@ export class MyInfoService { * @returns */ createFormWithMyInfo( - form: IPopulatedForm, - cookies: Record, + formFields: mongoose.LeanDocument[], + myInfoData: MyInfoData, + formId: string, ): ResultAsync< - IPublicFormView, - | MyInfoCookieAccessError - | MissingFeatureError - | MyInfoFetchError - | MyInfoAuthTypeError - | MyInfoCircuitBreakerError - | DatabaseError - | MyInfoMissingAccessTokenError - | MyInfoCookieStateError - | MyInfoNoESrvcIdError + Merge, + MyInfoError | MyInfoMissingAccessTokenError | MyInfoCookieAccessError > { + const uinFin = myInfoData.getUinFin() return ( - // 1. Validate form and extract myInfoData - this.fetchMyInfoData(form, cookies) - // 2. Fill the form based on the result - .andThen((myInfoData) => - this.prefillMyInfoFields(myInfoData, form.toJSON().form_fields).map( - (formFields) => ({ - form: { - ...form.getPublicView(), - form_fields: formFields, - }, - spcpSession: { userName: myInfoData.getUinFin() }, - }), - ), - ) + // 1. Fill the form based on the result + this.prefillMyInfoFields(myInfoData, formFields) // 3. Hash and save to database - .andThen((prefilledForm) => - this.saveMyInfoHashes( - prefilledForm.spcpSession.userName, - form._id, - prefilledForm.form.form_fields, - ).map(() => prefilledForm), + .asyncAndThen((prefilledFields) => + this.saveMyInfoHashes(uinFin, formId, prefilledFields).map(() => ({ + prefilledFields, + spcpSession: { userName: uinFin }, + })), ) ) } diff --git a/src/app/modules/myinfo/myinfo.types.ts b/src/app/modules/myinfo/myinfo.types.ts index 2be487cad8..40a9ab8278 100644 --- a/src/app/modules/myinfo/myinfo.types.ts +++ b/src/app/modules/myinfo/myinfo.types.ts @@ -27,6 +27,10 @@ export interface IPossiblyPrefilledField extends LeanDocument { fieldValue?: string } +export interface IPossiblyPrefilledFieldArray { + prefilledFields: IPossiblyPrefilledField[] +} + export type MyInfoHashPromises = Partial< Record> > diff --git a/src/app/modules/myinfo/myinfo.util.ts b/src/app/modules/myinfo/myinfo.util.ts index b6541556ec..7fd05ba7e0 100644 --- a/src/app/modules/myinfo/myinfo.util.ts +++ b/src/app/modules/myinfo/myinfo.util.ts @@ -368,7 +368,7 @@ export const extractMyInfoCookie = ( * @returns ok(cookie) the successful myInfoCookie * @returns err(cookie) the errored cookie */ -export const extractSuccessfulCookie = ( +export const assertMyInfoCookieSuccessState = ( cookie: MyInfoCookiePayload, ): Result => cookie.state === MyInfoCookieState.Success @@ -383,7 +383,7 @@ export const extractSuccessfulCookie = ( * @return err(MyInfoCookieStateError) if the extracted myInfoCookie was in an error state * @return err(MyInfoCookieAccessError) if the cookie has been accessed before */ -export const extractSuccessfulMyInfoCookie = ( +export const extractAndAssertMyInfoCookieValidity = ( cookies: Record, ): Result< MyInfoSuccessfulCookiePayload, @@ -392,8 +392,8 @@ export const extractSuccessfulMyInfoCookie = ( | MyInfoCookieAccessError > => extractMyInfoCookie(cookies) - .andThen((cookiePayload) => extractSuccessfulCookie(cookiePayload)) - .andThen((cookiePayload) => checkMyInfoCookieUsedCount(cookiePayload)) + .andThen((cookiePayload) => assertMyInfoCookieSuccessState(cookiePayload)) + .andThen((cookiePayload) => assertMyInfoCookieUnused(cookiePayload)) /** * checks if a MyInfo cookie has been used @@ -401,7 +401,7 @@ export const extractSuccessfulMyInfoCookie = ( * @returns ok(myInfoCookie) if the cookie has not been used before * @returns err(MyInfoCookieAccessError) if the cookie has been used before */ -export const checkMyInfoCookieUsedCount = ( +export const assertMyInfoCookieUnused = ( myInfoCookie: MyInfoSuccessfulCookiePayload, ): Result => myInfoCookie.usedCount <= 0 From 937fe083ca16f0a4e15bbeeb78102d9207e6156b Mon Sep 17 00:00:00 2001 From: seaerchin Date: Tue, 6 Apr 2021 10:46:57 +0800 Subject: [PATCH 58/86] refactor(public-form/controller): wip --- .../public-form/public-form.controller.ts | 160 +++++++++--------- 1 file changed, 84 insertions(+), 76 deletions(-) diff --git a/src/app/modules/form/public-form/public-form.controller.ts b/src/app/modules/form/public-form/public-form.controller.ts index 1d525a05f3..a44f0d665d 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -1,24 +1,21 @@ import { RequestHandler } from 'express' import { StatusCodes } from 'http-status-codes' -import { err, ok } from 'neverthrow' import querystring from 'querystring' +import { UnreachableCaseError } from 'ts-essentials' import { createLoggerWithLabel } from '../../../../config/logger' import { AuthType } from '../../../../types' import { isMongoError } from '../../../utils/handle-mongo-error' import { createReqMeta, getRequestIp } from '../../../utils/request' import { getFormIfPublic } from '../../auth/auth.service' -import { - MYINFO_COOKIE_NAME, - MYINFO_COOKIE_OPTIONS, -} from '../../myinfo/myinfo.constants' import { MyInfoCookieAccessError, MyInfoMissingAccessTokenError, } from '../../myinfo/myinfo.errors' -import { MyInfoCookiePayload } from '../../myinfo/myinfo.types' -import { extractSuccessfulMyInfoCookie } from '../../myinfo/myinfo.util' +import { MyInfoFactory } from '../../myinfo/myinfo.factory' +import { extractAndAssertMyInfoCookieValidity } from '../../myinfo/myinfo.util' import { InvalidJwtError, VerifyJwtError } from '../../spcp/spcp.errors' +import { SpcpFactory } from '../../spcp/spcp.factory' import { PrivateFormError } from '../form.errors' import * as FormService from '../form.service' @@ -190,9 +187,13 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( ) => { const { formId } = req.params - const formResult = await getFormIfPublic(formId).andThen((form) => - FormService.checkFormSubmissionLimitAndDeactivateForm(form), - ) + const formResult = await getFormIfPublic(formId) + .andThen((form) => + FormService.checkFormSubmissionLimitAndDeactivateForm(form), + ) + .andThen((form) => + FormService.setIsIntranetFormAccess(getRequestIp(req), form), + ) // Early return if form is not public or any error occurred. if (formResult.isErr()) { @@ -214,74 +215,81 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( return res.status(statusCode).json({ message: errorMessage }) } - const form = formResult.value - const publicForm = form.getPublicView() - const { authType } = form + const intranetForm = formResult.value + const { form, isIntranetUser } = intranetForm + const publicForm = intranetForm.form.getPublicView() + const { authType } = intranetForm.form - // Step 1: Call the appropriate handler to generate the public form view for authType of form - let publicFormViewResult = await PublicFormService.getAuthTypeHandlerForForm( - authType, - )(form, req.cookies) + // Step 1: Call the appropriate handler - // Step 2: Check if form is MyInfo and set/clear cookies accordingly - // NOTE: This is done to preserve type information on the errors - if (authType === AuthType.MyInfo) { - publicFormViewResult = publicFormViewResult - .andThen((publicFormView) => { - return extractSuccessfulMyInfoCookie(req.cookies).map( - (myInfoCookie) => { - const cookiePayload: MyInfoCookiePayload = { - ...myInfoCookie, - usedCount: myInfoCookie.usedCount + 1, - } - // NOTE: This is a side effect to set the cookie on the result after it has been successfully prefilled. - res.cookie(MYINFO_COOKIE_NAME, cookiePayload, MYINFO_COOKIE_OPTIONS) - return publicFormView - }, + switch (authType) { + // Do not need to do any extra chaining of services. + case AuthType.NIL: + return res.json({ form: publicForm }) + case AuthType.SP: + case AuthType.CP: + return SpcpFactory.getSpcpSession(authType, req.cookies) + .map((spcpSession) => + res.json({ + ...intranetForm, + spcpSession, + }), ) - }) - .orElse((error) => { - // NOTE: This is a side-effect as there is no need for cookie if data could not be retrieved. - res.clearCookie(MYINFO_COOKIE_NAME, MYINFO_COOKIE_OPTIONS) - if ( - error instanceof MyInfoMissingAccessTokenError || - error instanceof MyInfoCookieAccessError - ) { - return err(error) - } - return ok({ form: publicForm, myInfoError: true }) - }) - } - // NOTE: Once there is a valid form retrieved from the database, - // the client should always get a 200 response with the form's public view. - // Additional errors should be tagged onto the response object like myInfoError. - return ( - publicFormViewResult - // Step 3: Check and set whether form is accessed from intranet - .andThen((publicFormView) => - FormService.setIsIntranetFormAccess(getRequestIp(req), publicFormView), - ) - // Step 4: Return the public view - .map((publicFormView) => res.json(publicFormView)) - .mapErr((error) => { - if ( - error instanceof VerifyJwtError || - error instanceof InvalidJwtError - ) { - logger.error({ - message: 'Error getting public form', - meta: { - action: 'handleGetPublicForm', - ...createReqMeta(req), - formId, - }, - error, - }) - } - - return res.json({ - form: publicForm, + .mapErr((error) => { + // Step 3: Report relevant errors - verification failed for user + if ( + error instanceof VerifyJwtError || + error instanceof InvalidJwtError + ) { + logger.error({ + message: 'Error getting public form', + meta: { + action: 'handleGetPublicForm', + ...createReqMeta(req), + formId, + }, + error, + }) + } + return (res.json(intranetForm)) }) - }) - ) + case AuthType.MyInfo: { + return MyInfoFactory.fetchMyInfoData(form, req.cookies) + .andThen((myInfoData) => { + // MyInfoFactory.createFormMyInfoMeta + return MyInfoFactory.createFormWithMyInfo( + form.toJSON().form_fields, + myInfoData, + form._id, + )} + ) + .andThen(({ prefilledFields, spcpSession }) => { + return extractAndAssertMyInfoCookieValidity(req.cookies).map( + (myInfoCookie) => ({ + prefilledFields, + spcpSession, + myInfoCookie, + }), + ) + }) + .map(({myInfoCookie, prefilledFields, spcpSession}) => { + return res.cookie(.....).json({ + spcpSession, + form: { ...publicForm, formFields...{ } + }) + }) + .mapErr((error) => { + // ADD COMMENT WHY + const isMyInfoError = !(error instanceof MyInfoCookieAccessError || error instanceof MyInfoMissingAccessTokenError) + // clear cookie + return res.clearCookie().json({ + form: publicForm, + myInfoError: isMyInfoError || undefined + }), + ) + }) + default: + return new UnreachableCaseError(authType) + } } + } From 3a4e4b83bfe8b49e5ee974a7507b60222b73fb24 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Tue, 6 Apr 2021 12:07:26 +0800 Subject: [PATCH 59/86] refactor(form/service): remove result wrapping as only error is MissingFeatureError --- src/app/modules/form/form.service.ts | 59 ++++++++++++---------------- 1 file changed, 25 insertions(+), 34 deletions(-) diff --git a/src/app/modules/form/form.service.ts b/src/app/modules/form/form.service.ts index 57d2e016ab..8ef832230b 100644 --- a/src/app/modules/form/form.service.ts +++ b/src/app/modules/form/form.service.ts @@ -22,13 +22,8 @@ import { PossibleDatabaseError, transformMongoError, } from '../../utils/handle-mongo-error' -import { - ApplicationError, - DatabaseError, - MissingFeatureError, -} from '../core/core.errors' +import { ApplicationError, DatabaseError } from '../core/core.errors' -import { IIntranetForm } from './public-form/public-form.types' import { FormDeletedError, FormNotFoundError, @@ -251,35 +246,31 @@ export const getFormModelByResponseMode = ( * @returns ok(PublicFormView) if the form is accessed from the internet * @returns err(ApplicationError) if an error occured while checking if the ip of the request is from the intranet */ -export const setIsIntranetFormAccess = ( +export const checkIsIntranetFormAccess = ( ip: string, form: IPopulatedForm, -): Result => { - return ( - IntranetFactory.isIntranetIp(ip) - // NOTE: Need to annotate types because initializing the factory can cause MissingFeatureError - .andThen( - (isIntranetUser) => { - // Warn if form is being accessed from within intranet - // and the form has authentication set - if ( - isIntranetUser && - [AuthType.SP, AuthType.CP, AuthType.MyInfo].includes(form.authType) - ) { - logger.warn({ - message: - 'Attempting to access SingPass, CorpPass or MyInfo form from intranet', - meta: { - action: 'read', - formId: form._id, - }, - }) - } - return ok({ form, isIntranetUser }) - }, - ) - .orElse((error) => - error instanceof MissingFeatureError ? ok({ form }) : err(error), - ) +): boolean => { + const isIntranetIpResult = IntranetFactory.isIntranetIp(ip).andThen( + (isIntranetUser) => { + // Warn if form is being accessed from within intranet + // and the form has authentication set + if ( + isIntranetUser && + [AuthType.SP, AuthType.CP, AuthType.MyInfo].includes(form.authType) + ) { + logger.warn({ + message: + 'Attempting to access SingPass, CorpPass or MyInfo form from intranet', + meta: { + action: 'read', + formId: form._id, + }, + }) + } + return ok(isIntranetUser) + }, ) + + // This is required becausing the factory can throw missing feature error on initialization + return isIntranetIpResult.unwrapOr(false) } From a0b58a75e7774e5efdf0ba663ce0cf0d3f98db87 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Tue, 6 Apr 2021 12:13:43 +0800 Subject: [PATCH 60/86] refactor(myinfo): updated typings and comments for myInfo --- src/app/modules/myinfo/myinfo.factory.ts | 9 ++++----- src/app/modules/myinfo/myinfo.service.ts | 10 +++++----- src/app/modules/myinfo/myinfo.util.ts | 6 +++--- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/app/modules/myinfo/myinfo.factory.ts b/src/app/modules/myinfo/myinfo.factory.ts index 85628531d6..2be3708a6d 100644 --- a/src/app/modules/myinfo/myinfo.factory.ts +++ b/src/app/modules/myinfo/myinfo.factory.ts @@ -16,7 +16,6 @@ import { UserSession, } from '../../../types' import { DatabaseError, MissingFeatureError } from '../core/core.errors' -import { IPublicFormView } from '../form/public-form/public-form.types' import { ProcessedFieldResponse } from '../submission/submission.types' import { MyInfoData } from './myinfo.adapter' @@ -25,7 +24,6 @@ import { MyInfoCircuitBreakerError, MyInfoCookieAccessError, MyInfoCookieStateError, - MyInfoError, MyInfoFetchError, MyInfoHashDidNotMatchError, MyInfoHashingError, @@ -106,15 +104,16 @@ interface IMyInfoFactory { | MyInfoCircuitBreakerError | MyInfoFetchError | MissingFeatureError + | MyInfoCookieAccessError > - createFormWithMyInfo: ( + createFormWithMyInfoMeta: ( formFields: mongoose.LeanDocument[], myInfoData: MyInfoData, formId: string, ) => ResultAsync< Merge, - MyInfoError | MyInfoMissingAccessTokenError | MyInfoCookieAccessError + DatabaseError | MyInfoHashingError > } @@ -135,7 +134,7 @@ export const createMyInfoFactory = ({ parseMyInfoRelayState: () => err(error), extractUinFin: () => err(error), fetchMyInfoData: () => errAsync(error), - createFormWithMyInfo: () => errAsync(error), + createFormWithMyInfoMeta: () => errAsync(error), } } return new MyInfoService({ diff --git a/src/app/modules/myinfo/myinfo.service.ts b/src/app/modules/myinfo/myinfo.service.ts index 913553e85f..55a9742604 100644 --- a/src/app/modules/myinfo/myinfo.service.ts +++ b/src/app/modules/myinfo/myinfo.service.ts @@ -22,7 +22,6 @@ import { UserSession, } from '../../../types' import { DatabaseError, MissingFeatureError } from '../core/core.errors' -import { IPublicFormView } from '../form/public-form/public-form.types' import { ProcessedFieldResponse } from '../submission/submission.types' import { internalAttrListToScopes, MyInfoData } from './myinfo.adapter' @@ -36,7 +35,6 @@ import { MyInfoCircuitBreakerError, MyInfoCookieAccessError, MyInfoCookieStateError, - MyInfoError, MyInfoFetchError, MyInfoHashDidNotMatchError, MyInfoHashingError, @@ -512,15 +510,17 @@ export class MyInfoService { * Creates a form view with myInfo fields prefilled onto the form * @param form The form to validate and fill * @param cookies The cookies on the request - * @returns + * @returns ok({prefilledFields, spcpSession}) if the form could be filled and myInfoData saved + * @returns err(MyInfoHashingError) if myInfoData could not be hashed + * @returns err(DatabaseError) if an error occurred while trying to save myInfoData */ - createFormWithMyInfo( + createFormWithMyInfoMeta( formFields: mongoose.LeanDocument[], myInfoData: MyInfoData, formId: string, ): ResultAsync< Merge, - MyInfoError | MyInfoMissingAccessTokenError | MyInfoCookieAccessError + DatabaseError | MyInfoHashingError > { const uinFin = myInfoData.getUinFin() return ( diff --git a/src/app/modules/myinfo/myinfo.util.ts b/src/app/modules/myinfo/myinfo.util.ts index 7fd05ba7e0..b7da8ee929 100644 --- a/src/app/modules/myinfo/myinfo.util.ts +++ b/src/app/modules/myinfo/myinfo.util.ts @@ -362,7 +362,7 @@ export const extractMyInfoCookie = ( } /** - * Checks if myInfoCookie is successful and returns the result. + * Asserts that myInfoCookie is in success state * This function acts as a discriminator so that the type of the cookie is encoded in its type * @param cookie the cookie to * @returns ok(cookie) the successful myInfoCookie @@ -376,7 +376,7 @@ export const assertMyInfoCookieSuccessState = ( : err(new MyInfoCookieStateError()) /** - * Extracts a successful myInfoCookie from a request's cookies + * Extracts and asserts a successful myInfoCookie from a request's cookies * @param cookies Cookies in a request * @return ok(cookie) the successful myInfoCookie * @return err(MyInfoMissingAccessTokenError) if myInfoCookie is not present on the request @@ -396,7 +396,7 @@ export const extractAndAssertMyInfoCookieValidity = ( .andThen((cookiePayload) => assertMyInfoCookieUnused(cookiePayload)) /** - * checks if a MyInfo cookie has been used + * Asserts that the myInfoCookie has not been used before * @param myInfoCookie * @returns ok(myInfoCookie) if the cookie has not been used before * @returns err(MyInfoCookieAccessError) if the cookie has been used before From 43bc87fe7e30f66f8e3b0c389991db9b1528572b Mon Sep 17 00:00:00 2001 From: seaerchin Date: Tue, 6 Apr 2021 12:14:27 +0800 Subject: [PATCH 61/86] refactor(publicform): updated handleGetPublicForm method and removed unused types --- .../public-form/public-form.controller.ts | 140 +++++++++++------- .../form/public-form/public-form.types.ts | 12 +- 2 files changed, 87 insertions(+), 65 deletions(-) diff --git a/src/app/modules/form/public-form/public-form.controller.ts b/src/app/modules/form/public-form/public-form.controller.ts index a44f0d665d..6ab44464fb 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -5,9 +5,14 @@ import { UnreachableCaseError } from 'ts-essentials' import { createLoggerWithLabel } from '../../../../config/logger' import { AuthType } from '../../../../types' +import { ErrorDto } from '../../../../types/api' import { isMongoError } from '../../../utils/handle-mongo-error' import { createReqMeta, getRequestIp } from '../../../utils/request' import { getFormIfPublic } from '../../auth/auth.service' +import { + MYINFO_COOKIE_NAME, + MYINFO_COOKIE_OPTIONS, +} from '../../myinfo/myinfo.constants' import { MyInfoCookieAccessError, MyInfoMissingAccessTokenError, @@ -20,7 +25,7 @@ import { PrivateFormError } from '../form.errors' import * as FormService from '../form.service' import * as PublicFormService from './public-form.service' -import { RedirectParams } from './public-form.types' +import { PublicFormViewDto, RedirectParams } from './public-form.types' import { mapRouteError } from './public-form.utils' const logger = createLoggerWithLabel(module) @@ -181,19 +186,15 @@ export const handleRedirect: RequestHandler< * @returns 410 if form has been archived * @returns 500 if database error occurs or if the type of error is unknown */ -export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( - req, - res, -) => { +export const handleGetPublicForm: RequestHandler< + { formId: string }, + PublicFormViewDto | ErrorDto +> = async (req, res) => { const { formId } = req.params - const formResult = await getFormIfPublic(formId) - .andThen((form) => - FormService.checkFormSubmissionLimitAndDeactivateForm(form), - ) - .andThen((form) => - FormService.setIsIntranetFormAccess(getRequestIp(req), form), - ) + const formResult = await getFormIfPublic(formId).andThen((form) => + FormService.checkFormSubmissionLimitAndDeactivateForm(form), + ) // Early return if form is not public or any error occurred. if (formResult.isErr()) { @@ -215,28 +216,29 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( return res.status(statusCode).json({ message: errorMessage }) } - const intranetForm = formResult.value - const { form, isIntranetUser } = intranetForm - const publicForm = intranetForm.form.getPublicView() - const { authType } = intranetForm.form - - // Step 1: Call the appropriate handler + const form = formResult.value + const publicForm = form.getPublicView() + const { authType } = form + const isIntranetUser = FormService.checkIsIntranetFormAccess( + getRequestIp(req), + form, + ) switch (authType) { - // Do not need to do any extra chaining of services. case AuthType.NIL: - return res.json({ form: publicForm }) + return res.json({ form: publicForm, isIntranetUser }) case AuthType.SP: case AuthType.CP: return SpcpFactory.getSpcpSession(authType, req.cookies) .map((spcpSession) => res.json({ - ...intranetForm, + form, + isIntranetUser, spcpSession, }), ) .mapErr((error) => { - // Step 3: Report relevant errors - verification failed for user + // Report only relevant errors - verification failed for user here if ( error instanceof VerifyJwtError || error instanceof InvalidJwtError @@ -251,45 +253,69 @@ export const handleGetPublicForm: RequestHandler<{ formId: string }> = async ( error, }) } - return (res.json(intranetForm)) + return res.json({ form, isIntranetUser }) }) case AuthType.MyInfo: { - return MyInfoFactory.fetchMyInfoData(form, req.cookies) - .andThen((myInfoData) => { - // MyInfoFactory.createFormMyInfoMeta - return MyInfoFactory.createFormWithMyInfo( - form.toJSON().form_fields, - myInfoData, - form._id, - )} - ) - .andThen(({ prefilledFields, spcpSession }) => { - return extractAndAssertMyInfoCookieValidity(req.cookies).map( - (myInfoCookie) => ({ - prefilledFields, - spcpSession, - myInfoCookie, - }), - ) - }) - .map(({myInfoCookie, prefilledFields, spcpSession}) => { - return res.cookie(.....).json({ + // Step 1. Fetch required data and fill the form based off data retrieved + return ( + MyInfoFactory.fetchMyInfoData(form, req.cookies) + .andThen((myInfoData) => { + return MyInfoFactory.createFormWithMyInfoMeta( + form.toJSON().form_fields, + myInfoData, + form._id, + ) + }) + // Check if the user is signed in + .andThen(({ prefilledFields, spcpSession }) => { + return extractAndAssertMyInfoCookieValidity(req.cookies).map( + (myInfoCookie) => ({ + prefilledFields, spcpSession, - form: { ...publicForm, formFields...{ } - }) - }) - .mapErr((error) => { - // ADD COMMENT WHY - const isMyInfoError = !(error instanceof MyInfoCookieAccessError || error instanceof MyInfoMissingAccessTokenError) - // clear cookie - return res.clearCookie().json({ - form: publicForm, - myInfoError: isMyInfoError || undefined - }), - ) - }) + myInfoCookie, + }), + ) + }) + .map(({ myInfoCookie, prefilledFields, spcpSession }) => { + const updatedMyInfoCookie = { + ...myInfoCookie, + usedCount: myInfoCookie.usedCount + 1, + } + // Set the updated cookie accordingly and return the form back to the user + return res + .cookie( + MYINFO_COOKIE_NAME, + updatedMyInfoCookie, + MYINFO_COOKIE_OPTIONS, + ) + .json({ + spcpSession, + form: { ...form, form_fields: prefilledFields }, + isIntranetUser, + }) + }) + .mapErr((error) => { + // NOTE: If the user is not signed in or the access token has been used before, it is not an error. + // myInfoError is set to true only when the authentication provider rejects the user's attempt at auth + // or when there is a network or database error during the process of retrieval + const isMyInfoError = !( + error instanceof MyInfoCookieAccessError || + error instanceof MyInfoMissingAccessTokenError + ) + // No need for cookie if data could not be retrieved + // NOTE: If the user does not have any cookie, clearing the cookie still has the same result + return res + .clearCookie(MYINFO_COOKIE_NAME, MYINFO_COOKIE_OPTIONS) + .json({ + form: publicForm, + // Setting to undefined ensures that the frontend does not get myInfoError if it is false + myInfoError: isMyInfoError || undefined, + isIntranetUser, + }) + }) + ) + } default: return new UnreachableCaseError(authType) } } - } diff --git a/src/app/modules/form/public-form/public-form.types.ts b/src/app/modules/form/public-form/public-form.types.ts index bdbc5f8eed..6f970f46f6 100644 --- a/src/app/modules/form/public-form/public-form.types.ts +++ b/src/app/modules/form/public-form/public-form.types.ts @@ -1,7 +1,8 @@ import { ParamsDictionary } from 'express-serve-static-core' -import { IFieldSchema, IPopulatedForm, PublicForm } from 'src/types' +import { IFieldSchema, PublicForm } from 'src/types' +import { SpcpSession } from '../../../../types/spcp' import { IPossiblyPrefilledField } from '../../myinfo/myinfo.types' export type Metatags = { @@ -24,14 +25,9 @@ interface PossiblyPrefilledPublicForm extends Omit { form_fields: IPossiblyPrefilledField[] | IFieldSchema[] } -export interface IPublicFormView { +export type PublicFormViewDto = { form: PossiblyPrefilledPublicForm - spcpSession?: { userName: string } + spcpSession?: SpcpSession isIntranetUser?: boolean myInfoError?: true } - -export interface IIntranetForm { - isIntranetUser?: boolean - form: IPopulatedForm -} From beaa9e17611b63dffce9d047f7a6f75ddf70bafb Mon Sep 17 00:00:00 2001 From: seaerchin Date: Tue, 6 Apr 2021 13:26:35 +0800 Subject: [PATCH 62/86] refactor(public-form/service): removed unnused method --- .../form/public-form/public-form.service.ts | 30 +------------------ 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/src/app/modules/form/public-form/public-form.service.ts b/src/app/modules/form/public-form/public-form.service.ts index 87e1bba040..6d924cea1d 100644 --- a/src/app/modules/form/public-form/public-form.service.ts +++ b/src/app/modules/form/public-form/public-form.service.ts @@ -2,17 +2,10 @@ import mongoose from 'mongoose' import { errAsync, okAsync, ResultAsync } from 'neverthrow' import { createLoggerWithLabel } from '../../../../config/logger' -import { - AuthType, - FormController, - IFormFeedbackSchema, - IPopulatedForm, -} from '../../../../types' +import { IFormFeedbackSchema } from '../../../../types' import getFormModel from '../../../models/form.server.model' import getFormFeedbackModel from '../../../models/form_feedback.server.model' import { DatabaseError } from '../../core/core.errors' -import { MyInfoFactory } from '../../myinfo/myinfo.factory' -import { SpcpFactory } from '../../spcp/spcp.factory' import { FormNotFoundError } from '../form.errors' import { Metatags } from './public-form.types' @@ -109,24 +102,3 @@ export const createMetatags = ({ return okAsync(metatags) }) } - -export const getAuthTypeHandlerForForm = (authType: AuthType) => ( - form: IPopulatedForm, - cookies: Record, -): - | ReturnType - | ReturnType => { - // NOTE: This object is created to ensure that all cases are checked - const AuthTypeHandler: FormController< - | typeof SpcpFactory.createFormWithSpcpSession - | typeof MyInfoFactory.createFormWithMyInfo - > = { - [AuthType.SP]: () => SpcpFactory.createFormWithSpcpSession(form, cookies), - [AuthType.CP]: () => SpcpFactory.createFormWithSpcpSession(form, cookies), - [AuthType.MyInfo]: () => MyInfoFactory.createFormWithMyInfo(form, cookies), - [AuthType.NIL]: (form: IPopulatedForm) => - okAsync({ form: form.getPublicView() }), - } - - return AuthTypeHandler[authType](form, cookies) -} From 5f47ee6512953415742f05908fd9b0b429d91046 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Tue, 6 Apr 2021 13:27:19 +0800 Subject: [PATCH 63/86] fix(public-form/controller): changed returned form to be a publicForm --- src/app/modules/form/public-form/public-form.controller.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/modules/form/public-form/public-form.controller.ts b/src/app/modules/form/public-form/public-form.controller.ts index 6ab44464fb..ffa4e4d113 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -232,7 +232,7 @@ export const handleGetPublicForm: RequestHandler< return SpcpFactory.getSpcpSession(authType, req.cookies) .map((spcpSession) => res.json({ - form, + form: publicForm, isIntranetUser, spcpSession, }), @@ -253,7 +253,7 @@ export const handleGetPublicForm: RequestHandler< error, }) } - return res.json({ form, isIntranetUser }) + return res.json({ form: publicForm, isIntranetUser }) }) case AuthType.MyInfo: { // Step 1. Fetch required data and fill the form based off data retrieved @@ -290,7 +290,7 @@ export const handleGetPublicForm: RequestHandler< ) .json({ spcpSession, - form: { ...form, form_fields: prefilledFields }, + form: { ...publicForm, form_fields: prefilledFields }, isIntranetUser, }) }) From 4eb9804f9c4e8fd1fedba0461e312f6a2ada5b34 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Tue, 6 Apr 2021 13:28:48 +0800 Subject: [PATCH 64/86] test(myinfo/service): updated tests for myinfo service --- .../myinfo/__tests__/myinfo.service.spec.ts | 81 +++++++++---------- src/app/modules/myinfo/myinfo.service.ts | 2 +- 2 files changed, 41 insertions(+), 42 deletions(-) diff --git a/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts b/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts index fbbce0e684..b19ffe8781 100644 --- a/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts +++ b/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts @@ -1,5 +1,6 @@ import bcrypt from 'bcrypt' import mongoose from 'mongoose' +import { errAsync, ok } from 'neverthrow' import { mocked } from 'ts-jest/utils' import { v4 as uuidv4 } from 'uuid' @@ -18,13 +19,11 @@ import { import dbHandler from 'tests/unit/backend/helpers/jest-db' import { MyInfoData } from '../myinfo.adapter' -import { - MYINFO_CONSENT_PAGE_PURPOSE, - MYINFO_COOKIE_NAME, -} from '../myinfo.constants' +import { MYINFO_CONSENT_PAGE_PURPOSE } from '../myinfo.constants' import { MyInfoCircuitBreakerError, MyInfoFetchError, + MyInfoHashingError, MyInfoInvalidAccessTokenError, MyInfoMissingAccessTokenError, MyInfoParseRelayStateError, @@ -492,74 +491,74 @@ describe('MyInfoService', () => { it('should return the filled form when the form and cookies are valid', async () => { // Arrange - const mockReturnedParams = { - uinFin: MOCK_UINFIN, - data: MOCK_MYINFO_DATA, - } - mockGetPerson.mockResolvedValueOnce(mockReturnedParams) + const MOCK_MYINFO_DATA = ({ + getUinFin: jest.fn().mockReturnValue(MOCK_UINFIN), + } as unknown) as MyInfoData const expected = { - form: MOCK_MYINFO_FORM.getPublicView(), + prefilledFields: [], spcpSession: { userName: MOCK_UINFIN }, } // Spies to ensure that submethods have been called - const fetchMyInfoSpy = jest.spyOn(myInfoService, 'fetchMyInfoData') - const prefillMyInfoSpy = jest.spyOn(myInfoService, 'prefillMyInfoFields') + const prefillMyInfoSpy = jest + .spyOn(myInfoService, 'prefillMyInfoFields') + .mockReturnValueOnce(ok([])) const saveMyInfoSpy = jest.spyOn(myInfoService, 'saveMyInfoHashes') // Act - const result = await myInfoService.createFormWithMyInfo( - MOCK_MYINFO_FORM as IPopulatedForm, - { [MYINFO_COOKIE_NAME]: MOCK_SUCCESSFUL_COOKIE }, + const result = await myInfoService.createFormWithMyInfoMeta( + [], + MOCK_MYINFO_DATA, + MOCK_MYINFO_FORM._id, ) // Assert - expect(fetchMyInfoSpy).toHaveBeenCalled() expect(prefillMyInfoSpy).toHaveBeenCalled() expect(saveMyInfoSpy).toHaveBeenCalled() expect(result._unsafeUnwrap()).toEqual(expected) }) - it('should return MyInfoMissingAccessTokenError when there are no cookies in the request', async () => { + it('should return DatabaseError when the form could not be saved to the database', async () => { // Arrange - const expected = new MyInfoMissingAccessTokenError() + const expected = new DatabaseError('mock db error') + const MOCK_MYINFO_DATA = ({ + getUinFin: jest.fn().mockReturnValue(MOCK_UINFIN), + } as unknown) as MyInfoData const prefillMyInfoSpy = jest.spyOn(myInfoService, 'prefillMyInfoFields') - const saveMyInfoSpy = jest.spyOn(myInfoService, 'saveMyInfoHashes') + const saveMyInfoSpy = jest + .spyOn(myInfoService, 'saveMyInfoHashes') + .mockReturnValueOnce(errAsync(expected)) // Act - const result = await myInfoService.createFormWithMyInfo( - MOCK_MYINFO_FORM as IPopulatedForm, - {}, + const result = await myInfoService.createFormWithMyInfoMeta( + [], + MOCK_MYINFO_DATA, + MOCK_MYINFO_FORM._id, ) // Assert - expect(prefillMyInfoSpy).not.toHaveBeenCalled() - expect(saveMyInfoSpy).not.toHaveBeenCalled() + expect(prefillMyInfoSpy).toHaveBeenCalled() + expect(saveMyInfoSpy).toHaveBeenCalled() expect(result._unsafeUnwrapErr()).toEqual(expected) }) - it('should return DatabaseError when the form could not be saved to the database', async () => { + it('should return MyInfoHashingError when myInfoData could not be hashed', async () => { // Arrange - const expected = new DatabaseError( - 'Failed to save MyInfo hashes to database', - ) + const expected = new MyInfoHashingError('mock hash failed') + const MOCK_MYINFO_DATA = ({ + getUinFin: jest.fn().mockReturnValue(MOCK_UINFIN), + } as unknown) as MyInfoData const prefillMyInfoSpy = jest.spyOn(myInfoService, 'prefillMyInfoFields') - const saveMyInfoSpy = jest.spyOn(myInfoService, 'saveMyInfoHashes') - const mockReturnedParams = { - uinFin: MOCK_UINFIN, - data: MOCK_MYINFO_DATA, - } - mockGetPerson.mockResolvedValueOnce(mockReturnedParams) - - jest - .spyOn(MyInfoHash, 'updateHashes') - .mockRejectedValueOnce(new DatabaseError()) + const saveMyInfoSpy = jest + .spyOn(myInfoService, 'saveMyInfoHashes') + .mockReturnValueOnce(errAsync(expected)) // Act - const result = await myInfoService.createFormWithMyInfo( - MOCK_MYINFO_FORM as IPopulatedForm, - { [MYINFO_COOKIE_NAME]: MOCK_SUCCESSFUL_COOKIE }, + const result = await myInfoService.createFormWithMyInfoMeta( + [], + MOCK_MYINFO_DATA, + MOCK_MYINFO_FORM._id, ) // Assert diff --git a/src/app/modules/myinfo/myinfo.service.ts b/src/app/modules/myinfo/myinfo.service.ts index 55a9742604..179e75beb5 100644 --- a/src/app/modules/myinfo/myinfo.service.ts +++ b/src/app/modules/myinfo/myinfo.service.ts @@ -526,7 +526,7 @@ export class MyInfoService { return ( // 1. Fill the form based on the result this.prefillMyInfoFields(myInfoData, formFields) - // 3. Hash and save to database + // 2. Hash and save to database .asyncAndThen((prefilledFields) => this.saveMyInfoHashes(uinFin, formId, prefilledFields).map(() => ({ prefilledFields, From f77d712832be6a413b3608e1e607c87d992a26df Mon Sep 17 00:00:00 2001 From: seaerchin Date: Tue, 6 Apr 2021 13:53:11 +0800 Subject: [PATCH 65/86] test(public-form/controller): updated tests to fit iwth refactor --- .../__tests__/public-form.controller.spec.ts | 209 +++++++----------- 1 file changed, 78 insertions(+), 131 deletions(-) diff --git a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts index aa33208c97..00a3b0d8c2 100644 --- a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts +++ b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts @@ -475,9 +475,7 @@ describe('public-form.controller', () => { } beforeAll(() => { - MockFormService.setIsIntranetFormAccess.mockImplementation( - (_, publicForm) => ok({ ...publicForm, isIntranetUser: false }), - ) + MockFormService.checkIsIntranetFormAccess.mockReturnValue(false) }) it('should return 200 when there is no AuthType on the request', async () => { @@ -494,12 +492,6 @@ describe('public-form.controller', () => { MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( okAsync(MOCK_NIL_AUTH_FORM), ) - MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( - () => - okAsync({ - form: MOCK_NIL_AUTH_FORM.getPublicView(), - }), - ) // Act await PublicFormController.handleGetPublicForm( @@ -517,14 +509,11 @@ describe('public-form.controller', () => { it('should return 200 when client authenticates using SP', async () => { // Arrange + const MOCK_SPCP_SESSION = { userName: MOCK_JWT_PAYLOAD.userName } const MOCK_SP_AUTH_FORM = ({ ...BASE_FORM, authType: AuthType.SP, } as unknown) as IPopulatedForm - const MOCK_PUBLIC_SP_FORM_VIEW = { - form: MOCK_SP_AUTH_FORM.getPublicView(), - spcpSession: { userName: MOCK_JWT_PAYLOAD.userName }, - } const mockRes = expressHandler.mockResponse() MockAuthService.getFormIfPublic.mockReturnValueOnce( @@ -533,8 +522,8 @@ describe('public-form.controller', () => { MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( okAsync(MOCK_SP_AUTH_FORM), ) - MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( - () => okAsync(MOCK_PUBLIC_SP_FORM_VIEW), + MockSpcpFactory.getSpcpSession.mockReturnValueOnce( + okAsync(MOCK_SPCP_SESSION), ) // Act @@ -546,21 +535,19 @@ describe('public-form.controller', () => { // Assert expect(mockRes.json).toHaveBeenCalledWith({ - ...MOCK_PUBLIC_SP_FORM_VIEW, + form: MOCK_SP_AUTH_FORM.getPublicView(), isIntranetUser: false, + spcpSession: MOCK_SPCP_SESSION, }) }) it('should return 200 when client authenticates using CP', async () => { // Arrange + const MOCK_SPCP_SESSION = { userName: MOCK_JWT_PAYLOAD.userName } const MOCK_CP_AUTH_FORM = ({ ...BASE_FORM, authType: AuthType.CP, } as unknown) as IPopulatedForm - const MOCK_PUBLIC_CP_FORM_VIEW = { - form: MOCK_CP_AUTH_FORM.getPublicView(), - spcpSession: { userName: MOCK_JWT_PAYLOAD.userName }, - } const mockRes = expressHandler.mockResponse() MockAuthService.getFormIfPublic.mockReturnValueOnce( @@ -569,10 +556,9 @@ describe('public-form.controller', () => { MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( okAsync(MOCK_CP_AUTH_FORM), ) - MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( - () => okAsync(MOCK_PUBLIC_CP_FORM_VIEW), + MockSpcpFactory.getSpcpSession.mockReturnValueOnce( + okAsync(MOCK_SPCP_SESSION), ) - // Act await PublicFormController.handleGetPublicForm( MOCK_REQ, @@ -582,8 +568,9 @@ describe('public-form.controller', () => { // Assert expect(mockRes.json).toHaveBeenCalledWith({ - ...MOCK_PUBLIC_CP_FORM_VIEW, + form: MOCK_CP_AUTH_FORM.getPublicView(), isIntranetUser: false, + spcpSession: MOCK_SPCP_SESSION, }) }) @@ -598,23 +585,25 @@ describe('public-form.controller', () => { const MOCK_MYINFO_DATA = new MyInfoData({ uinFin: 'i am a fish', } as IPersonResponse) - const MOCK_PUBLIC_MYINFO_FORM_VIEW = { - form: MOCK_MYINFO_AUTH_FORM.getPublicView(), - spcpSession: { userName: MOCK_MYINFO_DATA.getUinFin() }, - } + const MOCK_SPCP_SESSION = { userName: MOCK_MYINFO_DATA.getUinFin() } const mockRes = expressHandler.mockResponse({ clearCookie: jest.fn().mockReturnThis(), + cookie: jest.fn().mockReturnThis(), }) - MockAuthService.getFormIfPublic.mockReturnValueOnce( okAsync(MOCK_MYINFO_AUTH_FORM), ) MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( okAsync(MOCK_MYINFO_AUTH_FORM), ) - - MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( - () => okAsync(MOCK_PUBLIC_MYINFO_FORM_VIEW), + MockMyInfoFactory.fetchMyInfoData.mockReturnValueOnce( + okAsync(MOCK_MYINFO_DATA), + ) + MockMyInfoFactory.createFormWithMyInfoMeta.mockReturnValueOnce( + okAsync({ + prefilledFields: [], + spcpSession: MOCK_SPCP_SESSION, + }), ) // Act @@ -626,8 +615,10 @@ describe('public-form.controller', () => { // Assert expect(mockRes.clearCookie).not.toHaveBeenCalled() + expect(mockRes.cookie).toHaveBeenCalled() expect(mockRes.json).toHaveBeenCalledWith({ - ...MOCK_PUBLIC_MYINFO_FORM_VIEW, + form: { ...MOCK_MYINFO_AUTH_FORM.getPublicView(), form_fields: [] }, + spcpSession: MOCK_SPCP_SESSION, isIntranetUser: false, }) }) @@ -637,6 +628,7 @@ describe('public-form.controller', () => { describe('errors in myInfo', () => { const MOCK_MYINFO_FORM = ({ ...BASE_FORM, + toJSON: jest.fn().mockReturnThis(), authType: AuthType.MyInfo, } as unknown) as IPopulatedForm @@ -646,24 +638,22 @@ describe('public-form.controller', () => { okAsync(MOCK_MYINFO_FORM), ) - MockFormService.setIsIntranetFormAccess.mockImplementation( - (ip, publicForm) => ok({ ...publicForm, isIntranetUser: false }), - ) + MockFormService.checkIsIntranetFormAccess.mockReturnValueOnce(false) MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValue( okAsync(MOCK_MYINFO_FORM), ) }) - it('should return 200 but the response should have cookies cleared without myInfoError when the request has no cookie', async () => { + it('should return 200 but the response should have cookies cleared with myInfoError set to undefined when the request has no cookie', async () => { // Arrange // 1. Mock the response and calls const mockRes = expressHandler.mockResponse({ clearCookie: jest.fn().mockReturnThis(), }) - MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( - () => errAsync(new MyInfoMissingAccessTokenError()), + MockMyInfoFactory.fetchMyInfoData.mockReturnValueOnce( + errAsync(new MyInfoMissingAccessTokenError()), ) // Act @@ -677,18 +667,20 @@ describe('public-form.controller', () => { expect(mockRes.clearCookie).toHaveBeenCalled() expect(mockRes.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_FORM.getPublicView(), + isIntranetUser: false, + myInfoError: undefined, }) }) - it('should return 200 but the response should have cookies cleared without myInfoError when the cookie has been used before', async () => { + it('should return 200 but the response should have cookies cleared with myInfoError set to undefined when the cookie has been used before', async () => { // Arrange // 1. Mock the response and calls const mockRes = expressHandler.mockResponse({ clearCookie: jest.fn().mockReturnThis(), }) - MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( - () => errAsync(new MyInfoCookieAccessError()), + MockMyInfoFactory.fetchMyInfoData.mockReturnValueOnce( + errAsync(new MyInfoCookieAccessError()), ) // Act @@ -702,6 +694,8 @@ describe('public-form.controller', () => { expect(mockRes.clearCookie).toHaveBeenCalled() expect(mockRes.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_FORM.getPublicView(), + isIntranetUser: false, + myInfoError: undefined, }) }) @@ -712,8 +706,8 @@ describe('public-form.controller', () => { clearCookie: jest.fn().mockReturnThis(), }) - MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( - () => errAsync(new MyInfoCookieStateError()), + MockMyInfoFactory.fetchMyInfoData.mockReturnValueOnce( + errAsync(new MyInfoCookieStateError()), ) // Act @@ -739,8 +733,8 @@ describe('public-form.controller', () => { clearCookie: jest.fn().mockReturnThis(), }) - MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( - () => errAsync(new MyInfoAuthTypeError()), + MockMyInfoFactory.fetchMyInfoData.mockReturnValueOnce( + errAsync(new MyInfoAuthTypeError()), ) // Act @@ -766,8 +760,8 @@ describe('public-form.controller', () => { clearCookie: jest.fn().mockReturnThis(), }) - MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( - () => errAsync(new MyInfoNoESrvcIdError()), + MockMyInfoFactory.fetchMyInfoData.mockReturnValueOnce( + errAsync(new MyInfoNoESrvcIdError()), ) // Act @@ -793,13 +787,12 @@ describe('public-form.controller', () => { clearCookie: jest.fn().mockReturnThis(), }) - MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( - () => - errAsync( - new MissingFeatureError( - 'testing is the missing feature' as FeatureNames, - ), + MockMyInfoFactory.fetchMyInfoData.mockReturnValueOnce( + errAsync( + new MissingFeatureError( + 'testing is the missing feature' as FeatureNames, ), + ), ) // Act @@ -821,12 +814,18 @@ describe('public-form.controller', () => { it('should return 200 but the response should have cookies cleared and myInfoError if a database error occurs while saving hashes', async () => { // Arrange // 1. Mock the response and calls + const MOCK_MYINFO_DATA = new MyInfoData({ + uinFin: 'i am a fish', + } as IPersonResponse) + const expected = new DatabaseError('fish error') const mockRes = expressHandler.mockResponse({ clearCookie: jest.fn().mockReturnThis(), }) - - MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( - () => errAsync(new DatabaseError()), + MockMyInfoFactory.fetchMyInfoData.mockReturnValueOnce( + okAsync(MOCK_MYINFO_DATA), + ) + MockMyInfoFactory.createFormWithMyInfoMeta.mockReturnValueOnce( + errAsync(expected), ) // Act @@ -862,8 +861,8 @@ describe('public-form.controller', () => { MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( okAsync(MOCK_SPCP_FORM), ) - MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( - () => errAsync(new MissingJwtError()), + MockSpcpFactory.getSpcpSession.mockReturnValueOnce( + errAsync(new MissingJwtError()), ) // Act @@ -879,6 +878,7 @@ describe('public-form.controller', () => { // json object should only have form property expect(mockRes.json).toHaveBeenCalledWith({ form: MOCK_SPCP_FORM.getPublicView(), + isIntranetUser: false, }) }) }) @@ -985,9 +985,8 @@ describe('public-form.controller', () => { }) describe('errors in form access', () => { - const MOCK_JWT_PAYLOAD: JwtPayload = { + const MOCK_SPCP_SESSION = { userName: 'mock', - rememberMe: false, } it('should return 200 with isIntranetUser set to false when a user accesses a form from outside intranet', async () => { @@ -1004,15 +1003,7 @@ describe('public-form.controller', () => { MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( okAsync(MOCK_NIL_AUTH_FORM), ) - MockFormService.setIsIntranetFormAccess.mockImplementation( - (_, publicForm) => ok({ ...publicForm, isIntranetUser: false }), - ) - MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( - () => - okAsync({ - form: MOCK_NIL_AUTH_FORM.getPublicView(), - }), - ) + MockFormService.checkIsIntranetFormAccess.mockReturnValueOnce(false) // Act await PublicFormController.handleGetPublicForm( @@ -1034,35 +1025,19 @@ describe('public-form.controller', () => { ...BASE_FORM, authType: AuthType.SP, } as unknown) as IPopulatedForm + const mockRes = expressHandler.mockResponse() - MockSpcpFactory.createFormWithSpcpSession.mockReturnValueOnce( - okAsync({ - form: MOCK_SP_AUTH_FORM.getPublicView(), - spcpSession: { userName: MOCK_JWT_PAYLOAD.userName }, - }), - ) - MockFormService.setIsIntranetFormAccess.mockImplementation( - (_, publicForm) => ok({ ...publicForm, isIntranetUser: true }), - ) MockSpcpFactory.getSpcpSession.mockReturnValueOnce( - okAsync(MOCK_JWT_PAYLOAD), + okAsync(MOCK_SPCP_SESSION), ) + MockFormService.checkIsIntranetFormAccess.mockReturnValueOnce(true) MockAuthService.getFormIfPublic.mockReturnValueOnce( okAsync(MOCK_SP_AUTH_FORM), ) MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( okAsync(MOCK_SP_AUTH_FORM), ) - MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( - () => - okAsync({ - form: MOCK_SP_AUTH_FORM.getPublicView(), - spcpSession: { - userName: MOCK_JWT_PAYLOAD.userName, - }, - }), - ) // Act await PublicFormController.handleGetPublicForm( @@ -1074,9 +1049,7 @@ describe('public-form.controller', () => { // Assert expect(mockRes.json).toHaveBeenCalledWith({ form: MOCK_SP_AUTH_FORM.getPublicView(), - spcpSession: { - userName: MOCK_JWT_PAYLOAD.userName, - }, + spcpSession: MOCK_SPCP_SESSION, isIntranetUser: true, }) }) @@ -1087,21 +1060,12 @@ describe('public-form.controller', () => { ...BASE_FORM, authType: AuthType.CP, } as unknown) as IPopulatedForm - const mockRes = expressHandler.mockResponse() - - MockSpcpFactory.createFormWithSpcpSession.mockReturnValueOnce( - okAsync({ - form: MOCK_CP_AUTH_FORM.getPublicView(), - spcpSession: { userName: MOCK_JWT_PAYLOAD.userName }, - }), - ) - MockFormService.setIsIntranetFormAccess.mockImplementation( - (_, publicForm) => ok({ ...publicForm, isIntranetUser: true }), - ) + const mockRes = expressHandler.mockResponse() + MockFormService.checkIsIntranetFormAccess.mockReturnValueOnce(true) MockSpcpFactory.getSpcpSession.mockReturnValueOnce( - okAsync(MOCK_JWT_PAYLOAD), + okAsync(MOCK_SPCP_SESSION), ) MockAuthService.getFormIfPublic.mockReturnValueOnce( okAsync(MOCK_CP_AUTH_FORM), @@ -1109,15 +1073,6 @@ describe('public-form.controller', () => { MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( okAsync(MOCK_CP_AUTH_FORM), ) - MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( - () => - okAsync({ - form: MOCK_CP_AUTH_FORM.getPublicView(), - spcpSession: { - userName: MOCK_JWT_PAYLOAD.userName, - }, - }), - ) // Act await PublicFormController.handleGetPublicForm( @@ -1129,9 +1084,7 @@ describe('public-form.controller', () => { // Assert expect(mockRes.json).toHaveBeenCalledWith({ form: MOCK_CP_AUTH_FORM.getPublicView(), - spcpSession: { - userName: MOCK_JWT_PAYLOAD.userName, - }, + spcpSession: MOCK_SPCP_SESSION, isIntranetUser: true, }) }) @@ -1146,6 +1099,7 @@ describe('public-form.controller', () => { } as unknown) as IPopulatedForm const mockRes = expressHandler.mockResponse({ clearCookie: jest.fn().mockReturnThis(), + cookie: jest.fn().mockReturnThis(), }) const MOCK_MYINFO_DATA = new MyInfoData({ @@ -1158,24 +1112,16 @@ describe('public-form.controller', () => { MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( okAsync(MOCK_MYINFO_AUTH_FORM), ) - MockMyInfoFactory.createFormWithMyInfo.mockReturnValueOnce( + MockFormService.checkIsIntranetFormAccess.mockReturnValueOnce(true) + MockMyInfoFactory.fetchMyInfoData.mockReturnValueOnce( + okAsync(MOCK_MYINFO_DATA), + ) + MockMyInfoFactory.createFormWithMyInfoMeta.mockReturnValueOnce( okAsync({ - form: MOCK_MYINFO_AUTH_FORM.getPublicView(), + prefilledFields: [], spcpSession: { userName: MOCK_MYINFO_DATA.getUinFin() }, }), ) - MockFormService.setIsIntranetFormAccess.mockImplementation( - (_, publicForm) => ok({ ...publicForm, isIntranetUser: true }), - ) - MockPublicFormService.getAuthTypeHandlerForForm.mockReturnValueOnce( - () => - okAsync({ - form: MOCK_MYINFO_AUTH_FORM.getPublicView(), - spcpSession: { - userName: MOCK_MYINFO_DATA.getUinFin(), - }, - }), - ) // Act await PublicFormController.handleGetPublicForm( @@ -1186,8 +1132,9 @@ describe('public-form.controller', () => { // Assert expect(mockRes.clearCookie).not.toHaveBeenCalled() + expect(mockRes.cookie).toHaveBeenCalled() expect(mockRes.json).toHaveBeenCalledWith({ - form: MOCK_MYINFO_AUTH_FORM.getPublicView(), + form: { ...MOCK_MYINFO_AUTH_FORM.getPublicView(), form_fields: [] }, spcpSession: { userName: MOCK_MYINFO_DATA.getUinFin() }, isIntranetUser: true, }) From de70fd8cd51ae782d773bf8d7a56227d645646ea Mon Sep 17 00:00:00 2001 From: seaerchin Date: Tue, 6 Apr 2021 14:49:48 +0800 Subject: [PATCH 66/86] chore(intranet): removed unused variables --- src/app/services/intranet/intranet.service.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/app/services/intranet/intranet.service.ts b/src/app/services/intranet/intranet.service.ts index 662e3d966d..91d957231b 100644 --- a/src/app/services/intranet/intranet.service.ts +++ b/src/app/services/intranet/intranet.service.ts @@ -1,9 +1,7 @@ import fs from 'fs' -import { ok, Result } from 'neverthrow' import { IIntranet } from '../../../config/feature-manager' import { createLoggerWithLabel } from '../../../config/logger' -import { ApplicationError } from '../../modules/core/core.errors' const logger = createLoggerWithLabel(module) From 3c18db402b52ec3b08bd8d51933d71150dcd6481 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Tue, 6 Apr 2021 14:51:09 +0800 Subject: [PATCH 67/86] test(intranet/service): fixes failing tests due to refactor --- src/app/services/intranet/__tests__/intranet.service.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/services/intranet/__tests__/intranet.service.spec.ts b/src/app/services/intranet/__tests__/intranet.service.spec.ts index 654256d133..ed4db49f4c 100644 --- a/src/app/services/intranet/__tests__/intranet.service.spec.ts +++ b/src/app/services/intranet/__tests__/intranet.service.spec.ts @@ -31,7 +31,7 @@ describe('IntranetService', () => { it('should return true when IP is in intranet IP list', () => { const result = intranetService.isIntranetIp(MOCK_IP_LIST[0]) - expect(result._unsafeUnwrap()).toBe(true) + expect(result).toBe(true) }) it('should return false when IP is not in intranet IP list', () => { @@ -39,7 +39,7 @@ describe('IntranetService', () => { const result = intranetService.isIntranetIp(ipNotInList) - expect(result._unsafeUnwrap()).toBe(false) + expect(result).toBe(false) }) }) }) From 5630a45bf4ca3e2a50e8fb5914cfc52772460b10 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Tue, 6 Apr 2021 17:43:23 +0800 Subject: [PATCH 68/86] refactor(app/utils): removed duplicate datatype in handle mongo errors --- src/app/utils/handle-mongo-error.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/utils/handle-mongo-error.ts b/src/app/utils/handle-mongo-error.ts index 31ed3e17bd..2ca25fcf43 100644 --- a/src/app/utils/handle-mongo-error.ts +++ b/src/app/utils/handle-mongo-error.ts @@ -6,6 +6,7 @@ import { DatabaseError, DatabasePayloadSizeError, DatabaseValidationError, + PossibleDatabaseError, } from '../modules/core/core.errors' /** From e75f3dce70ec58701aaa60c500ff469f0d32564b Mon Sep 17 00:00:00 2001 From: seaerchin Date: Tue, 6 Apr 2021 17:44:15 +0800 Subject: [PATCH 69/86] style(form/service): updated logger message --- src/app/modules/form/form.service.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/app/modules/form/form.service.ts b/src/app/modules/form/form.service.ts index bda7110fae..2677e8f321 100644 --- a/src/app/modules/form/form.service.ts +++ b/src/app/modules/form/form.service.ts @@ -220,7 +220,12 @@ export const checkFormSubmissionLimitAndDeactivateForm = ( // Map success case back into error to display to client as form has been // deactivated. return deactivateForm(formId).andThen(() => - errAsync(new PrivateFormError(form.inactiveMessage, form.title)), + errAsync( + new PrivateFormError( + 'Submission made after form submission limit was reached', + form.title, + ), + ), ) }) } From acca8081f836b5c549b7ccad385988d9caa992cc Mon Sep 17 00:00:00 2001 From: seaerchin Date: Tue, 6 Apr 2021 17:45:48 +0800 Subject: [PATCH 70/86] test(spcp/service): removed tests for deleted method; updated test for getSpcpSession --- .../spcp/__tests__/spcp.service.spec.ts | 76 ++----------------- 1 file changed, 5 insertions(+), 71 deletions(-) diff --git a/src/app/modules/spcp/__tests__/spcp.service.spec.ts b/src/app/modules/spcp/__tests__/spcp.service.spec.ts index 498ac282d3..a20b561d6f 100644 --- a/src/app/modules/spcp/__tests__/spcp.service.spec.ts +++ b/src/app/modules/spcp/__tests__/spcp.service.spec.ts @@ -4,10 +4,7 @@ import fs from 'fs' import { omit } from 'lodash' import { mocked } from 'ts-jest/utils' -import { - MOCK_COOKIE_AGE, - MOCK_MYINFO_FORM, -} from 'src/app/modules/myinfo/__tests__/myinfo.test.constants' +import { MOCK_COOKIE_AGE } from 'src/app/modules/myinfo/__tests__/myinfo.test.constants' import { ISpcpMyInfo } from 'src/config/feature-manager' import { AuthType } from 'src/types' @@ -22,7 +19,6 @@ import { MissingAttributesError, MissingJwtError, RetrieveAttributesError, - SpcpAuthTypeError, VerifyJwtError, } from '../spcp.errors' import { SpcpService } from '../spcp.service' @@ -30,7 +26,6 @@ import { JwtName } from '../spcp.types' import { MOCK_COOKIES, - MOCK_CP_FORM, MOCK_CP_JWT_PAYLOAD, MOCK_CP_SAML, MOCK_DESTINATION, @@ -42,7 +37,6 @@ import { MOCK_LOGIN_HTML, MOCK_REDIRECT_URL, MOCK_SERVICE_PARAMS as MOCK_PARAMS, - MOCK_SP_FORM, MOCK_SP_JWT_PAYLOAD, MOCK_SP_SAML, MOCK_SP_SAML_WRONG_HASH, @@ -820,7 +814,7 @@ describe('spcp.service', () => { const result = await spcpService.getSpcpSession(AuthType.SP, MOCK_COOKIES) // Assert - expect(result._unsafeUnwrap()).toBe(MOCK_SP_JWT_PAYLOAD) + expect(result._unsafeUnwrap()).toEqual(MOCK_SP_JWT_PAYLOAD) }) it('should return a CP JWT payload when there is a valid JWT in the request', async () => { @@ -836,7 +830,9 @@ describe('spcp.service', () => { const result = await spcpService.getSpcpSession(AuthType.CP, MOCK_COOKIES) // Assert - expect(result._unsafeUnwrap()).toBe(MOCK_CP_JWT_PAYLOAD) + expect(result._unsafeUnwrap()).toEqual({ + userName: MOCK_CP_JWT_PAYLOAD.userName, + }) }) it('should return MissingJwtError if there is no JWT when client authenticates using SP', async () => { @@ -926,66 +922,4 @@ describe('spcp.service', () => { expect(result._unsafeUnwrapErr()).toEqual(expected) }) }) - - describe('createFormWithSpcpSession', () => { - it('should return the public form view when clients authenticate using SP', async () => { - // Arrange - const spcpService = new SpcpService(MOCK_PARAMS) - // Assumes that SP auth client was instantiated first - const mockClient = mocked(MockAuthClient.mock.instances[0], true) - mockClient.verifyJWT.mockImplementationOnce((jwt, cb) => - cb(null, MOCK_SP_JWT_PAYLOAD), - ) - const expected = { - form: MOCK_SP_FORM.getPublicView(), - spcpSession: { userName: MOCK_SP_JWT_PAYLOAD.userName }, - } - - // Act - const result = spcpService.createFormWithSpcpSession( - MOCK_SP_FORM, - MOCK_COOKIES, - ) - - // Assert - expect((await result)._unsafeUnwrap()).toEqual(expected) - }) - - it('should return the public form view when clients authenticate using CP', async () => { - // Arrange - const spcpService = new SpcpService(MOCK_PARAMS) - // Assumes that SP auth client was instantiated first - const mockClient = mocked(MockAuthClient.mock.instances[1], true) - mockClient.verifyJWT.mockImplementationOnce((jwt, cb) => - cb(null, MOCK_CP_JWT_PAYLOAD), - ) - const expected = { - form: MOCK_CP_FORM.getPublicView(), - spcpSession: { userName: MOCK_CP_JWT_PAYLOAD.userName }, - } - - // Act - const result = spcpService.createFormWithSpcpSession( - MOCK_CP_FORM, - MOCK_COOKIES, - ) - - // Assert - expect((await result)._unsafeUnwrap()).toEqual(expected) - }) - it('should return SpcpAuthTypeError when auth type is not SP or CP', async () => { - // Arrange - const spcpService = new SpcpService(MOCK_PARAMS) - const expected = new SpcpAuthTypeError() - - // Act - const result = await spcpService.createFormWithSpcpSession( - MOCK_MYINFO_FORM, - MOCK_COOKIES, - ) - - // Assert - expect(result._unsafeUnwrapErr()).toEqual(expected) - }) - }) }) From 0ab99bbaf92692ee9edd62bc67ae717a4edc7868 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Tue, 6 Apr 2021 17:50:52 +0800 Subject: [PATCH 71/86] refactor(public-form/controller): extracts logger meta property into a variable --- .../form/public-form/public-form.controller.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/app/modules/form/public-form/public-form.controller.ts b/src/app/modules/form/public-form/public-form.controller.ts index ffa4e4d113..00e647162d 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -191,6 +191,11 @@ export const handleGetPublicForm: RequestHandler< PublicFormViewDto | ErrorDto > = async (req, res) => { const { formId } = req.params + const logMeta = { + action: 'handleGetPublicForm', + ...createReqMeta(req), + formId, + } const formResult = await getFormIfPublic(formId).andThen((form) => FormService.checkFormSubmissionLimitAndDeactivateForm(form), @@ -204,11 +209,7 @@ export const handleGetPublicForm: RequestHandler< if (isMongoError(error)) { logger.error({ message: 'Error retrieving public form', - meta: { - action: 'handleGetPublicForm', - ...createReqMeta(req), - formId, - }, + meta: logMeta, error, }) } @@ -245,11 +246,7 @@ export const handleGetPublicForm: RequestHandler< ) { logger.error({ message: 'Error getting public form', - meta: { - action: 'handleGetPublicForm', - ...createReqMeta(req), - formId, - }, + meta: logMeta, error, }) } From 6d272b7cbaafdf49f05acaadda58d7d10002855b Mon Sep 17 00:00:00 2001 From: seaerchin Date: Tue, 6 Apr 2021 17:51:54 +0800 Subject: [PATCH 72/86] style(form/service): updated action property of logger meta --- src/app/modules/form/form.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/modules/form/form.service.ts b/src/app/modules/form/form.service.ts index 2677e8f321..eb57ef5308 100644 --- a/src/app/modules/form/form.service.ts +++ b/src/app/modules/form/form.service.ts @@ -264,7 +264,7 @@ export const checkIsIntranetFormAccess = ( message: 'Attempting to access SingPass, CorpPass or MyInfo form from intranet', meta: { - action: 'read', + action: 'checkIsIntranetFormAccess', formId: form._id, }, }) From 00e52624d340ee828fc578ac9cd0cd921eb68d83 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Tue, 6 Apr 2021 17:55:15 +0800 Subject: [PATCH 73/86] docs(public-form/controller): updated comments for getPublicForm on conditions for myInfoError --- src/app/modules/form/public-form/public-form.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/modules/form/public-form/public-form.controller.ts b/src/app/modules/form/public-form/public-form.controller.ts index 00e647162d..afc219efc3 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -292,7 +292,7 @@ export const handleGetPublicForm: RequestHandler< }) }) .mapErr((error) => { - // NOTE: If the user is not signed in or the access token has been used before, it is not an error. + // NOTE: If the user is not signed in or if the user refreshes the page while logged in, it is not an error. // myInfoError is set to true only when the authentication provider rejects the user's attempt at auth // or when there is a network or database error during the process of retrieval const isMyInfoError = !( From 8bc83143582edf3fdfdad05d03c178356ab5dc59 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Tue, 6 Apr 2021 18:19:26 +0800 Subject: [PATCH 74/86] refactor(myinfo): deleted unused middleware; made fetchMyInfoPersonData private --- src/app/modules/myinfo/myinfo.middleware.ts | 101 -------------------- src/app/modules/myinfo/myinfo.service.ts | 2 +- 2 files changed, 1 insertion(+), 102 deletions(-) delete mode 100644 src/app/modules/myinfo/myinfo.middleware.ts diff --git a/src/app/modules/myinfo/myinfo.middleware.ts b/src/app/modules/myinfo/myinfo.middleware.ts deleted file mode 100644 index 2741a4a211..0000000000 --- a/src/app/modules/myinfo/myinfo.middleware.ts +++ /dev/null @@ -1,101 +0,0 @@ -// TODO (#144): move these into their respective controllers when -// those controllers are being refactored. -// A services module should not contain a controller. -import { RequestHandler } from 'express' -import { ParamsDictionary } from 'express-serve-static-core' - -import { createLoggerWithLabel } from '../../../config/logger' -import { AuthType, WithForm, WithJsonForm } from '../../../types' -import { createReqMeta } from '../../utils/request' - -import { MYINFO_COOKIE_NAME, MYINFO_COOKIE_OPTIONS } from './myinfo.constants' -import { MyInfoFactory } from './myinfo.factory' -import { MyInfoCookiePayload, MyInfoCookieState } from './myinfo.types' -import { extractMyInfoCookie, validateMyInfoForm } from './myinfo.util' - -const logger = createLoggerWithLabel(module) - -/** - * Middleware for prefilling MyInfo values. - * @returns next, always. If any error occurs, res.locals.myInfoError is set to true. - */ -export const addMyInfo: RequestHandler = async ( - req, - res, - next, -) => { - // TODO (#42): add proper types here when migrating away from middleware pattern - const formDocument = (req as WithForm).form - const formJson = formDocument.toJSON() - const myInfoCookieResult = extractMyInfoCookie(req.cookies) - - // No action needed if no cookie is present, this just means user is not signed in - if (formDocument.authType !== AuthType.MyInfo || myInfoCookieResult.isErr()) - return next() - const myInfoCookie = myInfoCookieResult.value - - // Error occurred while retrieving access token - if (myInfoCookie.state !== MyInfoCookieState.Success) { - res.locals.myInfoError = true - // Important - clear the cookie so that user will not see error on refresh - res.clearCookie(MYINFO_COOKIE_NAME, MYINFO_COOKIE_OPTIONS) - return next() - } - - // Access token is already used - if (myInfoCookie.usedCount > 0) { - res.clearCookie(MYINFO_COOKIE_NAME, MYINFO_COOKIE_OPTIONS) - return next() - } - - const requestedAttributes = (req as WithForm< - typeof req - >).form.getUniqueMyInfoAttrs() - return validateMyInfoForm(formDocument) - .asyncAndThen((form) => - MyInfoFactory.fetchMyInfoPersonData( - myInfoCookie.accessToken, - requestedAttributes, - form.esrvcId, - ), - ) - .andThen((myInfoData) => { - // Increment count in cookie - const cookiePayload: MyInfoCookiePayload = { - ...myInfoCookie, - usedCount: myInfoCookie.usedCount + 1, - } - res.cookie(MYINFO_COOKIE_NAME, cookiePayload, MYINFO_COOKIE_OPTIONS) - return MyInfoFactory.prefillMyInfoFields( - myInfoData, - formJson.form_fields, - ).asyncAndThen((prefilledFields) => { - formJson.form_fields = prefilledFields - ;(req as WithJsonForm).form = formJson - res.locals.spcpSession = { userName: myInfoData.getUinFin() } - return MyInfoFactory.saveMyInfoHashes( - myInfoData.getUinFin(), - formDocument._id, - prefilledFields, - ) - }) - }) - .map(() => next()) - .mapErr((error) => { - // No need for cookie if data could not be retrieved - res.clearCookie(MYINFO_COOKIE_NAME, MYINFO_COOKIE_OPTIONS) - logger.error({ - message: error.message, - meta: { - action: 'addMyInfo', - ...createReqMeta(req), - formId: formDocument._id, - esrvcId: formDocument.esrvcId, - requestedAttributes, - }, - error, - }) - res.locals.myInfoError = true - return next() - }) -} diff --git a/src/app/modules/myinfo/myinfo.service.ts b/src/app/modules/myinfo/myinfo.service.ts index 179e75beb5..91b2bf04a1 100644 --- a/src/app/modules/myinfo/myinfo.service.ts +++ b/src/app/modules/myinfo/myinfo.service.ts @@ -241,7 +241,7 @@ export class MyInfoService { * @returns the person object retrieved. * @throws an error on fetch failure or if circuit breaker is in the opened state. Use {@link CircuitBreaker#isOurError} to determine if a rejection was a result of the circuit breaker or the action. */ - fetchMyInfoPersonData( + private fetchMyInfoPersonData( accessToken: string, requestedAttributes: MyInfoAttribute[], singpassEserviceId: string, From 50ef88666b34a136c69ac4dd19f6f17fd7426e92 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Wed, 7 Apr 2021 10:13:18 +0800 Subject: [PATCH 75/86] refactor(myinfo): changed fetchMyInfoPersonData to become a private field --- src/app/modules/myinfo/myinfo.factory.ts | 10 --- src/app/modules/myinfo/myinfo.service.ts | 104 ++++++++++++----------- 2 files changed, 55 insertions(+), 59 deletions(-) diff --git a/src/app/modules/myinfo/myinfo.factory.ts b/src/app/modules/myinfo/myinfo.factory.ts index 2be3708a6d..3bd34eba16 100644 --- a/src/app/modules/myinfo/myinfo.factory.ts +++ b/src/app/modules/myinfo/myinfo.factory.ts @@ -12,7 +12,6 @@ import { IHashes, IMyInfoHashSchema, IPopulatedForm, - MyInfoAttribute, UserSession, } from '../../../types' import { DatabaseError, MissingFeatureError } from '../core/core.errors' @@ -48,14 +47,6 @@ interface IMyInfoFactory { retrieveAccessToken: ( authCode: string, ) => ResultAsync - fetchMyInfoPersonData: ( - accessToken: string, - requestedAttributes: MyInfoAttribute[], - singpassEserviceId: string, - ) => ResultAsync< - MyInfoData, - MyInfoCircuitBreakerError | MyInfoFetchError | MissingFeatureError - > parseMyInfoRelayState: ( relayState: string, ) => Result< @@ -125,7 +116,6 @@ export const createMyInfoFactory = ({ const error = new MissingFeatureError(FeatureNames.SpcpMyInfo) return { retrieveAccessToken: () => errAsync(error), - fetchMyInfoPersonData: () => errAsync(error), prefillMyInfoFields: () => err(error), saveMyInfoHashes: () => errAsync(error), fetchMyInfoHashes: () => errAsync(error), diff --git a/src/app/modules/myinfo/myinfo.service.ts b/src/app/modules/myinfo/myinfo.service.ts index 91b2bf04a1..4eaea3c01b 100644 --- a/src/app/modules/myinfo/myinfo.service.ts +++ b/src/app/modules/myinfo/myinfo.service.ts @@ -98,6 +98,12 @@ export class MyInfoService { */ #spCookieMaxAge: number + #fetchMyInfoPersonData: ( + accessToken: string, + requestedAttributes: MyInfoAttribute[], + singpassEserviceId: string, + ) => ResultAsync + /** * * @param myInfoConfig Environment variables including myInfoClientMode and myInfoKeyPath @@ -130,6 +136,54 @@ export class MyInfoService { this.#myInfoGovClient.getPerson(accessToken, attributes, eSrvcId), BREAKER_PARAMS, ) + + /** + * Fetches MyInfo person detail with given params. + * This function has circuit breaking built into it, and will throw an error + * if any recent usages of this function returned an error. + * @param params The params required to retrieve the data. + * @param params.uinFin The uin/fin of the person's data to retrieve. + * @param params.requestedAttributes The requested attributes to fetch. + * @param params.singpassEserviceId The eservice id of the form requesting the data. + * @returns the person object retrieved. + * @throws an error on fetch failure or if circuit breaker is in the opened state. Use {@link CircuitBreaker#isOurError} to determine if a rejection was a result of the circuit breaker or the action. + */ + this.#fetchMyInfoPersonData = function ( + accessToken: string, + requestedAttributes: MyInfoAttribute[], + singpassEserviceId: string, + ): ResultAsync { + return ResultAsync.fromPromise( + this.#myInfoPersonBreaker + .fire( + accessToken, + internalAttrListToScopes(requestedAttributes), + singpassEserviceId, + ) + .then((response) => new MyInfoData(response)), + (error) => { + const logMeta = { + action: 'fetchMyInfoPersonData', + requestedAttributes, + } + if (CircuitBreaker.isOurError(error)) { + logger.error({ + message: 'Circuit breaker tripped', + meta: logMeta, + error, + }) + return new MyInfoCircuitBreakerError() + } else { + logger.error({ + message: 'Error retrieving data from MyInfo', + meta: logMeta, + error, + }) + return new MyInfoFetchError() + } + }, + ) + } } /** @@ -230,54 +284,6 @@ export class MyInfoService { ) } - /** - * Fetches MyInfo person detail with given params. - * This function has circuit breaking built into it, and will throw an error - * if any recent usages of this function returned an error. - * @param params The params required to retrieve the data. - * @param params.uinFin The uin/fin of the person's data to retrieve. - * @param params.requestedAttributes The requested attributes to fetch. - * @param params.singpassEserviceId The eservice id of the form requesting the data. - * @returns the person object retrieved. - * @throws an error on fetch failure or if circuit breaker is in the opened state. Use {@link CircuitBreaker#isOurError} to determine if a rejection was a result of the circuit breaker or the action. - */ - private fetchMyInfoPersonData( - accessToken: string, - requestedAttributes: MyInfoAttribute[], - singpassEserviceId: string, - ): ResultAsync { - return ResultAsync.fromPromise( - this.#myInfoPersonBreaker - .fire( - accessToken, - internalAttrListToScopes(requestedAttributes), - singpassEserviceId, - ) - .then((response) => new MyInfoData(response)), - (error) => { - const logMeta = { - action: 'fetchMyInfoPersonData', - requestedAttributes, - } - if (CircuitBreaker.isOurError(error)) { - logger.error({ - message: 'Circuit breaker tripped', - meta: logMeta, - error, - }) - return new MyInfoCircuitBreakerError() - } else { - logger.error({ - message: 'Error retrieving data from MyInfo', - meta: logMeta, - error, - }) - return new MyInfoFetchError() - } - }, - ) - } - /** * Prefill given current form fields with given MyInfo data. * @param myInfoData @@ -497,7 +503,7 @@ export class MyInfoService { .andThen((myInfoCookie) => assertMyInfoCookieUnused(myInfoCookie)) .asyncAndThen((cookiePayload) => validateMyInfoForm(form).asyncAndThen((form) => - this.fetchMyInfoPersonData( + this.#fetchMyInfoPersonData( cookiePayload.accessToken, requestedAttributes, form.esrvcId, From d066a5dbee57c76b11f3850edf8323cc9c6f1733 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Wed, 7 Apr 2021 10:13:58 +0800 Subject: [PATCH 76/86] test(myinfo): fixed factory and service tests due to making myInfoPersonData private --- .../myinfo/__tests__/myinfo.factory.spec.ts | 20 ++- .../myinfo/__tests__/myinfo.service.spec.ts | 125 +++++++++--------- 2 files changed, 72 insertions(+), 73 deletions(-) diff --git a/src/app/modules/myinfo/__tests__/myinfo.factory.spec.ts b/src/app/modules/myinfo/__tests__/myinfo.factory.spec.ts index 4812b81efc..9146b3783b 100644 --- a/src/app/modules/myinfo/__tests__/myinfo.factory.spec.ts +++ b/src/app/modules/myinfo/__tests__/myinfo.factory.spec.ts @@ -2,7 +2,7 @@ import { mocked } from 'ts-jest/utils' import config from 'src/config/config' import { ISpcpMyInfo } from 'src/config/feature-manager' -import { Environment } from 'src/types' +import { Environment, IPopulatedForm } from 'src/types' import { MyInfoData } from '../myinfo.adapter' import { createMyInfoFactory } from '../myinfo.factory' @@ -45,10 +45,9 @@ describe('myinfo.factory', () => { ) const parseMyInfoRelayStateResult = MyInfoFactory.parseMyInfoRelayState('') const extractUinFinResult = MyInfoFactory.extractUinFin('') - const fetchMyInfoPersonDataResult = await MyInfoFactory.fetchMyInfoPersonData( - '', - [], - '', + const fetchMyInfoDataResult = await MyInfoFactory.fetchMyInfoData( + ({} as unknown) as IPopulatedForm, + {}, ) const prefillMyInfoFieldsResult = MyInfoFactory.prefillMyInfoFields( {} as MyInfoData, @@ -71,7 +70,7 @@ describe('myinfo.factory', () => { expect(parseMyInfoRelayStateResult._unsafeUnwrapErr()).toEqual(error) expect(createRedirectURLResult._unsafeUnwrapErr()).toEqual(error) expect(extractUinFinResult._unsafeUnwrapErr()).toEqual(error) - expect(fetchMyInfoPersonDataResult._unsafeUnwrapErr()).toEqual(error) + expect(fetchMyInfoDataResult._unsafeUnwrapErr()).toEqual(error) expect(prefillMyInfoFieldsResult._unsafeUnwrapErr()).toEqual(error) expect(saveMyInfoHashesResult._unsafeUnwrapErr()).toEqual(error) expect(fetchMyInfoHashesResult._unsafeUnwrapErr()).toEqual(error) @@ -94,10 +93,9 @@ describe('myinfo.factory', () => { ) const parseMyInfoRelayStateResult = MyInfoFactory.parseMyInfoRelayState('') const extractUinFinResult = MyInfoFactory.extractUinFin('') - const fetchMyInfoPersonDataResult = await MyInfoFactory.fetchMyInfoPersonData( - '', - [], - '', + const fetchMyInfoDataResult = await MyInfoFactory.fetchMyInfoData( + ({} as unknown) as IPopulatedForm, + {}, ) const prefillMyInfoFieldsResult = MyInfoFactory.prefillMyInfoFields( {} as MyInfoData, @@ -120,7 +118,7 @@ describe('myinfo.factory', () => { expect(parseMyInfoRelayStateResult._unsafeUnwrapErr()).toEqual(error) expect(createRedirectURLResult._unsafeUnwrapErr()).toEqual(error) expect(extractUinFinResult._unsafeUnwrapErr()).toEqual(error) - expect(fetchMyInfoPersonDataResult._unsafeUnwrapErr()).toEqual(error) + expect(fetchMyInfoDataResult._unsafeUnwrapErr()).toEqual(error) expect(prefillMyInfoFieldsResult._unsafeUnwrapErr()).toEqual(error) expect(saveMyInfoHashesResult._unsafeUnwrapErr()).toEqual(error) expect(fetchMyInfoHashesResult._unsafeUnwrapErr()).toEqual(error) diff --git a/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts b/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts index b19ffe8781..f57395692e 100644 --- a/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts +++ b/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts @@ -190,68 +190,6 @@ describe('MyInfoService', () => { }) }) - describe('fetchMyInfoPersonData', () => { - beforeEach(() => { - myInfoService = new MyInfoService(MOCK_SERVICE_PARAMS) - }) - - it('should call MyInfoGovClient.getPerson with the correct parameters', async () => { - const mockReturnedParams = { - uinFin: MOCK_UINFIN, - data: MOCK_MYINFO_DATA, - } - mockGetPerson.mockResolvedValueOnce(mockReturnedParams) - const result = await myInfoService.fetchMyInfoPersonData( - MOCK_ACCESS_TOKEN, - MOCK_REQUESTED_ATTRS, - MOCK_ESRVC_ID, - ) - - expect(mockGetPerson).toHaveBeenCalledWith( - MOCK_ACCESS_TOKEN, - MOCK_REQUESTED_ATTRS.concat('uinfin' as MyInfoAttribute), - MOCK_ESRVC_ID, - ) - expect(result._unsafeUnwrap()).toEqual(new MyInfoData(mockReturnedParams)) - }) - - it('should throw MyInfoFetchError when getPerson fails once', async () => { - mockGetPerson.mockRejectedValueOnce(new Error()) - const result = await myInfoService.fetchMyInfoPersonData( - MOCK_ACCESS_TOKEN, - MOCK_REQUESTED_ATTRS, - MOCK_ESRVC_ID, - ) - - expect(mockGetPerson).toHaveBeenCalledWith( - MOCK_ACCESS_TOKEN, - MOCK_REQUESTED_ATTRS.concat('uinfin' as MyInfoAttribute), - MOCK_ESRVC_ID, - ) - expect(result._unsafeUnwrapErr()).toEqual(new MyInfoFetchError()) - }) - - it('should throw MyInfoCircuitBreakerError when getPerson fails 5 times', async () => { - mockGetPerson.mockRejectedValue(new Error()) - for (let i = 0; i < 5; i++) { - await myInfoService.fetchMyInfoPersonData( - MOCK_ACCESS_TOKEN, - MOCK_REQUESTED_ATTRS, - MOCK_ESRVC_ID, - ) - } - const result = await myInfoService.fetchMyInfoPersonData( - MOCK_ACCESS_TOKEN, - MOCK_REQUESTED_ATTRS, - MOCK_ESRVC_ID, - ) - - // Last function call doesn't count as breaker is open, so expect 5 calls - expect(mockGetPerson).toHaveBeenCalledTimes(5) - expect(result._unsafeUnwrapErr()).toEqual(new MyInfoCircuitBreakerError()) - }) - }) - describe('prefillMyInfoFields', () => { it('should prefill fields correctly', () => { const mockData = new MyInfoData({ @@ -468,6 +406,69 @@ describe('MyInfoService', () => { expect(result._unsafeUnwrap()).toEqual(new MyInfoData(mockReturnedParams)) }) + it('should call MyInfoGovClient.getPerson with the correct parameters', async () => { + // Arrange + const mockReturnedParams = { + uinFin: MOCK_UINFIN, + data: MOCK_MYINFO_DATA, + } + mockGetPerson.mockResolvedValueOnce(mockReturnedParams) + + // Act + const result = await myInfoService.fetchMyInfoData( + MOCK_MYINFO_FORM as IPopulatedForm, + { MyInfoCookie: MOCK_SUCCESSFUL_COOKIE }, + ) + + // Assert + expect(mockGetPerson).toHaveBeenCalledWith( + MOCK_ACCESS_TOKEN, + MOCK_REQUESTED_ATTRS.concat('uinfin' as MyInfoAttribute), + MOCK_ESRVC_ID, + ) + expect(result._unsafeUnwrap()).toEqual(new MyInfoData(mockReturnedParams)) + }) + + it('should throw MyInfoFetchError when getPerson fails once', async () => { + // Arrange + mockGetPerson.mockRejectedValueOnce(new Error()) + + // Act + const result = await myInfoService.fetchMyInfoData( + MOCK_MYINFO_FORM as IPopulatedForm, + { MyInfoCookie: MOCK_SUCCESSFUL_COOKIE }, + ) + + // Assert + expect(mockGetPerson).toHaveBeenCalledWith( + MOCK_ACCESS_TOKEN, + MOCK_REQUESTED_ATTRS.concat('uinfin' as MyInfoAttribute), + MOCK_ESRVC_ID, + ) + expect(result._unsafeUnwrapErr()).toEqual(new MyInfoFetchError()) + }) + + it('should throw MyInfoCircuitBreakerError when getPerson fails 5 times', async () => { + // Arrange + mockGetPerson.mockRejectedValue(new Error()) + for (let i = 0; i < 5; i++) { + await myInfoService.fetchMyInfoData( + MOCK_MYINFO_FORM as IPopulatedForm, + { MyInfoCookie: MOCK_SUCCESSFUL_COOKIE }, + ) + } + + // Act + const result = await myInfoService.fetchMyInfoData( + MOCK_MYINFO_FORM as IPopulatedForm, + { MyInfoCookie: MOCK_SUCCESSFUL_COOKIE }, + ) + + // Assert + // Last function call doesn't count as breaker is open, so expect 5 calls + expect(mockGetPerson).toHaveBeenCalledTimes(5) + expect(result._unsafeUnwrapErr()).toEqual(new MyInfoCircuitBreakerError()) + }) it('should not validate the form if the cookie does not exist', async () => { // Arrange const expected = new MyInfoMissingAccessTokenError() From 6ae2fec16d7b5e842b464c62b1f1279af40ecad4 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Wed, 7 Apr 2021 10:14:36 +0800 Subject: [PATCH 77/86] chore(webhook/service/test): fixed import --- src/app/modules/webhook/__tests__/webhook.service.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/modules/webhook/__tests__/webhook.service.spec.ts b/src/app/modules/webhook/__tests__/webhook.service.spec.ts index 89d592655b..a1b74c5952 100644 --- a/src/app/modules/webhook/__tests__/webhook.service.spec.ts +++ b/src/app/modules/webhook/__tests__/webhook.service.spec.ts @@ -1,6 +1,5 @@ import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios' import { ObjectID } from 'bson' -import { SubmissionNotFoundError } from 'dist/backend/app/modules/submission/submission.errors' import mongoose from 'mongoose' import { mocked } from 'ts-jest/utils' @@ -20,6 +19,7 @@ import { import dbHandler from 'tests/unit/backend/helpers/jest-db' +import { SubmissionNotFoundError } from '../../submission/submission.errors' import { saveWebhookRecord, sendWebhook } from '../webhook.service' // define suite-wide mocks From 330378197a390178b83d3600f7e70efd7ce9136b Mon Sep 17 00:00:00 2001 From: seaerchin Date: Wed, 7 Apr 2021 15:38:59 +0800 Subject: [PATCH 78/86] chore(types/forms): removed unused type declaration --- src/types/form.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/types/form.ts b/src/types/form.ts index f4e8d1fdc3..3cbaa67321 100644 --- a/src/types/form.ts +++ b/src/types/form.ts @@ -268,14 +268,6 @@ export type FormMetaView = Pick< admin: IPopulatedUser } -/** - * Mapping type between authType into an output function to ensure that the indexed type has transformations for all forms. - * F is a function type that maps authType into the output - */ -export type FormController = { - [K in keyof typeof AuthType]: F -} - /** * The current session of a user who is logged in */ From 55b5be7e685e2842a5d2267f08329c1d8f37cbad Mon Sep 17 00:00:00 2001 From: seaerchin Date: Wed, 7 Apr 2021 18:27:21 +0800 Subject: [PATCH 79/86] style(form/service): chains calls together for clarity --- src/app/modules/form/form.service.ts | 44 ++++++++++++++-------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/src/app/modules/form/form.service.ts b/src/app/modules/form/form.service.ts index eb57ef5308..5ff5e62333 100644 --- a/src/app/modules/form/form.service.ts +++ b/src/app/modules/form/form.service.ts @@ -252,27 +252,27 @@ export const checkIsIntranetFormAccess = ( ip: string, form: IPopulatedForm, ): boolean => { - const isIntranetIpResult = IntranetFactory.isIntranetIp(ip).andThen( - (isIntranetUser) => { - // Warn if form is being accessed from within intranet - // and the form has authentication set - if ( - isIntranetUser && - [AuthType.SP, AuthType.CP, AuthType.MyInfo].includes(form.authType) - ) { - logger.warn({ - message: - 'Attempting to access SingPass, CorpPass or MyInfo form from intranet', - meta: { - action: 'checkIsIntranetFormAccess', - formId: form._id, - }, - }) - } - return ok(isIntranetUser) - }, + return ( + IntranetFactory.isIntranetIp(ip) + .andThen((isIntranetUser) => { + // Warn if form is being accessed from within intranet + // and the form has authentication set + if ( + isIntranetUser && + [AuthType.SP, AuthType.CP, AuthType.MyInfo].includes(form.authType) + ) { + logger.warn({ + message: + 'Attempting to access SingPass, CorpPass or MyInfo form from intranet', + meta: { + action: 'checkIsIntranetFormAccess', + formId: form._id, + }, + }) + } + return ok(isIntranetUser) + }) + // This is required becausing the factory can throw missing feature error on initialization + .unwrapOr(false) ) - - // This is required becausing the factory can throw missing feature error on initialization - return isIntranetIpResult.unwrapOr(false) } From dcc62761c71ad6b640e70501bcce5e0df34fa50e Mon Sep 17 00:00:00 2001 From: seaerchin Date: Wed, 7 Apr 2021 18:30:59 +0800 Subject: [PATCH 80/86] refactor(myinfo): refactored methods so that atomic operations are performed together --- src/app/modules/myinfo/myinfo.factory.ts | 29 +++++-------- src/app/modules/myinfo/myinfo.service.ts | 52 +++++++----------------- src/app/modules/myinfo/myinfo.types.ts | 4 -- src/types/form.ts | 8 ---- 4 files changed, 24 insertions(+), 69 deletions(-) diff --git a/src/app/modules/myinfo/myinfo.factory.ts b/src/app/modules/myinfo/myinfo.factory.ts index 3bd34eba16..d7f51b04b6 100644 --- a/src/app/modules/myinfo/myinfo.factory.ts +++ b/src/app/modules/myinfo/myinfo.factory.ts @@ -1,6 +1,5 @@ -import mongoose, { LeanDocument } from 'mongoose' +import { LeanDocument } from 'mongoose' import { err, errAsync, Result, ResultAsync } from 'neverthrow' -import { Merge } from 'type-fest' import config from '../../../config/config' import FeatureManager, { @@ -12,7 +11,6 @@ import { IHashes, IMyInfoHashSchema, IPopulatedForm, - UserSession, } from '../../../types' import { DatabaseError, MissingFeatureError } from '../core/core.errors' import { ProcessedFieldResponse } from '../submission/submission.types' @@ -36,7 +34,6 @@ import { MyInfoService } from './myinfo.service' import { IMyInfoRedirectURLArgs, IPossiblyPrefilledField, - IPossiblyPrefilledFieldArray, MyInfoParsedRelayState, } from './myinfo.types' @@ -53,10 +50,14 @@ interface IMyInfoFactory { MyInfoParsedRelayState, MyInfoParseRelayStateError | MissingFeatureError > - prefillMyInfoFields: ( + prefillAndSaveMyInfoFields: ( + formId: string, myInfoData: MyInfoData, currFormFields: LeanDocument, - ) => Result + ) => ResultAsync< + IPossiblyPrefilledField[], + MyInfoHashingError | DatabaseError + > saveMyInfoHashes: ( uinFin: string, formId: string, @@ -83,7 +84,7 @@ interface IMyInfoFactory { accessToken: string, ) => Result - fetchMyInfoData: ( + getMyInfoDataForForm: ( form: IPopulatedForm, cookies: Record, ) => ResultAsync< @@ -97,15 +98,6 @@ interface IMyInfoFactory { | MissingFeatureError | MyInfoCookieAccessError > - - createFormWithMyInfoMeta: ( - formFields: mongoose.LeanDocument[], - myInfoData: MyInfoData, - formId: string, - ) => ResultAsync< - Merge, - DatabaseError | MyInfoHashingError - > } export const createMyInfoFactory = ({ @@ -116,15 +108,14 @@ export const createMyInfoFactory = ({ const error = new MissingFeatureError(FeatureNames.SpcpMyInfo) return { retrieveAccessToken: () => errAsync(error), - prefillMyInfoFields: () => err(error), + prefillAndSaveMyInfoFields: () => errAsync(error), saveMyInfoHashes: () => errAsync(error), fetchMyInfoHashes: () => errAsync(error), checkMyInfoHashes: () => errAsync(error), createRedirectURL: () => err(error), parseMyInfoRelayState: () => err(error), extractUinFin: () => err(error), - fetchMyInfoData: () => errAsync(error), - createFormWithMyInfoMeta: () => errAsync(error), + getMyInfoDataForForm: () => errAsync(error), } } return new MyInfoService({ diff --git a/src/app/modules/myinfo/myinfo.service.ts b/src/app/modules/myinfo/myinfo.service.ts index 4eaea3c01b..99e5062269 100644 --- a/src/app/modules/myinfo/myinfo.service.ts +++ b/src/app/modules/myinfo/myinfo.service.ts @@ -9,7 +9,6 @@ import { cloneDeep } from 'lodash' import mongoose, { LeanDocument } from 'mongoose' import { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow' import CircuitBreaker from 'opossum' -import { Merge } from 'type-fest' import { createLoggerWithLabel } from '../../../config/logger' import { @@ -19,7 +18,6 @@ import { IMyInfoHashSchema, IPopulatedForm, MyInfoAttribute, - UserSession, } from '../../../types' import { DatabaseError, MissingFeatureError } from '../core/core.errors' import { ProcessedFieldResponse } from '../submission/submission.types' @@ -48,7 +46,6 @@ import { IMyInfoRedirectURLArgs, IMyInfoServiceConfig, IPossiblyPrefilledField, - IPossiblyPrefilledFieldArray, MyInfoParsedRelayState, } from './myinfo.types' import { @@ -286,14 +283,19 @@ export class MyInfoService { /** * Prefill given current form fields with given MyInfo data. + * Saves the has of the prefilled fields as well because the two operations are atomic and should not be separated * @param myInfoData * @param currFormFields * @returns currFormFields with the MyInfo fields prefilled with data from myInfoData */ - prefillMyInfoFields( + prefillAndSaveMyInfoFields( + formId: string, myInfoData: MyInfoData, currFormFields: LeanDocument, - ): Result { + ): ResultAsync< + IPossiblyPrefilledField[], + MyInfoHashingError | DatabaseError + > { const prefilledFields = currFormFields.map((field) => { if (!field.myInfo?.attr) return field @@ -308,7 +310,11 @@ export class MyInfoService { prefilledField.disabled = isReadOnly return prefilledField }) - return ok(prefilledFields) + return this.saveMyInfoHashes( + myInfoData.getUinFin(), + formId, + prefilledFields, + ).map(() => prefilledFields) } /** @@ -471,7 +477,7 @@ export class MyInfoService { } /** - * Extracts myInfo data using the provided form and the cookies of the request + * Gets myInfo data using the provided form and the cookies of the request * @param form the form to validate * @param cookies cookies of the request * @returns ok(MyInfoData) if the form has been validated successfully @@ -484,7 +490,7 @@ export class MyInfoService { * @returns err(MyInfoFetchError) if validated but the data could not be retrieved * @returns err(MissingFeatureError) if using an outdated version that does not support myInfo */ - fetchMyInfoData( + getMyInfoDataForForm( form: IPopulatedForm, cookies: Record, ): ResultAsync< @@ -511,34 +517,4 @@ export class MyInfoService { ), ) } - - /** - * Creates a form view with myInfo fields prefilled onto the form - * @param form The form to validate and fill - * @param cookies The cookies on the request - * @returns ok({prefilledFields, spcpSession}) if the form could be filled and myInfoData saved - * @returns err(MyInfoHashingError) if myInfoData could not be hashed - * @returns err(DatabaseError) if an error occurred while trying to save myInfoData - */ - createFormWithMyInfoMeta( - formFields: mongoose.LeanDocument[], - myInfoData: MyInfoData, - formId: string, - ): ResultAsync< - Merge, - DatabaseError | MyInfoHashingError - > { - const uinFin = myInfoData.getUinFin() - return ( - // 1. Fill the form based on the result - this.prefillMyInfoFields(myInfoData, formFields) - // 2. Hash and save to database - .asyncAndThen((prefilledFields) => - this.saveMyInfoHashes(uinFin, formId, prefilledFields).map(() => ({ - prefilledFields, - spcpSession: { userName: uinFin }, - })), - ) - ) - } } diff --git a/src/app/modules/myinfo/myinfo.types.ts b/src/app/modules/myinfo/myinfo.types.ts index 40a9ab8278..2be487cad8 100644 --- a/src/app/modules/myinfo/myinfo.types.ts +++ b/src/app/modules/myinfo/myinfo.types.ts @@ -27,10 +27,6 @@ export interface IPossiblyPrefilledField extends LeanDocument { fieldValue?: string } -export interface IPossiblyPrefilledFieldArray { - prefilledFields: IPossiblyPrefilledField[] -} - export type MyInfoHashPromises = Partial< Record> > diff --git a/src/types/form.ts b/src/types/form.ts index 3cbaa67321..e8964e443a 100644 --- a/src/types/form.ts +++ b/src/types/form.ts @@ -7,7 +7,6 @@ import { PublicView } from './database' import { IFieldSchema, MyInfoAttribute } from './field' import { ILogicSchema } from './form_logic' import { FormLogoState, IFormLogo } from './form_logo' -import { SpcpSession } from './spcp' import { IPopulatedUser, IUserSchema, PublicUser } from './user' export enum AuthType { @@ -267,10 +266,3 @@ export type FormMetaView = Pick< > & { admin: IPopulatedUser } - -/** - * The current session of a user who is logged in - */ -export type UserSession = { - spcpSession: SpcpSession -} From a372a18efe5b424f82ee22d6a180022be34c6e3e Mon Sep 17 00:00:00 2001 From: seaerchin Date: Wed, 7 Apr 2021 18:31:49 +0800 Subject: [PATCH 81/86] test(myinfo): fixes tests for myinfo --- .../myinfo/__tests__/myinfo.factory.spec.ts | 18 +-- .../myinfo/__tests__/myinfo.service.spec.ts | 113 +++--------------- 2 files changed, 24 insertions(+), 107 deletions(-) diff --git a/src/app/modules/myinfo/__tests__/myinfo.factory.spec.ts b/src/app/modules/myinfo/__tests__/myinfo.factory.spec.ts index 9146b3783b..932d5200ca 100644 --- a/src/app/modules/myinfo/__tests__/myinfo.factory.spec.ts +++ b/src/app/modules/myinfo/__tests__/myinfo.factory.spec.ts @@ -45,11 +45,12 @@ describe('myinfo.factory', () => { ) const parseMyInfoRelayStateResult = MyInfoFactory.parseMyInfoRelayState('') const extractUinFinResult = MyInfoFactory.extractUinFin('') - const fetchMyInfoDataResult = await MyInfoFactory.fetchMyInfoData( + const getMyInfoDataForFormResult = await MyInfoFactory.getMyInfoDataForForm( ({} as unknown) as IPopulatedForm, {}, ) - const prefillMyInfoFieldsResult = MyInfoFactory.prefillMyInfoFields( + const prefillAndSaveMyInfoFieldsResult = await MyInfoFactory.prefillAndSaveMyInfoFields( + '', {} as MyInfoData, [], ) @@ -70,8 +71,8 @@ describe('myinfo.factory', () => { expect(parseMyInfoRelayStateResult._unsafeUnwrapErr()).toEqual(error) expect(createRedirectURLResult._unsafeUnwrapErr()).toEqual(error) expect(extractUinFinResult._unsafeUnwrapErr()).toEqual(error) - expect(fetchMyInfoDataResult._unsafeUnwrapErr()).toEqual(error) - expect(prefillMyInfoFieldsResult._unsafeUnwrapErr()).toEqual(error) + expect(getMyInfoDataForFormResult._unsafeUnwrapErr()).toEqual(error) + expect(prefillAndSaveMyInfoFieldsResult._unsafeUnwrapErr()).toEqual(error) expect(saveMyInfoHashesResult._unsafeUnwrapErr()).toEqual(error) expect(fetchMyInfoHashesResult._unsafeUnwrapErr()).toEqual(error) expect(checkMyInfoHashesResult._unsafeUnwrapErr()).toEqual(error) @@ -93,11 +94,12 @@ describe('myinfo.factory', () => { ) const parseMyInfoRelayStateResult = MyInfoFactory.parseMyInfoRelayState('') const extractUinFinResult = MyInfoFactory.extractUinFin('') - const fetchMyInfoDataResult = await MyInfoFactory.fetchMyInfoData( + const getMyInfoDataForFormResult = await MyInfoFactory.getMyInfoDataForForm( ({} as unknown) as IPopulatedForm, {}, ) - const prefillMyInfoFieldsResult = MyInfoFactory.prefillMyInfoFields( + const prefillAndSaveMyInfoFieldsResult = await MyInfoFactory.prefillAndSaveMyInfoFields( + '', {} as MyInfoData, [], ) @@ -118,8 +120,8 @@ describe('myinfo.factory', () => { expect(parseMyInfoRelayStateResult._unsafeUnwrapErr()).toEqual(error) expect(createRedirectURLResult._unsafeUnwrapErr()).toEqual(error) expect(extractUinFinResult._unsafeUnwrapErr()).toEqual(error) - expect(fetchMyInfoDataResult._unsafeUnwrapErr()).toEqual(error) - expect(prefillMyInfoFieldsResult._unsafeUnwrapErr()).toEqual(error) + expect(getMyInfoDataForFormResult._unsafeUnwrapErr()).toEqual(error) + expect(prefillAndSaveMyInfoFieldsResult._unsafeUnwrapErr()).toEqual(error) expect(saveMyInfoHashesResult._unsafeUnwrapErr()).toEqual(error) expect(fetchMyInfoHashesResult._unsafeUnwrapErr()).toEqual(error) expect(checkMyInfoHashesResult._unsafeUnwrapErr()).toEqual(error) diff --git a/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts b/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts index f57395692e..418ec67bac 100644 --- a/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts +++ b/src/app/modules/myinfo/__tests__/myinfo.service.spec.ts @@ -1,10 +1,9 @@ import bcrypt from 'bcrypt' +import { ObjectId } from 'bson-ext' import mongoose from 'mongoose' -import { errAsync, ok } from 'neverthrow' import { mocked } from 'ts-jest/utils' import { v4 as uuidv4 } from 'uuid' -import { DatabaseError } from 'src/app/modules/core/core.errors' import { MyInfoService } from 'src/app/modules/myinfo/myinfo.service' import getMyInfoHashModel from 'src/app/modules/myinfo/myinfo_hash.model' import { ProcessedFieldResponse } from 'src/app/modules/submission/submission.types' @@ -18,12 +17,12 @@ import { import dbHandler from 'tests/unit/backend/helpers/jest-db' +import { DatabaseError } from '../../core/core.errors' import { MyInfoData } from '../myinfo.adapter' import { MYINFO_CONSENT_PAGE_PURPOSE } from '../myinfo.constants' import { MyInfoCircuitBreakerError, MyInfoFetchError, - MyInfoHashingError, MyInfoInvalidAccessTokenError, MyInfoMissingAccessTokenError, MyInfoParseRelayStateError, @@ -190,13 +189,14 @@ describe('MyInfoService', () => { }) }) - describe('prefillMyInfoFields', () => { - it('should prefill fields correctly', () => { + describe('prefillAndSaveMyInfoFields', () => { + it('should prefill fields correctly', async () => { const mockData = new MyInfoData({ data: MOCK_MYINFO_DATA, uinFin: MOCK_UINFIN, }) - const result = myInfoService.prefillMyInfoFields( + const result = await myInfoService.prefillAndSaveMyInfoFields( + new ObjectId().toHexString(), mockData, MOCK_FORM_FIELDS as IFieldSchema[], ) @@ -258,7 +258,7 @@ describe('MyInfoService', () => { MOCK_POPULATED_FORM_FIELDS as IPossiblyPrefilledField[], ) expect(result._unsafeUnwrapErr()).toEqual( - new Error('Failed to save MyInfo hashes to database'), + new DatabaseError('Failed to save MyInfo hashes to database'), ) }) }) @@ -381,7 +381,7 @@ describe('MyInfoService', () => { }) }) - describe('fetchMyInfoData', () => { + describe('getMyInfoDataForForm', () => { // NOTE: Mocks the underlying circuit breaker implementation to avoid network calls beforeEach(() => { myInfoService = new MyInfoService(MOCK_SERVICE_PARAMS) @@ -397,7 +397,7 @@ describe('MyInfoService', () => { mockGetPerson.mockResolvedValueOnce(mockReturnedParams) // Act - const result = await myInfoService.fetchMyInfoData( + const result = await myInfoService.getMyInfoDataForForm( MOCK_MYINFO_FORM as IPopulatedForm, { MyInfoCookie: MOCK_SUCCESSFUL_COOKIE }, ) @@ -415,7 +415,7 @@ describe('MyInfoService', () => { mockGetPerson.mockResolvedValueOnce(mockReturnedParams) // Act - const result = await myInfoService.fetchMyInfoData( + const result = await myInfoService.getMyInfoDataForForm( MOCK_MYINFO_FORM as IPopulatedForm, { MyInfoCookie: MOCK_SUCCESSFUL_COOKIE }, ) @@ -434,7 +434,7 @@ describe('MyInfoService', () => { mockGetPerson.mockRejectedValueOnce(new Error()) // Act - const result = await myInfoService.fetchMyInfoData( + const result = await myInfoService.getMyInfoDataForForm( MOCK_MYINFO_FORM as IPopulatedForm, { MyInfoCookie: MOCK_SUCCESSFUL_COOKIE }, ) @@ -452,14 +452,14 @@ describe('MyInfoService', () => { // Arrange mockGetPerson.mockRejectedValue(new Error()) for (let i = 0; i < 5; i++) { - await myInfoService.fetchMyInfoData( + await myInfoService.getMyInfoDataForForm( MOCK_MYINFO_FORM as IPopulatedForm, { MyInfoCookie: MOCK_SUCCESSFUL_COOKIE }, ) } // Act - const result = await myInfoService.fetchMyInfoData( + const result = await myInfoService.getMyInfoDataForForm( MOCK_MYINFO_FORM as IPopulatedForm, { MyInfoCookie: MOCK_SUCCESSFUL_COOKIE }, ) @@ -474,7 +474,7 @@ describe('MyInfoService', () => { const expected = new MyInfoMissingAccessTokenError() // Act - const result = await myInfoService.fetchMyInfoData( + const result = await myInfoService.getMyInfoDataForForm( MOCK_MYINFO_FORM as IPopulatedForm, {}, ) @@ -483,89 +483,4 @@ describe('MyInfoService', () => { expect(result._unsafeUnwrapErr()).toEqual(expected) }) }) - - describe('createFormWithMyInfo', () => { - // NOTE: Mocks the underlying circuit breaker implementation to avoid network calls - beforeEach(() => { - myInfoService = new MyInfoService(MOCK_SERVICE_PARAMS) - }) - - it('should return the filled form when the form and cookies are valid', async () => { - // Arrange - const MOCK_MYINFO_DATA = ({ - getUinFin: jest.fn().mockReturnValue(MOCK_UINFIN), - } as unknown) as MyInfoData - - const expected = { - prefilledFields: [], - spcpSession: { userName: MOCK_UINFIN }, - } - - // Spies to ensure that submethods have been called - const prefillMyInfoSpy = jest - .spyOn(myInfoService, 'prefillMyInfoFields') - .mockReturnValueOnce(ok([])) - const saveMyInfoSpy = jest.spyOn(myInfoService, 'saveMyInfoHashes') - - // Act - const result = await myInfoService.createFormWithMyInfoMeta( - [], - MOCK_MYINFO_DATA, - MOCK_MYINFO_FORM._id, - ) - - // Assert - expect(prefillMyInfoSpy).toHaveBeenCalled() - expect(saveMyInfoSpy).toHaveBeenCalled() - expect(result._unsafeUnwrap()).toEqual(expected) - }) - - it('should return DatabaseError when the form could not be saved to the database', async () => { - // Arrange - const expected = new DatabaseError('mock db error') - const MOCK_MYINFO_DATA = ({ - getUinFin: jest.fn().mockReturnValue(MOCK_UINFIN), - } as unknown) as MyInfoData - const prefillMyInfoSpy = jest.spyOn(myInfoService, 'prefillMyInfoFields') - const saveMyInfoSpy = jest - .spyOn(myInfoService, 'saveMyInfoHashes') - .mockReturnValueOnce(errAsync(expected)) - - // Act - const result = await myInfoService.createFormWithMyInfoMeta( - [], - MOCK_MYINFO_DATA, - MOCK_MYINFO_FORM._id, - ) - - // Assert - expect(prefillMyInfoSpy).toHaveBeenCalled() - expect(saveMyInfoSpy).toHaveBeenCalled() - expect(result._unsafeUnwrapErr()).toEqual(expected) - }) - - it('should return MyInfoHashingError when myInfoData could not be hashed', async () => { - // Arrange - const expected = new MyInfoHashingError('mock hash failed') - const MOCK_MYINFO_DATA = ({ - getUinFin: jest.fn().mockReturnValue(MOCK_UINFIN), - } as unknown) as MyInfoData - const prefillMyInfoSpy = jest.spyOn(myInfoService, 'prefillMyInfoFields') - const saveMyInfoSpy = jest - .spyOn(myInfoService, 'saveMyInfoHashes') - .mockReturnValueOnce(errAsync(expected)) - - // Act - const result = await myInfoService.createFormWithMyInfoMeta( - [], - MOCK_MYINFO_DATA, - MOCK_MYINFO_FORM._id, - ) - - // Assert - expect(prefillMyInfoSpy).toHaveBeenCalled() - expect(saveMyInfoSpy).toHaveBeenCalled() - expect(result._unsafeUnwrapErr()).toEqual(expected) - }) - }) }) From 38757ff758d59f130ed1bd8ddb7e2884765d7678 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Wed, 7 Apr 2021 18:33:28 +0800 Subject: [PATCH 82/86] style(spcp): renamed service methods for clarity; removed unused error --- src/app/modules/spcp/spcp.errors.ts | 12 ------------ src/app/modules/spcp/spcp.factory.ts | 4 ++-- src/app/modules/spcp/spcp.service.ts | 10 ++++------ 3 files changed, 6 insertions(+), 20 deletions(-) diff --git a/src/app/modules/spcp/spcp.errors.ts b/src/app/modules/spcp/spcp.errors.ts index cede9f50ec..7a523de4db 100644 --- a/src/app/modules/spcp/spcp.errors.ts +++ b/src/app/modules/spcp/spcp.errors.ts @@ -66,18 +66,6 @@ export class AuthTypeMismatchError extends ApplicationError { } } -/** - * Attempt to perform a SPCP-related operation on a form without SPCP - * authentication enabled. - */ -export class SpcpAuthTypeError extends ApplicationError { - constructor( - message = 'Spcp function called on form without Spcp authentication type', - ) { - super(message) - } -} - /** * Attributes given by SP/CP did not contain NRIC or entity ID/UID. */ diff --git a/src/app/modules/spcp/spcp.factory.ts b/src/app/modules/spcp/spcp.factory.ts index 68ae6a34c9..69cc2c031c 100644 --- a/src/app/modules/spcp/spcp.factory.ts +++ b/src/app/modules/spcp/spcp.factory.ts @@ -19,7 +19,7 @@ interface ISpcpFactory { createJWT: SpcpService['createJWT'] createJWTPayload: SpcpService['createJWTPayload'] getCookieSettings: SpcpService['getCookieSettings'] - getSpcpSession: SpcpService['getSpcpSession'] + extractJwtPayloadFromRequest: SpcpService['extractJwtPayloadFromRequest'] } export const createSpcpFactory = ({ @@ -39,7 +39,7 @@ export const createSpcpFactory = ({ createJWT: () => err(error), createJWTPayload: () => err(error), getCookieSettings: () => ({}), - getSpcpSession: () => errAsync(error), + extractJwtPayloadFromRequest: () => errAsync(error), } } return new SpcpService(props) diff --git a/src/app/modules/spcp/spcp.service.ts b/src/app/modules/spcp/spcp.service.ts index ffa27c379e..74911cdbda 100644 --- a/src/app/modules/spcp/spcp.service.ts +++ b/src/app/modules/spcp/spcp.service.ts @@ -6,7 +6,7 @@ import { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow' import { ISpcpMyInfo } from '../../../config/feature-manager' import { createLoggerWithLabel } from '../../../config/logger' -import { AuthType, SpcpSession } from '../../../types' +import { AuthType } from '../../../types' import { ApplicationError } from '../core/core.errors' import { @@ -401,17 +401,15 @@ export class SpcpService { * @return err(VerifyJwtError) if the jwt exists but could not be authenticated * @return err(InvalidJwtError) if the jwt exists but the payload is invalid */ - getSpcpSession( + extractJwtPayloadFromRequest( authType: AuthType.SP | AuthType.CP, cookies: SpcpCookies, ): ResultAsync< - SpcpSession, + JwtPayload, VerifyJwtError | InvalidJwtError | MissingJwtError > { return this.extractJwt(cookies, authType).asyncAndThen((jwtResult) => - this.extractJwtPayload(jwtResult, authType).map(({ userName }) => ({ - userName, - })), + this.extractJwtPayload(jwtResult, authType), ) } } From bdd52682266fdb3d597dc1c43262ded3a89dc071 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Wed, 7 Apr 2021 18:34:13 +0800 Subject: [PATCH 83/86] test(spcp): fixes spcp tests --- .../spcp/__tests__/spcp.service.spec.ts | 46 ++++++++++++++----- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/src/app/modules/spcp/__tests__/spcp.service.spec.ts b/src/app/modules/spcp/__tests__/spcp.service.spec.ts index a20b561d6f..43746bbcf4 100644 --- a/src/app/modules/spcp/__tests__/spcp.service.spec.ts +++ b/src/app/modules/spcp/__tests__/spcp.service.spec.ts @@ -800,7 +800,7 @@ describe('spcp.service', () => { }) }) - describe('getSpcpSession', () => { + describe('extractJwtPayloadFromRequest', () => { it('should return a SP JWT payload when there is a valid JWT in the request', async () => { // Arrange const spcpService = new SpcpService(MOCK_PARAMS) @@ -811,7 +811,10 @@ describe('spcp.service', () => { ) // Act - const result = await spcpService.getSpcpSession(AuthType.SP, MOCK_COOKIES) + const result = await spcpService.extractJwtPayloadFromRequest( + AuthType.SP, + MOCK_COOKIES, + ) // Assert expect(result._unsafeUnwrap()).toEqual(MOCK_SP_JWT_PAYLOAD) @@ -827,12 +830,13 @@ describe('spcp.service', () => { ) // Act - const result = await spcpService.getSpcpSession(AuthType.CP, MOCK_COOKIES) + const result = await spcpService.extractJwtPayloadFromRequest( + AuthType.CP, + MOCK_COOKIES, + ) // Assert - expect(result._unsafeUnwrap()).toEqual({ - userName: MOCK_CP_JWT_PAYLOAD.userName, - }) + expect(result._unsafeUnwrap()).toEqual(MOCK_CP_JWT_PAYLOAD) }) it('should return MissingJwtError if there is no JWT when client authenticates using SP', async () => { @@ -841,7 +845,10 @@ describe('spcp.service', () => { const expected = new MissingJwtError() // Act - const result = await spcpService.getSpcpSession(AuthType.SP, {}) + const result = await spcpService.extractJwtPayloadFromRequest( + AuthType.SP, + {}, + ) // Assert expect(result._unsafeUnwrapErr()).toEqual(expected) @@ -853,7 +860,10 @@ describe('spcp.service', () => { const expected = new MissingJwtError() // Act - const result = await spcpService.getSpcpSession(AuthType.CP, {}) + const result = await spcpService.extractJwtPayloadFromRequest( + AuthType.CP, + {}, + ) // Assert expect(result._unsafeUnwrapErr()).toEqual(expected) @@ -870,7 +880,10 @@ describe('spcp.service', () => { const expected = new VerifyJwtError() // Act - const result = await spcpService.getSpcpSession(AuthType.SP, MOCK_COOKIES) + const result = await spcpService.extractJwtPayloadFromRequest( + AuthType.SP, + MOCK_COOKIES, + ) // Assert expect(result._unsafeUnwrapErr()).toEqual(expected) @@ -887,7 +900,10 @@ describe('spcp.service', () => { const expected = new VerifyJwtError() // Act - const result = await spcpService.getSpcpSession(AuthType.CP, MOCK_COOKIES) + const result = await spcpService.extractJwtPayloadFromRequest( + AuthType.CP, + MOCK_COOKIES, + ) // Assert expect(result._unsafeUnwrapErr()).toEqual(expected) @@ -901,7 +917,10 @@ describe('spcp.service', () => { const expected = new InvalidJwtError() // Act - const result = await spcpService.getSpcpSession(AuthType.SP, MOCK_COOKIES) + const result = await spcpService.extractJwtPayloadFromRequest( + AuthType.SP, + MOCK_COOKIES, + ) // Assert expect(result._unsafeUnwrapErr()).toEqual(expected) @@ -916,7 +935,10 @@ describe('spcp.service', () => { const expected = new InvalidJwtError() // Act - const result = await spcpService.getSpcpSession(AuthType.CP, MOCK_COOKIES) + const result = await spcpService.extractJwtPayloadFromRequest( + AuthType.CP, + MOCK_COOKIES, + ) // Assert expect(result._unsafeUnwrapErr()).toEqual(expected) From 7d7c50f7ae6ff994135cee99faaaa79c824dd1ae Mon Sep 17 00:00:00 2001 From: seaerchin Date: Wed, 7 Apr 2021 18:35:59 +0800 Subject: [PATCH 84/86] refactor(public-form): updated controller to account for myinfo/spcp refactoring --- .../form/public-form/public-form.controller.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/app/modules/form/public-form/public-form.controller.ts b/src/app/modules/form/public-form/public-form.controller.ts index afc219efc3..d4bc7ea661 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -230,7 +230,7 @@ export const handleGetPublicForm: RequestHandler< return res.json({ form: publicForm, isIntranetUser }) case AuthType.SP: case AuthType.CP: - return SpcpFactory.getSpcpSession(authType, req.cookies) + return SpcpFactory.extractJwtPayloadFromRequest(authType, req.cookies) .map((spcpSession) => res.json({ form: publicForm, @@ -255,13 +255,16 @@ export const handleGetPublicForm: RequestHandler< case AuthType.MyInfo: { // Step 1. Fetch required data and fill the form based off data retrieved return ( - MyInfoFactory.fetchMyInfoData(form, req.cookies) + MyInfoFactory.getMyInfoDataForForm(form, req.cookies) .andThen((myInfoData) => { - return MyInfoFactory.createFormWithMyInfoMeta( - form.toJSON().form_fields, - myInfoData, + return MyInfoFactory.prefillAndSaveMyInfoFields( form._id, - ) + myInfoData, + form.toJSON().form_fields, + ).map((prefilledFields) => ({ + prefilledFields, + spcpSession: { userName: myInfoData.getUinFin() }, + })) }) // Check if the user is signed in .andThen(({ prefilledFields, spcpSession }) => { From f7fdc1a3cf7a1a0bdcbf0f14d6d62c6e32fb57b1 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Wed, 7 Apr 2021 18:36:40 +0800 Subject: [PATCH 85/86] test(public-form): updated tests --- .../__tests__/public-form.controller.spec.ts | 44 ++++++++----------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts index 00a3b0d8c2..e18cce6cdd 100644 --- a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts +++ b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts @@ -522,7 +522,7 @@ describe('public-form.controller', () => { MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( okAsync(MOCK_SP_AUTH_FORM), ) - MockSpcpFactory.getSpcpSession.mockReturnValueOnce( + MockSpcpFactory.extractJwtPayloadFromRequest.mockReturnValueOnce( okAsync(MOCK_SPCP_SESSION), ) @@ -556,7 +556,7 @@ describe('public-form.controller', () => { MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( okAsync(MOCK_CP_AUTH_FORM), ) - MockSpcpFactory.getSpcpSession.mockReturnValueOnce( + MockSpcpFactory.extractJwtPayloadFromRequest.mockReturnValueOnce( okAsync(MOCK_SPCP_SESSION), ) // Act @@ -596,14 +596,11 @@ describe('public-form.controller', () => { MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( okAsync(MOCK_MYINFO_AUTH_FORM), ) - MockMyInfoFactory.fetchMyInfoData.mockReturnValueOnce( + MockMyInfoFactory.getMyInfoDataForForm.mockReturnValueOnce( okAsync(MOCK_MYINFO_DATA), ) - MockMyInfoFactory.createFormWithMyInfoMeta.mockReturnValueOnce( - okAsync({ - prefilledFields: [], - spcpSession: MOCK_SPCP_SESSION, - }), + MockMyInfoFactory.prefillAndSaveMyInfoFields.mockReturnValueOnce( + okAsync([]), ) // Act @@ -652,7 +649,7 @@ describe('public-form.controller', () => { clearCookie: jest.fn().mockReturnThis(), }) - MockMyInfoFactory.fetchMyInfoData.mockReturnValueOnce( + MockMyInfoFactory.getMyInfoDataForForm.mockReturnValueOnce( errAsync(new MyInfoMissingAccessTokenError()), ) @@ -679,7 +676,7 @@ describe('public-form.controller', () => { clearCookie: jest.fn().mockReturnThis(), }) - MockMyInfoFactory.fetchMyInfoData.mockReturnValueOnce( + MockMyInfoFactory.getMyInfoDataForForm.mockReturnValueOnce( errAsync(new MyInfoCookieAccessError()), ) @@ -706,7 +703,7 @@ describe('public-form.controller', () => { clearCookie: jest.fn().mockReturnThis(), }) - MockMyInfoFactory.fetchMyInfoData.mockReturnValueOnce( + MockMyInfoFactory.getMyInfoDataForForm.mockReturnValueOnce( errAsync(new MyInfoCookieStateError()), ) @@ -733,7 +730,7 @@ describe('public-form.controller', () => { clearCookie: jest.fn().mockReturnThis(), }) - MockMyInfoFactory.fetchMyInfoData.mockReturnValueOnce( + MockMyInfoFactory.getMyInfoDataForForm.mockReturnValueOnce( errAsync(new MyInfoAuthTypeError()), ) @@ -760,7 +757,7 @@ describe('public-form.controller', () => { clearCookie: jest.fn().mockReturnThis(), }) - MockMyInfoFactory.fetchMyInfoData.mockReturnValueOnce( + MockMyInfoFactory.getMyInfoDataForForm.mockReturnValueOnce( errAsync(new MyInfoNoESrvcIdError()), ) @@ -787,7 +784,7 @@ describe('public-form.controller', () => { clearCookie: jest.fn().mockReturnThis(), }) - MockMyInfoFactory.fetchMyInfoData.mockReturnValueOnce( + MockMyInfoFactory.getMyInfoDataForForm.mockReturnValueOnce( errAsync( new MissingFeatureError( 'testing is the missing feature' as FeatureNames, @@ -821,10 +818,10 @@ describe('public-form.controller', () => { const mockRes = expressHandler.mockResponse({ clearCookie: jest.fn().mockReturnThis(), }) - MockMyInfoFactory.fetchMyInfoData.mockReturnValueOnce( + MockMyInfoFactory.getMyInfoDataForForm.mockReturnValueOnce( okAsync(MOCK_MYINFO_DATA), ) - MockMyInfoFactory.createFormWithMyInfoMeta.mockReturnValueOnce( + MockMyInfoFactory.prefillAndSaveMyInfoFields.mockReturnValueOnce( errAsync(expected), ) @@ -861,7 +858,7 @@ describe('public-form.controller', () => { MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( okAsync(MOCK_SPCP_FORM), ) - MockSpcpFactory.getSpcpSession.mockReturnValueOnce( + MockSpcpFactory.extractJwtPayloadFromRequest.mockReturnValueOnce( errAsync(new MissingJwtError()), ) @@ -1028,7 +1025,7 @@ describe('public-form.controller', () => { const mockRes = expressHandler.mockResponse() - MockSpcpFactory.getSpcpSession.mockReturnValueOnce( + MockSpcpFactory.extractJwtPayloadFromRequest.mockReturnValueOnce( okAsync(MOCK_SPCP_SESSION), ) MockFormService.checkIsIntranetFormAccess.mockReturnValueOnce(true) @@ -1064,7 +1061,7 @@ describe('public-form.controller', () => { const mockRes = expressHandler.mockResponse() MockFormService.checkIsIntranetFormAccess.mockReturnValueOnce(true) - MockSpcpFactory.getSpcpSession.mockReturnValueOnce( + MockSpcpFactory.extractJwtPayloadFromRequest.mockReturnValueOnce( okAsync(MOCK_SPCP_SESSION), ) MockAuthService.getFormIfPublic.mockReturnValueOnce( @@ -1113,14 +1110,11 @@ describe('public-form.controller', () => { okAsync(MOCK_MYINFO_AUTH_FORM), ) MockFormService.checkIsIntranetFormAccess.mockReturnValueOnce(true) - MockMyInfoFactory.fetchMyInfoData.mockReturnValueOnce( + MockMyInfoFactory.getMyInfoDataForForm.mockReturnValueOnce( okAsync(MOCK_MYINFO_DATA), ) - MockMyInfoFactory.createFormWithMyInfoMeta.mockReturnValueOnce( - okAsync({ - prefilledFields: [], - spcpSession: { userName: MOCK_MYINFO_DATA.getUinFin() }, - }), + MockMyInfoFactory.prefillAndSaveMyInfoFields.mockReturnValueOnce( + okAsync([]), ) // Act From bac3da7f56243cd38a3bf49b6bc23bf29bcdf141 Mon Sep 17 00:00:00 2001 From: seaerchin Date: Wed, 7 Apr 2021 18:42:25 +0800 Subject: [PATCH 86/86] fix(public-form/controller): fixed cp returning userInfo as ewll --- src/app/modules/form/public-form/public-form.controller.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/modules/form/public-form/public-form.controller.ts b/src/app/modules/form/public-form/public-form.controller.ts index d4bc7ea661..90034261da 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -231,11 +231,11 @@ export const handleGetPublicForm: RequestHandler< case AuthType.SP: case AuthType.CP: return SpcpFactory.extractJwtPayloadFromRequest(authType, req.cookies) - .map((spcpSession) => + .map(({ userName }) => res.json({ form: publicForm, isIntranetUser, - spcpSession, + spcpSession: { userName }, }), ) .mapErr((error) => {