From 5adb99219f64d3695c51ad0055010f558e122273 Mon Sep 17 00:00:00 2001 From: Lin Huiqing <37061143+LinHuiqing@users.noreply.github.com> Date: Thu, 31 Aug 2023 16:59:40 +0800 Subject: [PATCH 1/6] feat: % rollout of new storage submission endpoint (#6665) * feat: use new endpoint for storage submissions * fix: answerArray fields hidden error - response.answerArray did not return visibility true * feat: growthbook on frontend * feat: script to set encryptionBoundaryShift to true * feat: default `encryptionBoundaryShift` to true * fix: form server model backend tests - set encryptionBoundaryShift flag for encrypted forms * refactor: setBody -> additionalBodyFields naming * refactor: rename ...FormV2Args -> ...FormClearArgs * fix: tighter typing for answerArray * refactor: create FormDataArgs types * feat: fetch fallback for new storage submission - refactor: use 'clear' instead of 'V2' * fix: add missing dependencies to handleSubmitForm * feat: use db env feature flags for BE * fix: missing args with env feature flags * fix: admin-form.controller tests * fix: IncomingEncryptSubmission tests * feat: script to unset encryptionBoundaryShift flag * feat: rm form-level encryptionBoundaryShift flags * fix: use docker images from edge/main instead * fix: use dl-cdn rather than nl - nl.alpinelinux.org mirror is outdated * fix: typo for dl-cdn * fix: testing instead of main url - font-wqy-zenhei@edge only available on edge/testing (ref: https://repology.org/project/fonts:wqy-zenhei/versions) * fix: update alpinelinux mirror for dev docker * fix: add growthbook to connect src CSP * fix: add more growthbook paths to csp * fix: helmet tests * feat: growthbook reverse proxy api host * feat: use appropriate domains for proxy * revert: changes to add growthbook to CSP * docs: ENCRYPTION_BOUNDARY_SHIFT_ENCRYPTION_VERSION * refactor: isDev for clarity of apiHost * refactor: use constants for link and path - refactor: rm unused formsgSdkMode * feat: unset encryptionBoundaryShift only if present * refactor: mv feature loading to AppRouter --- docker-compose.yml | 2 + frontend/package-lock.json | 55 +++++++++ frontend/package.json | 1 + frontend/src/app/App.tsx | 5 +- frontend/src/app/AppRouter.tsx | 11 +- frontend/src/constants/links.ts | 3 + frontend/src/constants/routes.ts | 3 + frontend/src/contexts/GrowthbookContext.tsx | 26 ++++ .../admin-form/common/AdminViewFormService.ts | 12 +- .../public-form/PublicFormProvider.tsx | 54 +++++++-- .../features/public-form/PublicFormService.ts | 113 ++++++++++++++++-- .../src/features/public-form/mutations.ts | 16 +++ .../public-form/utils/createSubmission.ts | 31 ++++- frontend/src/growthbook.ts | 17 +++ .../unset-encryption-boundary-shift.js | 43 +++++++ shared/constants/feature-flags.ts | 1 + shared/types/core.ts | 1 + shared/types/form/form.ts | 3 - src/app/config/features/growthbook.config.ts | 18 +++ .../__tests__/form.server.model.spec.ts | 2 +- src/app/models/form.server.model.ts | 5 - .../__tests__/admin-form.controller.spec.ts | 12 ++ .../form/admin-form/admin-form.controller.ts | 4 +- src/app/modules/frontend/frontend.service.ts | 3 + .../submission/ParsedResponsesObject.class.ts | 7 +- .../email-submission.controller.ts | 6 +- .../IncomingEncryptSubmission.class.ts | 14 ++- .../IncomingEncryptSubmission.class.spec.ts | 9 +- .../encrypt-submission.controller.ts | 2 + .../encrypt-submission.middleware.ts | 39 ++++-- .../modules/submission/submission.utils.ts | 3 +- src/types/api/encrypt_submission.ts | 1 + src/types/form.ts | 3 - 33 files changed, 463 insertions(+), 62 deletions(-) create mode 100644 frontend/src/contexts/GrowthbookContext.tsx create mode 100644 frontend/src/growthbook.ts create mode 100644 scripts/20230826_unset-encryption-boundary-shift/unset-encryption-boundary-shift.js create mode 100644 src/app/config/features/growthbook.config.ts diff --git a/docker-compose.yml b/docker-compose.yml index 6cc5d1f8c3..065ab61ce2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -121,6 +121,8 @@ services: - GOGOV_API_KEY # Bearer token API key format - API_KEY_VERSION=v1 + # env vars for growthbook + - GROWTHBOOK_CLIENT_KEY mockpass: build: https://github.com/opengovsg/mockpass.git#v4.0.4 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b86df0e23b..ae5eccdd7b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "@emotion/react": "^11.7.0", "@emotion/styled": "^11.6.0", "@floating-ui/react-dom-interactions": "^0.9.3", + "@growthbook/growthbook-react": "^0.17.0", "@opengovsg/formsg-sdk": "^0.10.0", "@stablelib/base64": "^1.0.1", "@stripe/react-stripe-js": "^1.15.0", @@ -3864,6 +3865,31 @@ "resolved": "https://registry.npmjs.org/@gilbarbara/deep-equal/-/deep-equal-0.1.1.tgz", "integrity": "sha512-SjSBspHXlclODLtSoPIQwBhfeBjncC05NlNoFELJ6xZQkyYDJsVCcs7+f+etHR2cYPbHLjnh1C06lQlCbMEWEA==" }, + "node_modules/@growthbook/growthbook": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@growthbook/growthbook/-/growthbook-0.27.0.tgz", + "integrity": "sha512-gt/DWXfgyudY3gXAUjzKm9kMjVfCzfRc8d0nQQiXCP523ow23jThrmBzkRKCN+zxYVxUKcHjP9yjU9EKJqP4Fw==", + "dependencies": { + "dom-mutator": "^0.5.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@growthbook/growthbook-react": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@growthbook/growthbook-react/-/growthbook-react-0.17.0.tgz", + "integrity": "sha512-od+bSg3RD9AXfi/TWFDSzRaB6RuJLkUX5eFOZz8u8wL8yDbgd5RF9cwu4lQIF9/tyG1WLu4qipV+SM2v5s4BcQ==", + "dependencies": { + "@growthbook/growthbook": "^0.27.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^16.8.0-0 || ^17.0.0-0 || ^18.0.0-0" + } + }, "node_modules/@hapi/address": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-2.1.4.tgz", @@ -21833,6 +21859,14 @@ "utila": "~0.4" } }, + "node_modules/dom-mutator": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/dom-mutator/-/dom-mutator-0.5.0.tgz", + "integrity": "sha512-bbeX8HWE8JGzraFgbVBX4ws2g3heZFuTtrleQBuN7huy+7n2n7etSuVnot3/1z3jdY2MiwuvoS4Ep1UT2rrGBw==", + "engines": { + "node": ">=10" + } + }, "node_modules/dom-serializer": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz", @@ -51138,6 +51172,22 @@ "resolved": "https://registry.npmjs.org/@gilbarbara/deep-equal/-/deep-equal-0.1.1.tgz", "integrity": "sha512-SjSBspHXlclODLtSoPIQwBhfeBjncC05NlNoFELJ6xZQkyYDJsVCcs7+f+etHR2cYPbHLjnh1C06lQlCbMEWEA==" }, + "@growthbook/growthbook": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@growthbook/growthbook/-/growthbook-0.27.0.tgz", + "integrity": "sha512-gt/DWXfgyudY3gXAUjzKm9kMjVfCzfRc8d0nQQiXCP523ow23jThrmBzkRKCN+zxYVxUKcHjP9yjU9EKJqP4Fw==", + "requires": { + "dom-mutator": "^0.5.0" + } + }, + "@growthbook/growthbook-react": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@growthbook/growthbook-react/-/growthbook-react-0.17.0.tgz", + "integrity": "sha512-od+bSg3RD9AXfi/TWFDSzRaB6RuJLkUX5eFOZz8u8wL8yDbgd5RF9cwu4lQIF9/tyG1WLu4qipV+SM2v5s4BcQ==", + "requires": { + "@growthbook/growthbook": "^0.27.0" + } + }, "@hapi/address": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-2.1.4.tgz", @@ -64689,6 +64739,11 @@ "utila": "~0.4" } }, + "dom-mutator": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/dom-mutator/-/dom-mutator-0.5.0.tgz", + "integrity": "sha512-bbeX8HWE8JGzraFgbVBX4ws2g3heZFuTtrleQBuN7huy+7n2n7etSuVnot3/1z3jdY2MiwuvoS4Ep1UT2rrGBw==" + }, "dom-serializer": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index b3ece67cf9..84dfe1fb17 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "@emotion/react": "^11.7.0", "@emotion/styled": "^11.6.0", "@floating-ui/react-dom-interactions": "^0.9.3", + "@growthbook/growthbook-react": "^0.17.0", "@opengovsg/formsg-sdk": "^0.10.0", "@stablelib/base64": "^1.0.1", "@stripe/react-stripe-js": "^1.15.0", diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index c7aefeecde..0fedfda1a9 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -9,6 +9,7 @@ import { datadogLogs } from '@datadog/browser-logs' import { theme } from '~theme/index' import { AuthProvider } from '~contexts/AuthContext' +import { GrowthBookProvider } from '~contexts/GrowthbookContext' import { HttpError } from '~services/ApiService' import { AppHelmet } from './AppHelmet' @@ -70,7 +71,9 @@ export const App = (): JSX.Element => { - + + + diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index 9cab6884ec..e8594856f4 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -1,6 +1,7 @@ -import { lazy, Suspense } from 'react' +import { lazy, Suspense, useEffect } from 'react' import { Route, Routes } from 'react-router-dom' import { Box } from '@chakra-ui/react' +import { useGrowthBook } from '@growthbook/growthbook-react' import { ADMINFORM_PREVIEW_ROUTE, @@ -72,6 +73,14 @@ const WithSuspense = ({ children }: { children: React.ReactNode }) => ( ) export const AppRouter = (): JSX.Element => { + const growthbook = useGrowthBook() + useEffect(() => { + if (growthbook) { + // Load features from the GrowthBook API + growthbook.loadFeatures({ autoRefresh: true }) + } + }, [growthbook]) + return ( diff --git a/frontend/src/constants/links.ts b/frontend/src/constants/links.ts index b0f8563477..68d8958e28 100644 --- a/frontend/src/constants/links.ts +++ b/frontend/src/constants/links.ts @@ -84,3 +84,6 @@ export const OGP_SGID = 'https://go.gov.sg/sgid-formsg' export const OGP_FORMSG_REPO = 'https://github.com/opengovsg/formsg' export const FORMSG_UAT = 'https://uat.form.gov.sg' + +export const GROWTHBOOK_DEV_PROXY = + 'https://proxy-growthbook-stg.formsg.workers.dev' diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index 24e9ef6577..01eee2bf69 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -50,3 +50,6 @@ export const ACTIVE_ADMINFORM_RESULTS_ROUTE_REGEX = new RegExp( 'i', ) export const PAYMENT_PAGE_SUBROUTE = 'payment/:paymentId' + +// Path for growthbook api proxy, set up on cloudflare workers. Worker script: https://github.com/opengovsg/formsg-private/pull/171. +export const GROWTHBOOK_API_HOST_PATH = '/api/v1/proxy/growthbook' diff --git a/frontend/src/contexts/GrowthbookContext.tsx b/frontend/src/contexts/GrowthbookContext.tsx new file mode 100644 index 0000000000..5de2f6e58c --- /dev/null +++ b/frontend/src/contexts/GrowthbookContext.tsx @@ -0,0 +1,26 @@ +import { ReactNode } from 'react' +import { GrowthBookProvider as BaseGrowthBookProvider } from '@growthbook/growthbook-react' + +import { createGrowthbookInstance } from '~/growthbook' + +import { useEnv } from '~features/env/queries' + +/** + * Provider component that wraps your app and makes auth object available to any + * child component that calls `useAuth()`. + */ +export const GrowthBookProvider = ({ children }: { children: ReactNode }) => { + const { data: { growthbookClientKey } = {} } = useEnv() + + return ( + + {children} + + ) +} diff --git a/frontend/src/features/admin-form/common/AdminViewFormService.ts b/frontend/src/features/admin-form/common/AdminViewFormService.ts index dde6add4df..565bd17b9c 100644 --- a/frontend/src/features/admin-form/common/AdminViewFormService.ts +++ b/frontend/src/features/admin-form/common/AdminViewFormService.ts @@ -23,7 +23,7 @@ import { SubmitStorageFormArgs, } from '~features/public-form/PublicFormService' import { - createEmailSubmissionFormData, + createClearSubmissionFormData, createEncryptedSubmissionData, filterHiddenInputs, } from '~features/public-form/utils' @@ -166,7 +166,10 @@ export const submitEmailModeFormPreview = async ({ formInputs, formLogics, }) - const formData = createEmailSubmissionFormData(formFields, filteredInputs) + const formData = createClearSubmissionFormData({ + formFields, + formInputs: filteredInputs, + }) return ApiService.post( `${ADMIN_FORM_ENDPOINT}/${formId}/preview/submissions/email`, @@ -216,7 +219,10 @@ export const submitEmailModeFormPreviewWithFetch = async ({ formInputs, formLogics, }) - const formData = createEmailSubmissionFormData(formFields, filteredInputs) + const formData = createClearSubmissionFormData({ + formFields, + formInputs: filteredInputs, + }) const response = await fetch( `${API_BASE_URL}${ADMIN_FORM_ENDPOINT}/${formId}/preview/submissions/email`, diff --git a/frontend/src/features/public-form/PublicFormProvider.tsx b/frontend/src/features/public-form/PublicFormProvider.tsx index fecb70d246..68eeb73e69 100644 --- a/frontend/src/features/public-form/PublicFormProvider.tsx +++ b/frontend/src/features/public-form/PublicFormProvider.tsx @@ -11,6 +11,7 @@ import { SubmitHandler } from 'react-hook-form' import { useNavigate } from 'react-router-dom' import { useDisclosure } from '@chakra-ui/react' import { datadogLogs } from '@datadog/browser-logs' +import { useFeatureIsOn, useGrowthBook } from '@growthbook/growthbook-react' import { differenceInMilliseconds, isPast } from 'date-fns' import get from 'lodash/get' import simplur from 'simplur' @@ -130,6 +131,22 @@ export const PublicFormProvider = ({ /* enabled= */ !submissionData, ) + const growthbook = useGrowthBook() + + useEffect(() => { + if (growthbook) { + growthbook.setAttributes({ + // Only update the `formId` attribute, keep the rest the same + ...growthbook.getAttributes(), + formId, + }) + } + }, [growthbook, formId]) + + const routeToNewStorageModeSubmission = useFeatureIsOn( + featureFlags.encryptionBoundaryShift, + ) + // Scroll to top of page when user has finished their submission. useLayoutEffect(() => { if (submissionData) { @@ -252,8 +269,14 @@ export const PublicFormProvider = ({ submitStorageModeFormMutation, submitEmailModeFormFetchMutation, submitStorageModeFormFetchMutation, + submitStorageModeClearFormMutation, + submitStorageModeClearFormFetchMutation, } = usePublicFormMutations(formId, submissionData?.id ?? '') + const submitStorageModeVnMutation = routeToNewStorageModeSubmission + ? submitStorageModeClearFormMutation + : submitStorageModeFormMutation + const { handleLogoutMutation } = usePublicAuthMutations(formId) const navigate = useNavigate() @@ -464,7 +487,12 @@ export const PublicFormProvider = ({ }, }) - return submitStorageModeFormFetchMutation + const submitStorageModeVnFetchMutation = + routeToNewStorageModeSubmission + ? submitStorageModeClearFormFetchMutation + : submitStorageModeFormFetchMutation + + return submitStorageModeVnFetchMutation .mutateAsync( { ...formData, @@ -522,7 +550,7 @@ export const PublicFormProvider = ({ }) return ( - submitStorageModeFormMutation + submitStorageModeVnMutation .mutateAsync( { ...formData, @@ -578,21 +606,23 @@ export const PublicFormProvider = ({ [ data, enableTurnstileFeatureFlag, - getTurnstileResponse, - getCaptchaResponse, captchaType, + startTime, + isPaymentEnabled, + numVisibleFields, + useFetchForSubmissions, + getTurnstileResponse, showErrorToast, + getCaptchaResponse, + submitEmailModeFormFetchMutation, submitEmailModeFormMutation, - submitStorageModeFormMutation, - formId, + submitStorageModeVnMutation, + routeToNewStorageModeSubmission, + submitStorageModeClearFormFetchMutation, + submitStorageModeFormFetchMutation, navigate, + formId, storePaymentMemory, - submitEmailModeFormFetchMutation, - submitStorageModeFormFetchMutation, - useFetchForSubmissions, - numVisibleFields, - startTime, - isPaymentEnabled, ], ) diff --git a/frontend/src/features/public-form/PublicFormService.ts b/frontend/src/features/public-form/PublicFormService.ts index e51391e85f..76e198e076 100644 --- a/frontend/src/features/public-form/PublicFormService.ts +++ b/frontend/src/features/public-form/PublicFormService.ts @@ -25,13 +25,17 @@ import { import { FormFieldValues } from '~templates/Field' import { - createEmailSubmissionFormData, + createClearSubmissionFormData, createEncryptedSubmissionData, } from './utils/createSubmission' import { filterHiddenInputs } from './utils/filterHiddenInputs' export const PUBLIC_FORMS_ENDPOINT = '/forms' +// Encryption boundary shift RFC: https://docs.google.com/document/d/1VmNXS_xYY2Yg30AwVqzdndBp5yRJGSDsyjBnH51ktyc/edit?usp=sharing +// Encryption boundary shift implementation PR: https://github.com/opengovsg/FormSG/pull/6587 +const ENCRYPTION_BOUNDARY_SHIFT_ENCRYPTION_VERSION = 2 + /** * Gets public view of form, along with any * identify information obtained from Singpass/Corppass/MyInfo. @@ -93,6 +97,12 @@ export type SubmitStorageFormArgs = SubmitEmailFormArgs & { payments?: PaymentFieldsDto } +export type SubmitStorageFormClearArgs = SubmitEmailFormArgs & { + paymentReceiptEmail?: string + paymentProducts?: Array + payments?: PaymentFieldsDto +} + export const submitEmailModeForm = async ({ formFields, formLogics, @@ -107,11 +117,11 @@ export const submitEmailModeForm = async ({ formInputs, formLogics, }) - const formData = createEmailSubmissionFormData( + const formData = createClearSubmissionFormData({ formFields, - filteredInputs, + formInputs: filteredInputs, responseMetadata, - ) + }) return ApiService.post( `${PUBLIC_FORMS_ENDPOINT}/${formId}/submissions/email`, @@ -164,6 +174,95 @@ export const submitStorageModeForm = async ({ ).then(({ data }) => data) } +export const submitStorageModeClearForm = async ({ + formFields, + formLogics, + formInputs, + formId, + captchaResponse = null, + captchaType = '', + paymentReceiptEmail, + responseMetadata, + paymentProducts, + payments, +}: SubmitStorageFormClearArgs) => { + const filteredInputs = filterHiddenInputs({ + formFields, + formInputs, + formLogics, + }) + + const formData = createClearSubmissionFormData({ + formFields, + formInputs: filteredInputs, + responseMetadata, + paymentReceiptEmail, + paymentProducts, + payments, + version: ENCRYPTION_BOUNDARY_SHIFT_ENCRYPTION_VERSION, + }) + + return ApiService.post( + `${PUBLIC_FORMS_ENDPOINT}/${formId}/submissions/storage`, + formData, + { + params: { + captchaResponse: String(captchaResponse), + captchaType: captchaType, + }, + }, + ).then(({ data }) => data) +} + +// TODO (#5826): Fallback mutation using Fetch. Remove once network error is resolved +export const submitStorageModeClearFormWithFetch = async ({ + formFields, + formLogics, + formInputs, + formId, + captchaResponse = null, + captchaType = '', + paymentReceiptEmail, + responseMetadata, + paymentProducts, + payments, +}: SubmitStorageFormClearArgs) => { + const filteredInputs = filterHiddenInputs({ + formFields, + formInputs, + formLogics, + }) + + const formData = createClearSubmissionFormData({ + formFields, + formInputs: filteredInputs, + responseMetadata, + paymentReceiptEmail, + paymentProducts, + payments, + version: ENCRYPTION_BOUNDARY_SHIFT_ENCRYPTION_VERSION, + }) + + // Add captcha response to query string + const queryString = new URLSearchParams({ + captchaResponse: String(captchaResponse), + captchaType, + }).toString() + + const response = await fetch( + `${API_BASE_URL}${PUBLIC_FORMS_ENDPOINT}/${formId}/submissions/storage?${queryString}`, + { + method: 'POST', + body: formData, + headers: { + Accept: 'application/json', + }, + }, + ) + + return processFetchResponse(response) +} + // TODO (#5826): Fallback mutation using Fetch. Remove once network error is resolved export const submitEmailModeFormWithFetch = async ({ formFields, @@ -179,11 +278,11 @@ export const submitEmailModeFormWithFetch = async ({ formInputs, formLogics, }) - const formData = createEmailSubmissionFormData( + const formData = createClearSubmissionFormData({ formFields, - filteredInputs, + formInputs: filteredInputs, responseMetadata, - ) + }) // Add captcha response to query string const queryString = new URLSearchParams({ diff --git a/frontend/src/features/public-form/mutations.ts b/frontend/src/features/public-form/mutations.ts index 32dca83f19..f8d91a7d45 100644 --- a/frontend/src/features/public-form/mutations.ts +++ b/frontend/src/features/public-form/mutations.ts @@ -18,6 +18,8 @@ import { submitFormFeedback, submitFormIssue, SubmitStorageFormArgs, + submitStorageModeClearForm, + submitStorageModeClearFormWithFetch, submitStorageModeForm, submitStorageModeFormWithFetch, } from './PublicFormService' @@ -80,6 +82,12 @@ export const usePublicFormMutations = ( }, ) + const submitStorageModeClearFormMutation = useMutation( + (args: Omit) => { + return submitStorageModeClearForm({ ...args, formId }) + }, + ) + // TODO (#5826): Fallback mutation using Fetch. Remove once network error is resolved const submitEmailModeFormFetchMutation = useMutation( (args: Omit) => { @@ -93,6 +101,12 @@ export const usePublicFormMutations = ( }, ) + const submitStorageModeClearFormFetchMutation = useMutation( + (args: Omit) => { + return submitStorageModeClearFormWithFetch({ ...args, formId }) + }, + ) + const submitFormFeedbackMutation = useMutation( (args: SubmitFormFeedbackBodyDto) => submitFormFeedback(formId, submissionId, args), @@ -109,6 +123,8 @@ export const usePublicFormMutations = ( submitFormFeedbackMutation, submitStorageModeFormFetchMutation, submitEmailModeFormFetchMutation, + submitStorageModeClearFormMutation, + submitStorageModeClearFormFetchMutation, } } diff --git a/frontend/src/features/public-form/utils/createSubmission.ts b/frontend/src/features/public-form/utils/createSubmission.ts index 5eedd2ebbc..c24b723e58 100644 --- a/frontend/src/features/public-form/utils/createSubmission.ts +++ b/frontend/src/features/public-form/utils/createSubmission.ts @@ -77,21 +77,42 @@ export const createEncryptedSubmissionData = async ({ } } +type CreateEmailSubmissionFormDataArgs = { + formFields: FormFieldDto[] + formInputs: FormFieldValues + responseMetadata?: ResponseMetadata +} + +type CreateStorageSubmissionFormDataArgs = CreateEmailSubmissionFormDataArgs & { + paymentReceiptEmail?: string + paymentProducts?: ProductItem[] + payments?: PaymentFieldsDto + version: number +} + /** + * Used for both Email mode submissions and Storage mode submissions after encryption boundary shift. * @returns formData containing form responses and attachments. * @throws Error if form inputs are invalid. */ -export const createEmailSubmissionFormData = ( - formFields: FormFieldDto[], - formInputs: FormFieldValues, - responseMetadata?: ResponseMetadata, +export const createClearSubmissionFormData = ( + formDataArgs: + | CreateEmailSubmissionFormDataArgs + | CreateStorageSubmissionFormDataArgs, ) => { + const { formFields, formInputs, ...formDataArgsRest } = formDataArgs const responses = createResponsesArray(formFields, formInputs) const attachments = getAttachmentsMap(formFields, formInputs) // Convert content to FormData object. const formData = new FormData() - formData.append('body', JSON.stringify({ responses, responseMetadata })) + formData.append( + 'body', + JSON.stringify({ + responses, + ...formDataArgsRest, + }), + ) if (!isEmpty(attachments)) { forOwn(attachments, (attachment, fieldId) => { diff --git a/frontend/src/growthbook.ts b/frontend/src/growthbook.ts new file mode 100644 index 0000000000..c8aa435e67 --- /dev/null +++ b/frontend/src/growthbook.ts @@ -0,0 +1,17 @@ +import { GrowthBook } from '@growthbook/growthbook-react' + +import { GROWTHBOOK_DEV_PROXY } from '~constants/links' +import { GROWTHBOOK_API_HOST_PATH } from '~constants/routes' + +export const createGrowthbookInstance = (clientKey: string) => { + const isDev = process.env.NODE_ENV === 'development' + + return new GrowthBook({ + apiHost: `${ + isDev ? GROWTHBOOK_DEV_PROXY : process.env.REACT_APP_URL + }${GROWTHBOOK_API_HOST_PATH}`, + clientKey: clientKey, + // Enable easier debugging during development + enableDevMode: isDev, + }) +} diff --git a/scripts/20230826_unset-encryption-boundary-shift/unset-encryption-boundary-shift.js b/scripts/20230826_unset-encryption-boundary-shift/unset-encryption-boundary-shift.js new file mode 100644 index 0000000000..1854ef124f --- /dev/null +++ b/scripts/20230826_unset-encryption-boundary-shift/unset-encryption-boundary-shift.js @@ -0,0 +1,43 @@ +/* eslint-disable */ +/** + * This script sets encryptionBoundaryShift flag for all forms to true. + * A session is used to dry run the update operation before committing the changes. + */ + +// Start a session. +session = db.getMongo().startSession( { readPreference: { mode: "primary" } } ); + +// Start a transaction +session.startTransaction( { readConcern: { level: "local" }, writeConcern: { w: "majority" } } ); + +formsCollection = session.getDatabase("formsg").forms; + +console.log('=== before ===') + +// total # of documents +const totalDocs = formsCollection.countDocuments() +console.log('total # of documents:', totalDocs); + +// # of documents with encryptionBoundaryShift set +const docsSetBefore = formsCollection.countDocuments({ encryptionBoundaryShift: { $exists: true } }) +console.log('# of documents with encryptionBoundaryShift set:', docsSetBefore); + + +// set documents +formsCollection.updateMany( + { encryptionBoundaryShift: { $exists: true } }, + { + $unset: { encryptionBoundaryShift: true } + } +); + +console.log('=== after ===') + +// # of documents with encryptionBoundaryShift set to true (should match # of documents to modify) +const docsSetAfter = formsCollection.countDocuments({ encryptionBoundaryShift: { $exists: true } }) +console.log('# of documents with encryptionBoundaryShift set:', docsSetAfter); + +session.abortTransaction(); +// session.commitTransaction(); + +session.endSession(); diff --git a/shared/constants/feature-flags.ts b/shared/constants/feature-flags.ts index 3d2881b641..bd0267c58f 100644 --- a/shared/constants/feature-flags.ts +++ b/shared/constants/feature-flags.ts @@ -2,4 +2,5 @@ export const featureFlags = { payment: 'payment' as const, goLinks: 'goLinks' as const, turnstile: 'turnstile' as const, + encryptionBoundaryShift: 'encryption-boundary-shift' as const, } diff --git a/shared/types/core.ts b/shared/types/core.ts index d8cfadbc5b..b3d880bfbc 100644 --- a/shared/types/core.ts +++ b/shared/types/core.ts @@ -38,4 +38,5 @@ export type ClientEnvVars = { goGovBaseUrl: string adminFeedbackFieldThreshold: number adminFeedbackDisplayFrequency: number + growthbookClientKey: string } diff --git a/shared/types/form/form.ts b/shared/types/form/form.ts index 4831994125..b66101175e 100644 --- a/shared/types/form/form.ts +++ b/shared/types/form/form.ts @@ -150,14 +150,11 @@ export interface FormBase { webhook: FormWebhook responseMode: FormResponseMode - - encryptionBoundaryShift?: boolean } export interface EmailFormBase extends FormBase { responseMode: FormResponseMode.Email emails: string[] - encryptionBoundaryShift?: never } export interface StorageFormBase extends FormBase { diff --git a/src/app/config/features/growthbook.config.ts b/src/app/config/features/growthbook.config.ts new file mode 100644 index 0000000000..bc3f65e30c --- /dev/null +++ b/src/app/config/features/growthbook.config.ts @@ -0,0 +1,18 @@ +import convict, { Schema } from 'convict' + +export interface IGrowthbook { + growthbookClientKey: string +} + +const growthbookSchema: Schema = { + growthbookClientKey: { + doc: 'Growthbook Client Key', + format: String, + default: '', + env: 'GROWTHBOOK_CLIENT_KEY', + }, +} + +export const growthbookConfig = convict(growthbookSchema) + .validate({ allowed: 'strict' }) + .getProperties() diff --git a/src/app/models/__tests__/form.server.model.spec.ts b/src/app/models/__tests__/form.server.model.spec.ts index 3ef6e66ff0..bd2e6bfe0c 100644 --- a/src/app/models/__tests__/form.server.model.spec.ts +++ b/src/app/models/__tests__/form.server.model.spec.ts @@ -431,7 +431,7 @@ describe('Form Model', () => { describe('Encrypted form schema', () => { const ENCRYPT_FORM_DEFAULTS = merge( - { responseMode: 'encrypt', encryptionBoundaryShift: false }, + { responseMode: 'encrypt' }, FORM_DEFAULTS, PAYMENTS_DEFAULTS, ) diff --git a/src/app/models/form.server.model.ts b/src/app/models/form.server.model.ts index 1e120397d0..0e57fc723a 100644 --- a/src/app/models/form.server.model.ts +++ b/src/app/models/form.server.model.ts @@ -226,11 +226,6 @@ const EncryptedFormSchema = new Schema({ gstRegNo: { type: String, required: true, trim: true }, }, }, - - encryptionBoundaryShift: { - type: Boolean, - default: false, - }, }) const EncryptedFormDocumentSchema = diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts index b823057a56..be1f5d41ee 100644 --- a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts +++ b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts @@ -5420,6 +5420,7 @@ describe('admin-form.controller', () => { expect(MockParsedResponsesObject.parseResponses).toHaveBeenCalledWith( MOCK_FORM, MOCK_RESPONSES, + false, ) expect(MockAdminFormService.extractMyInfoFieldIds).toHaveBeenCalledWith( MOCK_FORM.form_fields, @@ -5954,6 +5955,7 @@ describe('admin-form.controller', () => { expect(MockParsedResponsesObject.parseResponses).toHaveBeenCalledWith( MOCK_FORM, MOCK_RESPONSES, + false, ) expect(MockAdminFormService.extractMyInfoFieldIds).not.toHaveBeenCalled() expect( @@ -6011,6 +6013,7 @@ describe('admin-form.controller', () => { expect(MockParsedResponsesObject.parseResponses).toHaveBeenCalledWith( MOCK_FORM, MOCK_RESPONSES, + false, ) expect(MockAdminFormService.extractMyInfoFieldIds).not.toHaveBeenCalled() expect( @@ -6068,6 +6071,7 @@ describe('admin-form.controller', () => { expect(MockParsedResponsesObject.parseResponses).toHaveBeenCalledWith( MOCK_FORM, MOCK_RESPONSES, + false, ) expect(MockAdminFormService.extractMyInfoFieldIds).not.toHaveBeenCalled() expect( @@ -6125,6 +6129,7 @@ describe('admin-form.controller', () => { expect(MockParsedResponsesObject.parseResponses).toHaveBeenCalledWith( MOCK_FORM, MOCK_RESPONSES, + false, ) expect(MockAdminFormService.extractMyInfoFieldIds).toHaveBeenCalledWith( MOCK_FORM.form_fields, @@ -6191,6 +6196,7 @@ describe('admin-form.controller', () => { expect(MockParsedResponsesObject.parseResponses).toHaveBeenCalledWith( MOCK_FORM, MOCK_RESPONSES, + false, ) expect(MockAdminFormService.extractMyInfoFieldIds).toHaveBeenCalledWith( MOCK_FORM.form_fields, @@ -6257,6 +6263,7 @@ describe('admin-form.controller', () => { expect(MockParsedResponsesObject.parseResponses).toHaveBeenCalledWith( MOCK_FORM, MOCK_RESPONSES, + false, ) expect(MockAdminFormService.extractMyInfoFieldIds).toHaveBeenCalledWith( MOCK_FORM.form_fields, @@ -6391,6 +6398,7 @@ describe('admin-form.controller', () => { MOCK_FORM, MOCK_RESPONSES, MOCK_ENCRYPTED_CONTENT, + true, ) expect( MockEncryptSubmissionService.createEncryptSubmissionWithoutSave, @@ -6793,6 +6801,7 @@ describe('admin-form.controller', () => { MOCK_FORM, MOCK_RESPONSES, MOCK_ENCRYPTED_CONTENT, + true, ) expect( MockEncryptSubmissionService.createEncryptSubmissionWithoutSave, @@ -6846,6 +6855,7 @@ describe('admin-form.controller', () => { MOCK_FORM, MOCK_RESPONSES, MOCK_ENCRYPTED_CONTENT, + true, ) expect( MockEncryptSubmissionService.createEncryptSubmissionWithoutSave, @@ -6899,6 +6909,7 @@ describe('admin-form.controller', () => { MOCK_FORM, MOCK_RESPONSES, MOCK_ENCRYPTED_CONTENT, + true, ) expect( MockEncryptSubmissionService.createEncryptSubmissionWithoutSave, @@ -6952,6 +6963,7 @@ describe('admin-form.controller', () => { MOCK_FORM, MOCK_RESPONSES, MOCK_ENCRYPTED_CONTENT, + true, ) expect( MockEncryptSubmissionService.createEncryptSubmissionWithoutSave, diff --git a/src/app/modules/form/admin-form/admin-form.controller.ts b/src/app/modules/form/admin-form/admin-form.controller.ts index 4597e6e137..2a7e615e55 100644 --- a/src/app/modules/form/admin-form/admin-form.controller.ts +++ b/src/app/modules/form/admin-form/admin-form.controller.ts @@ -1679,7 +1679,7 @@ export const submitEncryptPreview: ControllerHandler< }), ) .andThen((form) => - IncomingEncryptSubmission.init(form, responses, encryptedContent) + IncomingEncryptSubmission.init(form, responses, encryptedContent, true) // set as true as there's no need to gate anything if the its not a real submission .map((incomingSubmission) => ({ incomingSubmission, form })) .mapErr((error) => { logger.error({ @@ -1779,7 +1779,7 @@ export const submitEmailPreview: ControllerHandler< const parsedResponsesResult = await SubmissionService.validateAttachments( responses, form.responseMode, - ).andThen(() => ParsedResponsesObject.parseResponses(form, responses)) + ).andThen(() => ParsedResponsesObject.parseResponses(form, responses, false)) // email mode submissions (esp previews) does not need to use encryption boundary shift code if (parsedResponsesResult.isErr()) { logger.error({ message: 'Error while parsing responses for preview submission', diff --git a/src/app/modules/frontend/frontend.service.ts b/src/app/modules/frontend/frontend.service.ts index 0cd7349473..f97dfa8c29 100644 --- a/src/app/modules/frontend/frontend.service.ts +++ b/src/app/modules/frontend/frontend.service.ts @@ -3,6 +3,7 @@ import config from '../../config/config' import { captchaConfig } from '../../config/features/captcha.config' import { goGovConfig } from '../../config/features/gogov.config' import { googleAnalyticsConfig } from '../../config/features/google-analytics.config' +import { growthbookConfig } from '../../config/features/growthbook.config' import { paymentConfig } from '../../config/features/payment.config' import { sentryConfig } from '../../config/features/sentry.config' import { spcpMyInfoConfig } from '../../config/features/spcp-myinfo.config' @@ -40,5 +41,7 @@ export const getClientEnvVars = (): ClientEnvVars => { // Used for admin feedback in frontend adminFeedbackFieldThreshold: config.adminFeedbackFieldThreshold, adminFeedbackDisplayFrequency: config.adminFeedbackDisplayFrequency, + + growthbookClientKey: growthbookConfig.growthbookClientKey, } } diff --git a/src/app/modules/submission/ParsedResponsesObject.class.ts b/src/app/modules/submission/ParsedResponsesObject.class.ts index 196969d702..f867ee6343 100644 --- a/src/app/modules/submission/ParsedResponsesObject.class.ts +++ b/src/app/modules/submission/ParsedResponsesObject.class.ts @@ -84,11 +84,16 @@ export default class ParsedResponsesObject { static parseResponses( form: IFormDocument, responses: FieldResponse[], + encryptionBoundaryShiftEnabled: boolean, ): Result< ParsedResponsesObject, ProcessingError | ConflictError | ValidateFieldError > { - const filteredResponsesResult = getFilteredResponses(form, responses) + const filteredResponsesResult = getFilteredResponses( + form, + responses, + encryptionBoundaryShiftEnabled, + ) if (filteredResponsesResult.isErr()) { return err(filteredResponsesResult.error) } diff --git a/src/app/modules/submission/email-submission/email-submission.controller.ts b/src/app/modules/submission/email-submission/email-submission.controller.ts index 2cc6f73dde..852e2d2bb1 100644 --- a/src/app/modules/submission/email-submission/email-submission.controller.ts +++ b/src/app/modules/submission/email-submission/email-submission.controller.ts @@ -178,7 +178,11 @@ const submitEmailModeForm: ControllerHandler< form.responseMode, ) .andThen(() => - ParsedResponsesObject.parseResponses(form, req.body.responses), + ParsedResponsesObject.parseResponses( + form, + req.body.responses, + false, // email mode submissions do not need to use encryption boundary shift code + ), ) .map((parsedResponses) => ({ parsedResponses, form })) .mapErr((error) => { diff --git a/src/app/modules/submission/encrypt-submission/IncomingEncryptSubmission.class.ts b/src/app/modules/submission/encrypt-submission/IncomingEncryptSubmission.class.ts index 2c9a6de896..755e91923f 100644 --- a/src/app/modules/submission/encrypt-submission/IncomingEncryptSubmission.class.ts +++ b/src/app/modules/submission/encrypt-submission/IncomingEncryptSubmission.class.ts @@ -32,15 +32,16 @@ export default class IncomingEncryptSubmission extends IncomingSubmission { form: IPopulatedEncryptedForm, responses: FieldResponse[], encryptedContent: string, + encryptionBoundaryShiftEnabled: boolean, ): Result< IncomingEncryptSubmission, ProcessingError | ConflictError | ValidateFieldError[] > { return checkIsEncryptedEncoding(encryptedContent) .andThen(() => { - if (form.encryptionBoundaryShift) + if (encryptionBoundaryShiftEnabled) return ok(responses as FilteredResponse[]) - else return getFilteredResponses(form, responses) + else return getFilteredResponses(form, responses, false) }) .andThen((filteredResponses) => this.getFieldMap(form, filteredResponses).map((fieldMap) => ({ @@ -60,9 +61,12 @@ export default class IncomingEncryptSubmission extends IncomingSubmission { responseVisibilityPredicate(response: FieldResponse): boolean { return ( - 'answer' in response && - typeof response.answer === 'string' && - response.answer.trim() !== '' + ('answer' in response && + typeof response.answer === 'string' && + response.answer.trim() !== '') || + ('answerArray' in response && + Array.isArray(response.answerArray) && + response.answerArray.length > 0) ) } } diff --git a/src/app/modules/submission/encrypt-submission/__tests__/IncomingEncryptSubmission.class.spec.ts b/src/app/modules/submission/encrypt-submission/__tests__/IncomingEncryptSubmission.class.spec.ts index a841e2a604..05439fffe9 100644 --- a/src/app/modules/submission/encrypt-submission/__tests__/IncomingEncryptSubmission.class.spec.ts +++ b/src/app/modules/submission/encrypt-submission/__tests__/IncomingEncryptSubmission.class.spec.ts @@ -74,6 +74,7 @@ describe('IncomingEncryptSubmission', () => { } as unknown as IPopulatedEncryptedForm, responses, '', + false, ) expect(initResult._unsafeUnwrap().responses).toEqual(responses) }) @@ -125,10 +126,10 @@ describe('IncomingEncryptSubmission', () => { { responseMode: FormResponseMode.Encrypt, form_fields: basicFormFields, - encryptionBoundaryShift: false, } as unknown as IPopulatedEncryptedForm, responses, '', + false, ) const filteredResponses = [mobileResponse, emailResponse] expect(initResult._unsafeUnwrap().responses).toEqual(filteredResponses) @@ -175,10 +176,10 @@ describe('IncomingEncryptSubmission', () => { { responseMode: FormResponseMode.Encrypt, form_fields: basicFormFields, - encryptionBoundaryShift: true, } as unknown as IPopulatedEncryptedForm, responses, '', + true, ) expect(initResult._unsafeUnwrap().responses).toEqual(filteredResponses) }) @@ -211,6 +212,7 @@ describe('IncomingEncryptSubmission', () => { } as unknown as IPopulatedEncryptedForm, responses, '', + false, ) expect(initResult._unsafeUnwrapErr()).toEqual( new ConflictError('Some form fields are missing'), @@ -258,6 +260,7 @@ describe('IncomingEncryptSubmission', () => { } as unknown as IPopulatedEncryptedForm, responses, '', + false, ) expect(result.isOk()).toEqual(true) @@ -280,6 +283,7 @@ describe('IncomingEncryptSubmission', () => { } as unknown as IPopulatedEncryptedForm, [mobileResponse], '', + false, ) expect(result.isErr()).toEqual(true) @@ -305,6 +309,7 @@ describe('IncomingEncryptSubmission', () => { } as unknown as IPopulatedEncryptedForm, [], '', + false, ) expect(result.isErr()).toEqual(true) diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts index 8bf99d053a..84c3694da4 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts @@ -7,6 +7,7 @@ import mongoose from 'mongoose' import { okAsync } from 'neverthrow' import Stripe from 'stripe' +import { featureFlags } from '../../../../../shared/constants' import { ErrorDto, FormAuthType, @@ -135,6 +136,7 @@ const submitEncryptModeForm = async ( form, responses, encryptedContent, + req.formsg.featureFlags.includes(featureFlags.encryptionBoundaryShift), ) if (incomingSubmissionResult.isErr()) { const { statusCode, errorMessage } = mapRouteError( diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.middleware.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.middleware.ts index 2cc985ac4c..c46cb3222f 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.middleware.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.middleware.ts @@ -4,6 +4,7 @@ import { NextFunction } from 'express' import { StatusCodes } from 'http-status-codes' import { chain, omit } from 'lodash' +import { featureFlags } from '../../../../../shared/constants' import { BasicField, StorageModeAttachment, @@ -14,6 +15,7 @@ import { paymentConfig } from '../../../config/features/payment.config' import formsgSdk from '../../../config/formsg-sdk' import { createLoggerWithLabel } from '../../../config/logger' import { createReqMeta } from '../../../utils/request' +import * as FeatureFlagService from '../../feature-flags/feature-flags.service' import { JoiPaymentProduct } from '../../form/admin-form/admin-form.payments.constants' import * as FormService from '../../form/form.service' import ParsedResponsesObject from '../ParsedResponsesObject.class' @@ -130,9 +132,10 @@ export const checkNewBoundaryEnabled = async ( res: Parameters[1], next: NextFunction, ) => { - const formDef = req.formsg.formDef - - if (!formDef.encryptionBoundaryShift) { + const newBoundaryEnabled = req.formsg.featureFlags.includes( + featureFlags.encryptionBoundaryShift, + ) + if (!newBoundaryEnabled) { return res .status(StatusCodes.FORBIDDEN) .json({ message: 'This endpoint has not been enabled for this form.' }) @@ -160,7 +163,11 @@ export const validateSubmission = async ( formDef.responseMode, ) .andThen(() => - ParsedResponsesObject.parseResponses(formDef, req.body.responses), + ParsedResponsesObject.parseResponses( + formDef, + req.body.responses, + req.formsg.featureFlags.includes(featureFlags.encryptionBoundaryShift), + ), ) .map(() => next()) .mapErr((error) => { @@ -265,6 +272,7 @@ export const encryptSubmission = async ( const filteredResponses = getFilteredResponses( encryptedFormDef, req.body.responses, + req.formsg.featureFlags.includes(featureFlags.encryptionBoundaryShift), ) if (filteredResponses.isErr()) { @@ -338,7 +346,20 @@ export const createFormsgAndRetrieveForm = async ( if (req.formsg) return res.send(new FormsgReqBodyExistsError()) const formsg = {} as FormLoadedDto - // Step 2a: Retrieve form + // Step 2: Retrieve feature flags + const featureFlagsListResult = await FeatureFlagService.getEnabledFlags() + + if (featureFlagsListResult.isErr()) { + logger.error({ + message: 'Error occurred whilst retrieving enabled feature flags', + meta: logMeta, + error: featureFlagsListResult.error, + }) + } else { + formsg.featureFlags = featureFlagsListResult.value + } + + // Step 3a: Retrieve form const formResult = await FormService.retrieveFullFormById(formId) if (formResult.isErr()) { logger.warn({ @@ -350,11 +371,11 @@ export const createFormsgAndRetrieveForm = async ( return res.status(statusCode).json({ message: errorMessage }) } - // Step 2b: Set formsg.formDef in req.body + // Step 3b: Set formsg.formDef in req.body const formDef = formResult.value formsg.formDef = formDef - // Step 3a: Check if form is encrypt mode + // Step 4a: Check if form is encrypt mode const checkFormIsEncryptModeResult = checkFormIsEncryptMode(formDef) if (checkFormIsEncryptModeResult.isErr()) { logger.error({ @@ -370,10 +391,10 @@ export const createFormsgAndRetrieveForm = async ( }) } - // Step 3b: Set formsg.encryptedFormDef in req.body + // Step 4b: Set formsg.encryptedFormDef in req.body formsg.encryptedFormDef = checkFormIsEncryptModeResult.value - // Step 4: Check if form has public key + // Step 5: Check if form has public key if (!formDef.publicKey) { logger.warn({ message: 'Form does not have a public key', diff --git a/src/app/modules/submission/submission.utils.ts b/src/app/modules/submission/submission.utils.ts index 44130ef04f..761fdb1ded 100644 --- a/src/app/modules/submission/submission.utils.ts +++ b/src/app/modules/submission/submission.utils.ts @@ -149,10 +149,11 @@ export const extractEmailConfirmationData = ( export const getFilteredResponses = ( form: IFormDocument, responses: FieldResponse[], + encryptionBoundaryShiftEnabled: boolean, ): Result => { const isEncryptedMode = form.responseMode === FormResponseMode.Encrypt && - !form.encryptionBoundaryShift + !encryptionBoundaryShiftEnabled const responseModeFilter = getResponseModeFilter(isEncryptedMode) const formFieldModeFilter = getFormFieldModeFilter(isEncryptedMode) diff --git a/src/types/api/encrypt_submission.ts b/src/types/api/encrypt_submission.ts index db0f9bd723..0a97d9c5b5 100644 --- a/src/types/api/encrypt_submission.ts +++ b/src/types/api/encrypt_submission.ts @@ -32,6 +32,7 @@ export type ParsedStorageModeSubmissionBody = ParsedEmailModeSubmissionBody & { } export type FormLoadedDto = { + featureFlags: string[] formDef: IPopulatedForm encryptedFormDef: IPopulatedEncryptedForm } diff --git a/src/types/form.ts b/src/types/form.ts index 3e2993e065..b4bf67633b 100644 --- a/src/types/form.ts +++ b/src/types/form.ts @@ -112,8 +112,6 @@ export interface IFormSchema extends IForm, Document, PublicView { created?: Date lastModified?: Date - encryptionBoundaryShift?: boolean - /** * Replaces the field corresponding to given id to given new field * @param fieldId the id of the field to update @@ -304,7 +302,6 @@ export interface IEmailForm extends IForm { // strings to string array. emails: string[] | string publicKey?: never - encryptionBoundaryShift?: never } export type IEmailFormSchema = IEmailForm & IFormSchema From af594bb0a86a1986b3b0b7da910fb0c47f629ec7 Mon Sep 17 00:00:00 2001 From: Ken Lee Shu Ming Date: Thu, 31 Aug 2023 17:40:51 +0800 Subject: [PATCH 2/6] feat: payment security account email check (#6604) * test: add cases for failed whitelisting domain * feat: add agency domain check when connecting stripe account * feat: add feature flag to allow release in dark * test: add cases to test for disabled fetaure flag * chore: update comments for clarity * chore: remove unused imports * refactor: use guard clause for flag checks * refcator: rename defaultvalue to fallback as its is applied during errors * feat: add be to redirect with query params when domain check fails * feat: add fe to show alert when non-whitelisted domain error is returned * chore: fix typo on whitelisting * Update src/app/modules/payments/__tests__/stripe.service.spec.ts Co-authored-by: Lin Huiqing <37061143+LinHuiqing@users.noreply.github.com> --------- Co-authored-by: Lin Huiqing <37061143+LinHuiqing@users.noreply.github.com> --- .../PaymentSettingsSection.tsx | 50 ++++++-- shared/constants/errors.ts | 4 + shared/constants/feature-flags.ts | 1 + shared/constants/index.ts | 1 + .../feature-flags/feature-flags.service.ts | 49 ++++++- .../payments/__tests__/stripe.service.spec.ts | 121 +++++++++++++++++- src/app/modules/payments/stripe.controller.ts | 13 ++ src/app/modules/payments/stripe.service.ts | 87 ++++++++++--- 8 files changed, 293 insertions(+), 33 deletions(-) create mode 100644 shared/constants/errors.ts diff --git a/frontend/src/features/admin-form/settings/components/PaymentSettingsSection/PaymentSettingsSection.tsx b/frontend/src/features/admin-form/settings/components/PaymentSettingsSection/PaymentSettingsSection.tsx index 02eca3cc8f..f916b41b61 100644 --- a/frontend/src/features/admin-form/settings/components/PaymentSettingsSection/PaymentSettingsSection.tsx +++ b/frontend/src/features/admin-form/settings/components/PaymentSettingsSection/PaymentSettingsSection.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { useSearchParams } from 'react-router-dom' import { As, Divider, @@ -10,6 +11,10 @@ import { VStack, } from '@chakra-ui/react' +import { + DISALLOW_CONNECT_NON_WHITELIST_STRIPE_ACCOUNT, + ERROR_QUERY_PARAM_KEY, +} from '~shared/constants' import { FormResponseMode, PaymentChannel } from '~shared/types' import { BxsCheckCircle, BxsError, BxsInfoCircle } from '~assets/icons' @@ -39,6 +44,27 @@ const BeforeConnectionInstructions = ({ }): JSX.Element => { const [allowConnect, setAllowConnect] = useState(false) const { data: paymentGuideLink } = usePaymentGuideLink() + const [searchParams] = useSearchParams() + + const queryParams = Object.fromEntries([...searchParams]) + const isInvalidDomain = + queryParams[ERROR_QUERY_PARAM_KEY] === + DISALLOW_CONNECT_NON_WHITELIST_STRIPE_ACCOUNT + + if (isInvalidDomain) { + return ( + <> + + + Your Stripe account could not be connected because it was created + with a non-whitelisted email domain. Try reconnecting an account + that was created with a whitelisted email domain. + + + + + ) + } if (isProductionEnv) { return ( @@ -84,19 +110,19 @@ const BeforeConnectionInstructions = ({ /> ) - } else { - return ( - <> - - - You are currently in test mode. You can choose to skip connecting a - Stripe account after clicking the button below. - - - - - ) } + + return ( + <> + + + You are currently in test mode. You can choose to skip connecting a + Stripe account after clicking the button below. + + + + + ) } const ConnectionStatusText = ({ diff --git a/shared/constants/errors.ts b/shared/constants/errors.ts new file mode 100644 index 0000000000..cef0710f81 --- /dev/null +++ b/shared/constants/errors.ts @@ -0,0 +1,4 @@ +export const ERROR_QUERY_PARAM_KEY = 'error_type' + +// Payment errors namespace (100xxx) +export const DISALLOW_CONNECT_NON_WHITELIST_STRIPE_ACCOUNT = '100001' diff --git a/shared/constants/feature-flags.ts b/shared/constants/feature-flags.ts index bd0267c58f..42f8171758 100644 --- a/shared/constants/feature-flags.ts +++ b/shared/constants/feature-flags.ts @@ -2,5 +2,6 @@ export const featureFlags = { payment: 'payment' as const, goLinks: 'goLinks' as const, turnstile: 'turnstile' as const, + validateStripeEmailDomain: 'validateStripeEmailDomain' as const, encryptionBoundaryShift: 'encryption-boundary-shift' as const, } diff --git a/shared/constants/index.ts b/shared/constants/index.ts index 552e860119..41da90f12e 100644 --- a/shared/constants/index.ts +++ b/shared/constants/index.ts @@ -2,4 +2,5 @@ export * from './file' export * from './form' export * from './links' export * from './feature-flags' +export * from './errors' export * from './dates' diff --git a/src/app/modules/feature-flags/feature-flags.service.ts b/src/app/modules/feature-flags/feature-flags.service.ts index 063bf9d619..33d906d87a 100644 --- a/src/app/modules/feature-flags/feature-flags.service.ts +++ b/src/app/modules/feature-flags/feature-flags.service.ts @@ -1,7 +1,8 @@ import mongoose from 'mongoose' import { okAsync, ResultAsync } from 'neverthrow' -import { createLoggerWithLabel } from '../../config/logger' +import { featureFlags } from '../../../../shared/constants' +import { createLoggerWithLabel, CustomLoggerParams } from '../../config/logger' import getFeatureFlagModel from '../../models/feature_flag.server.model' import { DatabaseError } from '../core/core.errors' @@ -23,3 +24,49 @@ export const getEnabledFlags = (): ResultAsync => { okAsync(enabledFlagsDocs.map((doc) => doc.name)), ) } + +/** + * Wrapper over getEnabledFlags function to gracefully handle errors + * and returns the `options.fallbackValue` instead. + * + * @example + * ``` + * const logMeta = { + * action: 'linkStripeAccountToForm', + * } + * return getFeatureFlag(featureFlags.validateStripeEmailDomain, { + * fallbackValue: false, + * logMeta, + * }) + *.andThen((shouldValidateStripeEmailDomain) => ...) + *``` + * + * @param flag + * @param options.fallbackValue the value to fall back to in the event that there's an error retrieving the feature flag + * @returns boolean that represents the status of the feature flag or the fallback value + */ +export const getFeatureFlag = ( + flag: keyof typeof featureFlags, + options?: { + fallbackValue?: boolean + logMeta?: CustomLoggerParams['meta'] + }, +): ResultAsync => { + const _fallbackValue = options?.fallbackValue ?? false + return getEnabledFlags() + .andThen((featureFlagsListResult) => + okAsync(featureFlagsListResult.includes(flag)), + ) + .orElse((error) => { + logger.error({ + message: 'Error occurred whilst retrieving enabled feature flags', + meta: options?.logMeta + ? options.logMeta + : { + action: 'getFeatureFlag', + }, + error, + }) + return okAsync(_fallbackValue) + }) +} diff --git a/src/app/modules/payments/__tests__/stripe.service.spec.ts b/src/app/modules/payments/__tests__/stripe.service.spec.ts index f0f1c9f826..404ecbf4bd 100644 --- a/src/app/modules/payments/__tests__/stripe.service.spec.ts +++ b/src/app/modules/payments/__tests__/stripe.service.spec.ts @@ -19,6 +19,9 @@ import { IPopulatedUser, } from 'src/types' +import { InvalidDomainError } from '../../auth/auth.errors' +import * as AuthService from '../../auth/auth.service' +import * as FeatureFlagService from '../../feature-flags/feature-flags.service' import { PaymentNotFoundError } from '../payments.errors' import * as PaymentsService from '../payments.service' import { StripeMetadataInvalidError } from '../stripe.errors' @@ -689,7 +692,11 @@ describe('stripe.service', () => { }) }) describe('linkStripeAccountToForm', () => { - it('should call func to attach payment account information', async () => { + beforeEach(() => { + jest.restoreAllMocks() + }) + + it('should attach payment account information for new accounts', async () => { // Arrange await dbHandler.insertFormCollectionReqs({ userId: MOCK_USER_ID, @@ -701,8 +708,22 @@ describe('stripe.service', () => { }) .populate('admin') .execPopulate()) as IPopulatedEncryptedForm - const spiedFn = jest.spyOn(mockForm, 'addPaymentAccountId') - const expectedAccountId = 'accountId' + + const getFeatureFlagSpy = jest + .spyOn(FeatureFlagService, 'getFeatureFlag') + .mockReturnValueOnce(okAsync(true)) + const addPaymentAccountIdSpy = jest.spyOn(mockForm, 'addPaymentAccountId') + const expectedAccountId = 'newAccountId' + const stripeAccountsRetrieveApiSpy = jest + .spyOn(stripe.accounts, 'retrieve') + .mockReturnValueOnce( + Promise.resolve({ + email: 'mockEmail', + } as unknown as Stripe.Response), + ) + const authServiceSpy = jest + .spyOn(AuthService, 'validateEmailDomain') + .mockReturnValue(okAsync(true) as any) // Act const result = await StripeService.linkStripeAccountToForm(mockForm, { @@ -711,7 +732,10 @@ describe('stripe.service', () => { }) // Assert - expect(spiedFn).toHaveBeenCalledTimes(1) + expect(getFeatureFlagSpy).toHaveBeenCalledTimes(1) + expect(stripeAccountsRetrieveApiSpy).toHaveBeenCalledTimes(1) + expect(authServiceSpy).toHaveBeenCalledTimes(1) + expect(addPaymentAccountIdSpy).toHaveBeenCalledTimes(1) expect(result._unsafeUnwrap()).toBe(expectedAccountId) }) @@ -724,12 +748,26 @@ describe('stripe.service', () => { }) .populate('admin') .execPopulate()) as IPopulatedEncryptedForm + + const getFeatureFlagSpy = jest + .spyOn(FeatureFlagService, 'getFeatureFlag') + .mockReturnValueOnce(okAsync(true)) const expectedAccountId = 'existingAccountId' mockForm.payments_channel = { target_account_id: expectedAccountId, channel: PaymentChannel.Stripe, publishable_key: 'publishableKey', } + const stripeAccountsRetrieveApiSpy = jest + .spyOn(stripe.accounts, 'retrieve') + .mockReturnValueOnce( + Promise.resolve({ + email: 'mockEmail', + } as unknown as Stripe.Response), + ) + const authServiceSpy = jest + .spyOn(AuthService, 'validateEmailDomain') + .mockReturnValue(okAsync(true) as any) // Act const result = await StripeService.linkStripeAccountToForm(mockForm, { @@ -738,7 +776,82 @@ describe('stripe.service', () => { }) // Assert + expect(getFeatureFlagSpy).toHaveBeenCalledTimes(1) expect(result._unsafeUnwrap()).toBe(expectedAccountId) + expect(stripeAccountsRetrieveApiSpy).toHaveBeenCalledTimes(1) + expect(authServiceSpy).toHaveBeenCalledTimes(1) + }) + + it('should not connect when stripe account email is not whitelisted', async () => { + // Arrange + const mockForm = (await new EncryptedForm({ + admin: MOCK_USER, + title: 'Test Form', + publicKey: 'mockPublicKey', + }).execPopulate()) as IPopulatedEncryptedForm + const addPaymentAccountIdSpy = jest.spyOn(mockForm, 'addPaymentAccountId') + + const getFeatureFlagSpy = jest + .spyOn(FeatureFlagService, 'getFeatureFlag') + .mockReturnValueOnce(okAsync(true)) + const stripeAccountsRetrieveApiSpy = jest + .spyOn(stripe.accounts, 'retrieve') + .mockReturnValueOnce( + Promise.resolve({ + email: 'mockEmail', + } as unknown as Stripe.Response), + ) + const authServiceSpy = jest + .spyOn(AuthService, 'validateEmailDomain') + .mockReturnValue(errAsync(new InvalidDomainError())) + + // Act + const result = await StripeService.linkStripeAccountToForm(mockForm, { + accountId: 'accountId', + publishableKey: 'publishableKey', + }) + + // Assert + expect(getFeatureFlagSpy).toHaveBeenCalledTimes(1) + expect(stripeAccountsRetrieveApiSpy).toHaveBeenCalledTimes(1) + expect(authServiceSpy).toHaveBeenCalledTimes(1) + expect(addPaymentAccountIdSpy).toHaveBeenCalledTimes(0) + expect(result.isErr()).toBeTrue() + expect(result._unsafeUnwrapErr()).toBeInstanceOf(InvalidDomainError) + }) + + it('should not check email whitelisting if feature flag is disabled', async () => { + // Arrange + const mockForm = (await new EncryptedForm({ + admin: MOCK_USER, + title: 'Test Form', + publicKey: 'mockPublicKey', + }) + .populate('admin') + .execPopulate()) as IPopulatedEncryptedForm + + const getFeatureFlagSpy = jest + .spyOn(FeatureFlagService, 'getFeatureFlag') + .mockReturnValueOnce(okAsync(false)) + const addPaymentAccountIdSpy = jest.spyOn(mockForm, 'addPaymentAccountId') + const stripeAccountsRetrieveApiSpy = jest.spyOn( + stripe.accounts, + 'retrieve', + ) + const authServiceSpy = jest.spyOn(AuthService, 'validateEmailDomain') + + // Act + const result = await StripeService.linkStripeAccountToForm(mockForm, { + accountId: 'accountId', + publishableKey: 'publishableKey', + }) + + // Assert + expect(getFeatureFlagSpy).toHaveBeenCalledTimes(1) + expect(stripeAccountsRetrieveApiSpy).toHaveBeenCalledTimes(0) + expect(authServiceSpy).toHaveBeenCalledTimes(0) + expect(addPaymentAccountIdSpy).toHaveBeenCalledTimes(1) + expect(result.isOk()).toBeTrue() }) }) diff --git a/src/app/modules/payments/stripe.controller.ts b/src/app/modules/payments/stripe.controller.ts index 868145668a..ca05057d78 100644 --- a/src/app/modules/payments/stripe.controller.ts +++ b/src/app/modules/payments/stripe.controller.ts @@ -3,8 +3,13 @@ import { celebrate, Joi, Segments } from 'celebrate' import { StatusCodes } from 'http-status-codes' import { errAsync, okAsync, ResultAsync } from 'neverthrow' +import querystring from 'querystring' import Stripe from 'stripe' +import { + DISALLOW_CONNECT_NON_WHITELIST_STRIPE_ACCOUNT, + ERROR_QUERY_PARAM_KEY, +} from '../../../../shared/constants' import { ErrorDto, GetPaymentInfoDto, @@ -16,6 +21,7 @@ import config from '../../config/config' import { createLoggerWithLabel } from '../../config/logger' import { stripe } from '../../loaders/stripe' import { createReqMeta } from '../../utils/request' +import { InvalidDomainError } from '../auth/auth.errors' import { ControllerHandler } from '../core/core.types' import * as FormService from '../form/form.service' import * as PendingSubmissionModel from '../pending-submission/pending-submission.service' @@ -180,6 +186,13 @@ const _handleConnectOauthCallback: ControllerHandler< }, error, }) + if (error.constructor === InvalidDomainError) { + const queryString = querystring.stringify({ + [ERROR_QUERY_PARAM_KEY]: + DISALLOW_CONNECT_NON_WHITELIST_STRIPE_ACCOUNT, + }) + return res.redirect(redirectUrl + '?' + queryString) + } return res.redirect(redirectUrl) }) ) diff --git a/src/app/modules/payments/stripe.service.ts b/src/app/modules/payments/stripe.service.ts index 20cff044fd..aa3d8ef335 100644 --- a/src/app/modules/payments/stripe.service.ts +++ b/src/app/modules/payments/stripe.service.ts @@ -8,6 +8,7 @@ import Stripe from 'stripe' import { MarkRequired } from 'ts-essentials' import isURL from 'validator/lib/isURL' +import { featureFlags } from '../../../../shared/constants' import { PaymentStatus, ReconciliationReportLine, @@ -27,7 +28,10 @@ import { getMongoErrorMessage, transformMongoError, } from '../../utils/handle-mongo-error' +import { InvalidDomainError } from '../auth/auth.errors' +import * as AuthService from '../auth/auth.service' import { DatabaseError, DatabaseWriteConflictError } from '../core/core.errors' +import { getFeatureFlag } from '../feature-flags/feature-flags.service' import { FormNotFoundError } from '../form/form.errors' import { PendingSubmissionNotFoundError, @@ -672,24 +676,75 @@ export const linkStripeAccountToForm = ( accountId: string publishableKey: string }, -): ResultAsync => - ResultAsync.fromPromise( - form.addPaymentAccountId({ accountId, publishableKey }), - (error) => { - const errMsg = 'Failed to update payment account id' - logger.error({ - message: errMsg, - meta: { - action: 'linkStripeAccountToForm', - stripeAccountId: accountId, - stripePublishableKey: publishableKey, - formId: form._id, +): ResultAsync< + string, + DatabaseError | StripeFetchError | StripeAccountError | InvalidDomainError +> => { + const logMeta = { + action: 'linkStripeAccountToForm', + stripeAccountId: accountId, + stripePublishableKey: publishableKey, + formId: form._id, + } + + return getFeatureFlag(featureFlags.validateStripeEmailDomain, { + // If getFeatureFlag throws a DatabaseError, we want to log it, but respond + // to the client as if the flag is not found. + fallbackValue: false, + logMeta, + }) + .andThen((shouldValidateStripeEmailDomain) => { + if (!shouldValidateStripeEmailDomain) { + // skip validation + return okAsync(undefined) + } + + return ResultAsync.fromPromise( + stripe.accounts.retrieve(accountId), + (error) => { + logger.error({ + message: 'Error retriving Stripe account', + meta: { + action: 'linkStripeAccountToForm', + stripeAccountId: accountId, + }, + error, + }) + return new StripeFetchError(String(error)) }, - error, + ).andThen((account) => { + // Check if the email domain is whitelisted + if (!account.email) { + logger.error({ + message: 'Error retriving Stripe account email', + meta: { + action: 'linkStripeAccountToForm', + stripeAccountId: accountId, + account, + }, + }) + return errAsync( + new StripeAccountError('Stripe account email is missing'), + ) + } + return AuthService.validateEmailDomain(account.email) }) - return new DatabaseError(errMsg) - }, - ).map((updatedForm) => updatedForm.payments_channel.target_account_id) + }) + .andThen(() => + ResultAsync.fromPromise( + form.addPaymentAccountId({ accountId, publishableKey }), + (error) => { + const errMsg = 'Failed to update payment account id' + logger.error({ + message: errMsg, + meta: logMeta, + error, + }) + return new DatabaseError(errMsg) + }, + ).map((updatedForm) => updatedForm.payments_channel.target_account_id), + ) +} export const unlinkStripeAccountFromForm = ( form: IPopulatedEncryptedForm, From 808b92308a47b96e9a300f6f171aa21cea5a8185 Mon Sep 17 00:00:00 2001 From: Bryce Goh Date: Sun, 3 Sep 2023 17:16:51 +0800 Subject: [PATCH 3/6] fix: Find latest successful payment query to sort by completed payment date and constraint query to last 30 days (#6615) * fix: change query sort and add date constraint * test: add cases to test for query sort and date constraint * fix: standardise to use moment --- .../__tests__/payments.controller.spec.ts | 83 +++++++++++++++++++ .../__tests__/payments.service.spec.ts | 40 +++++++-- src/app/modules/payments/payments.service.ts | 6 +- 3 files changed, 123 insertions(+), 6 deletions(-) diff --git a/src/app/modules/payments/__tests__/payments.controller.spec.ts b/src/app/modules/payments/__tests__/payments.controller.spec.ts index 74f919b95b..901d93149b 100644 --- a/src/app/modules/payments/__tests__/payments.controller.spec.ts +++ b/src/app/modules/payments/__tests__/payments.controller.spec.ts @@ -2,6 +2,7 @@ import dbHandler from '__tests__/unit/backend/helpers/jest-db' import expressHandler from '__tests__/unit/backend/helpers/jest-express' import { ObjectId } from 'bson' import { StatusCodes } from 'http-status-codes' +import moment from 'moment-timezone' import mongoose from 'mongoose' import { PaymentStatus } from 'shared/types' @@ -41,6 +42,7 @@ describe('payments.controller', () => { email: email, completedPayment: { receiptUrl: 'http://form.gov.sg', + paymentDate: new Date(), }, gstEnabled: false, }) @@ -61,6 +63,87 @@ describe('payments.controller', () => { expect(mockRes.status).toHaveBeenCalledWith(200) expect(mockRes.send).toHaveBeenCalledOnce() }) + it('should return 200 and the latest payment record id if there are multiple successful payments', async () => { + const email = 'formsg@tech.gov.sg' + const now = moment().utc() + + // create 2 payments with different payment dates but same email + const latestPayment = await Payment.create({ + formId: MOCK_FORM_ID, + targetAccountId: 'acct_MOCK_ACCOUNT_ID', + pendingSubmissionId: new ObjectId(), + amount: 12345, + status: PaymentStatus.Succeeded, + paymentIntentId: 'pi_MOCK_PAYMENT_INTENT', + email: email, + completedPayment: { + receiptUrl: 'http://form.gov.sg', + paymentDate: now.toDate(), + }, + gstEnabled: false, + }) + await Payment.create({ + formId: MOCK_FORM_ID, + targetAccountId: 'acct_MOCK_ACCOUNT_ID', + pendingSubmissionId: new ObjectId(), + amount: 12345, + status: PaymentStatus.Succeeded, + paymentIntentId: 'pi_MOCK_PAYMENT_INTENT', + email: email, + completedPayment: { + receiptUrl: 'http://form.gov.sg', + paymentDate: now.subtract(1, 'hour').toDate(), + }, + gstEnabled: false, + }) + + const mockReq = expressHandler.mockRequest({ + params: { formId: MOCK_FORM_ID }, + body: { email }, + }) + + const mockRes = expressHandler.mockResponse() + + await PaymentsController.handleGetPreviousPaymentId( + mockReq, + mockRes, + jest.fn(), + ) + expect(mockRes.status).toHaveBeenCalledWith(200) + expect(mockRes.send).toHaveBeenCalledWith(latestPayment._id) + }) + it('should return 404 if there are no successful payments made within the alst 30 days', async () => { + const email = 'formsg@tech.gov.sg' + + await Payment.create({ + formId: MOCK_FORM_ID, + targetAccountId: 'acct_MOCK_ACCOUNT_ID', + pendingSubmissionId: new ObjectId(), + amount: 12345, + status: PaymentStatus.Succeeded, + paymentIntentId: 'pi_MOCK_PAYMENT_INTENT', + email: email, + completedPayment: { + receiptUrl: 'http://form.gov.sg', + paymentDate: moment().subtract(31, 'days').toDate(), + }, + gstEnabled: false, + }) + + const mockReq = expressHandler.mockRequest({ + params: { formId: MOCK_FORM_ID }, + body: { email }, + }) + + const mockRes = expressHandler.mockResponse() + + await PaymentsController.handleGetPreviousPaymentId( + mockReq, + mockRes, + jest.fn(), + ) + expect(mockRes.sendStatus).toHaveBeenCalledWith(404) + }) it('should return 404 if there are no previous payments by the specific email', async () => { const payment = await Payment.create({ formId: MOCK_FORM_ID, diff --git a/src/app/modules/payments/__tests__/payments.service.spec.ts b/src/app/modules/payments/__tests__/payments.service.spec.ts index 78ba5e2764..150c26d379 100644 --- a/src/app/modules/payments/__tests__/payments.service.spec.ts +++ b/src/app/modules/payments/__tests__/payments.service.spec.ts @@ -1,5 +1,6 @@ import dbHandler from '__tests__/unit/backend/helpers/jest-db' import { ObjectId } from 'bson' +import moment from 'moment-timezone' import mongoose, { Query } from 'mongoose' import { PaymentStatus } from 'shared/types' @@ -75,6 +76,7 @@ describe('payments.service', () => { describe('findLatestSuccessfulPaymentByEmailAndFormId', () => { const expectedObjectId = new ObjectId() const email = 'someone@mail.com' + const now = moment().utc() beforeEach(async () => { await dbHandler.clearCollection(Payment.collection.name) @@ -88,6 +90,9 @@ describe('payments.service', () => { email: email, status: PaymentStatus.Succeeded, gstEnabled: false, + completedPayment: { + paymentDate: now.toDate(), + }, }) }) afterEach(() => jest.clearAllMocks()) @@ -104,11 +109,9 @@ describe('payments.service', () => { expect(result.isOk()).toBeTrue() }) - it('should return the latest payment based on id creation', async () => { - const latestId = new ObjectId() - // create the latest payment object + it('should return the latest payment based on completed payment date', async () => { + // create a payment object with an older successful payment date await Payment.create({ - _id: latestId, formId: MOCK_FORM_ID, targetAccountId: 'acct_MOCK_ACCOUNT_ID', pendingSubmissionId: new ObjectId(), @@ -117,7 +120,11 @@ describe('payments.service', () => { email: email, status: PaymentStatus.Succeeded, gstEnabled: false, + completedPayment: { + paymentDate: now.subtract(1, 'days').toDate(), + }, }) + const result = await PaymentsService.findLatestSuccessfulPaymentByEmailAndFormId( email, @@ -126,7 +133,7 @@ describe('payments.service', () => { // Assert latest payment document result.map((payment) => { - expect(payment.id).toEqual(latestId.toHexString()) + expect(payment.id).toEqual(expectedObjectId.toHexString()) }) // Assert @@ -175,6 +182,29 @@ describe('payments.service', () => { ) expect(result.isErr()).toBeTrue() }) + it('should return with error if there is no successful payments for the email and formId within the last 30 days', async () => { + const newEmail = 'new@new.com' + const newFormId = new ObjectId().toHexString() + await Payment.create({ + formId: newFormId, + targetAccountId: 'acct_MOCK_ACCOUNT_ID', + pendingSubmissionId: new ObjectId(), + paymentIntentId: 'somePaymentIntentId', + amount: 314159, + email: newEmail, + status: PaymentStatus.Succeeded, + gstEnabled: false, + completedPayment: { + paymentDate: moment().subtract(31, 'days').utc().toDate(), + }, + }) + const result = + await PaymentsService.findLatestSuccessfulPaymentByEmailAndFormId( + newEmail, + newFormId, + ) + expect(result.isErr()).toBeTrue() + }) it('should return with error if mongodb is not ready', async () => { await dbHandler.closeDatabase() diff --git a/src/app/modules/payments/payments.service.ts b/src/app/modules/payments/payments.service.ts index bf02271be6..350f2cabd0 100644 --- a/src/app/modules/payments/payments.service.ts +++ b/src/app/modules/payments/payments.service.ts @@ -1,3 +1,4 @@ +import moment from 'moment-timezone' import { ObjectId } from 'mongodb' import mongoose from 'mongoose' import { errAsync, okAsync, ResultAsync } from 'neverthrow' @@ -312,8 +313,11 @@ export const findLatestSuccessfulPaymentByEmailAndFormId = ( email: email, formId: formId, status: PaymentStatus.Succeeded, + 'completedPayment.paymentDate': { + $gt: moment().subtract(30, 'days').utc().toDate(), + }, }) - .sort({ _id: -1 }) + .sort({ 'completedPayment.paymentDate': -1 }) .exec(), (error) => { logger.error({ From 6ba61554e3f9c83b27eb1aea8df5ddd241afe852 Mon Sep 17 00:00:00 2001 From: LinHuiqing Date: Mon, 4 Sep 2023 07:48:13 +0800 Subject: [PATCH 4/6] chore: bump version to v6.75.0 --- CHANGELOG.md | 11 +++++++++++ frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 5 files changed, 17 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3fa896f40..56133c2f0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,20 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). +#### [v6.75.0](https://github.com/opengovsg/FormSG/compare/v6.74.1...v6.75.0) + +- fix: Find latest successful payment query to sort by completed payment date and constraint query to last 30 days [`#6615`](https://github.com/opengovsg/FormSG/pull/6615) +- feat: payment security account email check [`#6604`](https://github.com/opengovsg/FormSG/pull/6604) +- feat: % rollout of new storage submission endpoint [`#6665`](https://github.com/opengovsg/FormSG/pull/6665) +- build: merge v6.74.1 into develop [`#6683`](https://github.com/opengovsg/FormSG/pull/6683) +- build: release v6.74.1 [`#6682`](https://github.com/opengovsg/FormSG/pull/6682) + #### [v6.74.1](https://github.com/opengovsg/FormSG/compare/v6.74.0...v6.74.1) +> 31 August 2023 + - fix: payment quantity modal input inconsistencies [`#6681`](https://github.com/opengovsg/FormSG/pull/6681) +- chore: bump version to 6.74.1 [`1c96e9e`](https://github.com/opengovsg/FormSG/commit/1c96e9ea74c772e8cd45bc6877dfc8b1e41e4832) #### [v6.74.0](https://github.com/opengovsg/FormSG/compare/v6.73.0...v6.74.0) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ae5eccdd7b..1b8bf31768 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "form-frontend", - "version": "6.74.0", + "version": "6.75.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "form-frontend", - "version": "6.74.0", + "version": "6.75.0", "hasInstallScript": true, "dependencies": { "@chakra-ui/react": "^1.8.6", diff --git a/frontend/package.json b/frontend/package.json index 84dfe1fb17..277aa7cf0b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "form-frontend", - "version": "6.74.0", + "version": "6.75.0", "homepage": ".", "private": true, "dependencies": { diff --git a/package-lock.json b/package-lock.json index 0da72946e2..f16a6bb582 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "FormSG", - "version": "6.74.1", + "version": "6.75.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "FormSG", - "version": "6.74.1", + "version": "6.75.0", "hasInstallScript": true, "dependencies": { "@aws-sdk/client-cloudwatch-logs": "^3.347.1", diff --git a/package.json b/package.json index 8bb929f43e..93505b32c0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "FormSG", "description": "Form Manager for Government", - "version": "6.74.1", + "version": "6.75.0", "homepage": "https://form.gov.sg", "authors": [ "FormSG " From a5749937952c1d70a16177114339e5cc0f4ded89 Mon Sep 17 00:00:00 2001 From: Lin Huiqing <37061143+LinHuiqing@users.noreply.github.com> Date: Mon, 4 Sep 2023 13:12:57 +0800 Subject: [PATCH 5/6] fix: payments params in new storage submission endpoint (#6689) * fix: joi validation for new storage endpoint * fix: pass payments params for new endpoint --- .../encrypt-submission.middleware.ts | 18 ++++++++++++++++++ .../modules/submission/submission.constants.ts | 9 --------- src/types/api/encrypt_submission.ts | 5 +++++ 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.middleware.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.middleware.ts index c46cb3222f..0732a944ac 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.middleware.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.middleware.ts @@ -119,6 +119,21 @@ export const validateEncryptSubmissionParams = celebrate({ export const validateStorageSubmissionParams = celebrate({ [Segments.BODY]: Joi.object({ ...sharedSubmissionParams, + paymentProducts: Joi.array().items( + Joi.object().keys({ + data: JoiPaymentProduct.required(), + selected: Joi.boolean(), + quantity: JoiInt.positive().required(), + }), + ), + paymentReceiptEmail: Joi.string(), + payments: Joi.object({ + amount_cents: Joi.number() + .integer() + .positive() + .min(paymentConfig.minPaymentAmountCents) + .max(paymentConfig.maxPaymentAmountCents), + }), version: Joi.number().required(), }), }) @@ -307,6 +322,9 @@ export const encryptSubmission = async ( responses: filteredResponses.value as EncryptFormFieldResponse[], encryptedContent, version: req.body.version, + paymentProducts: req.body.paymentProducts, + paymentReceiptEmail: req.body.paymentReceiptEmail, + payments: req.body.payments, } return next() diff --git a/src/app/modules/submission/submission.constants.ts b/src/app/modules/submission/submission.constants.ts index d76fd4b57e..6e566e13e9 100644 --- a/src/app/modules/submission/submission.constants.ts +++ b/src/app/modules/submission/submission.constants.ts @@ -1,7 +1,6 @@ import { Joi } from 'celebrate' import { BasicField } from '../../../../shared/types' -import { paymentConfig } from '../../config/features/payment.config' export const sharedSubmissionParams = { responses: Joi.array() @@ -25,14 +24,6 @@ export const sharedSubmissionParams = { .with('filename', 'content'), // if filename is present, content must be present ) .required(), - paymentReceiptEmail: Joi.string(), - payments: Joi.object({ - amount_cents: Joi.number() - .integer() - .positive() - .min(paymentConfig.minPaymentAmountCents) - .max(paymentConfig.maxPaymentAmountCents), - }), responseMetadata: Joi.object({ responseTimeMs: Joi.number(), numVisibleFields: Joi.number(), diff --git a/src/types/api/encrypt_submission.ts b/src/types/api/encrypt_submission.ts index 0a97d9c5b5..521e750ec1 100644 --- a/src/types/api/encrypt_submission.ts +++ b/src/types/api/encrypt_submission.ts @@ -3,6 +3,8 @@ import type { Merge } from 'type-fest' import { AttachmentResponse, FieldResponse, + PaymentFieldsDto, + ProductItem, StorageModeSubmissionContentDto, } from '../../../shared/types' import { IPopulatedEncryptedForm, IPopulatedForm } from '../form' @@ -28,6 +30,9 @@ export type EncryptFormFieldResponse = * ReceiverMiddleware.receiveStorageSubmission middleware. */ export type ParsedStorageModeSubmissionBody = ParsedEmailModeSubmissionBody & { + paymentProducts?: Array + paymentReceiptEmail?: string + payments?: PaymentFieldsDto version: number } From af06ba371fee38a26f1b27125e382521a0748dbe Mon Sep 17 00:00:00 2001 From: LinHuiqing Date: Mon, 4 Sep 2023 13:17:00 +0800 Subject: [PATCH 6/6] chore: bump version to 6.75.1 --- CHANGELOG.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56133c2f0f..43e0360b1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,20 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). +#### [v6.75.1](https://github.com/opengovsg/FormSG/compare/v6.75.0...v6.75.1) + +- fix: payments params in new storage submission endpoint [`#6689`](https://github.com/opengovsg/FormSG/pull/6689) + #### [v6.75.0](https://github.com/opengovsg/FormSG/compare/v6.74.1...v6.75.0) +> 4 September 2023 + - fix: Find latest successful payment query to sort by completed payment date and constraint query to last 30 days [`#6615`](https://github.com/opengovsg/FormSG/pull/6615) - feat: payment security account email check [`#6604`](https://github.com/opengovsg/FormSG/pull/6604) - feat: % rollout of new storage submission endpoint [`#6665`](https://github.com/opengovsg/FormSG/pull/6665) - build: merge v6.74.1 into develop [`#6683`](https://github.com/opengovsg/FormSG/pull/6683) - build: release v6.74.1 [`#6682`](https://github.com/opengovsg/FormSG/pull/6682) +- chore: bump version to v6.75.0 [`6ba6155`](https://github.com/opengovsg/FormSG/commit/6ba61554e3f9c83b27eb1aea8df5ddd241afe852) #### [v6.74.1](https://github.com/opengovsg/FormSG/compare/v6.74.0...v6.74.1) diff --git a/package-lock.json b/package-lock.json index f16a6bb582..563c749ee4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "FormSG", - "version": "6.75.0", + "version": "6.75.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "FormSG", - "version": "6.75.0", + "version": "6.75.1", "hasInstallScript": true, "dependencies": { "@aws-sdk/client-cloudwatch-logs": "^3.347.1", diff --git a/package.json b/package.json index 93505b32c0..e52334b99f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "FormSG", "description": "Form Manager for Government", - "version": "6.75.0", + "version": "6.75.1", "homepage": "https://form.gov.sg", "authors": [ "FormSG "