diff --git a/src/services/mockedData.ts b/src/services/mockedData.ts
index 31cd3d9a33..59b5e86751 100644
--- a/src/services/mockedData.ts
+++ b/src/services/mockedData.ts
@@ -225,6 +225,20 @@ export const institutionData = {
is_disabled_by_client: false,
},
}
+export const MFA_CREDENTIALS = [
+ {
+ guid: 'CRD-123',
+ institution_guid: 'INS-123',
+ external_id: 'UNIQUE_ID_FOR_THIS_CHALLENGE-123',
+ label: 'What city were you born in?',
+ field_name: 'What city were you born in?',
+ field_type: 0,
+ mfa: true,
+ status_code: 200,
+ options: [],
+ },
+]
+
export const MFA_MEMBER = {
connection_status: 3,
guid: 'MBR-123',
@@ -235,24 +249,12 @@ export const MFA_MEMBER = {
is_managed_by_user: true,
is_oauth: false,
metadata: null,
- mfa: {
- credentials: [
- {
- guid: 'CRD-123',
- institution_guid: 'INS-123',
- external_id: 'UNIQUE_ID_FOR_THIS_CHALLENGE-123',
- label: 'What city were you born in?',
- field_type: 0,
- mfa: true,
- status_code: 200,
- options: [],
- },
- ],
- },
+ mfa: MFA_CREDENTIALS,
name: 'Gringotts',
user_guid: 'USR-123',
verification_is_enabled: true,
}
+
export const NEW_MEMBER = {
aggregation_status: null,
background_aggregation_is_disabled: false,
diff --git a/src/views/mfa/DefaultMFA-test.tsx b/src/views/mfa/DefaultMFA-test.tsx
new file mode 100644
index 0000000000..91041dbf55
--- /dev/null
+++ b/src/views/mfa/DefaultMFA-test.tsx
@@ -0,0 +1,153 @@
+import React from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { render, screen } from 'src/utilities/testingLibrary'
+import { DefaultMFA } from 'src/views/mfa/DefaultMFA'
+import { AnalyticEvents, defaultEventMetadata } from 'src/const/Analytics'
+import { sha256 } from 'js-sha256'
+import { institutionData, member, MFA_CREDENTIALS } from 'src/services/mockedData'
+
+type MfaCredential = {
+ guid: string
+ label: string
+ [key: string]: unknown
+}
+
+describe('', () => {
+ const currentMember = member.member
+ const institution = institutionData.institution
+ const [securityQuestion] = MFA_CREDENTIALS
+ const pinCredential: MfaCredential = {
+ guid: 'CRD-002',
+ label: 'PIN',
+ field_name: 'PIN',
+ field_type: 0,
+ }
+
+ let onAnalyticsEvent: ReturnType
+ let onSubmit: ReturnType
+
+ beforeEach(() => {
+ onAnalyticsEvent = vi.fn()
+ onSubmit = vi.fn()
+ })
+
+ const renderDefaultMFA = (
+ mfaCredentials: MfaCredential[] = MFA_CREDENTIALS,
+ isSubmitting = false,
+ ) =>
+ render(
+ ,
+ { onAnalyticsEvent },
+ )
+
+ describe('Content Display', () => {
+ it('renders the credential prompt, required note, and continue button', () => {
+ renderDefaultMFA()
+
+ expect(screen.getByText(securityQuestion.label, { exact: false })).toBeInTheDocument()
+ expect(screen.getByText(/required/i)).toBeInTheDocument()
+ expect(screen.getByTestId('continue-button')).toHaveTextContent('Continue')
+ })
+
+ it('renders an input for each MFA credential', () => {
+ renderDefaultMFA([securityQuestion, pinCredential])
+
+ expect(screen.getByText(securityQuestion.label, { exact: false })).toBeInTheDocument()
+ expect(screen.getByText('PIN', { exact: false })).toBeInTheDocument()
+ expect(screen.getAllByRole('textbox')).toHaveLength(2)
+ })
+
+ it.each(['meta_data', 'image_data'])('renders the challenge image provided via %s', (field) => {
+ renderDefaultMFA([{ ...securityQuestion, [field]: 'data:image/png;base64,abc123' }])
+
+ expect(screen.getByAltText('Challenge Image')).toHaveAttribute(
+ 'src',
+ 'data:image/png;base64,abc123',
+ )
+ })
+
+ it('does not render a challenge image when none is provided', () => {
+ renderDefaultMFA()
+
+ expect(screen.queryByAltText('Challenge Image')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Submitting State', () => {
+ it('shows a checking label and disables the input while submitting', () => {
+ renderDefaultMFA(MFA_CREDENTIALS, true)
+
+ expect(screen.getByTestId('continue-button')).toHaveTextContent(/Checking/i)
+ expect(screen.getByRole('textbox')).toBeDisabled()
+ })
+ })
+
+ describe('Form Submission', () => {
+ it('submits the entered credential value', async () => {
+ const { user } = renderDefaultMFA()
+
+ await user.type(screen.getByRole('textbox'), '123456')
+ await user.click(screen.getByTestId('continue-button'))
+
+ expect(onSubmit).toHaveBeenCalledWith([{ guid: securityQuestion.guid, value: '123456' }])
+ })
+
+ it('submits a value for every credential', async () => {
+ const { user } = renderDefaultMFA([securityQuestion, pinCredential])
+
+ const inputs = screen.getAllByRole('textbox')
+ await user.type(inputs[0], '123456')
+ await user.type(inputs[1], '9876')
+ await user.click(screen.getByTestId('continue-button'))
+
+ expect(onSubmit).toHaveBeenCalledWith([
+ { guid: securityQuestion.guid, value: '123456' },
+ { guid: pinCredential.guid, value: '9876' },
+ ])
+ })
+ })
+
+ describe('Form Validation', () => {
+ it('does not submit and shows a required error when the field is empty', async () => {
+ const { user } = renderDefaultMFA()
+
+ await user.click(screen.getByTestId('continue-button'))
+
+ const errors = await screen.findAllByText((content) =>
+ content.includes(`${securityQuestion.label} is required`),
+ )
+ expect(errors.length).toBeGreaterThan(0)
+ expect(onSubmit).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('Analytics', () => {
+ it('sends the MFA entered-input event only on the first keystroke', async () => {
+ const { user } = renderDefaultMFA()
+ const input = screen.getByRole('textbox')
+
+ await user.type(input, '1')
+
+ expect(onAnalyticsEvent).toHaveBeenCalledWith(
+ `connect_${AnalyticEvents.MFA_ENTERED_INPUT}`,
+ expect.objectContaining({
+ institution_guid: institution.guid,
+ institution_name: institution.name,
+ member_guid: sha256(currentMember.guid),
+ widgetType: defaultEventMetadata.widgetType,
+ }),
+ )
+ expect(onAnalyticsEvent).toHaveBeenCalledTimes(1)
+
+ await user.type(input, '23456')
+
+ expect(onAnalyticsEvent).toHaveBeenCalledTimes(1)
+ })
+ })
+})
diff --git a/src/views/mfa/MFAForm-test.tsx b/src/views/mfa/MFAForm-test.tsx
new file mode 100644
index 0000000000..bf75868028
--- /dev/null
+++ b/src/views/mfa/MFAForm-test.tsx
@@ -0,0 +1,223 @@
+import React from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { render, screen } from 'src/utilities/testingLibrary'
+import { MFAForm } from 'src/views/mfa/MFAForm'
+import { AnalyticEvents, defaultEventMetadata } from 'src/const/Analytics'
+import { sha256 } from 'js-sha256'
+import { member, institutionData, MFA_CREDENTIALS } from 'src/services/mockedData'
+import { CredentialTypes } from 'src/const/Credential'
+
+type MFAOption = {
+ guid: string
+ credential_guid: string
+ label: string
+ value: string
+ data_uri?: string
+}
+
+type MFACredential = Omit<(typeof MFA_CREDENTIALS)[0], 'options'> & {
+ options?: MFAOption[]
+}
+
+describe('', () => {
+ const institution = institutionData.institution
+ const memberGuidHash = sha256(member.member.guid)
+
+ const createMember = (credentials: MFACredential[] = MFA_CREDENTIALS) => ({
+ ...member.member,
+ mfa: {
+ credentials,
+ },
+ })
+
+ const optionsCredentials: MFACredential[] = [
+ {
+ ...MFA_CREDENTIALS[0],
+ field_type: CredentialTypes.OPTIONS,
+ options: [
+ {
+ guid: 'OPT-001',
+ credential_guid: MFA_CREDENTIALS[0].guid,
+ label: 'Option 1',
+ value: 'option1',
+ },
+ {
+ guid: 'OPT-002',
+ credential_guid: MFA_CREDENTIALS[0].guid,
+ label: 'Option 2',
+ value: 'option2',
+ },
+ ],
+ },
+ ]
+
+ const imageCredentials: MFACredential[] = [
+ {
+ ...MFA_CREDENTIALS[0],
+ field_type: CredentialTypes.IMAGE_OPTIONS,
+ options: [
+ {
+ guid: 'IMG-001',
+ credential_guid: MFA_CREDENTIALS[0].guid,
+ label: 'Image 1',
+ value: 'image1',
+ data_uri: 'data:image/png;base64,abc',
+ },
+ {
+ guid: 'IMG-002',
+ credential_guid: MFA_CREDENTIALS[0].guid,
+ label: 'Image 2',
+ value: 'image2',
+ data_uri: 'data:image/png;base64,def',
+ },
+ ],
+ },
+ ]
+
+ let onAnalyticsEvent: ReturnType
+ let onSubmit: ReturnType
+
+ beforeEach(() => {
+ onAnalyticsEvent = vi.fn()
+ onSubmit = vi.fn()
+ })
+
+ const renderMFAForm = (currentMember = createMember()) =>
+ render(
+ ,
+ { onAnalyticsEvent },
+ )
+
+ describe('Title', () => {
+ it('renders the verify identity title for a standard MFA challenge', () => {
+ renderMFAForm()
+
+ expect(screen.getByText('Verify identity')).toBeInTheDocument()
+ })
+
+ it('renders the account selection title for a single account select challenge', () => {
+ const sasCredentials: MFACredential[] = [
+ { ...MFA_CREDENTIALS[0], external_id: 'single_account_select' },
+ ]
+
+ renderMFAForm(createMember(sasCredentials))
+
+ expect(screen.getByText('Account selection')).toBeInTheDocument()
+ })
+ })
+
+ describe('Submits the selected credentials', () => {
+ it('submits the typed value and reports the input event', async () => {
+ const { user } = renderMFAForm()
+
+ await user.type(screen.getByRole('textbox'), 'test123')
+ await user.click(screen.getByRole('button', { name: /continue/i }))
+
+ expect(onSubmit).toHaveBeenCalledWith([{ guid: MFA_CREDENTIALS[0].guid, value: 'test123' }])
+ expect(onAnalyticsEvent).toHaveBeenCalledWith(
+ `connect_${AnalyticEvents.MFA_SUBMITTED_INPUT}`,
+ expect.objectContaining({
+ institution_guid: institution.guid,
+ institution_name: institution.name,
+ member_guid: memberGuidHash,
+ widgetType: defaultEventMetadata.widgetType,
+ }),
+ )
+ })
+
+ it('submits the typed value when the user presses Enter in the field', async () => {
+ const { user } = renderMFAForm()
+
+ await user.type(screen.getByRole('textbox'), 'test123{Enter}')
+
+ expect(onSubmit).toHaveBeenCalledWith([{ guid: MFA_CREDENTIALS[0].guid, value: 'test123' }])
+ })
+
+ it('submits the chosen option and reports the option event', async () => {
+ const { user } = renderMFAForm(createMember(optionsCredentials))
+
+ await user.click(screen.getByText('Option 1'))
+ await user.click(screen.getByRole('button', { name: /continue/i }))
+
+ expect(onSubmit).toHaveBeenCalledWith([{ guid: MFA_CREDENTIALS[0].guid, value: 'OPT-001' }])
+ expect(onAnalyticsEvent).toHaveBeenCalledWith(
+ `connect_${AnalyticEvents.MFA_SUBMITTED_OPTION}`,
+ expect.objectContaining({
+ institution_guid: institution.guid,
+ institution_name: institution.name,
+ member_guid: memberGuidHash,
+ widgetType: defaultEventMetadata.widgetType,
+ }),
+ )
+ })
+
+ it('submits the chosen image and reports the image event', async () => {
+ const { user } = renderMFAForm(createMember(imageCredentials))
+
+ await user.click(screen.getAllByRole('img')[0])
+ await user.click(screen.getByRole('button', { name: /continue/i }))
+
+ expect(onSubmit).toHaveBeenCalledWith([{ guid: MFA_CREDENTIALS[0].guid, value: 'IMG-001' }])
+ expect(onAnalyticsEvent).toHaveBeenCalledWith(
+ `connect_${AnalyticEvents.MFA_SUBMITTED_IMAGE}`,
+ expect.objectContaining({
+ institution_guid: institution.guid,
+ institution_name: institution.name,
+ member_guid: memberGuidHash,
+ widgetType: defaultEventMetadata.widgetType,
+ }),
+ )
+ })
+ })
+
+ describe('Validation', () => {
+ it('blocks submission with an error until an option is selected', async () => {
+ const { user } = renderMFAForm(createMember(optionsCredentials))
+
+ await user.click(screen.getByRole('button', { name: /continue/i }))
+ expect(screen.getByText('Choose an option')).toBeInTheDocument()
+
+ await user.click(screen.getByText('Option 1'))
+ expect(screen.queryByText('Choose an option')).not.toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: /continue/i }))
+ expect(onSubmit).toHaveBeenCalledWith([{ guid: MFA_CREDENTIALS[0].guid, value: 'OPT-001' }])
+ })
+
+ it('blocks submission with an error until an image is selected', async () => {
+ const { user } = renderMFAForm(createMember(imageCredentials))
+
+ await user.click(screen.getByRole('button', { name: /continue/i }))
+ expect(screen.getByText('Choose an image')).toBeInTheDocument()
+
+ await user.click(screen.getAllByRole('img')[0])
+ expect(screen.queryByText('Choose an image')).not.toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: /continue/i }))
+ expect(onSubmit).toHaveBeenCalledWith([{ guid: MFA_CREDENTIALS[0].guid, value: 'IMG-001' }])
+ })
+
+ it('blocks submission with an error until an account is selected', async () => {
+ const sasCredentials: MFACredential[] = [
+ { ...optionsCredentials[0], external_id: 'single_account_select' },
+ ]
+ const { user } = renderMFAForm(createMember(sasCredentials))
+
+ expect(screen.getByText('Select an account to connect')).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: /continue/i }))
+ expect(screen.getByText('Account selection is required.')).toBeInTheDocument()
+
+ await user.click(screen.getByText('Option 1'))
+ expect(screen.queryByText('Account selection is required.')).not.toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: /continue/i }))
+ expect(onSubmit).toHaveBeenCalledWith([{ guid: MFA_CREDENTIALS[0].guid, value: 'OPT-001' }])
+ })
+ })
+})
diff --git a/src/views/mfa/MFAStep-test.tsx b/src/views/mfa/MFAStep-test.tsx
new file mode 100644
index 0000000000..81ec29ed30
--- /dev/null
+++ b/src/views/mfa/MFAStep-test.tsx
@@ -0,0 +1,166 @@
+import React from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { of, throwError } from 'rxjs'
+
+import MFAStepComponent from 'src/views/mfa/MFAStep'
+import { AnalyticEvents, defaultEventMetadata } from 'src/const/Analytics'
+import { render, screen, waitFor } from 'src/utilities/testingLibrary'
+import { member, institutionData, MFA_CREDENTIALS, initialState } from 'src/services/mockedData'
+import { ReadableStatuses } from 'src/const/Statuses'
+import { PostMessageContext } from 'src/ConnectWidget'
+import { apiValue as apiValueMock } from 'src/const/apiProviderMock'
+
+const MFAStep = MFAStepComponent as unknown as React.ComponentType>
+
+type RenderMFAStepOptions = {
+ apiOverrides?: Partial
+ credentials?: typeof MFA_CREDENTIALS
+ enableSupportRequests?: boolean
+ onPostMessage?: ReturnType
+}
+
+describe('MFAStep', () => {
+ const institution = institutionData.institution
+
+ let onAnalyticsEvent: ReturnType
+ let onGoBack: ReturnType
+
+ const createMember = (credentials: typeof MFA_CREDENTIALS = MFA_CREDENTIALS) => ({
+ ...member.member,
+ connection_status: ReadableStatuses.CHALLENGED,
+ mfa: { credentials },
+ })
+
+ beforeEach(() => {
+ onAnalyticsEvent = vi.fn()
+ onGoBack = vi.fn()
+ })
+
+ const renderMFAStep = ({
+ apiOverrides = {},
+ credentials = MFA_CREDENTIALS,
+ enableSupportRequests = true,
+ onPostMessage = vi.fn(),
+ }: RenderMFAStepOptions = {}) => {
+ const currentMember = createMember(credentials)
+ const utils = render(
+
+
+ ,
+ {
+ onAnalyticsEvent,
+ apiValue: { ...apiValueMock, ...apiOverrides },
+ preloadedState: {
+ ...initialState,
+ connect: {
+ ...initialState.connect,
+ members: [currentMember],
+ currentMemberGuid: currentMember.guid,
+ },
+ },
+ },
+ )
+
+ return { ...utils, currentMember, onPostMessage }
+ }
+
+ describe('Support Navigation', () => {
+ it('opens the support view and reports the analytics event when Get help is clicked', async () => {
+ const { user } = renderMFAStep()
+
+ await user.click(await screen.findByRole('button', { name: 'Get help' }))
+
+ expect(await screen.findByText('Request support')).toBeInTheDocument()
+ expect(onAnalyticsEvent).toHaveBeenCalledWith(
+ `connect_${AnalyticEvents.MFA_CLICKED_GET_HELP}`,
+ expect.objectContaining({ widgetType: defaultEventMetadata.widgetType }),
+ )
+ })
+
+ it('does not render the Get help button when support is disabled', () => {
+ renderMFAStep({ enableSupportRequests: false })
+
+ expect(screen.queryByRole('button', { name: 'Get help' })).not.toBeInTheDocument()
+ })
+
+ it('returns to the MFA form when the support view is closed', async () => {
+ const { user } = renderMFAStep()
+
+ await user.click(await screen.findByRole('button', { name: 'Get help' }))
+ expect(await screen.findByText('Request support')).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: /cancel/i }))
+
+ expect(await screen.findByRole('button', { name: 'Get help' })).toBeInTheDocument()
+ expect(screen.queryByText('Request support')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Error State', () => {
+ it('shows the error message and calls onGoBack when credentials are missing', async () => {
+ const { user } = renderMFAStep({ credentials: [] })
+
+ expect(
+ screen.getByText('Oops! Something went wrong. Please try again later.'),
+ ).toBeInTheDocument()
+
+ await user.click(screen.getByText('Go Back'))
+
+ expect(onGoBack).toHaveBeenCalled()
+ })
+ })
+
+ describe('MFA Form Rendering', () => {
+ it('renders the MFA form and institution block when credentials are present', () => {
+ renderMFAStep()
+
+ expect(screen.getByText(new RegExp(MFA_CREDENTIALS[0].label))).toBeInTheDocument()
+ expect(screen.getByTestId('continue-button')).toBeInTheDocument()
+ expect(screen.getByText(institution.name)).toBeInTheDocument()
+ })
+ })
+
+ describe('MFA Form Submission', () => {
+ it('posts the submit message and calls the update API with the answer', async () => {
+ const updateMFA = vi.fn().mockReturnValue(of(createMember()))
+ const { user, currentMember, onPostMessage } = renderMFAStep({ apiOverrides: { updateMFA } })
+
+ await user.type(screen.getByRole('textbox'), 'myAnswer123')
+ await user.click(screen.getByTestId('continue-button'))
+
+ expect(onPostMessage).toHaveBeenCalledWith('connect/submitMFA', {
+ member_guid: currentMember.guid,
+ })
+ await waitFor(() => expect(updateMFA).toHaveBeenCalled())
+ expect(updateMFA.mock.calls[0][0]).toEqual(
+ expect.objectContaining({
+ credentials: [{ guid: MFA_CREDENTIALS[0].guid, value: 'myAnswer123' }],
+ }),
+ )
+ })
+
+ it('keeps the continue button available after a failed submission', async () => {
+ const updateMFA = vi.fn().mockReturnValue(throwError(() => new Error('update failed')))
+ const { user } = renderMFAStep({ apiOverrides: { updateMFA } })
+
+ await user.type(screen.getByRole('textbox'), 'test123')
+ await user.click(screen.getByTestId('continue-button'))
+
+ expect(await screen.findByRole('button', { name: 'Continue' })).toBeInTheDocument()
+ })
+
+ it('shows the checking state while the submission is pending', async () => {
+ const updateMFA = vi.fn().mockReturnValue(new Promise(() => {}))
+ const { user } = renderMFAStep({ apiOverrides: { updateMFA } })
+
+ await user.type(screen.getByRole('textbox'), 'test123')
+ await user.click(screen.getByTestId('continue-button'))
+
+ expect(await screen.findByText(/Checking/i)).toBeInTheDocument()
+ })
+ })
+})
diff --git a/src/views/mfa/__tests__/MFAStep-test.tsx b/src/views/mfa/__tests__/MFAStep-test.tsx
deleted file mode 100644
index 605d2dd52b..0000000000
--- a/src/views/mfa/__tests__/MFAStep-test.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import React from 'react'
-
-import MFAStep from 'src/views/mfa/MFAStep'
-import { AnalyticEvents } from 'src/const/Analytics'
-import { render, screen } from 'src/utilities/testingLibrary'
-
-const mockSendAnalyticsEvent = vi.fn()
-
-vi.mock('src/hooks/useAnalyticsEvent', () => {
- return { default: () => mockSendAnalyticsEvent }
-})
-
-describe('MFAStep', () => {
- const onGoBack = vi.fn()
- const defaultProps = {
- enableSupportRequests: true,
- institution: { guid: 'INS-123' },
- onGoBack,
- ref: React.createRef(),
- }
-
- it('can navigate to Support when Support is enabled', async () => {
- const { user } = render()
- const supportButton = await screen.findByRole('button', { name: 'Get help' })
-
- expect(supportButton).toBeInTheDocument()
-
- await user.click(supportButton)
- expect(mockSendAnalyticsEvent).toHaveBeenCalledWith(AnalyticEvents.MFA_CLICKED_GET_HELP)
- expect(await screen.findByText('Request support')).toBeInTheDocument()
- })
-
- it('does not render the support button when Support is disabled', async () => {
- const noSupportProps = {
- ...defaultProps,
- enableSupportRequests: false,
- }
- render()
- expect(screen.queryByRole('button', { name: 'Get help' })).not.toBeInTheDocument()
- })
-})