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() - }) -})