Skip to content

Commit

Permalink
feat: mask cp uid in autoreply to respondent (#1109)
Browse files Browse the repository at this point in the history
* feat: mask CP user NRIC in autoreply

* refactor: use enum for SPCPValidatedFields

* chore: fix typing issues

* fix: relative path

* refactor: rename SPCPValidatedFields to SPCPFieldTitle

* chore: define function for masking which does not mutate parameters and shift to email-submission controller

* chore: add tests

* docs: refactor comments to be compatible with vscode github extension
  • Loading branch information
tshuli authored Feb 9, 2021
1 parent 2239e48 commit ec5aa9a
Show file tree
Hide file tree
Showing 10 changed files with 140 additions and 42 deletions.
2 changes: 2 additions & 0 deletions src/app/modules/spcp/spcp.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
Expand Down
13 changes: 9 additions & 4 deletions src/app/modules/spcp/spcp.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -166,7 +171,7 @@ export const createSingpassParsedResponses = (
return [
{
_id: '',
question: 'SingPass Validated NRIC',
question: SPCPFieldTitle.SpNric,
fieldType: BasicField.Nric,
isVisible: true,
answer: uinFin,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import { cloneDeep, merge } from 'lodash'

import {
BasicField,
EmailAutoReplyField,
FieldResponse,
IAttachmentResponse,
ISingleAnswerResponse,
SPCPFieldTitle,
} from 'src/types'

import {
Expand All @@ -15,6 +17,7 @@ import {
getInvalidFileExtensions,
handleDuplicatesInAttachments,
mapAttachmentsFromResponses,
maskUidOnLastField,
} from '../email-submission.util'

const validSingleFile = {
Expand Down Expand Up @@ -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> = T & {
answer: string
}

const getResponse = (
_id: string,
answer: string,
): WithQuestion<ISingleAnswerResponse> =>
(({
_id,
fieldType: BasicField.Attachment,
question: 'mockQuestion',
answer,
} as unknown) as WithQuestion<ISingleAnswerResponse>)

describe('email-submission.util', () => {
describe('getInvalidFileExtensions', () => {
Expand Down Expand Up @@ -171,30 +182,30 @@ 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', () => {
const attachment = validSingleFile
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,
)
})
Expand Down Expand Up @@ -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)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import * as EmailSubmissionService from './email-submission.service'
import {
mapAttachmentsFromResponses,
mapRouteError,
maskUidOnLastField,
} from './email-submission.util'

const logger = createLoggerWithLabel(module)
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
IAttachmentInfo,
IAttachmentResponse,
MapRouteError,
SPCPFieldTitle,
} from '../../../../types'
import {
CaptchaConnectionError,
Expand Down Expand Up @@ -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
}
3 changes: 2 additions & 1 deletion src/app/services/mail/mail.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,8 @@ export class MailService {
autoReplyMailDatas,
attachments = [],
}: SendAutoReplyEmailsArgs): Promise<PromiseSettledResult<true>[]> => {
// 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,
Expand Down
13 changes: 7 additions & 6 deletions src/public/modules/forms/helpers/process-decrypted-content.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const { SPCPFieldTitle } = require('../../../../types')
const {
CURRENT_VERIFIED_FIELDS,
VerifiedKeys,
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/types/field/fieldTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
11 changes: 8 additions & 3 deletions tests/end-to-end/helpers/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -646,21 +650,21 @@ 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',
},
]
const expectedAutoReplyData = [
{
question: 'SingPass Validated NRIC',
question: SPCPFieldTitle.SpNric,
answerTemplate: [resLocalFixtures.uinFin],
},
]
const expectedJsonData = [
{
question: 'SingPass Validated NRIC',
question: SPCPFieldTitle.SpNric,
answer: resLocalFixtures.uinFin,
},
]
Expand All @@ -678,35 +682,35 @@ 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',
},
]
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,
},
]
Expand Down

0 comments on commit ec5aa9a

Please sign in to comment.