diff --git a/src/app/modules/spcp/spcp.controller.ts b/src/app/modules/spcp/spcp.controller.ts index 127f88965a..b8ca4ff8b4 100644 --- a/src/app/modules/spcp/spcp.controller.ts +++ b/src/app/modules/spcp/spcp.controller.ts @@ -267,6 +267,8 @@ export const appendVerifiedSPCPResponses: RequestHandler< req.body.parsedResponses.push(...createSingpassParsedResponses(uinFin)) break case AuthType.CP: + // Note that maskUidOnLastField() relies on the fact that userInfo is pushed in last to parsedResponses + // TODO(#1104): Remove this comment after refactoring req.body.parsedResponses.push( ...createCorppassParsedResponses(uinFin, userInfo), ) diff --git a/src/app/modules/spcp/spcp.util.ts b/src/app/modules/spcp/spcp.util.ts index 95a62fb459..c8197376c4 100644 --- a/src/app/modules/spcp/spcp.util.ts +++ b/src/app/modules/spcp/spcp.util.ts @@ -3,7 +3,12 @@ import crypto from 'crypto' import { StatusCodes } from 'http-status-codes' import { createLoggerWithLabel } from '../../../config/logger' -import { AuthType, BasicField, MapRouteError } from '../../../types' +import { + AuthType, + BasicField, + MapRouteError, + SPCPFieldTitle, +} from '../../../types' import { MissingFeatureError } from '../core/core.errors' import { ProcessedSingleAnswerResponse } from '../submission/submission.types' @@ -166,7 +171,7 @@ export const createSingpassParsedResponses = ( return [ { _id: '', - question: 'SingPass Validated NRIC', + question: SPCPFieldTitle.SpNric, fieldType: BasicField.Nric, isVisible: true, answer: uinFin, @@ -186,14 +191,14 @@ export const createCorppassParsedResponses = ( return [ { _id: '', - question: 'CorpPass Validated UEN', + question: SPCPFieldTitle.CpUen, fieldType: BasicField.ShortText, isVisible: true, answer: uinFin, }, { _id: '', - question: 'CorpPass Validated UID', + question: SPCPFieldTitle.CpUid, fieldType: BasicField.Nric, isVisible: true, answer: userInfo, diff --git a/src/app/modules/submission/email-submission/__tests__/email-submission.util.spec.ts b/src/app/modules/submission/email-submission/__tests__/email-submission.util.spec.ts index 6300d45f18..b69e579210 100644 --- a/src/app/modules/submission/email-submission/__tests__/email-submission.util.spec.ts +++ b/src/app/modules/submission/email-submission/__tests__/email-submission.util.spec.ts @@ -4,9 +4,11 @@ import { cloneDeep, merge } from 'lodash' import { BasicField, + EmailAutoReplyField, FieldResponse, IAttachmentResponse, ISingleAnswerResponse, + SPCPFieldTitle, } from 'src/types' import { @@ -15,6 +17,7 @@ import { getInvalidFileExtensions, handleDuplicatesInAttachments, mapAttachmentsFromResponses, + maskUidOnLastField, } from '../email-submission.util' const validSingleFile = { @@ -61,12 +64,20 @@ const zipOnlyValid = { const MOCK_ANSWER = 'mockAnswer' -const getResponse = (_id: string, answer: string): ISingleAnswerResponse => ({ - _id, - fieldType: BasicField.Attachment, - question: 'mockQuestion', - answer, -}) +type WithQuestion = T & { + answer: string +} + +const getResponse = ( + _id: string, + answer: string, +): WithQuestion => + (({ + _id, + fieldType: BasicField.Attachment, + question: 'mockQuestion', + answer, + } as unknown) as WithQuestion) describe('email-submission.util', () => { describe('getInvalidFileExtensions', () => { @@ -171,19 +182,19 @@ describe('email-submission.util', () => { [firstAttachment, secondAttachment], ) expect(firstResponse.answer).toBe(firstAttachment.filename) - expect((firstResponse as IAttachmentResponse).filename).toBe( + expect(((firstResponse as unknown) as IAttachmentResponse).filename).toBe( firstAttachment.filename, ) - expect((firstResponse as IAttachmentResponse).content).toEqual( - firstAttachment.content, - ) + expect( + ((firstResponse as unknown) as IAttachmentResponse).content, + ).toEqual(firstAttachment.content) expect(secondResponse.answer).toBe(secondAttachment.filename) - expect((secondResponse as IAttachmentResponse).filename).toBe( - secondAttachment.filename, - ) - expect((secondResponse as IAttachmentResponse).content).toEqual( - secondAttachment.content, - ) + expect( + ((secondResponse as unknown) as IAttachmentResponse).filename, + ).toBe(secondAttachment.filename) + expect( + ((secondResponse as unknown) as IAttachmentResponse).content, + ).toEqual(secondAttachment.content) }) it('should overwrite answer with filename when they are different', () => { @@ -191,10 +202,10 @@ describe('email-submission.util', () => { const response = getResponse(attachment.fieldId, MOCK_ANSWER) addAttachmentToResponses([response], [attachment]) expect(response.answer).toBe(attachment.filename) - expect((response as IAttachmentResponse).filename).toBe( + expect(((response as unknown) as IAttachmentResponse).filename).toBe( attachment.filename, ) - expect((response as IAttachmentResponse).content).toEqual( + expect(((response as unknown) as IAttachmentResponse).content).toEqual( attachment.content, ) }) @@ -293,4 +304,31 @@ describe('email-submission.util', () => { }) }) }) + + describe('maskUidOnLastField', () => { + it('should mask UID on SPCPFieldTitle.CpUid if it is the last field of autoReplyData', () => { + const autoReplyData: EmailAutoReplyField[] = [ + { question: 'question 1', answerTemplate: ['answer1'] }, + { question: SPCPFieldTitle.CpUid, answerTemplate: ['S1234567A'] }, + ] + const maskedAutoReplyData: EmailAutoReplyField[] = [ + { question: 'question 1', answerTemplate: ['answer1'] }, + { question: SPCPFieldTitle.CpUid, answerTemplate: ['*****567A'] }, + ] + expect(maskUidOnLastField(autoReplyData)).toEqual(maskedAutoReplyData) + }) + it('should not mask UID on form field with same name as SPCPFieldTitle.CpUid if it is not the last field of autoReplyData', () => { + const autoReplyData: EmailAutoReplyField[] = [ + { question: 'question 1', answerTemplate: ['answer1'] }, + { question: 'CorpPass Validated UID', answerTemplate: ['S9999999Z'] }, + { question: SPCPFieldTitle.CpUid, answerTemplate: ['S1234567A'] }, + ] + const maskedAutoReplyData: EmailAutoReplyField[] = [ + { question: 'question 1', answerTemplate: ['answer1'] }, + { question: 'CorpPass Validated UID', answerTemplate: ['S9999999Z'] }, + { question: SPCPFieldTitle.CpUid, answerTemplate: ['*****567A'] }, + ] + expect(maskUidOnLastField(autoReplyData)).toEqual(maskedAutoReplyData) + }) + }) }) 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 8c3fa9c705..298ee514b9 100644 --- a/src/app/modules/submission/email-submission/email-submission.controller.ts +++ b/src/app/modules/submission/email-submission/email-submission.controller.ts @@ -17,6 +17,7 @@ import * as EmailSubmissionService from './email-submission.service' import { mapAttachmentsFromResponses, mapRouteError, + maskUidOnLastField, } from './email-submission.util' const logger = createLoggerWithLabel(module) @@ -227,7 +228,10 @@ export const handleEmailSubmission: RequestHandler< parsedResponses, submission, attachments, - autoReplyData: emailData.autoReplyData, + autoReplyData: + authType === AuthType.CP + ? maskUidOnLastField(emailData.autoReplyData) + : emailData.autoReplyData, }).mapErr((error) => { logger.error({ message: 'Error while sending email confirmations', diff --git a/src/app/modules/submission/email-submission/email-submission.util.ts b/src/app/modules/submission/email-submission/email-submission.util.ts index acfb73dca7..f4bc67a266 100644 --- a/src/app/modules/submission/email-submission/email-submission.util.ts +++ b/src/app/modules/submission/email-submission/email-submission.util.ts @@ -14,6 +14,7 @@ import { IAttachmentInfo, IAttachmentResponse, MapRouteError, + SPCPFieldTitle, } from '../../../../types' import { CaptchaConnectionError, @@ -518,3 +519,34 @@ export const concatAttachmentsAndResponses = ( response += attachments.reduce((acc, { content }) => acc + content, '') return response } + +export const maskUidOnLastField = ( + autoReplyData: EmailAutoReplyField[], +): EmailAutoReplyField[] => { + // Mask corppass UID and show only last 4 chars in autoreply to form filler + // This does not affect response email to form admin + // Function assumes corppass UID is last in the autoReplyData array - see appendVerifiedSPCPResponses() + // TODO(#1104): Refactor to move validation and construction of parsedResponses in class constructor + // This will allow for proper tagging of corppass UID field instead of checking field title and position + + const maskedAutoReplyData = autoReplyData.map( + (autoReplyField: EmailAutoReplyField, index) => { + if ( + autoReplyField.question === SPCPFieldTitle.CpUid && // Check field title + index === autoReplyData.length - 1 // Check field position + ) { + return { + question: autoReplyField.question, + answerTemplate: autoReplyField.answerTemplate.map((answer) => { + return answer.length >= 4 // defensive, in case UID length is less than 4 + ? '*'.repeat(answer.length - 4) + answer.substr(-4) + : answer + }), + } + } else { + return autoReplyField + } + }, + ) + return maskedAutoReplyData +} diff --git a/src/app/services/mail/mail.service.ts b/src/app/services/mail/mail.service.ts index 4bb0f76c90..5d08aadd22 100644 --- a/src/app/services/mail/mail.service.ts +++ b/src/app/services/mail/mail.service.ts @@ -458,7 +458,8 @@ export class MailService { autoReplyMailDatas, attachments = [], }: SendAutoReplyEmailsArgs): Promise[]> => { - // Data to render both the submission details mail HTML body PDF. + // Data to render both the submission details mail HTML body and PDF. + const renderData: AutoreplySummaryRenderData = { refNo: submission.id, formTitle: form.title, diff --git a/src/public/modules/forms/helpers/process-decrypted-content.js b/src/public/modules/forms/helpers/process-decrypted-content.js index c237312b65..a1e4cb2afa 100644 --- a/src/public/modules/forms/helpers/process-decrypted-content.js +++ b/src/public/modules/forms/helpers/process-decrypted-content.js @@ -1,3 +1,4 @@ +const { SPCPFieldTitle } = require('../../../../types') const { CURRENT_VERIFIED_FIELDS, VerifiedKeys, @@ -12,25 +13,25 @@ const getResponseFromVerifiedField = (type, value) => { switch (type) { case VerifiedKeys.SpUinFin: return { - question: 'SingPass Validated NRIC', + question: SPCPFieldTitle.SpNric, fieldType: 'nric', answer: value, // Just a unique identifier for CSV header uniqueness - _id: 'SingPass Validated NRIC', + _id: SPCPFieldTitle.SpNric, } case VerifiedKeys.CpUen: return { - question: 'CorpPass Validated UEN', + question: SPCPFieldTitle.CpUen, fieldType: 'textfield', answer: value, - _id: 'CorpPass Validated UEN', + _id: SPCPFieldTitle.CpUen, } case VerifiedKeys.CpUid: return { - question: 'CorpPass Validated UID', + question: SPCPFieldTitle.CpUid, fieldType: 'nric', answer: value, - _id: 'CorpPass Validated UID', + _id: SPCPFieldTitle.CpUid, } default: return null diff --git a/src/types/field/fieldTypes.ts b/src/types/field/fieldTypes.ts index 432516baa0..c270c227c3 100644 --- a/src/types/field/fieldTypes.ts +++ b/src/types/field/fieldTypes.ts @@ -52,3 +52,9 @@ export enum MyInfoAttribute { WorkpassExpiryDate = 'workpassexpirydate', GraduationYear = 'gradyear', } + +export enum SPCPFieldTitle { + SpNric = 'SingPass Validated NRIC', + CpUid = 'CorpPass Validated UID', + CpUen = 'CorpPass Validated UEN', +} diff --git a/tests/end-to-end/helpers/util.js b/tests/end-to-end/helpers/util.js index 9b97a31898..b90cc37e01 100644 --- a/tests/end-to-end/helpers/util.js +++ b/tests/end-to-end/helpers/util.js @@ -27,6 +27,11 @@ const { } = require('./selectors') const { types } = require('../../../dist/backend/shared/resources/basic') + +const { + SPCPFieldTitle, +} = require('../../../dist/backend/types/field/fieldTypes') + const NON_SUBMITTED_FIELDS = types .filter((field) => !field.submitted) .map((field) => field.name) @@ -1157,15 +1162,15 @@ const getAuthFields = (authType, authData) => { case 'SP': return [ makeField({ - title: 'SingPass Validated NRIC', + title: SPCPFieldTitle.SpNric, val: authData.testSpNric, }), ] case 'CP': return [ - { title: 'CorpPass Validated UEN', val: authData.testCpUen }, + { title: SPCPFieldTitle.CpUen, val: authData.testCpUen }, { - title: 'CorpPass Validated UID', + title: SPCPFieldTitle.CpUid, val: authData.testCpNric, }, ].map(makeField) diff --git a/tests/unit/backend/controllers/email-submissions.server.controller.spec.js b/tests/unit/backend/controllers/email-submissions.server.controller.spec.js index 9939d6cec9..7f64db96ba 100644 --- a/tests/unit/backend/controllers/email-submissions.server.controller.spec.js +++ b/tests/unit/backend/controllers/email-submissions.server.controller.spec.js @@ -20,6 +20,10 @@ const Verification = dbHandler.makeModel( ) const vfnConstants = require('../../../../dist/backend/shared/util/verification') +const { + SPCPFieldTitle, +} = require('../../../../dist/backend/types/field/fieldTypes') + describe('Email Submissions Controller', () => { // Declare global variables let sendSubmissionMailSpy @@ -646,7 +650,7 @@ describe('Email Submissions Controller', () => { reqFixtures.form.authType = 'SP' const expectedFormData = [ { - question: 'SingPass Validated NRIC', + question: SPCPFieldTitle.SpNric, answerTemplate: [resLocalFixtures.uinFin], answer: resLocalFixtures.uinFin, fieldType: 'nric', @@ -654,13 +658,13 @@ describe('Email Submissions Controller', () => { ] const expectedAutoReplyData = [ { - question: 'SingPass Validated NRIC', + question: SPCPFieldTitle.SpNric, answerTemplate: [resLocalFixtures.uinFin], }, ] const expectedJsonData = [ { - question: 'SingPass Validated NRIC', + question: SPCPFieldTitle.SpNric, answer: resLocalFixtures.uinFin, }, ] @@ -678,13 +682,13 @@ describe('Email Submissions Controller', () => { reqFixtures.form.authType = 'CP' const expectedFormData = [ { - question: 'CorpPass Validated UEN', + question: SPCPFieldTitle.CpUen, answerTemplate: [resLocalFixtures.uinFin], answer: resLocalFixtures.uinFin, fieldType: 'textfield', }, { - question: 'CorpPass Validated UID', + question: SPCPFieldTitle.CpUid, answerTemplate: [resLocalFixtures.userInfo], answer: resLocalFixtures.userInfo, fieldType: 'nric', @@ -692,21 +696,21 @@ describe('Email Submissions Controller', () => { ] const expectedAutoReplyData = [ { - question: 'CorpPass Validated UEN', + question: SPCPFieldTitle.CpUen, answerTemplate: [resLocalFixtures.uinFin], }, { - question: 'CorpPass Validated UID', + question: SPCPFieldTitle.CpUid, answerTemplate: [resLocalFixtures.userInfo], }, ] const expectedJsonData = [ { - question: 'CorpPass Validated UEN', + question: SPCPFieldTitle.CpUen, answer: resLocalFixtures.uinFin, }, { - question: 'CorpPass Validated UID', + question: SPCPFieldTitle.CpUid, answer: resLocalFixtures.userInfo, }, ]