diff --git a/.env b/.env index ce7ca5d0..e884a7de 100644 --- a/.env +++ b/.env @@ -46,3 +46,4 @@ APP_ID='' MFE_CONFIG_API_URL='' # Fallback in local style files PARAGON_THEME_URLS={} +RECAPTCHA_SITE_KEY_WEB='' diff --git a/.env.development b/.env.development index 5263135b..bbd1c0f5 100644 --- a/.env.development +++ b/.env.development @@ -46,3 +46,4 @@ ZENDESK_KEY='' ZENDESK_LOGO_URL='' # Fallback in local style files PARAGON_THEME_URLS={} +RECAPTCHA_SITE_KEY_WEB='' diff --git a/.env.test b/.env.test index ac296154..56971a87 100644 --- a/.env.test +++ b/.env.test @@ -21,3 +21,4 @@ MFE_CONFIG_API_URL='' COHESION_WRITE_KEY='' COHESION_SOURCE_KEY='' PARAGON_THEME_URLS={} +RECAPTCHA_SITE_KEY_WEB='' diff --git a/package-lock.json b/package-lock.json index 6447d089..019e3868 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-error-boundary": "^4.0.13", + "react-google-recaptcha-v3": "^1.11.0", "react-helmet": "6.1.0", "react-loading-skeleton": "3.5.0", "react-redux": "7.2.9", @@ -2956,9 +2957,9 @@ } }, "node_modules/@formatjs/ts-transformer/node_modules/@types/node": { - "version": "22.18.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.3.tgz", - "integrity": "sha512-gTVM8js2twdtqM+AE2PdGEe9zGQY4UvmFjan9rZcVb6FGdStfjWoWejdmy4CfWVO9rh5MiYQGZloKAGkJt8lMw==", + "version": "22.18.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.4.tgz", + "integrity": "sha512-UJdblFqXymSBhmZf96BnbisoFIr8ooiiBRMolQgg77Ea+VM37jXw76C2LQr9n8wm9+i/OvlUlW6xSvqwzwqznw==", "devOptional": true, "dependencies": { "undici-types": "~6.21.0" @@ -5435,9 +5436,9 @@ } }, "node_modules/@jsonjoy.com/json-pack": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.11.0.tgz", - "integrity": "sha512-nLqSTAYwpk+5ZQIoVp7pfd/oSKNWlEdvTq2LzVA4r2wtWZg6v+5u0VgBOaDJuUfNOuw/4Ysq6glN5QKSrOCgrA==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.12.0.tgz", + "integrity": "sha512-qPwWjCmcwtDiY9MZ4hz3YY4UYkVleWq5rXt+EEx4Jtl22byaLlfBEn8vXze43P2/30DkwzQvdA56EB3TH7t3Pg==", "dependencies": { "@jsonjoy.com/base64": "^1.1.2", "@jsonjoy.com/buffers": "^1.0.0", @@ -8199,12 +8200,12 @@ } }, "node_modules/@types/node": { - "version": "24.4.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.4.0.tgz", - "integrity": "sha512-gUuVEAK4/u6F9wRLznPUU4WGUacSEBDPoC2TrBkw3GAnOLHBL45QdfHOXp1kJ4ypBGLxTOB+t7NJLpKoC3gznQ==", + "version": "24.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.0.tgz", + "integrity": "sha512-y1dMvuvJspJiPSDZUQ+WMBvF7dpnEqN4x9DDC9ie5Fs/HUZJA3wFp7EhHoVaKX/iI0cRoECV8X2jL8zi0xrHCg==", "devOptional": true, "dependencies": { - "undici-types": "~7.11.0" + "undici-types": "~7.12.0" } }, "node_modules/@types/node-forge": { @@ -10161,9 +10162,9 @@ } }, "node_modules/browserslist": { - "version": "4.26.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.0.tgz", - "integrity": "sha512-P9go2WrP9FiPwLv3zqRD/Uoxo0RSHjzFCiQz7d4vbmwNqQFo9T9WCeP/Qn5EbcKQY6DBbkxEXNcpJOmncNrb7A==", + "version": "4.26.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz", + "integrity": "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==", "devOptional": true, "funding": [ { @@ -10180,7 +10181,7 @@ } ], "dependencies": { - "baseline-browser-mapping": "^2.8.2", + "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", "electron-to-chromium": "^1.5.218", "node-releases": "^2.0.21", @@ -10345,9 +10346,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001741", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz", - "integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==", + "version": "1.0.30001743", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001743.tgz", + "integrity": "sha512-e6Ojr7RV14Un7dz6ASD0aZDmQPT/A+eZU+nuTNfjqmRrmkmQlnTNWH0SKmqagx9PeW87UVqapSurtAXifmtdmw==", "devOptional": true, "funding": [ { @@ -12562,9 +12563,9 @@ } }, "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "devOptional": true, "dependencies": { "is-arrayish": "^0.2.1" @@ -22541,6 +22542,18 @@ } } }, + "node_modules/react-google-recaptcha-v3": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/react-google-recaptcha-v3/-/react-google-recaptcha-v3-1.11.0.tgz", + "integrity": "sha512-kLQqpz/77m8+trpBwzqcxNtvWZYoZ/YO6Vm2cVTHW8hs80BWUfDpC7RDwuAvpswwtSYApWfaSpIDFWAIBNIYxQ==", + "dependencies": { + "hoist-non-react-statics": "^3.3.2" + }, + "peerDependencies": { + "react": "^16.3 || ^17.0 || ^18.0 || ^19.0", + "react-dom": "^17.0 || ^18.0 || ^19.0" + } + }, "node_modules/react-helmet": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-6.1.0.tgz", @@ -25162,9 +25175,9 @@ } }, "node_modules/ts-jest": { - "version": "29.4.1", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.1.tgz", - "integrity": "sha512-SaeUtjfpg9Uqu8IbeDKtdaS0g8lS6FT6OzM3ezrDfErPJPHNDo/Ey+VFGP1bQIDfagYDLyRpd7O15XpG1Es2Uw==", + "version": "29.4.2", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.2.tgz", + "integrity": "sha512-pBNOkn4HtuLpNrXTMVRC9b642CBaDnKqWXny4OzuoULT9S7Kf8MMlaRe2veKax12rjf5WcpMBhVPbQurlWGNxA==", "dev": true, "dependencies": { "bs-logger": "^0.2.6", @@ -25534,9 +25547,9 @@ } }, "node_modules/undici-types": { - "version": "7.11.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.11.0.tgz", - "integrity": "sha512-kt1ZriHTi7MU+Z/r9DOdAI3ONdaR3M3csEaRc6ewa4f4dTvX4cQCbJ4NkEn0ohE4hHtq85+PhPSTY+pO/1PwgA==", + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", + "integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==", "devOptional": true }, "node_modules/unicode-canonical-property-names-ecmascript": { diff --git a/package.json b/package.json index 42406448..698a60fb 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "react-error-boundary": "^4.0.13", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-google-recaptcha-v3": "^1.11.0", "react-helmet": "6.1.0", "react-loading-skeleton": "3.5.0", "react-redux": "7.2.9", diff --git a/src/MainApp.jsx b/src/MainApp.jsx index 7beaa3d1..f866f3cb 100755 --- a/src/MainApp.jsx +++ b/src/MainApp.jsx @@ -2,6 +2,7 @@ import React from 'react'; import { getConfig } from '@edx/frontend-platform'; import { AppProvider } from '@edx/frontend-platform/react'; +import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3'; import { Helmet } from 'react-helmet'; import { Navigate, Route, Routes } from 'react-router-dom'; @@ -32,34 +33,43 @@ import './index.scss'; registerIcons(); -const MainApp = () => ( - - - - - - } /> - } - /> - - } - /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - -); +const MainApp = () => { + const recaptchaKey = getConfig().RECAPTCHA_SITE_KEY_WEB; + + return ( + + + + + + + } /> + } + /> + + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + + ); +}; export default MainApp; diff --git a/src/config/index.js b/src/config/index.js index 6399b549..61239dbd 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -37,6 +37,7 @@ const configuration = { ALGOLIA_APP_ID: process.env.ALGOLIA_APP_ID || '', ALGOLIA_SEARCH_API_KEY: process.env.ALGOLIA_SEARCH_API_KEY || '', AUTO_GENERATED_USERNAME_EXPERIMENT_ID: process.env.AUTO_GENERATED_USERNAME_EXPERIMENT_ID || '', + RECAPTCHA_SITE_KEY_WEB: process.env.RECAPTCHA_SITE_KEY_WEB || '', }; export default configuration; diff --git a/src/register/RegistrationPage.jsx b/src/register/RegistrationPage.jsx index 50236e80..639c5b3e 100644 --- a/src/register/RegistrationPage.jsx +++ b/src/register/RegistrationPage.jsx @@ -31,6 +31,7 @@ import { FORM_SUBMISSION_ERROR, TPA_AUTHENTICATION_FAILURE, } from './data/constants'; +import useRecaptchaSubmission from './data/hooks'; import { AUTO_GENERATED_USERNAME_REGISTRATION_EXP_VARIATION, NOT_INITIALIZED } from './data/optimizelyExperiment/helper'; import useAutoGeneratedUsernameExperimentVariation from './data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation'; import getBackendValidations from './data/selectors'; @@ -61,6 +62,7 @@ const RegistrationPage = (props) => { const { formatMessage } = useIntl(); const dispatch = useDispatch(); + const { executeWithFallback } = useRecaptchaSubmission('submit_registration_form'); const registrationEmbedded = isHostAvailableInQueryParams(); const platformName = getConfig().SITE_NAME; const flags = { @@ -230,7 +232,7 @@ const RegistrationPage = (props) => { } }; - const registerUser = () => { + const registerUser = async () => { const totalRegistrationTime = (Date.now() - formStartTime) / 1000; let payload = { ...formFields, app_name: APP_NAME }; @@ -250,7 +252,7 @@ const RegistrationPage = (props) => { fieldDescriptions, formatMessage, ); - setErrors({ ...fieldErrors }); + setErrors({ ...fieldErrors, captchaError: '' }); dispatch(setEmailSuggestionInStore(emailSuggestion)); // returning if not valid @@ -259,15 +261,28 @@ const RegistrationPage = (props) => { return; } - // Preparing payload for submission + let recaptchaToken = null; + try { + recaptchaToken = await executeWithFallback(); + } catch (err) { + setErrors(prev => ({ + ...prev, + captchaError: err.message, + })); + return; + } + payload = prepareRegistrationPayload( payload, configurableFormFields, flags.showMarketingEmailOptInCheckbox, totalRegistrationTime, - queryParams); + queryParams, + ); + if (recaptchaToken) { + payload = { ...payload, captcha_token: recaptchaToken }; + } - // making register call dispatch(registerNewUser(payload)); }; @@ -395,6 +410,11 @@ const RegistrationPage = (props) => { fieldDescriptions={fieldDescriptions} countriesCodesList={countriesCodesList} /> + {errors?.captchaError && ( +
+ {errors.captchaError} +
+ )} ({ getLocale: jest.fn(), })); jest.mock('./data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation', () => jest.fn()); +jest.mock('./data/hooks', () => ({ + __esModule: true, + default: jest.fn(() => ({ + executeWithFallback: jest.fn(), + })), +})); const mockStore = configureStore(); mockTagular(); @@ -134,6 +141,11 @@ describe('RegistrationPage', () => { }; window.location = { search: '' }; useAutoGeneratedUsernameExperimentVariation.mockReturnValue(NOT_INITIALIZED); + useRecaptchaSubmission.mockReturnValue({ + executeWithFallback: jest.fn().mockResolvedValue(null), + isReady: true, + isLoading: false, + }); }); afterEach(() => { @@ -175,7 +187,7 @@ describe('RegistrationPage', () => { // ******** test registration form submission ******** - it('should submit form for valid input', () => { + it('should submit form for valid input', async () => { getLocale.mockImplementation(() => ('en-us')); jest.spyOn(global.Date, 'now').mockImplementation(() => 0); @@ -200,10 +212,12 @@ describe('RegistrationPage', () => { const button = container.querySelector('button.btn-brand'); fireEvent.click(button); - expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' })); + await waitFor(() => { + expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' })); + }); }); - it('should submit form without password field when current provider is present', () => { + it('should submit form without password field when current provider is present', async () => { jest.spyOn(global.Date, 'now').mockImplementation(() => 0); const formPayload = { @@ -233,7 +247,9 @@ describe('RegistrationPage', () => { populateRequiredFields(getByLabelText, formPayload, true); const button = container.querySelector('button.btn-brand'); fireEvent.click(button); - expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...formPayload, country: 'PK' })); + await waitFor(() => { + expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...formPayload, country: 'PK' })); + }); }); it('should display an error when form is submitted with an invalid email', () => { @@ -284,7 +300,7 @@ describe('RegistrationPage', () => { expect(validationErrors.textContent).toContain(usernameError); }); - it('should submit form with marketing email opt in value', () => { + it('should submit form with marketing email opt in value', async () => { mergeConfig({ MARKETING_EMAILS_OPT_IN: 'true', }); @@ -308,14 +324,16 @@ describe('RegistrationPage', () => { populateRequiredFields(getByLabelText, payload); const button = container.querySelector('button.btn-brand'); fireEvent.click(button); - expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' })); + await waitFor(() => { + expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' })); + }); mergeConfig({ MARKETING_EMAILS_OPT_IN: '', }); }); - it('should submit form without UsernameField when autoGeneratedUsernameEnabled is true', () => { + it('should submit form without UsernameField when autoGeneratedUsernameEnabled is true', async () => { mergeConfig({ ENABLE_AUTO_GENERATED_USERNAME: true, }); @@ -335,7 +353,9 @@ describe('RegistrationPage', () => { populateRequiredFields(getByLabelText, payload, false, true); const button = container.querySelector('button.btn-brand'); fireEvent.click(button); - expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' })); + await waitFor(() => { + expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' })); + }); mergeConfig({ ENABLE_AUTO_GENERATED_USERNAME: false, }); @@ -367,6 +387,124 @@ describe('RegistrationPage', () => { // ******** test registration form validations ******** + it('should submit form with valid reCAPTCHA token', async () => { + getLocale.mockImplementation(() => ('en-us')); + jest.spyOn(global.Date, 'now').mockImplementation(() => 0); + + useRecaptchaSubmission.mockReturnValue({ + executeWithFallback: jest.fn().mockResolvedValue('mock-recaptcha-token'), + isReady: true, + isLoading: false, + }); + + const payload = { + name: 'John Doe', + username: 'john_doe', + email: 'john.doe@gmail.com', + password: 'password1', + country: 'Pakistan', + honor_code: true, + total_registration_time: 0, + app_name: APP_NAME, + }; + + store.dispatch = jest.fn(store.dispatch); + const { getByLabelText, container } = render(routerWrapper(reduxWrapper())); + populateRequiredFields(getByLabelText, payload); + + const button = container.querySelector('button.btn-brand'); + fireEvent.click(button); + + await waitFor(() => { + const actions = store.dispatch.mock.calls.map(call => call[0]); + const registerAction = actions.find(a => a.type === registerNewUser().type); + + expect(registerAction).toBeTruthy(); + expect(registerAction.payload).toMatchObject({ + registrationInfo: { + ...payload, + country: 'PK', + captcha_token: 'mock-recaptcha-token', + }, + }); + }); + }); + + it('should display error when reCAPTCHA verification fails', async () => { + getLocale.mockImplementation(() => ('en-us')); + jest.spyOn(global.Date, 'now').mockImplementation(() => 0); + + useRecaptchaSubmission.mockReturnValue({ + executeWithFallback: jest.fn().mockRejectedValue(new Error('CAPTCHA verification failed.')), + isReady: true, + isLoading: false, + }); + + const payload = { + name: 'John Doe', + username: 'john_doe', + email: 'john.doe@gmail.com', + password: 'password1', + country: 'Pakistan', + honor_code: true, + total_registration_time: 0, + }; + + store.dispatch = jest.fn(store.dispatch); + const { getByLabelText, container } = render(routerWrapper(reduxWrapper())); + populateRequiredFields(getByLabelText, payload); + + const button = container.querySelector('button.btn-brand'); + fireEvent.click(button); + + await waitFor(() => { + const captchaError = container.querySelector('.pgn__form-text-invalid'); + expect(captchaError.textContent).toContain('CAPTCHA verification failed.'); + }); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.objectContaining({ + type: registerNewUser().type, + })); + }); + + it('should submit without reCAPTCHA token if reCAPTCHA is disabled', async () => { + getLocale.mockImplementation(() => ('en-us')); + jest.spyOn(global.Date, 'now').mockImplementation(() => 0); + + useRecaptchaSubmission.mockReturnValue({ + executeWithFallback: jest.fn().mockResolvedValue(null), + isReady: true, + isLoading: false, + }); + + const payload = { + name: 'John Doe', + username: 'john_doe', + email: 'john.doe@gmail.com', + password: 'password1', + country: 'Pakistan', + honor_code: true, + total_registration_time: 0, + app_name: APP_NAME, + }; + + store.dispatch = jest.fn(store.dispatch); + const { getByLabelText, container } = render(routerWrapper(reduxWrapper())); + populateRequiredFields(getByLabelText, payload); + + const button = container.querySelector('button.btn-brand'); + fireEvent.click(button); + + await waitFor(() => { + expect(store.dispatch).toHaveBeenCalledWith( + registerNewUser({ + ...payload, + country: 'PK', + }), + ); + }); + }); + it('should show error messages for required fields on empty form submission', () => { const { container } = render(routerWrapper(reduxWrapper())); @@ -846,7 +984,7 @@ describe('RegistrationPage', () => { expect(registrationFormElement).toBeFalsy(); }); - it('should auto register if autoSubmitRegForm is true and pipeline details are loaded', () => { + it('should auto register if autoSubmitRegForm is true and pipeline details are loaded', async () => { jest.spyOn(global.Date, 'now').mockImplementation(() => 0); getLocale.mockImplementation(() => ('en-us')); @@ -890,15 +1028,17 @@ describe('RegistrationPage', () => { store.dispatch = jest.fn(store.dispatch); render(routerWrapper(reduxWrapper())); - expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ - name: 'John Doe', - username: 'john_doe', - email: 'john.doe@example.com', - country: 'PK', - social_auth_provider: 'Apple', - total_registration_time: 0, - app_name: APP_NAME, - })); + await waitFor(() => { + expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ + name: 'John Doe', + username: 'john_doe', + email: 'john.doe@example.com', + country: 'PK', + social_auth_provider: 'Apple', + total_registration_time: 0, + app_name: APP_NAME, + })); + }); }); }); }); diff --git a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx index 38e692e3..97589b2d 100644 --- a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx +++ b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx @@ -4,7 +4,7 @@ import { mergeConfig } from '@edx/frontend-platform'; import { getLocale, IntlProvider, } from '@edx/frontend-platform/i18n'; -import { fireEvent, render } from '@testing-library/react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; import { BrowserRouter as Router } from 'react-router-dom'; import configureStore from 'redux-mock-store'; @@ -225,7 +225,7 @@ describe('ConfigurableRegistrationForm', () => { expect(document.querySelector('#tos')).toBeTruthy(); }); - it('should submit form with fields returned by backend in payload', () => { + it('should submit form with fields returned by backend in payload', async () => { mergeConfig({ SHOW_CONFIGURABLE_EDX_FIELDS: true, }); @@ -265,7 +265,9 @@ describe('ConfigurableRegistrationForm', () => { fireEvent.click(submitButton); - expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK', app_name: APP_NAME })); + await waitFor(() => { + expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK', app_name: APP_NAME })); + }); }); it('should show error messages for required fields on empty form submission', () => { diff --git a/src/register/data/hooks.js b/src/register/data/hooks.js new file mode 100644 index 00000000..4eabd015 --- /dev/null +++ b/src/register/data/hooks.js @@ -0,0 +1,40 @@ +import { useCallback } from 'react'; + +import { getConfig } from '@edx/frontend-platform'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { useGoogleReCaptcha } from 'react-google-recaptcha-v3'; + +import messages from '../messages'; + +const useRecaptchaSubmission = (actionName = 'submit') => { + const { formatMessage } = useIntl(); + const { executeRecaptcha } = useGoogleReCaptcha(); + const recaptchaKey = getConfig().RECAPTCHA_SITE_KEY_WEB; + + const isReady = !!executeRecaptcha || !recaptchaKey; + + const executeWithFallback = useCallback(async () => { + if (executeRecaptcha && recaptchaKey) { + const token = await executeRecaptcha(actionName); + if (!token) { + throw new Error(formatMessage(messages['registration.captcha.verification.label'])); + } + return token; + } + + // Fallback: no reCAPTCHA or not ready + if (recaptchaKey) { + // eslint-disable-next-line no-console + console.warn(`reCAPTCHA not ready for action: ${actionName}. Proceeding without token.`); + } + return null; + }, [executeRecaptcha, recaptchaKey, actionName, formatMessage]); + + return { + executeWithFallback, + isReady, + isLoading: recaptchaKey && !executeRecaptcha, + }; +}; + +export default useRecaptchaSubmission; diff --git a/src/register/data/hooks.test.jsx b/src/register/data/hooks.test.jsx new file mode 100644 index 00000000..e47760a1 --- /dev/null +++ b/src/register/data/hooks.test.jsx @@ -0,0 +1,90 @@ +import { getConfig } from '@edx/frontend-platform'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { renderHook } from '@testing-library/react'; +import { useGoogleReCaptcha } from 'react-google-recaptcha-v3'; + +import useRecaptchaSubmission from './hooks'; + +jest.mock('@edx/frontend-platform', () => ({ + getConfig: jest.fn(), +})); + +jest.mock('react-google-recaptcha-v3', () => ({ + useGoogleReCaptcha: jest.fn(), +})); + +jest.mock('@edx/frontend-platform/i18n', () => ({ + ...jest.requireActual('@edx/frontend-platform/i18n'), + useIntl: () => ({ formatMessage: (msg) => msg.defaultMessage || msg }), +})); + +describe('useRecaptchaSubmission', () => { + beforeEach(() => { + getConfig.mockReturnValue({ RECAPTCHA_SITE_KEY_WEB: 'test-key' }); + useGoogleReCaptcha.mockReturnValue({ executeRecaptcha: jest.fn() }); + }); + + it('should throw error if reCAPTCHA returns empty token', async () => { + useGoogleReCaptcha.mockReturnValue({ + executeRecaptcha: jest.fn().mockResolvedValue(null), + }); + + const { result } = renderHook(() => useRecaptchaSubmission('test_action'), { + wrapper: ({ children }) => {children}, + }); + + await expect(result.current.executeWithFallback()).rejects.toThrow( + 'CAPTCHA verification failed.', + ); + }); + + it('should warn and return null if reCAPTCHA key exists but executeRecaptcha is not ready', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + useGoogleReCaptcha.mockReturnValue({ + executeRecaptcha: undefined, + }); + + const { result } = renderHook(() => useRecaptchaSubmission('test_action'), { + wrapper: ({ children }) => {children}, + }); + + const token = await result.current.executeWithFallback(); + + expect(token).toBeNull(); + expect(warnSpy).toHaveBeenCalledWith( + 'reCAPTCHA not ready for action: test_action. Proceeding without token.', + ); + + warnSpy.mockRestore(); + }); + + it('should handle undefined RECAPTCHA_SITE_KEY_WEB gracefully', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + getConfig.mockReturnValue({ RECAPTCHA_SITE_KEY_WEB: undefined }); + + const { result } = renderHook(() => useRecaptchaSubmission('test_action'), { + wrapper: ({ children }) => {children}, + }); + + const token = await result.current.executeWithFallback(); + + expect(token).toBeNull(); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it('should return token if reCAPTCHA succeeds', async () => { + useGoogleReCaptcha.mockReturnValue({ + executeRecaptcha: jest.fn().mockResolvedValue('valid-token'), + }); + + const { result } = renderHook(() => useRecaptchaSubmission('test_action'), { + wrapper: ({ children }) => {children}, + }); + + const token = await result.current.executeWithFallback(); + expect(token).toBe('valid-token'); + }); +}); diff --git a/src/register/messages.jsx b/src/register/messages.jsx index f1750e6e..fe15394d 100644 --- a/src/register/messages.jsx +++ b/src/register/messages.jsx @@ -206,6 +206,11 @@ const messages = defineMessages({ defaultMessage: 'Did you mean', description: 'Did you mean alert suggestion', }, + 'registration.captcha.verification.label': { + id: 'registration.captcha.verification.label', + defaultMessage: 'CAPTCHA verification failed.', + description: 'CAPTCHA verification failed', + }, }); export default messages;