From 79106ebbf013278cc066e95ddfde12268de9faad Mon Sep 17 00:00:00 2001 From: Kevin Foong <55353265+kevin9foong@users.noreply.github.com> Date: Thu, 27 Jun 2024 14:45:21 +0800 Subject: [PATCH] feat(admin-form): support nric masking (#7388) * feat(admin-form): add frontend nric mask toggle to singpass auth settings * fix(admin-form): fix spacing and change units from px to rem * feat(admin-form): create isNricMaskingEnabled field in database when form is created * feat(admin-form): enable fetching and updating isNricMaskingEnabled field in backend routes * feat(admin-form): connect frontend component with server api routes * fix(admin-form): make code terser, remove unused params, fix nullish-coalesce operator usage * fix(admin-page): fix flickering bug * feat(duplicate-api): implement duplication of isNricMaskEnabled field * feat(public-form): prevent nric from reaching frontend if masked * feat(nric-mask): apply masking when submitting forms * feat(nric-mask): update frontend template to reflect masking toggle changes * fix(index.html): checkout to develop branch * fix: remove console.log * fix(toggle-component): replace nullish coalesce with ternary operator * chore(public-form-controller): remove masking when fetching public form at backend * fix: refactor email-submission-controller to mask at single location * fix: clean up nric-mask util function and unused imports * fix: update field with evaluated map value * feat: add nric masking to the frontend public form provider * feat: remove tooltip and labelComponentRight from Toggle to match design * feat: update settings auth page to reflect design master * feat: add err message * feat: change px to rem * feat: disable independently for nric masking and auth toggle * fix: change MyInfo to Myinfo in description message * fix: update form model test suite to expect new isNricMaskEnabled field * feat: add tests for nric mask util and masking for email submission * feat: add test for isNricMaskDisabled for email submission * feat: add tests for storage mode submission masking * fix: fix bug with submission not being able to save authType and defaulting to nil * feat: add stories for updated settings auth page * feat: update playwright test helper to support new auth setting flow * fix: fix review comments and run frontend lint fix * fix: fix based on chromatic review * fix: fix review comments * feat: add testing for getDuplicateParams to ensure that nricmask is correctly duplicated * refactor: extract skeleton component out to own file --------- Co-authored-by: Ken --- __tests__/e2e/helpers/createForm.ts | 28 ++- .../admin-form/common/AdminViewFormService.ts | 17 +- .../BuilderAndDesignContent/StartPageView.tsx | 9 +- .../create/end-page/EndPageContent.tsx | 9 +- .../features/admin-form/preview/constants.ts | 1 + .../settings/SettingsAuthPage.stories.tsx | 88 +++++++- .../admin-form/settings/SettingsAuthPage.tsx | 2 +- .../admin-form/settings/SettingsService.ts | 9 + .../AuthSettingsDescriptionText.tsx | 21 ++ .../AuthSettingsDisabledExplanationText.tsx | 33 +++ .../AuthSettingsSection.tsx | 173 ++-------------- .../AuthSettingsSectionContainer.tsx | 6 +- .../AuthSettingsSectionSkeleton.tsx | 14 ++ .../AuthSettingsSingpassSection.tsx | 29 +++ .../FormNricMaskToggle.tsx | 39 ++++ .../FormSingpassAuthToggle.tsx | 45 ++++ .../SingpassAuthOptionsRadio.tsx | 121 +++++++++++ .../AuthSettingsSection/constants.ts | 9 +- .../settings/components/FormCaptchaToggle.tsx | 2 +- .../FormIssueNotificationToggle.tsx | 5 +- .../features/admin-form/settings/mutations.ts | 125 ++++++++---- .../public-form/PublicFormProvider.tsx | 6 + shared/constants/form.ts | 2 + shared/types/form/form.ts | 1 + shared/utils/__tests__/nric-mask.spec.ts | 10 + shared/utils/nric-mask.ts | 26 +++ .../__tests__/form.server.model.spec.ts | 47 +++++ src/app/models/form.server.model.ts | 6 + .../form/admin-form/admin-form.middlewares.ts | 1 + .../email-submission.controller.spec.ts | 173 ++++++++++++++++ .../email-submission.controller.ts | 14 +- .../encrypt-submission.controller.spec.ts | 193 ++++++++++++++++++ .../encrypt-submission.controller.ts | 19 +- .../encrypt-submission.types.ts | 2 +- src/types/form.ts | 3 + 35 files changed, 1050 insertions(+), 238 deletions(-) create mode 100644 frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsDescriptionText.tsx create mode 100644 frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsDisabledExplanationText.tsx create mode 100644 frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSectionSkeleton.tsx create mode 100644 frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSingpassSection.tsx create mode 100644 frontend/src/features/admin-form/settings/components/AuthSettingsSection/FormNricMaskToggle.tsx create mode 100644 frontend/src/features/admin-form/settings/components/AuthSettingsSection/FormSingpassAuthToggle.tsx create mode 100644 frontend/src/features/admin-form/settings/components/AuthSettingsSection/SingpassAuthOptionsRadio.tsx create mode 100644 shared/utils/__tests__/nric-mask.spec.ts create mode 100644 shared/utils/nric-mask.ts create mode 100644 src/app/modules/submission/email-submission/__tests__/email-submission.controller.spec.ts create mode 100644 src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.controller.spec.ts diff --git a/__tests__/e2e/helpers/createForm.ts b/__tests__/e2e/helpers/createForm.ts index 1133ea9483..f8f19603c5 100644 --- a/__tests__/e2e/helpers/createForm.ts +++ b/__tests__/e2e/helpers/createForm.ts @@ -291,20 +291,30 @@ const addAuthSettings = async ( await page.getByRole('tab', { name: 'Singpass' }).click() // Ensure that we are on the auth page - await expect( - page.getByRole('heading', { name: 'Enable Singpass authentication' }), - ).toBeVisible() + await expect(page.getByRole('heading', { name: 'Singpass' })).toBeVisible() await page .locator('label', { - has: page.locator( - `input[type='radio'][value='${formSettings.authType}']`, - ), + has: page.locator('[aria-label="Enable Singpass authentication"]'), }) - .first() // Since 'Singpass' will match all radio options, pick the first matching one. - .click({ position: { x: 1, y: 1 } }) // Clicking the center of the sgid button launches the sgid contact form, put this here until we get rid of the link + .click() - await expectToast(page, /form authentication successfully updated/i) + await expectToast(page, /singpass authentication successfully enabled/i) + + // Don't need to click if SGID is desired auth type + // since SGID is the default once Singpass is enabled + if (formSettings.authType !== FormAuthType.SGID) { + await page + .locator('label', { + has: page.locator( + `input[type='radio'][value='${formSettings.authType}']`, + ), + }) + .first() // Since 'Singpass' will match all radio options, pick the first matching one. + .click({ position: { x: 1, y: 1 } }) // Clicking the center of the sgid button launches the sgid contact form, put this here until we get rid of the link + + await expectToast(page, /singpass authentication successfully updated/i) + } switch (formSettings.authType) { case FormAuthType.SP: diff --git a/frontend/src/features/admin-form/common/AdminViewFormService.ts b/frontend/src/features/admin-form/common/AdminViewFormService.ts index 61d2b11b0c..0550a11635 100644 --- a/frontend/src/features/admin-form/common/AdminViewFormService.ts +++ b/frontend/src/features/admin-form/common/AdminViewFormService.ts @@ -24,7 +24,10 @@ import { filterHiddenInputs, } from '~features/public-form/utils' -import { PREVIEW_MOCK_UINFIN } from '../preview/constants' +import { + PREVIEW_MASKED_MOCK_UINFIN, + PREVIEW_MOCK_UINFIN, +} from '../preview/constants' // endpoint exported for testing export const ADMIN_FORM_ENDPOINT = '/admin/forms' @@ -58,7 +61,11 @@ export const previewForm = async ( // Add default mock authenticated state if previewing an authenticatable form // and if server has not already sent back a mock authenticated state. if (data.form.authType !== FormAuthType.NIL && !data.spcpSession) { - data.spcpSession = { userName: PREVIEW_MOCK_UINFIN } + data.spcpSession = { + userName: data.form.isNricMaskEnabled + ? PREVIEW_MASKED_MOCK_UINFIN + : PREVIEW_MOCK_UINFIN, + } } // Inject MyInfo preview values into form fields (if they are MyInfo fields). @@ -87,7 +94,11 @@ export const viewFormTemplate = async ( // Add default mock authenticated state if previewing an authenticatable form // and if server has not already sent back a mock authenticated state. if (data.form.authType !== FormAuthType.NIL && !data.spcpSession) { - data.spcpSession = { userName: PREVIEW_MOCK_UINFIN } + data.spcpSession = { + userName: data.form.isNricMaskEnabled + ? PREVIEW_MASKED_MOCK_UINFIN + : PREVIEW_MOCK_UINFIN, + } } // Inject MyInfo preview values into form fields (if they are MyInfo fields). diff --git a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignContent/StartPageView.tsx b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignContent/StartPageView.tsx index 87c7dc52e3..78bf0c033f 100644 --- a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignContent/StartPageView.tsx +++ b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignContent/StartPageView.tsx @@ -6,7 +6,10 @@ import { FormAuthType, FormLogoState, FormStartPage } from '~shared/types' import { useIsMobile } from '~hooks/useIsMobile' -import { PREVIEW_MOCK_UINFIN } from '~features/admin-form/preview/constants' +import { + PREVIEW_MASKED_MOCK_UINFIN as PREVIEW_MASKED_MOCK_UINFIN, + PREVIEW_MOCK_UINFIN, +} from '~features/admin-form/preview/constants' import { useEnv } from '~features/env/queries' import { FormInstructions } from '~features/public-form/components/FormInstructions/FormInstructions' import { @@ -197,7 +200,9 @@ export const StartPageView = () => { showHeader loggedInId={ form && form.authType !== FormAuthType.NIL - ? PREVIEW_MOCK_UINFIN + ? form.isNricMaskEnabled + ? PREVIEW_MASKED_MOCK_UINFIN + : PREVIEW_MOCK_UINFIN : undefined } {...formHeaderProps} diff --git a/frontend/src/features/admin-form/create/end-page/EndPageContent.tsx b/frontend/src/features/admin-form/create/end-page/EndPageContent.tsx index 899ba8ccb8..a7f4e07875 100644 --- a/frontend/src/features/admin-form/create/end-page/EndPageContent.tsx +++ b/frontend/src/features/admin-form/create/end-page/EndPageContent.tsx @@ -13,7 +13,10 @@ import { PaymentsThankYouSvgr } from '~components/FormEndPage/PaymentsThankYouSv import { ThankYouSvgr } from '~components/FormEndPage/ThankYouSvgr' import { useAdminForm } from '~features/admin-form/common/queries' -import { PREVIEW_MOCK_UINFIN } from '~features/admin-form/preview/constants' +import { + PREVIEW_MASKED_MOCK_UINFIN, + PREVIEW_MOCK_UINFIN, +} from '~features/admin-form/preview/constants' import { useEnv } from '~features/env/queries' import { FormBannerLogo, @@ -90,7 +93,9 @@ export const EndPageContent = (): JSX.Element => { onLogout={undefined} loggedInId={ form && form.authType !== FormAuthType.NIL - ? PREVIEW_MOCK_UINFIN + ? form.isNricMaskEnabled + ? PREVIEW_MASKED_MOCK_UINFIN + : PREVIEW_MOCK_UINFIN : undefined } /> diff --git a/frontend/src/features/admin-form/preview/constants.ts b/frontend/src/features/admin-form/preview/constants.ts index cad5f73ab2..e07557d54a 100644 --- a/frontend/src/features/admin-form/preview/constants.ts +++ b/frontend/src/features/admin-form/preview/constants.ts @@ -1 +1,2 @@ export const PREVIEW_MOCK_UINFIN = 'S1234567A' +export const PREVIEW_MASKED_MOCK_UINFIN = '*****567A' diff --git a/frontend/src/features/admin-form/settings/SettingsAuthPage.stories.tsx b/frontend/src/features/admin-form/settings/SettingsAuthPage.stories.tsx index ed50e67158..00f193268c 100644 --- a/frontend/src/features/admin-form/settings/SettingsAuthPage.stories.tsx +++ b/frontend/src/features/admin-form/settings/SettingsAuthPage.stories.tsx @@ -35,25 +35,42 @@ export default { } as Meta const Template: Story = () => -export const PrivateEmailForm = Template.bind({}) -PrivateEmailForm.parameters = { +export const PrivateEmailNilAuthForm = Template.bind({}) +PrivateEmailNilAuthForm.parameters = { msw: buildMswRoutes({ status: FormStatus.Private }), } -export const PrivateStorageForm = Template.bind({}) -PrivateStorageForm.parameters = { +export const PrivateStorageNilAuthForm = Template.bind({}) +PrivateStorageNilAuthForm.parameters = { msw: buildMswRoutes({ responseMode: FormResponseMode.Encrypt, status: FormStatus.Private, }), } -export const PublicEmailSingpassForm = Template.bind({}) -PublicEmailSingpassForm.parameters = { +export const PublicEmailNilAuthForm = Template.bind({}) +PublicEmailNilAuthForm.parameters = { msw: buildMswRoutes({ + responseMode: FormResponseMode.Email, status: FormStatus.Public, - authType: FormAuthType.SP, - esrvcId: 'STORYBOOK-TEST', + }), +} + +export const PublicStorageNilAuthForm = Template.bind({}) +PublicStorageNilAuthForm.parameters = { + msw: buildMswRoutes({ + responseMode: FormResponseMode.Encrypt, + status: FormStatus.Public, + }), +} + +// purpose: tests that isNricMaskEnabled should not affect setting options available +export const PublicStorageNilAuthFormNricMaskingEnabled = Template.bind({}) +PublicStorageNilAuthFormNricMaskingEnabled.parameters = { + msw: buildMswRoutes({ + responseMode: FormResponseMode.Encrypt, + status: FormStatus.Public, + isNricMaskEnabled: true, }), } @@ -63,9 +80,31 @@ PrivateStorageCorppassForm.parameters = { status: FormStatus.Private, authType: FormAuthType.CP, responseMode: FormResponseMode.Encrypt, + esrvcId: 'STORYBOOK-TEST', + }), +} + +export const PublicEmailSingpassForm = Template.bind({}) +PublicEmailSingpassForm.parameters = { + msw: buildMswRoutes({ + status: FormStatus.Public, + authType: FormAuthType.SP, + esrvcId: 'STORYBOOK-TEST', }), } +export const PrivateEmailMyInfoWithoutMyInfoFieldsForm = Template.bind({}) +PrivateEmailMyInfoWithoutMyInfoFieldsForm.parameters = { + msw: [ + ...buildMswRoutes({ + status: FormStatus.Private, + authType: FormAuthType.MyInfo, + esrvcId: 'STORYBOOK-TEST', + }), + ...createFormBuilderMocks({ form_fields: [] }), + ], +} + export const PrivateEmailMyinfoForm = Template.bind({}) PrivateEmailMyinfoForm.parameters = { msw: [ @@ -78,6 +117,39 @@ PrivateEmailMyinfoForm.parameters = { ], } +export const PublicEmailMyInfoForm = Template.bind({}) +PublicEmailMyInfoForm.parameters = { + msw: [ + ...buildMswRoutes({ + status: FormStatus.Public, + authType: FormAuthType.MyInfo, + esrvcId: 'STORYBOOK-TEST', + }), + ...createFormBuilderMocks({ form_fields: MOCK_FORM_FIELDS_WITH_MYINFO }), + ], +} + +export const PrivateEmailSingpassFormNricMaskingEnabled = Template.bind({}) +PrivateEmailSingpassFormNricMaskingEnabled.parameters = { + msw: buildMswRoutes({ + status: FormStatus.Private, + authType: FormAuthType.SGID, + isNricMaskEnabled: true, + }), +} +export const PrivateEmailMyInfoFormNricMaskingEnabled = Template.bind({}) +PrivateEmailMyInfoFormNricMaskingEnabled.parameters = { + msw: [ + ...buildMswRoutes({ + status: FormStatus.Private, + authType: FormAuthType.MyInfo, + esrvcId: 'STORYBOOK-TEST', + isNricMaskEnabled: true, + }), + ...createFormBuilderMocks({ form_fields: MOCK_FORM_FIELDS_WITH_MYINFO }), + ], +} + export const Loading = Template.bind({}) Loading.parameters = { msw: [getAdminFormSettings({ delay: 'infinite' })], diff --git a/frontend/src/features/admin-form/settings/SettingsAuthPage.tsx b/frontend/src/features/admin-form/settings/SettingsAuthPage.tsx index 6f6fff49f7..ea52b3f1e4 100644 --- a/frontend/src/features/admin-form/settings/SettingsAuthPage.tsx +++ b/frontend/src/features/admin-form/settings/SettingsAuthPage.tsx @@ -18,7 +18,7 @@ export const SettingsAuthPage = (): JSX.Element => { return ( <> - Enable Singpass authentication + Singpass ) diff --git a/frontend/src/features/admin-form/settings/SettingsService.ts b/frontend/src/features/admin-form/settings/SettingsService.ts index bb1691c9e8..9778b786f4 100644 --- a/frontend/src/features/admin-form/settings/SettingsService.ts +++ b/frontend/src/features/admin-form/settings/SettingsService.ts @@ -91,6 +91,15 @@ export const updateFormAuthType: UpdateFormFn<'authType'> = async ( return updateFormSettings(formId, { authType: newAuthType }) } +export const updateFormNricMask: UpdateFormFn<'isNricMaskEnabled'> = async ( + formId, + newIsNricMaskEnabled, +) => { + return updateFormSettings(formId, { + isNricMaskEnabled: newIsNricMaskEnabled, + }) +} + export const updateFormEsrvcId: UpdateFormFn<'esrvcId'> = async ( formId, newEsrvcId, diff --git a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsDescriptionText.tsx b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsDescriptionText.tsx new file mode 100644 index 0000000000..52c46df96a --- /dev/null +++ b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsDescriptionText.tsx @@ -0,0 +1,21 @@ +import { Text } from '@chakra-ui/react' + +import { GUIDE_SPCP_ESRVCID } from '~constants/links' +import Link from '~components/Link' + +export const AuthSettingsDescriptionText = () => { + return ( + + Authenticate respondents by NRIC/FIN.{' '} + e.stopPropagation()} + > + Learn more about Singpass authentication + + + ) +} diff --git a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsDisabledExplanationText.tsx b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsDisabledExplanationText.tsx new file mode 100644 index 0000000000..7ead62f0d0 --- /dev/null +++ b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsDisabledExplanationText.tsx @@ -0,0 +1,33 @@ +import { Box } from '@chakra-ui/react' + +import InlineMessage from '~components/InlineMessage' + +interface AuthSettingsDisabledExplanationTextProps { + isFormPublic: boolean + containsMyInfoFields: boolean +} + +const CONTAINS_MYINFO_FIELDS_DISABLED_EXPLANATION_TEXT = + 'To change Singpass authentication settings, close your form to new responses and remove all existing Myinfo fields.' +const FORM_IS_PUBLIC_DISABLED_EXPLANATION_TEXT = + 'To change Singpass authentication settings, close your form to new responses.' + +export const AuthSettingsDisabledExplanationText = ({ + isFormPublic, + containsMyInfoFields, +}: AuthSettingsDisabledExplanationTextProps) => { + const explanationText = containsMyInfoFields + ? CONTAINS_MYINFO_FIELDS_DISABLED_EXPLANATION_TEXT + : isFormPublic + ? FORM_IS_PUBLIC_DISABLED_EXPLANATION_TEXT + : null + + if (!explanationText) { + return null + } + return ( + + {explanationText} + + ) +} diff --git a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSection.tsx b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSection.tsx index 9a79d64bcc..9e11242b16 100644 --- a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSection.tsx +++ b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSection.tsx @@ -1,60 +1,23 @@ -import { - Fragment, - KeyboardEventHandler, - MouseEventHandler, - useCallback, - useMemo, - useState, -} from 'react' -import { Box, Flex, Skeleton, Spacer, Text } from '@chakra-ui/react' +import { useMemo } from 'react' +import { Box } from '@chakra-ui/react' import { FormAuthType, FormSettings, FormStatus } from '~shared/types/form' -import { GUIDE_SPCP_ESRVCID } from '~constants/links' -import InlineMessage from '~components/InlineMessage' -import Link from '~components/Link' -import Radio from '~components/Radio' -import { Tag } from '~components/Tag' - import { useAdminForm } from '~features/admin-form/common/queries' import { isMyInfo } from '~features/myinfo/utils' -import { useMutateFormSettings } from '../../mutations' - -import { FORM_AUTHTYPES } from './constants' -import { EsrvcIdBox } from './EsrvcIdBox' - -const esrvcidRequired = (authType: FormAuthType) => { - switch (authType) { - case FormAuthType.SP: - case FormAuthType.MyInfo: - case FormAuthType.CP: - return true - default: - return false - } -} +import { AuthSettingsDescriptionText } from './AuthSettingsDescriptionText' +import { AuthSettingsDisabledExplanationText } from './AuthSettingsDisabledExplanationText' +import { AuthSettingsSingpassSection } from './AuthSettingsSingpassSection' +import { FormSingpassAuthToggle } from './FormSingpassAuthToggle' interface AuthSettingsSectionProps { settings: FormSettings } -export const AuthSettingsSectionSkeleton = (): JSX.Element => { - return ( - - {Object.entries(FORM_AUTHTYPES).map(([authType, textToRender]) => ( - - {textToRender} - - ))} - - ) -} - export const AuthSettingsSection = ({ settings, }: AuthSettingsSectionProps): JSX.Element => { - const { mutateFormAuthType } = useMutateFormSettings() const { data: form } = useAdminForm() const containsMyInfoFields = useMemo( @@ -62,120 +25,26 @@ export const AuthSettingsSection = ({ [form?.form_fields], ) - const [focusedValue, setFocusedValue] = useState() - const isFormPublic = settings.status === FormStatus.Public - const isDisabled = useCallback( - (authType: FormAuthType) => - isFormPublic || containsMyInfoFields || mutateFormAuthType.isLoading, - [isFormPublic, containsMyInfoFields, mutateFormAuthType.isLoading], - ) - - const isEsrvcIdBoxDisabled = useMemo( - () => isFormPublic || mutateFormAuthType.isLoading, - [isFormPublic, mutateFormAuthType.isLoading], - ) - - const handleEnterKeyDown: KeyboardEventHandler = useCallback( - (e) => { - if ( - (e.key === 'Enter' || e.key === ' ') && - focusedValue && - !isDisabled(focusedValue) && - focusedValue !== settings.authType - ) { - return mutateFormAuthType.mutate(focusedValue) - } - }, - [focusedValue, isDisabled, mutateFormAuthType, settings.authType], - ) - - const handleOptionClick = useCallback( - (authType: FormAuthType): MouseEventHandler => - (e) => { - if ( - !isDisabled(authType) && - e.type === 'click' && - // Required so only real clicks get registered. - // Typical radio behaviour is that the 'click' event is triggered on change. - // See: https://www.w3.org/TR/2012/WD-html5-20121025/content-models.html#interactive-content - // https://github.com/facebook/react/issues/7407#issuecomment-237082712 - e.clientX !== 0 && - e.clientY !== 0 && - authType !== settings.authType - ) { - return mutateFormAuthType.mutate(authType) - } - }, - [isDisabled, mutateFormAuthType, settings.authType], - ) - - const radioOptions: [FormAuthType, string][] = Object.entries( - FORM_AUTHTYPES, - ) as [FormAuthType, string][] - return ( - - Authenticate respondents by NRIC.{' '} - e.stopPropagation()} - > - Learn more about Singpass authentication - - - {isFormPublic ? ( - - To change authentication method, close your form to new responses. - - ) : containsMyInfoFields ? ( - - To change authentication method, remove existing Myinfo fields on your - form. You can still update your e-service ID. - + + + + {settings.authType !== FormAuthType.NIL ? ( + ) : null} - setFocusedValue(e)} - > - {radioOptions.map(([authType, text]) => ( - - - - - {text} - {authType === FormAuthType.SGID || - authType === FormAuthType.SGID_MyInfo ? ( - <> - - - Free - - - ) : null} - - - - {esrvcidRequired(authType) && authType === settings.authType ? ( - - ) : null} - - ))} - ) } diff --git a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSectionContainer.tsx b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSectionContainer.tsx index c09c0b2188..59be85bc0c 100644 --- a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSectionContainer.tsx +++ b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSectionContainer.tsx @@ -1,9 +1,7 @@ import { useAdminFormSettings } from '../../queries' -import { - AuthSettingsSection, - AuthSettingsSectionSkeleton, -} from './AuthSettingsSection' +import { AuthSettingsSection } from './AuthSettingsSection' +import { AuthSettingsSectionSkeleton } from './AuthSettingsSectionSkeleton' export const AuthSettingsSectionContainer = (): JSX.Element => { const { data: settings } = useAdminFormSettings() diff --git a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSectionSkeleton.tsx b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSectionSkeleton.tsx new file mode 100644 index 0000000000..626b2ed051 --- /dev/null +++ b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSectionSkeleton.tsx @@ -0,0 +1,14 @@ +import { Skeleton } from '@chakra-ui/react' + +import Toggle from '~components/Toggle' + +import { AuthSettingsDescriptionText } from './AuthSettingsDescriptionText' + +export const AuthSettingsSectionSkeleton = (): JSX.Element => { + return ( + + + + + ) +} diff --git a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSingpassSection.tsx b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSingpassSection.tsx new file mode 100644 index 0000000000..2a1ab54b33 --- /dev/null +++ b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSingpassSection.tsx @@ -0,0 +1,29 @@ +import { Divider } from '@chakra-ui/react' + +import { FormSettings } from '~shared/types/form' + +import { FormNricMaskToggle } from './FormNricMaskToggle' +import { SingpassAuthOptionsRadio } from './SingpassAuthOptionsRadio' + +export interface AuthSettingsSingpassSectionProps { + settings: FormSettings + isFormPublic: boolean + containsMyInfoFields: boolean +} + +export const AuthSettingsSingpassSection = ({ + settings, + isFormPublic, + containsMyInfoFields, +}: AuthSettingsSingpassSectionProps): JSX.Element => { + return ( + <> + + + + + ) +} diff --git a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/FormNricMaskToggle.tsx b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/FormNricMaskToggle.tsx new file mode 100644 index 0000000000..faf39820a6 --- /dev/null +++ b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/FormNricMaskToggle.tsx @@ -0,0 +1,39 @@ +import { useCallback } from 'react' + +import { FormSettings } from '~shared/types/form' + +import Toggle from '~components/Toggle' + +import { useMutateFormSettings } from '../../mutations' + +interface FormNricMaskToggleProps { + settings: FormSettings + isDisabled: boolean +} + +export const FormNricMaskToggle = ({ + settings, + isDisabled, +}: FormNricMaskToggleProps): JSX.Element => { + const isNricMaskEnabled = settings?.isNricMaskEnabled + + const { mutateNricMask } = useMutateFormSettings() + + const handleToggleNricMask = useCallback(() => { + if (!settings || mutateNricMask.isLoading) return + const nextIsNricMaskEnabled = !settings.isNricMaskEnabled + return mutateNricMask.mutate(nextIsNricMaskEnabled) + }, [mutateNricMask, settings]) + + return ( + + ) +} diff --git a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/FormSingpassAuthToggle.tsx b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/FormSingpassAuthToggle.tsx new file mode 100644 index 0000000000..7fcbdd25bd --- /dev/null +++ b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/FormSingpassAuthToggle.tsx @@ -0,0 +1,45 @@ +import { useCallback } from 'react' + +import { FormAuthType, FormSettings } from '~shared/types' + +import Toggle from '~components/Toggle' + +import { useMutateFormSettings } from '../../mutations' + +interface FormSingpassAuthToggleProps { + settings: FormSettings + isDisabled: boolean +} + +const DEFAULT_FORM_AUTH_TYPE = FormAuthType.SGID + +export const FormSingpassAuthToggle = ({ + settings, + isDisabled, +}: FormSingpassAuthToggleProps): JSX.Element => { + const isSingpassAuthEnabled = + settings && settings?.authType !== FormAuthType.NIL + + const { mutateFormAuthType } = useMutateFormSettings() + + const handleToggleSingpassAuth = useCallback(() => { + if (!settings || mutateFormAuthType.isLoading) return + const nextAuthType = + settings.authType === FormAuthType.NIL + ? DEFAULT_FORM_AUTH_TYPE + : FormAuthType.NIL + return mutateFormAuthType.mutate(nextAuthType) + }, [mutateFormAuthType, settings, DEFAULT_FORM_AUTH_TYPE]) + + return ( + + ) +} diff --git a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/SingpassAuthOptionsRadio.tsx b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/SingpassAuthOptionsRadio.tsx new file mode 100644 index 0000000000..1185d7d76a --- /dev/null +++ b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/SingpassAuthOptionsRadio.tsx @@ -0,0 +1,121 @@ +import { + Fragment, + KeyboardEventHandler, + MouseEventHandler, + useCallback, + useState, +} from 'react' +import { Box, Flex, Spacer } from '@chakra-ui/react' + +import { FormAuthType, FormSettings, FormStatus } from '~shared/types' + +import Radio from '~components/Radio' +import { Tag } from '~components/Tag' + +import { useMutateFormSettings } from '../../mutations' + +import { FORM_SINGPASS_AUTHTYPES } from './constants' +import { EsrvcIdBox } from './EsrvcIdBox' + +export interface SingpassAuthOptionsRadioProps { + settings: FormSettings + isDisabled: boolean +} + +const isEsrvcidRequired = (authType: FormAuthType) => { + switch (authType) { + case FormAuthType.SP: + case FormAuthType.MyInfo: + case FormAuthType.CP: + return true + default: + return false + } +} + +const radioOptions: [FormAuthType, string][] = Object.entries( + FORM_SINGPASS_AUTHTYPES, +) as [FormAuthType, string][] + +export const SingpassAuthOptionsRadio = ({ + settings, + isDisabled, +}: SingpassAuthOptionsRadioProps): JSX.Element => { + const { mutateFormAuthType } = useMutateFormSettings() + const [focusedValue, setFocusedValue] = useState() + + const isFormPublic = settings.status === FormStatus.Public + + const isEsrvcIdBoxDisabled = isFormPublic || mutateFormAuthType.isLoading + + const checkIsDisabled = useCallback(() => { + return isDisabled || mutateFormAuthType.isLoading + }, [isDisabled, mutateFormAuthType.isLoading]) + + const handleEnterKeyDown: KeyboardEventHandler = useCallback( + (e) => { + if ( + (e.key === 'Enter' || e.key === ' ') && + focusedValue && + !checkIsDisabled() && + focusedValue !== settings.authType + ) { + return mutateFormAuthType.mutate(focusedValue) + } + }, + [focusedValue, checkIsDisabled, mutateFormAuthType, settings.authType], + ) + + const handleOptionClick = useCallback( + (authType: FormAuthType): MouseEventHandler => + (e) => { + if ( + !checkIsDisabled() && + e.type === 'click' && + // Required so only real clicks get registered. + // Typical radio behaviour is that the 'click' event is triggered on change. + // See: https://www.w3.org/TR/2012/WD-html5-20121025/content-models.html#interactive-content + // https://github.com/facebook/react/issues/7407#issuecomment-237082712 + e.clientX !== 0 && + e.clientY !== 0 && + authType !== settings.authType + ) { + return mutateFormAuthType.mutate(authType) + } + }, + [mutateFormAuthType, settings.authType, checkIsDisabled], + ) + + return ( + setFocusedValue(e)} + > + {radioOptions.map(([authType, text]) => ( + + + + + {text} + {authType === FormAuthType.SGID || + authType === FormAuthType.SGID_MyInfo ? ( + <> + + + Free + + + ) : null} + + + + {isEsrvcidRequired(authType) && authType === settings.authType ? ( + + ) : null} + + ))} + + ) +} diff --git a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/constants.ts b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/constants.ts index 01a6e5837e..1479c7a352 100644 --- a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/constants.ts +++ b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/constants.ts @@ -1,13 +1,8 @@ import { FormAuthType } from '~shared/types/form' -export type EmailFormAuthType = FormAuthType -export type StorageFormAuthType = FormAuthType +type FormSingpassAuthType = Exclude -export const FORM_AUTHTYPES: Record< - StorageFormAuthType | EmailFormAuthType, - string -> = { - [FormAuthType.NIL]: 'None', +export const FORM_SINGPASS_AUTHTYPES: Record = { [FormAuthType.SGID]: 'Singpass App-only Login', [FormAuthType.SGID_MyInfo]: 'Singpass App-only with Myinfo', [FormAuthType.SP]: 'Singpass', diff --git a/frontend/src/features/admin-form/settings/components/FormCaptchaToggle.tsx b/frontend/src/features/admin-form/settings/components/FormCaptchaToggle.tsx index 08dc15be9f..df87c9ef90 100644 --- a/frontend/src/features/admin-form/settings/components/FormCaptchaToggle.tsx +++ b/frontend/src/features/admin-form/settings/components/FormCaptchaToggle.tsx @@ -10,7 +10,7 @@ export const FormCaptchaToggle = (): JSX.Element => { const { data: settings, isLoading: isLoadingSettings } = useAdminFormSettings() - const hasCaptcha = useMemo(() => settings && settings?.hasCaptcha, [settings]) + const hasCaptcha = useMemo(() => settings?.hasCaptcha, [settings]) const { mutateFormCaptcha } = useMutateFormSettings() diff --git a/frontend/src/features/admin-form/settings/components/FormIssueNotificationToggle.tsx b/frontend/src/features/admin-form/settings/components/FormIssueNotificationToggle.tsx index a4c254bcb5..2ba9e21dd0 100644 --- a/frontend/src/features/admin-form/settings/components/FormIssueNotificationToggle.tsx +++ b/frontend/src/features/admin-form/settings/components/FormIssueNotificationToggle.tsx @@ -10,10 +10,7 @@ export const FormIssueNotificationToggle = (): JSX.Element => { const { data: settings, isLoading: isLoadingSettings } = useAdminFormSettings() - const hasIssueNotification = useMemo( - () => settings && settings?.hasIssueNotification, - [settings], - ) + const hasIssueNotification = settings?.hasIssueNotification const { mutateFormIssueNotification } = useMutateFormSettings() diff --git a/frontend/src/features/admin-form/settings/mutations.ts b/frontend/src/features/admin-form/settings/mutations.ts index 6ab9b87249..27875a1eb1 100644 --- a/frontend/src/features/admin-form/settings/mutations.ts +++ b/frontend/src/features/admin-form/settings/mutations.ts @@ -36,6 +36,7 @@ import { updateFormInactiveMessage, updateFormIssueNotification, updateFormLimit, + updateFormNricMask, updateFormStatus, updateFormTitle, updateFormWebhookRetries, @@ -229,55 +230,96 @@ export const useMutateFormSettings = () => { }, ) + const generateFormAuthTypeMutationToastMessageText = ( + prevAuthType: FormAuthType | undefined, + nextAuthType: FormAuthType, + ) => { + if (prevAuthType === FormAuthType.NIL) { + return 'Singpass authentication successfully enabled.' + } + if (nextAuthType === FormAuthType.NIL) { + return 'Singpass authentication successfully disabled.' + } + return 'Singpass authentication successfully updated.' + } + const mutateFormAuthType = useMutation< FormSettings, ApiError, FormAuthType, { previousSettings?: FormSettings } - >((nextAuthType: FormAuthType) => updateFormAuthType(formId, nextAuthType), { - // Optimistic update - onMutate: async (newData) => { - // Cancel any outgoing refetches (so they don't overwrite our optimistic update) - await queryClient.cancelQueries(formSettingsQueryKey) - - // Snapshot the previous value - const previousSettings = - queryClient.getQueryData(formSettingsQueryKey) - - // Optimistically update to the new value - queryClient.setQueryData( - formSettingsQueryKey, - (old) => { - if (!old) return - return { - ...old, - authType: newData, - } - }, - ) - - // Return a context object with the snapshotted value - return { previousSettings } - }, - onSuccess: (newData) => { - handleSuccess({ - newData, - toastDescription: 'Form authentication successfully updated.', - }) + >( + (nextAuthType: FormAuthType) => { + return updateFormAuthType(formId, nextAuthType) }, - onError: (error, _newData, context) => { - if (context?.previousSettings) { - queryClient.setQueryData(formSettingsQueryKey, context.previousSettings) - } - handleError(error) + { + // Optimistic update + onMutate: async (newData) => { + // Cancel any outgoing refetches (so they don't overwrite our optimistic update) + await queryClient.cancelQueries(formSettingsQueryKey) + + // Snapshot the previous value + const previousSettings = + queryClient.getQueryData(formSettingsQueryKey) + + // Optimistically update to the new value + queryClient.setQueryData( + formSettingsQueryKey, + (old) => { + if (!old) return + return { + ...old, + authType: newData, + } + }, + ) + + // Return a context object with the snapshotted value + return { previousSettings } + }, + onSuccess: (newData, newAuthType, context) => { + const prevAuthType = context?.previousSettings?.authType + handleSuccess({ + newData, + toastDescription: generateFormAuthTypeMutationToastMessageText( + prevAuthType, + newAuthType, + ), + }) + }, + onError: (error, _newData, context) => { + if (context?.previousSettings) { + queryClient.setQueryData( + formSettingsQueryKey, + context.previousSettings, + ) + } + handleError(error) + }, + onSettled: (_data, error) => { + if (error) { + // Refetch data if any error occurs + queryClient.invalidateQueries(formSettingsQueryKey) + } + }, }, - onSettled: (_data, error) => { - if (error) { - // Refetch data if any error occurs - queryClient.invalidateQueries(formSettingsQueryKey) - } + ) + + const mutateNricMask = useMutation( + (nextIsNricMaskEnabled: boolean) => + updateFormNricMask(formId, nextIsNricMaskEnabled), + { + onSuccess: (newData) => { + handleSuccess({ + newData, + toastDescription: newData.isNricMaskEnabled + ? 'NRIC masking is now enabled on your form.' + : 'NRIC masking is now disabled on your form.', + }) + }, + onError: handleError, }, - }) + ) const mutateFormWebhookUrl = useMutation( (nextUrl?: string) => updateFormWebhookUrl(formId, nextUrl), @@ -348,6 +390,7 @@ export const useMutateFormSettings = () => { mutateFormEmails, mutateFormTitle, mutateFormAuthType, + mutateNricMask, mutateFormEsrvcId, mutateFormBusiness, mutateGST, diff --git a/frontend/src/features/public-form/PublicFormProvider.tsx b/frontend/src/features/public-form/PublicFormProvider.tsx index 0d4604e011..fc7c72cbdd 100644 --- a/frontend/src/features/public-form/PublicFormProvider.tsx +++ b/frontend/src/features/public-form/PublicFormProvider.tsx @@ -30,6 +30,7 @@ import { ProductItem, PublicFormDto, } from '~shared/types/form' +import { maskNric } from '~shared/utils/nric-mask' import { dollarsToCents } from '~shared/utils/payments' import { MONGODB_ID_REGEX } from '~constants/routes' @@ -142,6 +143,11 @@ export const PublicFormProvider = ({ /* enabled= */ !submissionData, ) + // Mask Nric if isNricMaskEnabled is true + if (data?.form.isNricMaskEnabled && data.spcpSession?.userName) { + data.spcpSession.userName = maskNric(data.spcpSession.userName) + } + const { isNotFormId, toast, vfnToastIdRef, expiryInMs, ...commonFormValues } = useCommonFormProvider(formId) diff --git a/shared/constants/form.ts b/shared/constants/form.ts index 86444f4393..fca00f0db6 100644 --- a/shared/constants/form.ts +++ b/shared/constants/form.ts @@ -1,6 +1,7 @@ const PUBLIC_FORM_FIELDS = [ 'admin', 'authType', + 'isNricMaskEnabled', 'endPage', 'esrvcId', 'form_fields', @@ -29,6 +30,7 @@ export const MULTIRESPONDENT_PUBLIC_FORM_FIELDS = [ const FORM_SETTINGS_FIELDS = [ 'responseMode', 'authType', + 'isNricMaskEnabled', 'esrvcId', 'hasCaptcha', 'hasIssueNotification', diff --git a/shared/types/form/form.ts b/shared/types/form/form.ts index e632f0bd6d..48df079cdd 100644 --- a/shared/types/form/form.ts +++ b/shared/types/form/form.ts @@ -145,6 +145,7 @@ export interface FormBase { hasCaptcha: boolean hasIssueNotification: boolean authType: FormAuthType + isNricMaskEnabled: boolean status: FormStatus diff --git a/shared/utils/__tests__/nric-mask.spec.ts b/shared/utils/__tests__/nric-mask.spec.ts new file mode 100644 index 0000000000..5954255937 --- /dev/null +++ b/shared/utils/__tests__/nric-mask.spec.ts @@ -0,0 +1,10 @@ +import { maskNric } from '../nric-mask' + +describe('Nric masking utility', () => { + it('should mask the nric', () => { + const unmasked_nric = 'S1234567A' + const masked_nric = '*****567A' + + expect(maskNric(unmasked_nric)).toEqual(masked_nric) + }) +}) diff --git a/shared/utils/nric-mask.ts b/shared/utils/nric-mask.ts new file mode 100644 index 0000000000..9bb3743a73 --- /dev/null +++ b/shared/utils/nric-mask.ts @@ -0,0 +1,26 @@ +/** + * Replaces all characters but the last `numCharsShown` characters of a given string with a mask string. + * @param str string to mask + * @param numCharsShown number of characters to display at the end + * @param maskString string to replace masked characters with + * @returns masked string + */ +const maskString = ( + str: string, + numCharsShown: number, + maskString: string = '*', +) => { + return ( + str.slice(0, -numCharsShown).replace(/./g, maskString) + + str.slice(-numCharsShown) + ) +} + +/** + * Masks all characters but the last 4 characters of a given nric. + * @param nric NRIC e.g. S1234567A + * @returns masked NRIC e.g. S*****567A + */ +export const maskNric = (nric: string): string => { + return maskString(nric, 4, '*') +} diff --git a/src/app/models/__tests__/form.server.model.spec.ts b/src/app/models/__tests__/form.server.model.spec.ts index 3992d76cfe..3f737abbd5 100644 --- a/src/app/models/__tests__/form.server.model.spec.ts +++ b/src/app/models/__tests__/form.server.model.spec.ts @@ -72,6 +72,7 @@ const MOCK_MULTIRESPONDENT_FORM_PARAMS = { const FORM_DEFAULTS = { authType: 'NIL', + isNricMaskEnabled: false, inactiveMessage: 'If you think this is a mistake, please contact the agency that gave you the form link.', isListed: true, @@ -2619,6 +2620,52 @@ describe('Form Model', () => { }) }) + describe('getDuplicateParams', () => { + it('should duplicate all required fields', () => { + // Arrange + const MOCK_ALL_OVERRIDE_PARAMS = { + admin: 'duplicated admin', + title: 'duplicated title', + responseMode: FormResponseMode.Email, + emails: ['duplicated email'], + submissionLimit: null, + } + const MOCK_ALL_FORM_PARAMS = { + title: 'Test Form', + admin: MOCK_ADMIN_OBJ_ID, + authType: FormAuthType.SP, + isNricMaskEnabled: true, + inactiveMessage: 'inactive_test', + responseMode: FormResponseMode.Encrypt, + submissionLimit: 1000, + } + const sourceForm = new Form(MOCK_ALL_FORM_PARAMS) + + // Act + const duplicatedForm = sourceForm.getDuplicateParams( + MOCK_ALL_OVERRIDE_PARAMS, + ) + + // Assert + expect(duplicatedForm.title).toEqual(MOCK_ALL_OVERRIDE_PARAMS.title) + expect(duplicatedForm.admin).toEqual(MOCK_ALL_OVERRIDE_PARAMS.admin) + expect(duplicatedForm.responseMode).toEqual( + MOCK_ALL_OVERRIDE_PARAMS.responseMode, + ) + expect(duplicatedForm.emails).toEqual(MOCK_ALL_OVERRIDE_PARAMS.emails) + expect(duplicatedForm.submissionLimit).toEqual( + MOCK_ALL_OVERRIDE_PARAMS.submissionLimit, + ) + expect(duplicatedForm.authType).toEqual(MOCK_ALL_FORM_PARAMS.authType) + expect(duplicatedForm.isNricMaskEnabled).toEqual( + MOCK_ALL_FORM_PARAMS.isNricMaskEnabled, + ) + expect(duplicatedForm.inactiveMessage).toEqual( + MOCK_ALL_FORM_PARAMS.inactiveMessage, + ) + }) + }) + describe('insertFormField', () => { it('should return updated document with inserted form field', async () => { // Arrange diff --git a/src/app/models/form.server.model.ts b/src/app/models/form.server.model.ts index ab48f2630d..3fce97ba6d 100644 --- a/src/app/models/form.server.model.ts +++ b/src/app/models/form.server.model.ts @@ -517,6 +517,11 @@ const compileFormModel = (db: Mongoose): IFormModel => { }, }, + isNricMaskEnabled: { + type: Boolean, + default: false, + }, + // This must be before `status` since `status` has setters reliant on // whether esrvcId is available, and mongoose@v6 now saves objects with keys // in the order the keys are specifified in the schema instead of the object. @@ -700,6 +705,7 @@ const compileFormModel = (db: Mongoose): IFormModel => { 'startPage', 'endPage', 'authType', + 'isNricMaskEnabled', 'inactiveMessage', 'responseMode', 'submissionLimit', diff --git a/src/app/modules/form/admin-form/admin-form.middlewares.ts b/src/app/modules/form/admin-form/admin-form.middlewares.ts index ec5de1fa4c..ce5a88d8dc 100644 --- a/src/app/modules/form/admin-form/admin-form.middlewares.ts +++ b/src/app/modules/form/admin-form/admin-form.middlewares.ts @@ -21,6 +21,7 @@ const webhookSettingsValidator = Joi.object({ export const updateSettingsValidator = celebrate({ [Segments.BODY]: Joi.object({ authType: Joi.string().valid(...Object.values(FormAuthType)), + isNricMaskEnabled: Joi.boolean(), emails: Joi.alternatives().try( Joi.array().items(Joi.string().email()), Joi.string().email({ multiple: true }), diff --git a/src/app/modules/submission/email-submission/__tests__/email-submission.controller.spec.ts b/src/app/modules/submission/email-submission/__tests__/email-submission.controller.spec.ts new file mode 100644 index 0000000000..b9ef3b1e08 --- /dev/null +++ b/src/app/modules/submission/email-submission/__tests__/email-submission.controller.spec.ts @@ -0,0 +1,173 @@ +import expressHandler from '__tests__/unit/backend/helpers/jest-express' +import { ObjectId } from 'bson' +import { ok, okAsync } from 'neverthrow' +import { FormAuthType } from 'shared/types' + +import * as FormService from 'src/app/modules/form/form.service' +import { SgidService } from 'src/app/modules/sgid/sgid.service' +import * as EmailSubmissionService from 'src/app/modules/submission/email-submission/email-submission.service' +import * as SubmissionService from 'src/app/modules/submission/submission.service' +import MailService from 'src/app/services/mail/mail.service' +import { + FormFieldSchema, + IEmailSubmissionSchema, + IPopulatedEmailForm, + IPopulatedForm, +} from 'src/types' + +import { submitEmailModeForm } from '../email-submission.controller' + +jest.mock( + 'src/app/modules/submission/email-submission/email-submission.service', +) +jest.mock('src/app/modules/form/form.service') +jest.mock('src/app/modules/submission/submission.service') +jest.mock('src/app/modules/sgid/sgid.service') +jest.mock('src/app/modules/submission/submissions.statsd-client') +jest.mock('src/app/services/mail/mail.service') + +const MockEmailSubmissionService = jest.mocked(EmailSubmissionService) +const MockFormService = jest.mocked(FormService) +const MockSubmissionService = jest.mocked(SubmissionService) +const MockSgidService = jest.mocked(SgidService) +const MockMailService = jest.mocked(MailService) + +describe('email-submission.controller', () => { + describe('nricMask', () => { + beforeEach(() => { + const MOCK_SUBMISSION_HASH = { + hash: 'some hash', + salt: 'some salt', + } + MockEmailSubmissionService.hashSubmission.mockReturnValueOnce( + okAsync(MOCK_SUBMISSION_HASH), + ) + MockEmailSubmissionService.saveSubmissionMetadata.mockReturnValueOnce( + okAsync({} as IEmailSubmissionSchema), + ) + MockFormService.isFormPublic.mockReturnValueOnce(ok(true)) + + MockMailService.sendSubmissionToAdmin.mockReturnValueOnce(okAsync(true)) + + MockSubmissionService.validateAttachments.mockReturnValueOnce( + okAsync(true), + ) + MockSubmissionService.sendEmailConfirmations.mockReturnValueOnce( + okAsync(true), + ) + }) + afterEach(() => { + jest.clearAllMocks() + }) + + it('should mask nric if form isNricMaskEnabled is true', async () => { + // Arrange + const mockFormId = new ObjectId() + const mockSgidAuthTypeAndNricMaskingEnabledForm = { + _id: mockFormId, + title: 'some form', + authType: FormAuthType.SGID, + isNricMaskEnabled: true, + form_fields: [] as FormFieldSchema[], + } as IPopulatedForm + const MOCK_JWT_PAYLOAD_WITH_NRIC = { + userName: 'S1234567A', + } + const MOCK_VALID_SGID_PAYLOAD = { + userName: MOCK_JWT_PAYLOAD_WITH_NRIC.userName, + rememberMe: false, + } + MockFormService.retrieveFullFormById.mockReturnValueOnce( + okAsync(mockSgidAuthTypeAndNricMaskingEnabledForm), + ) + MockEmailSubmissionService.checkFormIsEmailMode.mockReturnValueOnce( + ok(mockSgidAuthTypeAndNricMaskingEnabledForm as IPopulatedEmailForm), + ) + MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( + okAsync(mockSgidAuthTypeAndNricMaskingEnabledForm), + ) + MockSgidService.extractSgidSingpassJwtPayload.mockReturnValueOnce( + ok(MOCK_VALID_SGID_PAYLOAD), + ) + + const MOCK_REQ = expressHandler.mockRequest({ + params: { formId: 'some id' }, + body: { + responses: [], + }, + }) + const mockRes = expressHandler.mockResponse({ + clearCookie: jest.fn().mockReturnThis(), + }) + + // Act + await submitEmailModeForm(MOCK_REQ, mockRes, jest.fn()) + + // Assert email should be sent + expect(MockMailService.sendSubmissionToAdmin).toHaveBeenCalledTimes(1) + expect( + MockSubmissionService.sendEmailConfirmations, + ).toHaveBeenCalledTimes(1) + // Assert nric is masked in email payload + expect( + MockMailService.sendSubmissionToAdmin.mock.calls[0][0].formData[0] + .answer, + ).toEqual('*****567A') + }) + + it('should not mask nric if form isNricMaskEnabled is false', async () => { + // Arrange + const mockFormId = new ObjectId() + const mockSgidAuthTypeAndNricMaskingDisabledForm = { + _id: mockFormId, + title: 'some form', + authType: FormAuthType.SGID, + isNricMaskEnabled: false, + form_fields: [] as FormFieldSchema[], + } as IPopulatedForm + const MOCK_JWT_PAYLOAD_WITH_NRIC = { + userName: 'S1234567A', + } + const MOCK_VALID_SGID_PAYLOAD = { + userName: MOCK_JWT_PAYLOAD_WITH_NRIC.userName, + rememberMe: false, + } + MockFormService.retrieveFullFormById.mockReturnValueOnce( + okAsync(mockSgidAuthTypeAndNricMaskingDisabledForm), + ) + MockEmailSubmissionService.checkFormIsEmailMode.mockReturnValueOnce( + ok(mockSgidAuthTypeAndNricMaskingDisabledForm as IPopulatedEmailForm), + ) + MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( + okAsync(mockSgidAuthTypeAndNricMaskingDisabledForm), + ) + MockSgidService.extractSgidSingpassJwtPayload.mockReturnValueOnce( + ok(MOCK_VALID_SGID_PAYLOAD), + ) + + const MOCK_REQ = expressHandler.mockRequest({ + params: { formId: 'some id' }, + body: { + responses: [], + }, + }) + const mockRes = expressHandler.mockResponse({ + clearCookie: jest.fn().mockReturnThis(), + }) + + // Act + await submitEmailModeForm(MOCK_REQ, mockRes, jest.fn()) + + // Assert email should be sent + expect(MockMailService.sendSubmissionToAdmin).toHaveBeenCalledTimes(1) + expect( + MockSubmissionService.sendEmailConfirmations, + ).toHaveBeenCalledTimes(1) + // Assert nric is masked + expect( + MockMailService.sendSubmissionToAdmin.mock.calls[0][0].formData[0] + .answer, + ).toEqual(MOCK_JWT_PAYLOAD_WITH_NRIC.userName) + }) + }) +}) 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..fea4a06be8 100644 --- a/src/app/modules/submission/email-submission/email-submission.controller.ts +++ b/src/app/modules/submission/email-submission/email-submission.controller.ts @@ -1,11 +1,13 @@ import { ok, okAsync, ResultAsync } from 'neverthrow' import { + BasicField, FormAuthType, SubmissionErrorDto, SubmissionResponseDto, } from '../../../../../shared/types' import { CaptchaTypes } from '../../../../../shared/types/captcha' +import { maskNric } from '../../../../../shared/utils/nric-mask' import { IPopulatedEmailForm } from '../../../../types' import { ParsedEmailModeSubmissionBody } from '../../../../types/api' import { createLoggerWithLabel } from '../../../config/logger' @@ -46,7 +48,7 @@ import { mapRouteError, SubmissionEmailObj } from './email-submission.util' const logger = createLoggerWithLabel(module) -const submitEmailModeForm: ControllerHandler< +export const submitEmailModeForm: ControllerHandler< { formId: string }, SubmissionResponseDto | SubmissionErrorDto, ParsedEmailModeSubmissionBody, @@ -302,6 +304,16 @@ const submitEmailModeForm: ControllerHandler< } }) .andThen(({ form, parsedResponses, hashedFields }) => { + if (form.isNricMaskEnabled) { + parsedResponses.ndiResponses = parsedResponses.ndiResponses.map( + (response) => { + if (response.fieldType === BasicField.Nric) { + return { ...response, answer: maskNric(response.answer) } + } + return response + }, + ) + } // Create data for response email as well as email confirmation const emailData = new SubmissionEmailObj( parsedResponses.getAllResponses(), diff --git a/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.controller.spec.ts b/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.controller.spec.ts new file mode 100644 index 0000000000..35e1916447 --- /dev/null +++ b/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.controller.spec.ts @@ -0,0 +1,193 @@ +import dbHandler from '__tests__/unit/backend/helpers/jest-db' +import expressHandler from '__tests__/unit/backend/helpers/jest-express' +import { ObjectId } from 'bson' +import { merge } from 'lodash' +import mongoose from 'mongoose' +import { ok, okAsync } from 'neverthrow' +import { FormAuthType, MyInfoAttribute } from 'shared/types' + +import { getEncryptSubmissionModel } from 'src/app/models/submission.server.model' +import * as OidcService from 'src/app/modules/spcp/spcp.oidc.service/index' +import { OidcServiceType } from 'src/app/modules/spcp/spcp.oidc.service/spcp.oidc.service.types' +import * as VerifiedContentService from 'src/app/modules/verified-content/verified-content.service' +import { + EncryptVerificationContentParams, + SpVerifiedContent, +} from 'src/app/modules/verified-content/verified-content.types' +import MailService from 'src/app/services/mail/mail.service' +import { FormFieldSchema, IPopulatedEncryptedForm } from 'src/types' +import { EncryptSubmissionDto, FormCompleteDto } from 'src/types/api' + +import { submitEncryptModeFormForTest } from '../encrypt-submission.controller' +import { SubmitEncryptModeFormHandlerRequest } from '../encrypt-submission.types' + +jest.mock('src/app/utils/pipeline-middleware', () => { + const MockPipeline = jest.fn().mockImplementation(() => { + return { + execute: jest.fn(() => { + return okAsync(true) + }), + } + }) + + return { + Pipeline: MockPipeline, + } +}) +jest.mock('src/app/modules/spcp/spcp.oidc.service') +jest.mock('src/app/services/mail/mail.service') +jest.mock('src/app/modules/verified-content/verified-content.service', () => { + const originalModule = jest.requireActual( + 'src/app/modules/verified-content/verified-content.service', + ) + return { + ...originalModule, + getVerifiedContent: jest.fn(originalModule.getVerifiedContent), + encryptVerifiedContent: jest.fn( + ({ verifiedContent }: EncryptVerificationContentParams) => + ok((verifiedContent as SpVerifiedContent).uinFin), + ), + } +}) + +const MockOidcService = jest.mocked(OidcService) +const MockMailService = jest.mocked(MailService) +const MockVerifiedContentService = jest.mocked(VerifiedContentService) + +const EncryptSubmission = getEncryptSubmissionModel(mongoose) + +describe('encrypt-submission.controller', () => { + describe('nricMask', () => { + beforeAll(async () => await dbHandler.connect()) + afterEach(async () => await dbHandler.clearDatabase()) + afterAll(async () => await dbHandler.closeDatabase()) + + beforeEach(() => { + MockOidcService.getOidcService.mockReturnValue({ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + extractJwt: (_arg1) => ok('jwt'), + // eslint-disable-next-line @typescript-eslint/no-unused-vars + extractJwtPayload: (_arg1) => + okAsync(merge(MOCK_JWT_PAYLOAD, MOCK_COOKIE_TIMESTAMP)), + } as OidcServiceType) + + MockMailService.sendSubmissionToAdmin.mockResolvedValue(okAsync(true)) + }) + + const MOCK_NRIC = 'S1234567A' + const MOCK_MASKED_NRIC = '*****567A' + + const MOCK_JWT_PAYLOAD = { + userName: MOCK_NRIC, + rememberMe: false, + } + const MOCK_COOKIE_TIMESTAMP = { + iat: 1, + exp: 1, + } + + it('should mask nric if form isNricMaskEnabled is true', async () => { + // Arrange + const mockFormId = new ObjectId() + const mockSpAuthTypeAndNricMaskingEnabledForm = { + _id: mockFormId, + title: 'some form', + authType: FormAuthType.SP, + isNricMaskEnabled: true, + form_fields: [] as FormFieldSchema[], + getUniqueMyInfoAttrs: () => [] as MyInfoAttribute[], + } as IPopulatedEncryptedForm + + const MOCK_REQ = merge( + expressHandler.mockRequest({ + params: { formId: 'some id' }, + body: { + responses: [], + }, + }), + { + formsg: { + encryptedPayload: { + encryptedContent: 'encryptedContent', + version: 1, + }, + formDef: { + authType: FormAuthType.SP, + }, + encryptedFormDef: mockSpAuthTypeAndNricMaskingEnabledForm, + } as unknown as EncryptSubmissionDto, + } as unknown as FormCompleteDto, + ) as unknown as SubmitEncryptModeFormHandlerRequest + const mockRes = expressHandler.mockResponse() + + // Act + await submitEncryptModeFormForTest(MOCK_REQ, mockRes) + + // Assert + // that verified content is generated using the masked nric + expect( + MockVerifiedContentService.getVerifiedContent, + ).toHaveBeenCalledWith({ + type: mockSpAuthTypeAndNricMaskingEnabledForm.authType, + data: { uinFin: MOCK_MASKED_NRIC, userInfo: undefined }, + }) + // that the saved submission is masked + const savedSubmission = await EncryptSubmission.findOne() + + expect(savedSubmission).toBeDefined() + expect(savedSubmission!.verifiedContent).toEqual(MOCK_MASKED_NRIC) + }) + + it('should not mask nric if form isNricMaskEnabled is false', async () => { + // Arrange + const mockFormId = new ObjectId() + const mockSpAuthTypeAndNricMaskingEnabledForm = { + _id: mockFormId, + title: 'some form', + authType: FormAuthType.SP, + isNricMaskEnabled: false, + form_fields: [] as FormFieldSchema[], + getUniqueMyInfoAttrs: () => [] as MyInfoAttribute[], + } as IPopulatedEncryptedForm + + const MOCK_REQ = merge( + expressHandler.mockRequest({ + params: { formId: 'some id' }, + body: { + responses: [], + }, + }), + { + formsg: { + encryptedPayload: { + encryptedContent: 'encryptedContent', + version: 1, + }, + formDef: { + authType: FormAuthType.SP, + }, + encryptedFormDef: mockSpAuthTypeAndNricMaskingEnabledForm, + } as unknown as EncryptSubmissionDto, + } as unknown as FormCompleteDto, + ) as unknown as SubmitEncryptModeFormHandlerRequest + const mockRes = expressHandler.mockResponse() + + // Act + await submitEncryptModeFormForTest(MOCK_REQ, mockRes) + + // Assert + // that verified content is generated using the masked nric + expect( + MockVerifiedContentService.getVerifiedContent, + ).toHaveBeenCalledWith({ + type: mockSpAuthTypeAndNricMaskingEnabledForm.authType, + data: { uinFin: MOCK_NRIC, userInfo: undefined }, + }) + // that the saved submission is masked + const savedSubmission = await EncryptSubmission.findOne() + + expect(savedSubmission).toBeDefined() + expect(savedSubmission!.verifiedContent).toEqual(MOCK_NRIC) + }) + }) +}) 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 cf18910d54..c19aac09a6 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts @@ -14,6 +14,7 @@ import { PaymentType, StorageModeSubmissionContentDto, } from '../../../../../shared/types' +import { maskNric } from '../../../../../shared/utils/nric-mask' import { IEncryptedForm, IEncryptedSubmissionSchema, @@ -243,6 +244,19 @@ const submitEncryptModeForm = async ( } } + // Mask if Nric masking is enabled + if ( + uinFin && + form.isNricMaskEnabled && + (form.authType === FormAuthType.SP || + form.authType === FormAuthType.CP || + form.authType === FormAuthType.SGID || + form.authType === FormAuthType.MyInfo || + form.authType === FormAuthType.SGID_MyInfo) + ) { + uinFin = maskNric(uinFin) + } + // Encrypt Verified SPCP Fields let verified if ( @@ -303,7 +317,7 @@ const submitEncryptModeForm = async ( const submissionContent: EncryptSubmissionContent = { form: form._id, - auth: form.authType, + authType: form.authType, myInfoFields: form.getUniqueMyInfoAttrs(), encryptedContent: encryptedContent, verifiedContent: verified, @@ -342,6 +356,8 @@ const submitEncryptModeForm = async ( }) } +export const submitEncryptModeFormForTest = submitEncryptModeForm + const _createPaymentSubmission = async ({ req, res, @@ -608,7 +624,6 @@ const _createSubmission = async ({ logMeta: CustomLoggerParams['meta'] }) => { const submission = new EncryptSubmission(submissionContent) - try { await submission.save() } catch (err) { diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.types.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.types.ts index c5ca26424c..0bafb5c0b3 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.types.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.types.ts @@ -79,7 +79,7 @@ export type SubmitEncryptModeFormHandlerRequest = export type EncryptSubmissionContent = { form: IPopulatedEncryptedForm['_id'] - auth: IPopulatedEncryptedForm['authType'] + authType: IPopulatedEncryptedForm['authType'] myInfoFields: MyInfoAttribute[] encryptedContent: string verifiedContent: string | undefined diff --git a/src/types/form.ts b/src/types/form.ts index 1bd445ea2d..218fd04402 100644 --- a/src/types/form.ts +++ b/src/types/form.ts @@ -63,6 +63,7 @@ type FormDefaultableKey = | 'hasCaptcha' | 'hasIssueNotification' | 'authType' + | 'isNricMaskEnabled' | 'status' | 'inactiveMessage' | 'submissionLimit' @@ -101,6 +102,7 @@ export type PickDuplicateForm = Pick< | 'startPage' | 'endPage' | 'authType' + | 'isNricMaskEnabled' | 'inactiveMessage' | 'submissionLimit' | 'responseMode' @@ -259,6 +261,7 @@ interface IFormBaseDocument { hasCaptcha: NonNullable hasIssueNotification: NonNullable authType: NonNullable + isNricMaskEnabled: NonNullable status: NonNullable inactiveMessage: NonNullable // NOTE: Due to the way creating a form works, creating a form without specifying submissionLimit will throw an error.