diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index fc19b864fb..a1600fc88c 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -12,14 +12,14 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: 14 + node-version: 18 - name: Install dependencies run: npm ci - name: Install Playwright Browsers run: npx playwright install --with-deps - name: Build env: - NODE_OPTIONS: '--max-old-space-size=4096' + NODE_OPTIONS: '--max-old-space-size=4096 --openssl-legacy-provider' run: npm run build - name: Run Playwright tests run: npx playwright test diff --git a/.npmrc b/.npmrc index 4d936e8e68..93b5312678 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ unsafe-perm=true +legacy-peer-deps=true \ No newline at end of file diff --git a/README.md b/README.md index 86cf13cbb8..12eac309a9 100755 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Install [docker and docker-compose](https://docs.docker.com/get-docker/). To install the relevant npm packages, run the following in the root direcory: ```bash -npm install --legacy-peer-deps +npm install ``` To prevent breaking changes to webpack4 introduced in node 17 and above, enable the `--openssl-legacy-provider` flag: @@ -99,7 +99,7 @@ only takes ~15 seconds to finish starting up the image. ### Adding dependencies -Run `npm install --legacy-peer-deps` as per usual. +Run `npm install` as per usual. For backend, run diff --git a/__tests__/e2e/constants/field.ts b/__tests__/e2e/constants/field.ts index 828329f3d1..4a1cc9175f 100644 --- a/__tests__/e2e/constants/field.ts +++ b/__tests__/e2e/constants/field.ts @@ -14,6 +14,7 @@ import { ImageFieldBase, LongTextFieldBase, MobileFieldBase, + MyInfoAttribute, NricFieldBase, NumberFieldBase, RadioFieldBase, @@ -43,6 +44,10 @@ type E2ePickFieldMetadata = Pick< > & Partial> +type E2eFieldMyInfoable = { + myInfo?: { attr: MyInfoAttribute; verified: boolean } +} + // Field filling data type E2eFieldSingleValue = { val: string } type E2eFieldMultiValue = { val: string[] } @@ -67,7 +72,8 @@ export type E2eFieldMetadata = E2eFieldHidden) | (E2ePickFieldMetadata & E2eFieldSingleValue & - E2eFieldHidden) + E2eFieldHidden & + E2eFieldMyInfoable) | (E2ePickFieldMetadata< DecimalFieldBase, 'ValidationOptions' | 'validateByValue' @@ -76,7 +82,8 @@ export type E2eFieldMetadata = E2eFieldHidden) | (E2ePickFieldMetadata & E2eFieldSingleValue & - E2eFieldHidden) + E2eFieldHidden & + E2eFieldMyInfoable) | (E2ePickFieldMetadata< EmailFieldBase, | 'isVerifiable' @@ -95,9 +102,13 @@ export type E2eFieldMetadata = | (E2ePickFieldMetadata & E2eFieldSingleValue & E2eFieldHidden) - | (E2ePickFieldMetadata & // Omit 'isVerfiable', since we can't test that. + | (E2ePickFieldMetadata< + MobileFieldBase, + 'allowIntlNumbers' | 'isVerifiable' + > & E2eFieldSingleValue & - E2eFieldHidden) + E2eFieldHidden & + E2eFieldMyInfoable) | (E2ePickFieldMetadata & E2eFieldSingleValue & E2eFieldHidden) @@ -119,7 +130,8 @@ export type E2eFieldMetadata = 'ValidationOptions' | 'allowPrefill' > & E2eFieldSingleValue & - E2eFieldHidden) + E2eFieldHidden & + E2eFieldMyInfoable) | (E2ePickFieldMetadata & E2eFieldHidden) | (E2ePickFieldMetadata< TableFieldBase, @@ -182,10 +194,12 @@ export const ALL_FIELDS: E2eFieldMetadata[] = [ fieldType: BasicField.Email, isVerifiable: true, autoReplyOptions: { - hasAutoReply: false, - autoReplyMessage: '', - autoReplySender: '', - autoReplySubject: '', + hasAutoReply: true, + autoReplyMessage: + 'Thank you for your submission. We will reach out to you in 3 working days.', + autoReplySender: 'GovTech Singapore', + autoReplySubject: 'Thanks for submitting', + // TODO: Save to pdf doesn't work. includeFormSummary: false, }, hasAllowedEmailDomains: false, @@ -217,6 +231,7 @@ export const ALL_FIELDS: E2eFieldMetadata[] = [ { title: 'Mobile', fieldType: BasicField.Mobile, + isVerifiable: false, // Always has to be false, since we can't really test for mobile verification. allowIntlNumbers: true, // Number should start with +(country code), if allowIntlNumbers. Otherwise, just the 8 digit input. val: '+6598889999', diff --git a/__tests__/e2e/constants/settings.ts b/__tests__/e2e/constants/settings.ts index 0d83fb2c9d..7bfbc51a8f 100644 --- a/__tests__/e2e/constants/settings.ts +++ b/__tests__/e2e/constants/settings.ts @@ -12,5 +12,10 @@ export type E2eSettingsOptions = { closedFormMessage?: string emails?: string[] authType: FormAuthType + /** If authType is SPCP/MyInfo, eserviceId is required. */ esrvcId?: string + /** If authType is non-NIL, nric is required. */ + nric?: string + /** If authType is CP, uen is required */ + uen?: string } diff --git a/__tests__/e2e/email-submission.spec.ts b/__tests__/e2e/email-submission.spec.ts index cb149184fa..01d10ef2fc 100644 --- a/__tests__/e2e/email-submission.spec.ts +++ b/__tests__/e2e/email-submission.spec.ts @@ -1,6 +1,12 @@ import { Page } from '@playwright/test' import mongoose from 'mongoose' -import { BasicField, LogicConditionState, LogicType } from 'shared/types' +import { + BasicField, + FormAuthType, + LogicConditionState, + LogicType, + MyInfoAttribute, +} from 'shared/types' import { IFormModel } from 'src/types' @@ -15,9 +21,10 @@ import { } from './constants' import { createForm, fillForm, submitForm, verifySubmission } from './helpers' import { + createBlankVersion, + createMyInfoField, + createOptionalVersion, deleteDocById, - getBlankVersion, - getOptionalVersion, getSettings, makeModel, makeMongooseFixtures, @@ -55,7 +62,7 @@ test.describe('Email form submission', () => { }) => { // Define const formFields = ALL_FIELDS.map((ff) => - getBlankVersion(getOptionalVersion(ff)), + createBlankVersion(createOptionalVersion(ff)), ) const formLogics = NO_LOGIC const formSettings = getSettings() @@ -98,7 +105,7 @@ test.describe('Email form submission', () => { val: '1-test-att.txt', } as E2eFieldMetadata, { - ...getBlankVersion(getOptionalVersion(baseField)), + ...createBlankVersion(createOptionalVersion(baseField)), title: 'Attachment 1', } as E2eFieldMetadata, { @@ -115,52 +122,72 @@ test.describe('Email form submission', () => { await runTest(page, { formFields, formLogics, formSettings }) }) - // TODO: Uncomment these tests when mockpass has been fixed. - // test('Create and submit email mode form with Singpass authentication', async ({ - // page, - // }) => { - // // Define - // const formFields = ALL_FIELDS - // const formLogics = NO_LOGIC - // const formSettings = getSettings({ - // authType: FormAuthType.SP, - // esrvcId: process.env.SINGPASS_ESRVC_ID, - // }) + test('Create and submit email mode form with Singpass authentication', async ({ + page, + }) => { + // Define + const formFields = ALL_FIELDS + const formLogics = NO_LOGIC + const formSettings = getSettings({ + authType: FormAuthType.SP, + }) - // // Test - // await runTest(page, { formFields, formLogics, formSettings }) - // }) + // Test + await runTest(page, { formFields, formLogics, formSettings }) + }) - // test('Create and submit email mode form with Corppass authentication', async ({ - // page, - // }) => { - // // Define - // const formFields = ALL_FIELDS - // const formLogics = NO_LOGIC - // const formSettings = getSettings({ - // authType: FormAuthType.CP, - // esrvcId: process.env.CORPPASS_ESRVC_ID, - // }) + test('Create and submit email mode form with Corppass authentication', async ({ + page, + }) => { + // Define + const formFields = ALL_FIELDS + const formLogics = NO_LOGIC + const formSettings = getSettings({ + authType: FormAuthType.CP, + }) - // // Test - // await runTest(page, { formFields, formLogics, formSettings }) - // }) + // Test + await runTest(page, { formFields, formLogics, formSettings }) + }) - // test('Create and submit email mode form with SGID authentication', async ({ - // page, - // }) => { - // // Define - // const formFields = ALL_FIELDS - // const formLogics = NO_LOGIC - // const formSettings = getSettings({ - // authType: FormAuthType.SGID, - // }) + test('Create and submit email mode form with SGID authentication', async ({ + page, + }) => { + // Define + const formFields = ALL_FIELDS + const formLogics = NO_LOGIC + const formSettings = getSettings({ + authType: FormAuthType.SGID, + }) - // // Test - // await runTest(page, { formFields, formLogics, formSettings }) - // }) + // Test + await runTest(page, { formFields, formLogics, formSettings }) + }) - // TODO: Add test for MyInfo when mockpass has been fixed. + test('Create and submit email mode form with MyInfo fields', async ({ + page, + }) => { + // Define + const formFields = [ + // Short answer + createMyInfoField(MyInfoAttribute.Name, 'LIM YONG XIANG', true), + // Dropdown + createMyInfoField(MyInfoAttribute.Sex, 'MALE', true), + // Date + createMyInfoField(MyInfoAttribute.DateOfBirth, '06/10/1980', true), + // Mobile + createMyInfoField(MyInfoAttribute.MobileNo, '97399245', false), + // Unverified + createMyInfoField(MyInfoAttribute.WorkpassStatus, 'Live', false), + ] + const formLogics = NO_LOGIC + const formSettings = getSettings({ + authType: FormAuthType.MyInfo, + }) + + // Test + await runTest(page, { formFields, formLogics, formSettings }) + }) test('Create and submit email mode form with all fields shown by logic', async ({ page, diff --git a/__tests__/e2e/helpers/createForm.ts b/__tests__/e2e/helpers/createForm.ts index d491652c6e..a2b0392586 100644 --- a/__tests__/e2e/helpers/createForm.ts +++ b/__tests__/e2e/helpers/createForm.ts @@ -1,7 +1,10 @@ import { Page } from '@playwright/test' import cuid from 'cuid' import { format } from 'date-fns' -import { BASICFIELD_TO_DRAWER_META } from 'frontend/src/features/admin-form/create/constants' +import { + BASICFIELD_TO_DRAWER_META, + MYINFO_FIELD_TO_DRAWER_META, +} from 'frontend/src/features/admin-form/create/constants' import { BasicField, DateSelectedValidation, @@ -9,6 +12,7 @@ import { FormStatus, LogicConditionState, LogicType, + MyInfoAttribute, } from 'shared/types' import { IFormModel, IFormSchema } from 'src/types' @@ -27,6 +31,7 @@ import { expectToast, fillDropdown, fillMultiDropdown, + getMyInfoAttribute, getTitleWithQuestionNumber, } from '../utils' @@ -41,9 +46,8 @@ export const createForm = async ( { formFields, formLogics, formSettings }: E2eForm, ): Promise => { const formId = await addForm(page) - await addFields(page, formFields) - await addLogics(page, { formFields, formLogics }) await addSettings(page, { formId, formSettings }) + await addFieldsAndLogic(page, formFields, formLogics) const form = await Form.findById(formId) @@ -89,328 +93,7 @@ const addForm = async (page: Page): Promise => { return formId! } -/** Adds all prescribed fields to the form. - * Precondition: page must be currently on the admin builder page for the form. - * @param {Page} page Playwright page - * @param {E2eFieldMetadata[]} formFields the form fields to create - */ -const addFields = async ( - page: Page, - formFields: E2eFieldMetadata[], -): Promise => { - await expect(page).toHaveURL(new RegExp(`${ADMIN_FORM_PAGE_PREFIX}/.*`, 'i')) - - await page.getByRole('button', { name: 'Add fields' }).click() - - for (const field of formFields) { - const label = BASICFIELD_TO_DRAWER_META[field.fieldType].label - const isNonInput = NON_INPUT_FIELD_TYPES.includes(field.fieldType) - - // Get button with exact fieldtype label text - await page.getByRole('button').locator(`text="${label}"`).click() - - // Enter title for input fields and Section - if (isNonInput) { - if (field.fieldType === BasicField.Section) { - await page.getByLabel('Section heading').fill(field.title) - } - // Images and Statements don't have titles - } else { - await page.getByLabel('Question').fill(field.title) - } - - // Toggle required for input fields except Table field (required toggled for individual columns) - if ( - !isNonInput && - field.fieldType !== BasicField.Table && - field.required === false - ) { - await page.getByText('Required').click() - } - - // Enter field description. - if (field.description) { - if (field.fieldType === BasicField.Statement) { - await page.getByLabel('Paragraph').fill(field.description) - } else { - await page.getByLabel('Description').fill(field.description) - } - } - - // Handle the rest of the individual fields. - switch (field.fieldType) { - case BasicField.Attachment: - await fillDropdown( - page, - page.getByRole('textbox', { - name: 'Maximum size of individual attachment', - }), - `${field.attachmentSize} MB`, - ) - break - case BasicField.Checkbox: - if (field.validateByValue) { - await page.getByLabel('Selection limits').click() - if (field.ValidationOptions.customMin) { - await page - .getByPlaceholder('Minimum') - .nth(1) - .fill(field.ValidationOptions.customMin.toString()) - } - if (field.ValidationOptions.customMax) { - await page - .getByPlaceholder('Maximimum') - .nth(1) - .fill(field.ValidationOptions.customMax.toString()) - } - } - // Fall through to set "Others" and "Options". - case BasicField.Radio: - if (field.othersRadioButton) { - await page.getByText('Others').first().click() - } - // Fall through to set "Options". - case BasicField.Dropdown: - await page.getByLabel('Options').fill(field.fieldOptions.join('\n')) - break - case BasicField.Date: - { - if (!field.dateValidation.selectedDateValidation) break - await page.getByRole('combobox').first().click() - await page - .getByText(field.dateValidation.selectedDateValidation) - .click() - if ( - field.dateValidation.selectedDateValidation === - DateSelectedValidation.Custom - ) { - if (field.dateValidation.customMinDate) { - await page - .locator('[name="dateValidation.customMinDate"]') - .fill(format(field.dateValidation.customMinDate, 'dd/MM/yyyy')) - } - if (field.dateValidation.customMaxDate) { - await page - .locator('[name="dateValidation.customMaxDate"]') - .fill(format(field.dateValidation.customMaxDate, 'dd/MM/yyyy')) - } - } - } - break - case BasicField.Decimal: - if (field.validateByValue) { - await page.getByText('Number validation').click() - if (field.ValidationOptions.customMin) { - await page - .getByPlaceholder('Minimum value') - .nth(1) - .fill(field.ValidationOptions.customMin.toString()) - } - if (field.ValidationOptions.customMax) { - await page - .getByPlaceholder('Maximum value') - .nth(1) - .fill(field.ValidationOptions.customMax.toString()) - } - } - break - case BasicField.Email: - if (field.isVerifiable) { - await page.locator('label:has-text("OTP verification")').click() - if (field.hasAllowedEmailDomains) { - await page.getByText('Restrict email domains').click() - await page - .getByLabel('Domains allowed') - .fill(field.allowedEmailDomains.join('\n')) - } - } - if (field.autoReplyOptions.hasAutoReply) { - await page.getByText('Email confirmation').click() - await page - .getByLabel('Subject') - .fill(field.autoReplyOptions.autoReplySubject) - await page - .getByLabel('Sender name') - .fill(field.autoReplyOptions.autoReplySender) - await page - .getByLabel('Content') - .fill(field.autoReplyOptions.autoReplyMessage) - if (field.autoReplyOptions.includeFormSummary) { - await page.getByText('Include PDF response').click() - } - } - break - case BasicField.Image: - await page.setInputFiles('input[type="file"]', field.path) - break - case BasicField.LongText: - case BasicField.Number: - case BasicField.ShortText: - if (field.ValidationOptions.selectedValidation) { - // Select from dropdown - await page - .locator(`[id="ValidationOptions.selectedValidation"]`) - .fill(field.ValidationOptions.selectedValidation) - await page - .getByRole('option', { - name: field.ValidationOptions.selectedValidation, - }) - .click() - if (field.ValidationOptions.customVal) { - await page - .getByPlaceholder('Number of characters') - .nth(1) - .fill(field.ValidationOptions.customVal.toString()) - } - } - break - case BasicField.Mobile: - if (field.allowIntlNumbers) { - await page.getByText('Allow international numbers').click() - } - break - case BasicField.Rating: - await fillDropdown( - page, - page.getByRole('textbox', { name: 'Number of steps' }), - String(field.ratingOptions.steps), - ) - await fillDropdown( - page, - page.getByRole('textbox', { name: 'Shape' }), - field.ratingOptions.shape, - ) - break - case BasicField.Table: - await page.getByLabel('Minimum rows').fill(String(field.minimumRows)) - if (field.addMoreRows) { - await page.getByText('Allow respondent to add more rows').click() - if (field.maximumRows) { - await page - .getByLabel('Maximum rows allowed') - .fill(String(field.maximumRows)) - } - } - // First table option - for (let i = 0; i < field.columns.length; i++) { - const col = field.columns[i] - if (i !== 0) { - await page.getByRole('button', { name: 'Add column' }).click() - } - await page.getByLabel(`Column ${i + 1}`).fill(col.title) - await page.getByLabel('Column type').nth(i).click() - await page - .getByRole('option', { - name: BASICFIELD_TO_DRAWER_META[col.columnType].label, - }) - .click() - if (!col.required) { - await page.getByText('Required').nth(i).click() - } - if (col.columnType === BasicField.Dropdown) { - await page - .locator(`[id="columns\\.${i}\\.fieldOptions"]`) - .fill(col.fieldOptions.join('\n')) - } - } - break - } - - await page.getByRole('button', { name: 'Create field' }).click() - await expectToast(page, /the .* was created/i) - } - - await page.reload() -} - /** Goes to settings page and adds settings, and toggle form to be open. - * Precondition: must be on the admin builder page with no dirty fields. - * @param {Page} page Playwright page - * @param {E2eFieldMetadata[]} formFields the form fields used to create the form - * @param {E2eLogic[]} formLogics the form logic to create - */ -const addLogics = async ( - page: Page, - { - formFields, - formLogics, - }: { formFields: E2eFieldMetadata[]; formLogics: E2eLogic[] }, -) => { - if (formLogics.length === 0) return - - // Navigate to the logic tab. - await page.getByRole('button', { name: 'Add logic' }).click() - - for (const logic of formLogics) { - // The 0th button called 'Add logic' is the sidebar tab nav button - await page.getByRole('button', { name: 'Add logic' }).nth(1).click() - - // Add logic conditions - for (let i = 0; i < logic.conditions.length; i++) { - const { field, state, value } = logic.conditions[i] - - if (i) await page.getByRole('button', { name: 'Add condition' }).click() - - await fillDropdown( - page, - page.locator(`id=conditions.${i}.field`), - getTitleWithQuestionNumber(formFields, field), - ) - await fillDropdown( - page, - page.locator(`id=conditions.${i}.state`), - // Frontend removes leading 'is' from the condition name for rendering, so replicate that behavior. - state.replace(/^is\s/i, ''), - ) - const valueInput = page.locator(`id=conditions.${i}.value`) - switch (state) { - case LogicConditionState.Either: - await fillMultiDropdown(page, valueInput, value) - break - default: - switch (formFields[field].fieldType) { - case BasicField.Dropdown: - case BasicField.Radio: - case BasicField.Rating: - case BasicField.YesNo: - await fillDropdown(page, valueInput, value) - break - default: - await valueInput.fill(value) - break - } - break - } - } - - const logicTypeInput = page.locator('id=logicType') - switch (logic.logicType) { - case LogicType.ShowFields: - await fillDropdown(page, logicTypeInput, 'Show field(s)') - await fillMultiDropdown( - page, - page.locator('id=show'), - logic.show.map((n) => getTitleWithQuestionNumber(formFields, n)), - ) - break - case LogicType.PreventSubmit: - await fillDropdown(page, logicTypeInput, 'Disable submission') - await page.locator('id=preventSubmitMessage').fill(logic.message) - break - } - - // Save - await page.getByText('Add logic').click() - - // Check toast - await expectToast(page, /the logic was successfully created/i) - } - - await page.reload() -} - -/** Goes to settings page and adds settings, and toggle form to be open. - * Precondition: must be on the admin builder page with no dirty fields. * @param {Page} page Playwright page * @param {string} formId the formId * @param {E2eSettingsOptions} formSettings the form settings to update @@ -422,9 +105,6 @@ const addSettings = async ( formSettings, }: { formId: string; formSettings: E2eSettingsOptions }, ): Promise => { - // Check precondition - await expect(page).toHaveURL(`${ADMIN_FORM_PAGE_PREFIX}/${formId}`) - await page.getByText('Settings').click() await expect(page).toHaveURL(`${ADMIN_FORM_PAGE_PREFIX}/${formId}/settings`) @@ -553,7 +233,7 @@ const addAuthSettings = async ( formSettings.authType === FormAuthType.SP ? '' : formSettings.authType === FormAuthType.SGID - ? ' App-only Login (Free)' + ? ' App-only Login' : formSettings.authType === FormAuthType.MyInfo ? ' with MyInfo' : ' (Corporate)' @@ -561,22 +241,22 @@ const addAuthSettings = async ( await page .locator('label', { has: page.getByRole('radio', { name }) }) - .click() + .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, /form authentication successfully updated/i) - if (formSettings.esrvcId) { - switch (formSettings.authType) { - case FormAuthType.SP: - case FormAuthType.CP: - case FormAuthType.MyInfo: - await page.locator(`id=esrvcId`).fill(formSettings.esrvcId) - await page.keyboard.press('Enter') - await expectToast(page, /e-service id successfully updated/i) - break - default: - break - } + switch (formSettings.authType) { + case FormAuthType.SP: + case FormAuthType.CP: + case FormAuthType.MyInfo: + if (!formSettings.esrvcId) throw new Error('No esrvcid provided!') + await page.locator(`id=esrvcId`).fill(formSettings.esrvcId) + await page.keyboard.press('Enter') + await expectToast(page, /e-service id successfully updated/i) + break + default: + break } } @@ -614,3 +294,370 @@ const addCollaborators = async ( await page.getByRole('button', { name: 'Close' }).click() } + +const addFieldsAndLogic = async ( + page: Page, + formFields: E2eFieldMetadata[], + formLogics: E2eLogic[], +) => { + await page.getByText('Create').click() + await expect(page).toHaveURL(new RegExp(`${ADMIN_FORM_PAGE_PREFIX}/.*`, 'i')) + + await addFields(page, formFields) + await addLogics(page, formFields, formLogics) +} + +/** Adds all prescribed fields to the form. + * Precondition: is already on the Create page. + * @param {Page} page Playwright page + * @param {E2eFieldMetadata[]} formFields the form fields to create + */ +const addFields = async ( + page: Page, + formFields: E2eFieldMetadata[], +): Promise => { + // Navigate to the fields tab. + await page.getByRole('button', { name: 'Add fields' }).click() + + for (const field of formFields) { + const myInfoAttr = getMyInfoAttribute(field) + + myInfoAttr + ? await addMyInfoField(page, field, myInfoAttr) + : await addBasicField(page, field) + + await page.getByRole('button', { name: 'Create field' }).click() + await expectToast(page, /the .* was created/i) + } + + await page.reload() +} + +/** Adds all prescribed basic fields to the form. + * Precondition: page must be currently on the admin builder page for the form. + * @param {Page} page Playwright page + * @param {E2eFieldMetadata} field the form field to create + */ +const addBasicField = async ( + page: Page, + field: E2eFieldMetadata, +): Promise => { + await page.getByRole('tab', { name: 'Basic' }).click() + + const label = BASICFIELD_TO_DRAWER_META[field.fieldType].label + const isNonInput = NON_INPUT_FIELD_TYPES.includes(field.fieldType) + + // Get button with exact fieldtype label text + await page.getByRole('button', { name: label, exact: true }).click() + + // Enter title for input fields and Section + if (isNonInput) { + if (field.fieldType === BasicField.Section) { + await page.getByLabel('Section heading').fill(field.title) + } + // Images and Statements don't have titles + } else { + await page.getByLabel('Question').fill(field.title) + } + + // Toggle required for input fields except Table field (required toggled for individual columns) + if ( + !isNonInput && + field.fieldType !== BasicField.Table && + field.required === false + ) { + await page.getByText('Required').click() + } + + // Enter field description. + if (field.description) { + if (field.fieldType === BasicField.Statement) { + await page.getByLabel('Paragraph').fill(field.description) + } else { + await page.getByLabel('Description').fill(field.description) + } + } + + // Handle the rest of the individual fields. + switch (field.fieldType) { + case BasicField.Attachment: + await fillDropdown( + page, + page.getByRole('textbox', { + name: 'Maximum size of individual attachment', + }), + `${field.attachmentSize} MB`, + ) + break + case BasicField.Checkbox: + if (field.validateByValue) { + await page.getByLabel('Selection limits').click() + if (field.ValidationOptions.customMin) { + await page + .getByPlaceholder('Minimum') + .nth(1) + .fill(field.ValidationOptions.customMin.toString()) + } + if (field.ValidationOptions.customMax) { + await page + .getByPlaceholder('Maximimum') + .nth(1) + .fill(field.ValidationOptions.customMax.toString()) + } + } + // Fall through to set "Others" and "Options". + case BasicField.Radio: + if (field.othersRadioButton) { + await page.getByText('Others').first().click() + } + // Fall through to set "Options". + case BasicField.Dropdown: + await page.getByLabel('Options').fill(field.fieldOptions.join('\n')) + break + case BasicField.Date: + { + if (!field.dateValidation.selectedDateValidation) break + await page.getByRole('combobox').first().click() + await page + .getByText(field.dateValidation.selectedDateValidation) + .click() + if ( + field.dateValidation.selectedDateValidation === + DateSelectedValidation.Custom + ) { + if (field.dateValidation.customMinDate) { + await page + .locator('[name="dateValidation.customMinDate"]') + .fill(format(field.dateValidation.customMinDate, 'dd/MM/yyyy')) + } + if (field.dateValidation.customMaxDate) { + await page + .locator('[name="dateValidation.customMaxDate"]') + .fill(format(field.dateValidation.customMaxDate, 'dd/MM/yyyy')) + } + } + } + break + case BasicField.Decimal: + if (field.validateByValue) { + await page.getByText('Number validation').click() + if (field.ValidationOptions.customMin) { + await page + .getByPlaceholder('Minimum value') + .nth(1) + .fill(field.ValidationOptions.customMin.toString()) + } + if (field.ValidationOptions.customMax) { + await page + .getByPlaceholder('Maximum value') + .nth(1) + .fill(field.ValidationOptions.customMax.toString()) + } + } + break + case BasicField.Email: + if (field.isVerifiable) { + await page.locator('label:has-text("OTP verification")').click() + if (field.hasAllowedEmailDomains) { + await page.getByText('Restrict email domains').click() + await page + .getByLabel('Domains allowed') + .fill(field.allowedEmailDomains.join('\n')) + } + } + if (field.autoReplyOptions.hasAutoReply) { + await page.getByText('Email confirmation').click() + await page + .getByLabel('Subject') + .fill(field.autoReplyOptions.autoReplySubject) + await page + .getByLabel('Sender name') + .fill(field.autoReplyOptions.autoReplySender) + await page + .getByLabel('Content') + .fill(field.autoReplyOptions.autoReplyMessage) + // TODO: Print to pdf doesn't work. + // if (field.autoReplyOptions.includeFormSummary) { + // await page.getByText('Include PDF response').click() + // } + } + break + case BasicField.Image: + await page.setInputFiles('input[type="file"]', field.path) + break + case BasicField.LongText: + case BasicField.Number: + case BasicField.ShortText: + if (field.ValidationOptions.selectedValidation) { + // Select from dropdown + await page + .locator(`[id="ValidationOptions.selectedValidation"]`) + .fill(field.ValidationOptions.selectedValidation) + await page + .getByRole('option', { + name: field.ValidationOptions.selectedValidation, + }) + .click() + if (field.ValidationOptions.customVal) { + await page + .getByPlaceholder('Number of characters') + .nth(1) + .fill(field.ValidationOptions.customVal.toString()) + } + } + break + case BasicField.Mobile: + if (field.allowIntlNumbers) { + await page.getByText('Allow international numbers').click() + } + break + case BasicField.Rating: + await fillDropdown( + page, + page.getByRole('textbox', { name: 'Number of steps' }), + String(field.ratingOptions.steps), + ) + await fillDropdown( + page, + page.getByRole('textbox', { name: 'Shape' }), + field.ratingOptions.shape, + ) + break + case BasicField.Table: + await page.getByLabel('Minimum rows').fill(String(field.minimumRows)) + if (field.addMoreRows) { + await page.getByText('Allow respondent to add more rows').click() + if (field.maximumRows) { + await page + .getByLabel('Maximum rows allowed') + .fill(String(field.maximumRows)) + } + } + // First table option + for (let i = 0; i < field.columns.length; i++) { + const col = field.columns[i] + if (i !== 0) { + await page.getByRole('button', { name: 'Add column' }).click() + } + await page.getByLabel(`Column ${i + 1}`).fill(col.title) + await page.getByLabel('Column type').nth(i).click() + await page + .getByRole('option', { + name: BASICFIELD_TO_DRAWER_META[col.columnType].label, + }) + .click() + if (!col.required) { + await page.getByText('Required').nth(i).click() + } + if (col.columnType === BasicField.Dropdown) { + await page + .locator(`[id="columns\\.${i}\\.fieldOptions"]`) + .fill(col.fieldOptions.join('\n')) + } + } + break + } +} + +/** Adds all prescribed MyInfo fields to the form. + * Precondition: page must be currently on the admin builder page for the form. + * @param {Page} page Playwright page + * @param {E2eFieldMetadata} field the form field to create + * @param {MyInfoAttribute} attr the MyInfo attribute to be added + */ +const addMyInfoField = async ( + page: Page, + field: E2eFieldMetadata, + attr: MyInfoAttribute, +): Promise => { + const label = MYINFO_FIELD_TO_DRAWER_META[attr].label + + await page.getByRole('tab', { name: 'MyInfo' }).click() + await page.getByRole('button', { name: label, exact: true }).click() +} + +/** Goes to settings page and adds settings, and toggle form to be open. + * Precondition: is already on the Create page. + * @param {Page} page Playwright page + * @param {E2eFieldMetadata[]} formFields the form fields used to create the form + * @param {E2eLogic[]} formLogics the form logic to create + */ +const addLogics = async ( + page: Page, + formFields: E2eFieldMetadata[], + formLogics: E2eLogic[], +) => { + if (formLogics.length === 0) return + + // Navigate to the logic tab. + await page.getByRole('button', { name: 'Add logic' }).click() + + for (const logic of formLogics) { + // The 0th button called 'Add logic' is the sidebar tab nav button + await page.getByRole('button', { name: 'Add logic' }).nth(1).click() + + // Add logic conditions + for (let i = 0; i < logic.conditions.length; i++) { + const { field, state, value } = logic.conditions[i] + + if (i > 0) { + await page.getByRole('button', { name: 'Add condition' }).click() + } + + await fillDropdown( + page, + page.locator(`id=conditions.${i}.field`), + getTitleWithQuestionNumber(formFields, field), + ) + await fillDropdown( + page, + page.locator(`id=conditions.${i}.state`), + // Frontend removes leading 'is' from the condition name for rendering, so replicate that behavior. + state.replace(/^is\s/i, ''), + ) + const valueInput = page.locator(`id=conditions.${i}.value`) + switch (state) { + case LogicConditionState.Either: + await fillMultiDropdown(page, valueInput, value) + break + default: + switch (formFields[field].fieldType) { + case BasicField.Dropdown: + case BasicField.Radio: + case BasicField.Rating: + case BasicField.YesNo: + await fillDropdown(page, valueInput, value) + break + default: + await valueInput.fill(value) + break + } + break + } + } + + const logicTypeInput = page.locator('id=logicType') + switch (logic.logicType) { + case LogicType.ShowFields: + await fillDropdown(page, logicTypeInput, 'Show field(s)') + await fillMultiDropdown( + page, + page.locator('id=show'), + logic.show.map((n) => getTitleWithQuestionNumber(formFields, n)), + ) + break + case LogicType.PreventSubmit: + await fillDropdown(page, logicTypeInput, 'Disable submission') + await page.locator('id=preventSubmitMessage').fill(logic.message) + break + } + + // Save + await page.getByText('Add logic').click() + + // Check toast + await expectToast(page, /the logic was successfully created/i) + } + + await page.reload() +} diff --git a/__tests__/e2e/helpers/submitForm.ts b/__tests__/e2e/helpers/submitForm.ts index c256d51d2c..053b1043f3 100644 --- a/__tests__/e2e/helpers/submitForm.ts +++ b/__tests__/e2e/helpers/submitForm.ts @@ -10,7 +10,7 @@ import { NON_INPUT_FIELD_TYPES, PUBLIC_FORM_PAGE_PREFIX, } from '../constants' -import { extractOtp, fillDropdown } from '../utils' +import { extractOtp, fillDropdown, isMyInfoableFieldType } from '../utils' export type SubmitFormProps = { form: IFormSchema @@ -75,6 +75,8 @@ const authForm = async ( ): Promise => { if (formSettings.authType === FormAuthType.NIL) return + if (!formSettings.nric) throw new Error('No nric provided!') + // Check that the submit button is not visible. await expect( page.locator( @@ -88,13 +90,28 @@ const authForm = async ( formSettings.authType === FormAuthType.CP ? ' (Corporate)' : formSettings.authType === FormAuthType.SGID - ? ' App-only Login' + ? ' app' : '' }`, }) .click() - // TODO: Actually login! Can't do yet because no mockpass. + // Mockpass talks to FormSG to login here. + if (formSettings.authType === FormAuthType.MyInfo) { + // Click the consent button to share info with FormSG + await page.getByRole('button', { name: 'Submit' }).click() + } + + // Redirected to the form fields page. Verify log out button is visible with + // the correct uin, to verify that we have been logged in correctly. + let uin = formSettings.nric + if (formSettings.authType === FormAuthType.CP) { + if (!formSettings.uen) throw new Error('No uen provided!') + uin = formSettings.uen + } + + const logoutButton = page.getByRole('button', { name: 'Log out' }) + await expect(logoutButton).toContainText(uin) } /** @@ -112,7 +129,8 @@ const fillFields = async ( formFields: E2eFieldMetadata[] }, ): Promise => { - // Check that the submit button is visible. + // Check that the submit button is visible - this guarantees that we are on + // the form fields page. await expect( page.locator( 'button:text-matches("(submit now)|(submission disabled)", "gi")', @@ -135,8 +153,7 @@ const fillFields = async ( }) // Fill form fields - for (let i = 0; i < fieldMetasWithIds.length; i++) { - const field = fieldMetasWithIds[i] + for (const field of fieldMetasWithIds) { // Ignore fields that are non-input. if (NON_INPUT_FIELD_TYPES.includes(field.fieldType)) continue @@ -155,6 +172,20 @@ const fillFields = async ( const input = page.locator(`id=${field._id}`) + if (isMyInfoableFieldType(field) && field.myInfo) { + // If the field is MyInfo, and the input is disabled or already contains + // a value, skip filling it. The value will be checked in the submission. + const inputIsDisabled = await input.isDisabled() + if (inputIsDisabled !== field.myInfo.verified) { + // input should be disabled iff myInfo field data is verified + throw new Error( + 'MyInfo field verified status does not match field definition.', + ) + } + const inputValue = await input.inputValue() + if (inputIsDisabled || inputValue) continue + } + switch (field.fieldType) { case BasicField.ShortText: case BasicField.LongText: diff --git a/__tests__/e2e/helpers/verifySubmission.ts b/__tests__/e2e/helpers/verifySubmission.ts index 0921090521..d754893d03 100644 --- a/__tests__/e2e/helpers/verifySubmission.ts +++ b/__tests__/e2e/helpers/verifySubmission.ts @@ -1,9 +1,9 @@ import { expect, Page } from '@playwright/test' import { format, parse } from 'date-fns' import { readFileSync } from 'fs' -import { BasicField, FormResponseMode } from 'shared/types' +import { BasicField, FormAuthType, FormResponseMode } from 'shared/types' -import { IFormSchema } from 'src/types' +import { IFormSchema, SgidFieldTitle, SPCPFieldTitle } from 'src/types' import { ADMIN_EMAIL, @@ -12,7 +12,14 @@ import { E2eFieldMetadata, E2eSettingsOptions, } from '../constants' -import { getSubmission } from '../utils' +import { + getAutoreplyEmail, + getSubmission, + isMyInfoableFieldType, + isVerifiableFieldType, +} from '../utils' + +const MAIL_FROM = 'donotreply@mail.form.gov.sg' export type VerifySubmissionProps = { form: IFormSchema @@ -30,6 +37,49 @@ export type VerifySubmissionProps = { * @param {string} responseId the response id of the submission to be verified */ export const verifySubmission = async ( + page: Page, + verifySubmissionProps: VerifySubmissionProps, +): Promise => { + const { form, formFields, responseId } = verifySubmissionProps + + // Verify the submission content + switch (form.responseMode) { + case FormResponseMode.Email: + await verifyEmailSubmission(page, verifySubmissionProps) + break + case FormResponseMode.Encrypt: + // TODO: add verifier for Encrypt submissions + break + } + + // Verify that post-submission actions were taken + + // Email autoreplies should be sent + for (const field of formFields) { + if (field.fieldType !== BasicField.Email) continue + if (field.val && field.autoReplyOptions.hasAutoReply) { + const { autoReplySender, autoReplySubject, autoReplyMessage } = + field.autoReplyOptions + + const email = await getAutoreplyEmail(responseId, field.val) + + // Check content of autoreply emails + expect(email.subject === autoReplySubject).toBeTruthy() + expect(email.from[0].name === autoReplySender).toBeTruthy() + expect(email.html.includes(autoReplyMessage)).toBeTruthy() + } + } +} + +/** + * Get the submission email from maildev, and ensure that the contents and attachments + * match what is submitted. + * @param {Page} page the Playwright page + * @param {IFormSchema} form the form from the database + * @param {E2eFieldMetadata[]} formFields the field metadata used to create and fill the form + * @param {string} responseId the response id of the submission to be verified + */ +export const verifyEmailSubmission = async ( page: Page, { form, formFields, formSettings, responseId }: VerifySubmissionProps, ): Promise => { @@ -37,7 +87,7 @@ export const verifySubmission = async ( const submission = await getSubmission(form.title, responseId) // Verify email metadata - expect(submission.from).toContain('donotreply@mail.form.gov.sg') + expect(submission.from).toContain(MAIL_FROM) const emails = formSettings.emails ?? [] emails.unshift(ADMIN_EMAIL) @@ -48,17 +98,37 @@ export const verifySubmission = async ( // Subject need not be verified, since we got the email via the subject. + const expectSubmissionContains = expectContains(submission.html) + // Verify form responses in email for (const field of formFields) { const responseArray = getResponseArray(field, FormResponseMode.Email) if (!responseArray) continue - const contained = [ - getResponseTitle(field, false, FormResponseMode.Email), + expectSubmissionContains([ + getResponseTitle(field, FormResponseMode.Email), ...responseArray, - ] - expectContains(submission.html, contained) + ]) expectAttachment(field, submission.attachments) } + + if (formSettings.authType !== FormAuthType.NIL) { + // Verify that form auth correctly returned NRIC (SPCP/SGID) and UEN (CP) + if (!formSettings.nric) throw new Error('No nric provided!') + switch (formSettings.authType) { + case FormAuthType.SP: + case FormAuthType.MyInfo: + expectSubmissionContains([SPCPFieldTitle.SpNric, formSettings.nric]) + break + case FormAuthType.CP: + expectSubmissionContains([SPCPFieldTitle.CpUid, formSettings.nric]) + if (!formSettings.uen) throw new Error('No uen provided!') + expectSubmissionContains([SPCPFieldTitle.CpUen, formSettings.uen]) + break + case FormAuthType.SGID: + expectSubmissionContains([SgidFieldTitle.SgidNric, formSettings.nric]) + break + } + } } // Utility for getting responses for tables @@ -91,33 +161,36 @@ const TABLE_HANDLER = { /** * Gets the title of a field as it is displayed in a response. * @param {E2eFieldMetadata} field field used to create and fill form - * @param {boolean} isInJson Whether the title is within the JSON data for email submissions * @param {FormResponseMode} formMode form response mode * @returns {string} the field title displayed in the response. */ const getResponseTitle = ( field: E2eFieldMetadata, - isInJson: boolean, formMode: FormResponseMode, ): string => { - if (field.fieldType === 'table') { + // MyInfo fields + if (isMyInfoableFieldType(field) && field.myInfo) { + if (field.myInfo.verified) return `[MyInfo] ${field.title}` + return field.title + } + + // Basic fields + if (field.fieldType === BasicField.Table) { + // Delegate the work to the table handler return TABLE_HANDLER.getName(field, formMode) - } else if (field.fieldType === 'attachment') { - let title = field.title - if (formMode === 'email') { - title = `[attachment] ${title}` - } - return title - } else { - if (isInJson) { - if (field.title.startsWith('[verified] ')) { - return field.title.substring(11) - } else if (field.title.startsWith('[MyInfo] ')) { - return field.title.substring(9) - } + } + if (field.fieldType === BasicField.Attachment) { + switch (formMode) { + case FormResponseMode.Email: + return `[attachment] ${field.title}` + case FormResponseMode.Encrypt: + return field.title } - return field.title } + if (isVerifiableFieldType(field)) { + if (field.isVerifiable && field.val) return `[verified] ${field.title}` + } + return field.title } /** @@ -181,7 +254,7 @@ const getResponseArray = ( * @param {string} container string in which to search * @param {string[]} containedArray Array of values to search for */ -const expectContains = (container: string, containedArray: string[]) => { +const expectContains = (container: string) => (containedArray: string[]) => { for (const contained of containedArray) { expect(container).toContain(contained) } diff --git a/__tests__/e2e/login.spec.ts b/__tests__/e2e/login.spec.ts index 7aed112802..0d6e52c47b 100644 --- a/__tests__/e2e/login.spec.ts +++ b/__tests__/e2e/login.spec.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-non-null-assertion */ import { expect, test } from '@playwright/test' import cuid from 'cuid' diff --git a/__tests__/e2e/setup/.test-env b/__tests__/e2e/setup/.test-env index 9650c1072b..61d70c685b 100644 --- a/__tests__/e2e/setup/.test-env +++ b/__tests__/e2e/setup/.test-env @@ -1,19 +1,17 @@ MOCKPASS_PORT=5156 -#Needed for MyInfo -SINGPASS_ESRVC_ID=Test-eServiceId-Sp -SP_OIDC_NDI_DISCOVERY_ENDPOINT=https://stg-id.singpass.gov.sg/.well-known/openid-configuration -SP_OIDC_NDI_JWKS_ENDPOINT=https://stg-id.singpass.gov.sg/.well-known/keys +SP_OIDC_NDI_DISCOVERY_ENDPOINT=http://localhost:5156/singpass/v2/.well-known/openid-configuration +SP_OIDC_NDI_JWKS_ENDPOINT=http://localhost:5156/singpass/v2/.well-known/keys SP_OIDC_RP_CLIENT_ID=rpClientId -SP_OIDC_RP_REDIRECT_URL=https://staging.form.gov.sg/api/v3/singpass/login -SP_OIDC_RP_JWKS_PUBLIC_PATH=./tests/certs/test_sp_rp_public_jwks.json -SP_OIDC_RP_JWKS_SECRET_PATH=./tests/certs/test_sp_rp_secret_jwks.json -CP_OIDC_NDI_DISCOVERY_ENDPOINT=https://stg-id.corppass.gov.sg/.well-known/openid-configuration -CP_OIDC_NDI_JWKS_ENDPOINT=https://stg-id.corppass.gov.sg/.well-known/keys +SP_OIDC_RP_REDIRECT_URL=http://localhost:5000/api/v3/singpass/login +SP_OIDC_RP_JWKS_PUBLIC_PATH=./__tests__/e2e/setup/certs/test_sp_rp_public_jwks.json +SP_OIDC_RP_JWKS_SECRET_PATH=./__tests__/e2e/setup/certs/test_sp_rp_secret_jwks.json +CP_OIDC_NDI_DISCOVERY_ENDPOINT=http://localhost:5156/corppass/v2/.well-known/openid-configuration +CP_OIDC_NDI_JWKS_ENDPOINT=http://localhost:5156/corppass/v2/.well-known/keys CP_OIDC_RP_CLIENT_ID=rpClientId -CP_OIDC_RP_REDIRECT_URL=https://staging.form.gov.sg/api/v3/corppass/login -CP_OIDC_RP_JWKS_PUBLIC_PATH=./tests/certs/test_cp_rp_public_jwks.json -CP_OIDC_RP_JWKS_SECRET_PATH=./tests/certs/test_cp_rp_secret_jwks.json +CP_OIDC_RP_REDIRECT_URL=http://localhost:5000/api/v3/corppass/login +CP_OIDC_RP_JWKS_PUBLIC_PATH=./__tests__/e2e/setup/certs/test_cp_rp_public_jwks.json +CP_OIDC_RP_JWKS_SECRET_PATH=./__tests__/e2e/setup/certs/test_cp_rp_secret_jwks.json MYINFO_CLIENT_CONFIG=dev MYINFO_FORMSG_KEY_PATH=./node_modules/@opengovsg/mockpass/static/certs/key.pem @@ -21,25 +19,18 @@ MYINFO_CERT_PATH=./node_modules/@opengovsg/mockpass/static/certs/spcp.crt MYINFO_CLIENT_ID=mockClientId MYINFO_CLIENT_SECRET=mockClientSecret MYINFO_JWT_SECRET=mockJwtSecret +SINGPASS_ESRVC_ID=Test-eServiceId-Sp # Needed for MyInfo -CP_FORMSG_KEY_PATH=./node_modules/@opengovsg/mockpass/static/certs/key.pem -CP_FORMSG_CERT_PATH=./node_modules/@opengovsg/mockpass/static/certs/server.crt -CP_IDP_CERT_PATH=./node_modules/@opengovsg/mockpass/static/certs/spcp.crt - -CORPPASS_ASSERT_ENDPOINT=http://localhost:5000/corppass/login -CORPPASS_IDP_LOGIN_URL=http://localhost:5156/corppass/logininitial -CORPPASS_IDP_ENDPOINT=http://localhost:5156/corppass/soap -CORPPASS_PARTNER_ENTITY_ID=https://staging.form.gov.sg/corppass -CORPPASS_ESRVC_ID=FORMSG-CP-TEST -CORPPASS_IDP_ID=https://saml.corppass.gov.sg/FIM/sps/CorpIDPFed/saml20 +SGID_HOSTNAME=http://localhost:5156/sgid +SGID_CLIENT_ID=sgidclientid +SGID_CLIENT_SECRET=sgidclientsecret +SGID_REDIRECT_URI=http://localhost:5000/sgid/login +SGID_PRIVATE_KEY=./node_modules/@opengovsg/mockpass/static/certs/key.pem +SGID_PUBLIC_KEY=./node_modules/@opengovsg/mockpass/static/certs/server.crt -SHOW_LOGIN_PAGE=true IS_SP_MAINTENANCE=Date/Time-SP IS_CP_MAINTENANCE=Date/Time-CP -MOCKPASS_NRIC=S6005038D -MOCKPASS_UEN=123456789A - GOOGLE_CAPTCHA=123456789 GOOGLE_CAPTCHA_PUBLIC=987654321 @@ -73,13 +64,6 @@ MONGO_BINARY_VERSION=4.0.22 MOCK_WEBHOOK_CONFIG_FILE=webhook-server-config.csv MOCK_WEBHOOK_PORT=4000 -SGID_ENDPOINT=http://localhost:5156/sgid/v1/oauth -SGID_CLIENT_ID=sgidclientid -SGID_CLIENT_SECRET=sgidclientsecret -SGID_REDIRECT_URI=http://localhost:5000/sgid/login -SGID_PRIVATE_KEY=./node_modules/@opengovsg/mockpass/static/certs/key.pem -SGID_PUBLIC_KEY=./node_modules/@opengovsg/mockpass/static/certs/server.crt - AWS_ACCESS_KEY_ID=fakeAccessKeyId AWS_SECRET_ACCESS_KEY=fakeSecretAccessKey @@ -95,4 +79,11 @@ REACT_SWITCH_ENV_FEEDBACK_FORM_ID_RESPONDENT=62da6a569ee8e90143b5da26 REACT_MIGRATION_ANGULAR_END_DATE=15 September 2022 REACT_MIGRATION_RESP_ROLLOUT_EMAIL=100 REACT_MIGRATION_RESP_ROLLOUT_STORAGE=100 -REACT_MIGRATION_ADMIN_ROLLOUT=100 \ No newline at end of file +REACT_MIGRATION_ADMIN_ROLLOUT=100 + +# Mockpass env vars +MOCKPASS_NRIC=S9812379B +MOCKPASS_UID=S8979373D # Not used by mockpass but keep in sync with MOCKPASS_UEN for Corppass tests +MOCKPASS_UEN=123456789A +SP_RP_JWKS_ENDPOINT=http://localhost:5000/singpass/.well-known/jwks.json +CP_RP_JWKS_ENDPOINT=http://localhost:5000/api/v3/corppass/.well-known/jwks.json diff --git a/__tests__/e2e/setup/certs/test_cp_rp_public_jwks.json b/__tests__/e2e/setup/certs/test_cp_rp_public_jwks.json new file mode 100644 index 0000000000..f0581a3588 --- /dev/null +++ b/__tests__/e2e/setup/certs/test_cp_rp_public_jwks.json @@ -0,0 +1,22 @@ +{ + "keys": [ + { + "kty": "EC", + "use": "sig", + "crv": "P-521", + "kid": "sig-1658197410", + "x": "ACpWhB_ylUHl1lJd0m75UFhBbgLvmeTj5ieyAkOfqO0jZ6PAEEBXbEmRLpElt0CF5trzgK_n1vv25NKkXC1LrU_x", + "y": "Ae3WEayBs-MCluNcow18W3ks6K3nw3Zo18IZZhyYBXN8_HcpzLCrepcCPvs6-q4dUj5DlvYnvobxf2tBU7UDXZPZ", + "alg": "ES512" + }, + { + "kty": "EC", + "use": "enc", + "crv": "P-521", + "kid": "enc-1658197405", + "x": "ATkhBceKJGRTuU5U6qmJtP7-78Xg1NBJQr5eQNBt3zHm__N-MkYLj1EbDY8w0khUFIrTkdas2tE9owQAQqSbqwzo", + "y": "AJBlQ0rSBbAAWxNeLs2Wk92Wd0SrbnI5HRY4Xfapz6tDaI5gaBIV3yH6fjVC516lEdvfmpxNe_L3102ZyMwUn6nf", + "alg": "ECDH-ES+A256KW" + } + ] +} \ No newline at end of file diff --git a/__tests__/e2e/setup/certs/test_cp_rp_secret_jwks.json b/__tests__/e2e/setup/certs/test_cp_rp_secret_jwks.json new file mode 100644 index 0000000000..7b3f765d25 --- /dev/null +++ b/__tests__/e2e/setup/certs/test_cp_rp_secret_jwks.json @@ -0,0 +1,24 @@ +{ + "keys": [ + { + "kty": "EC", + "d": "AQ4L4eNqfeD-hPGn391T8BCBVeCayoQSt0-aEKGyCmKogqs1qnhC5ori2XaetKi0FGmk8CFhnhkYC5Ic4wDanvxF", + "use": "sig", + "crv": "P-521", + "kid": "sig-1658197410", + "x": "ACpWhB_ylUHl1lJd0m75UFhBbgLvmeTj5ieyAkOfqO0jZ6PAEEBXbEmRLpElt0CF5trzgK_n1vv25NKkXC1LrU_x", + "y": "Ae3WEayBs-MCluNcow18W3ks6K3nw3Zo18IZZhyYBXN8_HcpzLCrepcCPvs6-q4dUj5DlvYnvobxf2tBU7UDXZPZ", + "alg": "ES512" + }, + { + "kty": "EC", + "d": "AR4bd2zX1BDwWNJNxfcXPk7WiCje-F14QXNwOzQeNGL0d6fIoIPjXkafYcaKuR4PhAW_WXlCYPCebsYmV6QAIvEK", + "use": "enc", + "crv": "P-521", + "kid": "enc-1658197405", + "x": "ATkhBceKJGRTuU5U6qmJtP7-78Xg1NBJQr5eQNBt3zHm__N-MkYLj1EbDY8w0khUFIrTkdas2tE9owQAQqSbqwzo", + "y": "AJBlQ0rSBbAAWxNeLs2Wk92Wd0SrbnI5HRY4Xfapz6tDaI5gaBIV3yH6fjVC516lEdvfmpxNe_L3102ZyMwUn6nf", + "alg": "ECDH-ES+A256KW" + } + ] +} \ No newline at end of file diff --git a/__tests__/e2e/setup/certs/test_sp_rp_public_jwks.json b/__tests__/e2e/setup/certs/test_sp_rp_public_jwks.json new file mode 100644 index 0000000000..334dc38aac --- /dev/null +++ b/__tests__/e2e/setup/certs/test_sp_rp_public_jwks.json @@ -0,0 +1,22 @@ +{ + "keys": [ + { + "kty": "EC", + "use": "sig", + "crv": "P-521", + "kid": "sig-2022-06-04T09:22:28Z", + "x": "AAj_CAKL9NmP6agPCMto6_LiYQqko3o3ZWTtBg75bA__Z8yKEv_CwHzaibkVLnJ9XKWxCQeyEk9ROLhJoJuZxnsI", + "y": "AZeoe0v-EwqD3oo1V5lxUAmC80qHt-ybqOsl1mYKPgE_ctGcD4hj8tVhmD0Of6ARuKVTxNWej-X82hEW_7Aa-XpR", + "alg": "ES512" + }, + { + "kty": "EC", + "use": "enc", + "crv": "P-521", + "kid": "enc-2022-06-04T13:46:15Z", + "x": "AB-16HyJwnlSZbQtqhFskADqFrm6rgX9XeaV8FgynX61750GCRbYjoueDosSNt-qzK5QNHskdQw0QZ700YF2JIlb", + "y": "AZwYlSBSdV-CxGRMz6ovTvWxKJ6e44gaZHf-YfbJV7w9VdAJb3OuzbHNGRuzNDjEa8eH-paLDaAB84ezrEm1SRHq", + "alg": "ECDH-ES+A256KW" + } + ] +} diff --git a/__tests__/e2e/setup/certs/test_sp_rp_secret_jwks.json b/__tests__/e2e/setup/certs/test_sp_rp_secret_jwks.json new file mode 100644 index 0000000000..319c5573ea --- /dev/null +++ b/__tests__/e2e/setup/certs/test_sp_rp_secret_jwks.json @@ -0,0 +1,24 @@ +{ + "keys": [ + { + "kty": "EC", + "d": "AFOzlND2sq43ykty-VZXw-IEIOyHkBsNXUU77o5yEYcktpoMe9Dl3jsaXwzRK6wtDJH_uoz4IG1Uj4J_WyH5O3GS", + "use": "sig", + "crv": "P-521", + "kid": "sig-2022-06-04T09:22:28Z", + "x": "AAj_CAKL9NmP6agPCMto6_LiYQqko3o3ZWTtBg75bA__Z8yKEv_CwHzaibkVLnJ9XKWxCQeyEk9ROLhJoJuZxnsI", + "y": "AZeoe0v-EwqD3oo1V5lxUAmC80qHt-ybqOsl1mYKPgE_ctGcD4hj8tVhmD0Of6ARuKVTxNWej-X82hEW_7Aa-XpR", + "alg": "ES512" + }, + { + "kty": "EC", + "d": "AP7xECOnlKW-FuLpe1h3ULZoqFzScFrbyAEQTFFG49j5HRHl0k13-6_6nWnwJ9Y8sTrGOWH4GszmDBBZGGvESJQr", + "use": "enc", + "crv": "P-521", + "kid": "enc-2022-06-04T13:46:15Z", + "x": "AB-16HyJwnlSZbQtqhFskADqFrm6rgX9XeaV8FgynX61750GCRbYjoueDosSNt-qzK5QNHskdQw0QZ700YF2JIlb", + "y": "AZwYlSBSdV-CxGRMz6ovTvWxKJ6e44gaZHf-YfbJV7w9VdAJb3OuzbHNGRuzNDjEa8eH-paLDaAB84ezrEm1SRHq", + "alg": "ECDH-ES+A256KW" + } + ] +} \ No newline at end of file diff --git a/__tests__/e2e/utils/field.ts b/__tests__/e2e/utils/field.ts index 761f473a92..12d5b88582 100644 --- a/__tests__/e2e/utils/field.ts +++ b/__tests__/e2e/utils/field.ts @@ -1,5 +1,6 @@ import { Locator, Page } from '@playwright/test' -import { BasicField } from 'shared/types' +import { MYINFO_ATTRIBUTE_MAP } from 'shared/constants/field/myinfo' +import { BasicField, MyInfoAttribute } from 'shared/types' import { E2eFieldMetadata, NON_INPUT_FIELD_TYPES } from '../constants/field' @@ -8,7 +9,7 @@ import { E2eFieldMetadata, NON_INPUT_FIELD_TYPES } from '../constants/field' * @param {E2eFieldMetadata} field * @returns {E2eFieldMetadata} optional field */ -export const getOptionalVersion = ( +export const createOptionalVersion = ( field: E2eFieldMetadata, ): E2eFieldMetadata => { switch (field.fieldType) { @@ -31,7 +32,9 @@ export const getOptionalVersion = ( * @param {E2eFieldMetadata} field * @param {E2eFieldMetadata} field with blank value */ -export const getBlankVersion = (field: E2eFieldMetadata): E2eFieldMetadata => { +export const createBlankVersion = ( + field: E2eFieldMetadata, +): E2eFieldMetadata => { switch (field.fieldType) { case BasicField.Image: case BasicField.Section: @@ -53,6 +56,105 @@ export const getBlankVersion = (field: E2eFieldMetadata): E2eFieldMetadata => { } } +/** + * Given a MyInfo field type, creates a MyInfo field to use in the e2e form + * definition. If the MyInfo field is prefilled, it's value will be checked + * against val. + * @param {MyInfoAttribute} type the type of MyInfo field to be created + * @param {string} val optional. the value of the input to the MyInfo field + */ +export const createMyInfoField = ( + attr: MyInfoAttribute, + val: string, + verified: boolean, +): E2eFieldMetadata => { + const { + value: title, + fieldType, + fieldOptions = [], + } = MYINFO_ATTRIBUTE_MAP[attr] + const fieldBase = { + myInfo: { attr, verified }, + title, + val, + } + + switch (fieldType) { + case BasicField.Date: + return { + fieldType, + ...fieldBase, + dateValidation: { + selectedDateValidation: null, + customMinDate: null, + customMaxDate: null, + }, + } + case BasicField.Dropdown: + return { fieldType, ...fieldBase, fieldOptions } + case BasicField.Mobile: + return { + fieldType, + ...fieldBase, + isVerifiable: false, + allowIntlNumbers: false, + } + case BasicField.ShortText: + return { + fieldType, + ...fieldBase, + ValidationOptions: { selectedValidation: null, customVal: null }, + } + } +} + +/** + * Given a field, checks if it is a MyInfoable field type. + * @param {E2eFieldMetadata} field the field data used to create the field + * @return {boolean} if the field type is MyInfoable + */ +export const isMyInfoableFieldType = ( + field: E2eFieldMetadata, +): field is E2eFieldMetadata & { + fieldType: + | BasicField.Date + | BasicField.Dropdown + | BasicField.Mobile + | BasicField.ShortText +} => { + switch (field.fieldType) { + case BasicField.Date: + case BasicField.Dropdown: + case BasicField.Mobile: + case BasicField.ShortText: + return true + default: + return false + } +} + +/** + * Given a field, gets the MyInfo attribute. + * @param {E2eFieldMetadata} field the field data used to create the field + * @return {MyInfoAttribute | undefined} the MyInfo attribute for this field, if it is a MyInfo field, otherwise undefined + */ +export const getMyInfoAttribute = ( + field: E2eFieldMetadata, +): MyInfoAttribute | undefined => + isMyInfoableFieldType(field) ? field.myInfo?.attr : undefined + +/** + * Given a field, checks if it is a verifiable field type. + * @param {E2eFieldMetadata} field the field data used to create the field + * @return {boolean} if the field type is verifiable + */ +export const isVerifiableFieldType = ( + field: E2eFieldMetadata, +): field is E2eFieldMetadata & { + fieldType: BasicField.Mobile | BasicField.Email +} => + field.fieldType === BasicField.Mobile || field.fieldType === BasicField.Email + /** * Given a dropdown input field, fills the dropdown by picking the correct option * from the popover diff --git a/__tests__/e2e/utils/mail.ts b/__tests__/e2e/utils/mail.ts index 974caff27f..3141af2837 100644 --- a/__tests__/e2e/utils/mail.ts +++ b/__tests__/e2e/utils/mail.ts @@ -52,31 +52,22 @@ const MAIL_CLIENT = { axios.get(`${MAIL_URL}/email/${id}/attachment/${filename}`), } -const getEmailsByRecipient = ( - inbox: MailData[], - toEmail: string, -): MailData[] => { - return inbox - .filter((e) => e.to[0].address === toEmail) - .sort((a, b) => (a.time > b.time ? -1 : 1)) -} - -const getEmailsBySubject = (inbox: MailData[], subject: string): MailData[] => { - return inbox - .filter((e) => e.subject === subject) - .sort((a, b) => (a.time > b.time ? -1 : 1)) +const getEmailsBy = async ( + filterFn: (email: MailData) => boolean, +): Promise => { + const inbox = await MAIL_CLIENT.getAll() + return inbox.filter(filterFn).sort((a, b) => (a.time > b.time ? -1 : 1)) } /** * Retrieves an OTP from the inbox. - * @param email The email the OTP was sent to. + * @param recipient The email the OTP was sent to. */ -export const extractOtp = async (email: string): Promise => { - const inbox = await MAIL_CLIENT.getAll() - const emails = getEmailsByRecipient(inbox, email) +export const extractOtp = async (recipient: string): Promise => { + const emails = await getEmailsBy((e) => e.to[0].address === recipient) const lastEmail = emails.pop() - if (!lastEmail) throw Error(`mailbox for ${email} is empty`) + if (!lastEmail) throw Error(`mailbox for ${recipient} is empty`) const otp = lastEmail.html.match(/\d{6}/)?.[0] if (!otp) throw Error('otp was not found in email') @@ -88,7 +79,8 @@ export const extractOtp = async (email: string): Promise => { /** * Retrieves an email sent by FormSG. - * @param {string} formName Title of form + * @param {string} formName title of form + * @param {string} responseId response ID of the submission * @returns {object} subject, sender, recipient and html content of email */ export const getSubmission = async ( @@ -97,8 +89,7 @@ export const getSubmission = async ( ): Promise => { const subject = `formsg-auto: ${formName} (#${responseId})` - const inbox = await MAIL_CLIENT.getAll() - const emails = getEmailsBySubject(inbox, subject) + const emails = await getEmailsBy((e) => e.subject === subject) const lastEmail = emails.pop() if (!lastEmail) throw Error(`mailbox does not contain subject "${subject}"`) @@ -132,3 +123,31 @@ const getSubmissionAttachments = async ( } return atts } + +/** + * Retrieves an autoreply email sent by FormSG. + * @param {string} responseId response ID of the submission + * @param {string} recipient email address of the form filler + * @returns {MailData} email for the autoreply sent to the recipient + */ +export const getAutoreplyEmail = async ( + responseId: string, + recipient: string, +): Promise => { + const emails = await getEmailsBy( + (email) => + email.to[0].address === recipient && + email.html.includes(`Response ID: ${responseId}`), + ) + + const lastEmail = emails.pop() + if (!lastEmail) { + throw Error( + `mailbox does not contain autoreply email for response ID ${responseId}`, + ) + } + + await MAIL_CLIENT.deleteById(lastEmail.id) + + return lastEmail +} diff --git a/__tests__/e2e/utils/settings.ts b/__tests__/e2e/utils/settings.ts index 8b4dc538a3..f92581f6d0 100644 --- a/__tests__/e2e/utils/settings.ts +++ b/__tests__/e2e/utils/settings.ts @@ -4,10 +4,31 @@ import { E2eSettingsOptions } from '../constants/settings' export const getSettings = ( custom?: Partial, -): E2eSettingsOptions => ({ - status: FormStatus.Public, - collaborators: [], - authType: FormAuthType.NIL, - // By default, if emails is undefined, only the admin (current user) will receive. - ...custom, -}) +): E2eSettingsOptions => { + // Inject form auth settings + if (custom?.authType && custom.authType !== FormAuthType.NIL) { + // Only SGID does not require e-service ID + if (custom.authType !== FormAuthType.SGID && !custom.esrvcId) { + custom.esrvcId = 'test_esrvcid' + } + // All auth types have an NRIC + if (!custom.nric) { + custom.nric = + custom.authType === FormAuthType.CP + ? process.env.MOCKPASS_UID + : process.env.MOCKPASS_NRIC + } + // Only CP has UEN and a special associated UID + if (custom.authType === FormAuthType.CP && !custom.uen) { + custom.uen = process.env.MOCKPASS_UEN + } + } + + return { + status: FormStatus.Public, + collaborators: [], + authType: FormAuthType.NIL, + // By default, if emails is undefined, only the admin (current user) will receive. + ...custom, + } +}