feat: updated v2 captcha to v3 in post editor (#803)

* feat: updated v2 captcha to v3 in post editor

* feat: added google captcha v3 for comment

* test: added test cases

* test: added test case to update the post

* test: updated test case for preview node

* test: updated test case for comment error

* test: removed mock file

* fix: removed comments

---------

Co-authored-by: sundasnoreen12 <sundasnoreen12@gmail.com>
This commit is contained in:
Ahtisham Shahid
2025-08-22 16:54:52 +05:00
committed by GitHub
parent 76fabbf7a6
commit 28b1b1973b
10 changed files with 230 additions and 347 deletions

13
package-lock.json generated
View File

@@ -28,6 +28,7 @@
"react": "18.3.1",
"react-dom": "18.3.1",
"react-google-recaptcha": "^3.1.0",
"react-google-recaptcha-v3": "^1.11.0",
"react-helmet": "6.1.0",
"react-redux": "7.2.9",
"react-router": "6.18.0",
@@ -21912,6 +21913,18 @@
"react": ">=16.4.1"
}
},
"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",

View File

@@ -48,6 +48,7 @@
"react": "18.3.1",
"react-dom": "18.3.1",
"react-google-recaptcha": "^3.1.0",
"react-google-recaptcha-v3": "^1.11.0",
"react-helmet": "6.1.0",
"react-redux": "7.2.9",
"react-router": "6.18.0",

View File

@@ -1,38 +1,46 @@
import React, { lazy, Suspense } from 'react';
import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3';
import { useSelector } from 'react-redux';
import { Route, Routes } from 'react-router-dom';
import Spinner from '../../components/Spinner';
import { Routes as ROUTES } from '../../data/constants';
import { selectCaptchaSettings } from '../data/selectors';
const PostEditor = lazy(() => import('../posts/post-editor/PostEditor'));
const PostCommentsView = lazy(() => import('../post-comments/PostCommentsView'));
const DiscussionContent = () => {
const postEditorVisible = useSelector((state) => state.threads.postEditorVisible);
const captchaSettings = useSelector(selectCaptchaSettings);
return (
<div className="d-flex bg-light-400 flex-column w-75 w-xs-100 w-xl-75 align-items-center overflow-auto">
<div className="d-flex flex-column w-100">
<Suspense fallback={(<Spinner />)}>
<Routes>
{postEditorVisible ? (
<Route path={ROUTES.POSTS.NEW_POST} element={<PostEditor />} />
) : (
<>
{ROUTES.POSTS.EDIT_POST.map(route => (
<Route key={route} path={route} element={<PostEditor editExisting />} />
))}
{ROUTES.COMMENTS.PATH.map(route => (
<Route key={route} path={route} element={<PostCommentsView />} />
))}
</>
)}
</Routes>
</Suspense>
<GoogleReCaptchaProvider
reCaptchaKey={captchaSettings.siteKey}
useEnterprise
>
<div className="d-flex bg-light-400 flex-column w-75 w-xs-100 w-xl-75 align-items-center overflow-auto">
<div className="d-flex flex-column w-100">
<Suspense fallback={(<Spinner />)}>
<Routes>
{postEditorVisible ? (
<Route path={ROUTES.POSTS.NEW_POST} element={<PostEditor />} />
) : (
<>
{ROUTES.POSTS.EDIT_POST.map(route => (
<Route key={route} path={route} element={<PostEditor editExisting />} />
))}
{ROUTES.COMMENTS.PATH.map(route => (
<Route key={route} path={route} element={<PostCommentsView />} />
))}
</>
)}
</Routes>
</Suspense>
</div>
</div>
</div>
</GoogleReCaptchaProvider>
);
};

View File

@@ -1,9 +1,8 @@
import React, { useRef } from 'react';
import {
act, fireEvent, render, screen, waitFor, within,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { useGoogleReCaptcha } from 'react-google-recaptcha-v3';
import { IntlProvider } from 'react-intl';
import {
MemoryRouter, Route, Routes, useLocation,
@@ -27,12 +26,6 @@ import fetchCourseConfig from '../data/thunks';
import DiscussionContent from '../discussions-home/DiscussionContent';
import { getThreadsApiUrl } from '../posts/data/api';
import { fetchThread, fetchThreads } from '../posts/data/thunks';
import MockReCAPTCHA, {
mockOnChange,
mockOnError,
mockOnExpired,
mockReset,
} from '../posts/post-editor/mocksData/react-google-recaptcha';
import fetchCourseTopics from '../topics/data/thunks';
import { getDiscussionTourUrl } from '../tours/data/api';
import selectTours from '../tours/data/selectors';
@@ -63,7 +56,11 @@ let testLocation;
let container;
let unmount;
jest.mock('react-google-recaptcha', () => MockReCAPTCHA);
jest.mock('react-google-recaptcha-v3', () => ({
useGoogleReCaptcha: jest.fn(),
// eslint-disable-next-line react/prop-types
GoogleReCaptchaProvider: ({ children }) => <div>{children}</div>,
}));
async function mockAxiosReturnPagedComments(threadId, threadType = ThreadType.DISCUSSION, page = 1, count = 2) {
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult', { can_delete: true }, {
@@ -310,6 +307,8 @@ describe('ThreadView', () => {
it('should show and hide the editor', async () => {
await setupCourseConfig();
const mockExecuteRecaptchaNew = jest.fn(() => Promise.resolve('mock-token'));
useGoogleReCaptcha.mockReturnValue({ executeRecaptcha: mockExecuteRecaptchaNew });
await waitFor(() => renderComponent(discussionPostId));
const post = screen.getByTestId('post-thread-1');
@@ -348,13 +347,18 @@ describe('ThreadView', () => {
it('should allow posting a comment with CAPTCHA', async () => {
await setupCourseConfig(true, false, false);
const mockExecuteRecaptcha = jest.fn(() => Promise.resolve('mock-token'));
useGoogleReCaptcha.mockReturnValue({ executeRecaptcha: mockExecuteRecaptcha });
await waitFor(() => renderComponent(discussionPostId));
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
await act(async () => { fireEvent.click(within(hoverCard).getByRole('button', { name: /Add comment/i })); });
await act(async () => { fireEvent.click(screen.getByText(/submit/i)); });
await act(async () => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'New comment with CAPTCHA' } }); });
await act(async () => { fireEvent.click(screen.getByText('Solve CAPTCHA')); });
await act(async () => { fireEvent.click(screen.getByText(/submit/i)); });
await waitFor(() => {
@@ -366,10 +370,43 @@ describe('ThreadView', () => {
raw_body: 'New comment with CAPTCHA',
thread_id: 'thread-1',
});
expect(mockOnChange).toHaveBeenCalled();
});
});
it('should show captcha error if executeRecaptcha returns null token', async () => {
await setupCourseConfig(true, false, false);
const mockExecuteRecaptcha = jest.fn().mockResolvedValue(null);
useGoogleReCaptcha.mockReturnValue({ executeRecaptcha: mockExecuteRecaptcha });
await waitFor(() => renderComponent(discussionPostId));
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
await act(async () => { fireEvent.click(within(hoverCard).getByRole('button', { name: /Add comment/i })); });
await act(async () => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'New comment with CAPTCHA' } }); });
await act(async () => { fireEvent.click(screen.getByText(/submit/i)); });
expect(screen.getByText('CAPTCHA verification failed.')).toBeInTheDocument();
});
it('should show captcha error if executeRecaptcha throws', async () => {
await setupCourseConfig(true, false, false);
const mockExecuteRecaptcha = jest.fn().mockRejectedValue(new Error('recaptcha failed'));
useGoogleReCaptcha.mockReturnValue({ executeRecaptcha: mockExecuteRecaptcha });
await waitFor(() => renderComponent(discussionPostId));
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
await act(async () => { fireEvent.click(within(hoverCard).getByRole('button', { name: /Add comment/i })); });
await act(async () => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'New comment with CAPTCHA' } }); });
await act(async () => { fireEvent.click(screen.getByText(/submit/i)); });
expect(screen.getByText('CAPTCHA verification failed.')).toBeInTheDocument();
});
it('should allow posting a comment', async () => {
await setupCourseConfig();
await waitFor(() => renderComponent(discussionPostId));
@@ -659,46 +696,6 @@ describe('ThreadView', () => {
describe('for discussion thread', () => {
const findLoadMoreCommentsButton = () => screen.findByTestId('load-more-comments');
it('renders the mocked ReCAPTCHA.', async () => {
await setupCourseConfig(true, false, false);
await waitFor(() => renderComponent(discussionPostId));
await act(async () => {
fireEvent.click(screen.queryByText('Add comment'));
});
expect(screen.getByTestId('mocked-recaptcha')).toBeInTheDocument();
});
it('successfully calls onTokenChange when Solve CAPTCHA button is clicked', async () => {
await setupCourseConfig(true, false, false);
await waitFor(() => renderComponent(discussionPostId));
await act(async () => {
fireEvent.click(screen.queryByText('Add comment'));
});
const solveButton = screen.getByText('Solve CAPTCHA');
fireEvent.click(solveButton);
expect(mockOnChange).toHaveBeenCalled();
});
it('successfully calls onExpired handler when CAPTCHA expires', async () => {
await setupCourseConfig(true, false, false);
await waitFor(() => renderComponent(discussionPostId));
await act(async () => {
fireEvent.click(screen.queryByText('Add comment'));
});
fireEvent.click(screen.getByText('Expire CAPTCHA'));
expect(mockOnExpired).toHaveBeenCalled();
});
it('successfully calls onError handler when CAPTCHA errors', async () => {
await setupCourseConfig(true, false, false);
await waitFor(() => renderComponent(discussionPostId));
await act(async () => {
fireEvent.click(screen.queryByText('Add comment'));
});
fireEvent.click(screen.getByText('Error CAPTCHA'));
expect(mockOnError).toHaveBeenCalled();
});
it('shown post not found when post id does not belong to course', async () => {
await waitFor(() => renderComponent('unloaded-id'));
expect(await screen.findByText('Thread not found', { exact: true }))
@@ -1161,61 +1158,3 @@ describe('ThreadView', () => {
expect(screen.queryByTestId('tinymce-editor')).toBeInTheDocument();
});
});
describe('MockReCAPTCHA', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('uses defaultProps when props are not provided', () => {
render(<MockReCAPTCHA />);
expect(screen.getByTestId('mocked-recaptcha')).toBeInTheDocument();
fireEvent.click(screen.getByText('Solve CAPTCHA'));
fireEvent.click(screen.getByText('Expire CAPTCHA'));
fireEvent.click(screen.getByText('Error CAPTCHA'));
expect(mockOnChange).toHaveBeenCalled();
expect(mockOnExpired).toHaveBeenCalled();
expect(mockOnError).toHaveBeenCalled();
});
it('triggers all callbacks and exposes reset via ref', () => {
const onChange = jest.fn();
const onExpired = jest.fn();
const onError = jest.fn();
const Wrapper = () => {
const recaptchaRef = useRef(null);
return (
<div>
<MockReCAPTCHA
ref={recaptchaRef}
onChange={onChange}
onExpired={onExpired}
onError={onError}
/>
<button onClick={() => recaptchaRef.current.reset()} data-testid="reset-btn" type="button">Reset</button>
</div>
);
};
const { getByText, getByTestId } = render(<Wrapper />);
fireEvent.click(getByText('Solve CAPTCHA'));
fireEvent.click(getByText('Expire CAPTCHA'));
fireEvent.click(getByText('Error CAPTCHA'));
fireEvent.click(getByTestId('reset-btn'));
expect(mockOnChange).toHaveBeenCalled();
expect(mockOnExpired).toHaveBeenCalled();
expect(mockOnError).toHaveBeenCalled();
expect(onChange).toHaveBeenCalledWith('mock-token');
expect(onExpired).toHaveBeenCalled();
expect(onError).toHaveBeenCalled();
expect(mockReset).toHaveBeenCalled();
});
});

View File

@@ -5,7 +5,7 @@ import PropTypes from 'prop-types';
import { Button, Form, StatefulButton } from '@openedx/paragon';
import { Formik } from 'formik';
import ReCAPTCHA from 'react-google-recaptcha';
import { useGoogleReCaptcha } from 'react-google-recaptcha-v3';
import { useSelector } from 'react-redux';
import * as Yup from 'yup';
@@ -49,7 +49,7 @@ const CommentEditor = ({
const [isSubmitting, setIsSubmitting] = useState(false);
const editorRef = useRef(null);
const formRef = useRef(null);
const recaptchaRef = useRef(null);
const { executeRecaptcha } = useGoogleReCaptcha();
const { authenticatedUser } = useContext(AppContext);
const { enableInContextSidebar } = useContext(DiscussionContext);
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
@@ -63,13 +63,10 @@ const CommentEditor = ({
const isUserLearner = useSelector(selectIsUserLearner);
const contentCreationRateLimited = useSelector(selectContentCreationRateLimited);
const postStatus = useSelector(selectPostStatus);
const [captchaError, setCaptchaError] = useState('');
const shouldRequireCaptcha = !id && captchaSettings.enabled && isUserLearner;
const captchaValidation = {
recaptchaToken: Yup.string().required(intl.formatMessage(messages.captchaVerificationLabel)),
};
const canDisplayEditReason = (edit
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
&& author !== authenticatedUser.username
@@ -82,15 +79,12 @@ const CommentEditor = ({
const validationSchema = Yup.object().shape({
comment: Yup.string()
.required(),
...(shouldRequireCaptcha ? { recaptchaToken: Yup.string().required() } : { }),
...editReasonCodeValidation,
...(shouldRequireCaptcha ? captchaValidation : {}),
});
const initialValues = {
comment: editorContent,
editReasonCode: lastEdit?.reasonCode || (userIsStaff && canDisplayEditReason ? 'violates-guidelines' : undefined),
recaptchaToken: '',
};
useEffect(() => {
@@ -99,20 +93,8 @@ const CommentEditor = ({
}
}, [contentCreationRateLimited, id]);
const handleCaptchaChange = useCallback((token, setFieldValue) => {
setFieldValue('recaptchaToken', token || '');
}, []);
const handleCaptchaExpired = useCallback((setFieldValue) => {
setFieldValue('recaptchaToken', '');
}, []);
const handleCloseEditor = useCallback((resetForm) => {
resetForm({ values: initialValues });
// Reset CAPTCHA when hiding editor
if (recaptchaRef.current) {
recaptchaRef.current.reset();
}
}, [initialValues]);
const deleteEditorContent = useCallback(async () => {
@@ -125,13 +107,14 @@ const CommentEditor = ({
}, [parentId, id, threadId, setDraftComments, setDraftResponses]);
useEffect(() => {
if (postStatus === RequestStatus.SUCCESSFUL && isSubmitting) {
if (postStatus === RequestStatus.SUCCESSFUL && isSubmitting && !captchaError) {
onCloseEditor();
}
}, [postStatus, isSubmitting]);
const saveUpdatedComment = useCallback(async (values, { resetForm }) => {
setIsSubmitting(true);
let recaptchaToken;
if (id) {
const payload = {
...values,
@@ -139,7 +122,20 @@ const CommentEditor = ({
};
await dispatch(editComment(id, payload));
} else {
await dispatch(addComment(values.comment, threadId, parentId, enableInContextSidebar, shouldRequireCaptcha ? values.recaptchaToken : ''));
if (shouldRequireCaptcha && executeRecaptcha) {
try {
recaptchaToken = await executeRecaptcha('submit_post');
if (!recaptchaToken) {
setCaptchaError(intl.formatMessage(messages.captchaVerificationLabel));
return;
}
} catch (error) {
setCaptchaError(intl.formatMessage(messages.captchaVerificationLabel));
return;
}
setCaptchaError('');
}
await dispatch(addComment(values.comment, threadId, parentId, enableInContextSidebar, shouldRequireCaptcha ? recaptchaToken : ''));
}
/* istanbul ignore if: TinyMCE is mocked so this cannot be easily tested */
if (editorRef.current) {
@@ -147,7 +143,7 @@ const CommentEditor = ({
}
handleCloseEditor(resetForm);
deleteEditorContent();
}, [id, threadId, parentId, enableInContextSidebar, handleCloseEditor, shouldRequireCaptcha]);
}, [id, threadId, parentId, enableInContextSidebar, handleCloseEditor, shouldRequireCaptcha, executeRecaptcha]);
// The editorId is used to autosave contents to localstorage. This format means that the autosave is scoped to
// the current comment id, or the current comment parent or the curren thread.
const editorId = `comment-editor-${id || parentId || threadId}`;
@@ -194,7 +190,6 @@ const CommentEditor = ({
handleBlur,
handleChange,
resetForm,
setFieldValue,
}) => (
<Form onSubmit={handleSubmit} className={formClasses} ref={formRef}>
{canDisplayEditReason && (
@@ -250,32 +245,11 @@ const CommentEditor = ({
</Form.Control.Feedback>
)}
<PostPreviewPanel htmlNode={values.comment} />
{/* CAPTCHA Section - Only show for new posts from non-staff users */}
{ shouldRequireCaptcha && captchaSettings.siteKey && (
<div className="mb-3">
<Form.Group
isInvalid={isFormikFieldInvalid('recaptchaToken', {
errors,
touched,
})}
>
<Form.Label className="h6">
{intl.formatMessage(messages.verifyHumanLabel)}
</Form.Label>
<div className="d-flex justify-content-start">
<ReCAPTCHA
ref={recaptchaRef}
sitekey={captchaSettings.siteKey}
onChange={(token) => handleCaptchaChange(token, setFieldValue)}
onExpired={() => handleCaptchaExpired(setFieldValue)}
onError={() => handleCaptchaExpired(setFieldValue)}
/>
</div>
<FormikErrorFeedback name="recaptchaToken" />
</Form.Group>
</div>
) }
{ shouldRequireCaptcha && captchaSettings.siteKey && captchaError && (
<div className="mb-3 pgn__form-text-invalid pgn__form-text">
{captchaError}
</div>
)}
<div className="d-flex py-2 justify-content-end">
<Button
variant="outline-primary"

View File

@@ -221,15 +221,10 @@ const messages = defineMessages({
}`,
description: 'sort message showing current sorting',
},
verifyHumanLabel: {
id: 'discussions.verify.human.label',
defaultMessage: 'Verify you are human',
description: 'Verify you are human description.',
},
captchaVerificationLabel: {
id: 'discussions.captcha.verification.label',
defaultMessage: 'Please complete the CAPTCHA verification',
description: 'Please complete the CAPTCHA to continue.',
defaultMessage: 'CAPTCHA verification failed.',
description: 'CAPTCHA verification failed',
},
});

View File

@@ -1,5 +1,5 @@
import React, {
useCallback, useContext, useEffect, useRef,
useCallback, useContext, useEffect, useRef, useState,
} from 'react';
import PropTypes from 'prop-types';
@@ -9,7 +9,7 @@ import {
import { Help, Post } from '@openedx/paragon/icons';
import { Formik } from 'formik';
import { isEmpty } from 'lodash';
import ReCAPTCHA from 'react-google-recaptcha';
import { useGoogleReCaptcha } from 'react-google-recaptcha-v3';
import { useDispatch, useSelector } from 'react-redux';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import * as Yup from 'yup';
@@ -66,13 +66,14 @@ const PostEditor = ({
const location = useLocation();
const dispatch = useDispatch();
const editorRef = useRef(null);
const recaptchaRef = useRef(null);
const { executeRecaptcha } = useGoogleReCaptcha();
const { courseId, postId } = useParams();
const { authenticatedUser } = useContext(AppContext);
const { category, enableInContextSidebar } = useContext(DiscussionContext);
const topicId = useCurrentDiscussionTopic();
const commentsPagePath = useCommentsPagePath();
const [submitting, dispatchSubmit] = useDispatchWithState();
const [captchaError, setCaptchaError] = useState('');
const enableInContext = useSelector(selectEnableInContext);
const nonCoursewareTopics = useSelector(enableInContext ? inContextNonCourseware : selectNonCoursewareTopics);
const nonCoursewareIds = useSelector(enableInContext ? inContextCoursewareIds : selectNonCoursewareIds);
@@ -91,6 +92,7 @@ const PostEditor = ({
const captchaSettings = useSelector(selectCaptchaSettings);
const isUserLearner = useSelector(selectIsUserLearner);
const contentCreationRateLimited = useSelector(selectContentCreationRateLimited);
const shouldRequireCaptcha = !postId && captchaSettings.enabled && isUserLearner;
const canDisplayEditReason = (editExisting
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
@@ -101,11 +103,6 @@ const PostEditor = ({
editReasonCode: Yup.string().required(intl.formatMessage(messages.editReasonCodeError)),
};
const shouldRequireCaptcha = !postId && captchaSettings.enabled && isUserLearner;
const captchaValidation = {
recaptchaToken: Yup.string().required(intl.formatMessage(messages.captchaVerificationLabel)),
};
const enableNotifyAllLearnersTour = useCallback((enabled) => {
const data = {
enabled,
@@ -119,15 +116,7 @@ const PostEditor = ({
return () => {
enableNotifyAllLearnersTour(false);
};
}, []);
const handleCaptchaChange = useCallback((token, setFieldValue) => {
setFieldValue('recaptchaToken', token || '');
}, []);
const handleCaptchaExpired = useCallback((setFieldValue) => {
setFieldValue('recaptchaToken', '');
}, []);
}, [enableNotifyAllLearnersTour]);
const canSelectCohort = useCallback((tId) => {
// If the user isn't privileged, they can't edit the cohort.
@@ -155,15 +144,10 @@ const PostEditor = ({
editReasonCode: post?.lastEdit?.reasonCode || (
userIsStaff && canDisplayEditReason ? 'violates-guidelines' : undefined
),
recaptchaToken: '',
};
const hideEditor = useCallback((resetForm) => {
resetForm({ values: initialValues });
// Reset CAPTCHA when hiding editor
if (recaptchaRef.current) {
recaptchaRef.current.reset();
}
if (editExisting) {
const newLocation = discussionsPath(commentsPagePath, {
courseId,
@@ -190,6 +174,21 @@ const PostEditor = ({
);
const submitForm = useCallback(async (values, { resetForm }) => {
let recaptchaToken;
if (shouldRequireCaptcha && executeRecaptcha) {
try {
recaptchaToken = await executeRecaptcha('submit_post');
if (!recaptchaToken) {
setCaptchaError(intl.formatMessage(messages.captchaVerificationLabel));
return;
}
} catch (error) {
setCaptchaError(intl.formatMessage(messages.captchaVerificationLabel));
return;
}
setCaptchaError('');
}
if (editExisting) {
await dispatchSubmit(updateExistingThread(postId, {
topicId: values.topic,
@@ -200,7 +199,7 @@ const PostEditor = ({
}));
} else {
const cohort = canSelectCohort(values.topic) ? selectedCohort(values.cohort) : undefined;
// Include CAPTCHA token in the request for new posts
await dispatchSubmit(createNewThread({
courseId,
topicId: values.topic,
@@ -213,7 +212,7 @@ const PostEditor = ({
cohort,
enableInContextSidebar,
notifyAllLearners: values.notifyAllLearners,
...(shouldRequireCaptcha ? { recaptchaToken: values.recaptchaToken } : {}),
...(shouldRequireCaptcha && recaptchaToken ? { recaptchaToken } : {}),
}));
}
@@ -223,7 +222,7 @@ const PostEditor = ({
hideEditor(resetForm);
}, [
allowAnonymous, allowAnonymousToPeers, canSelectCohort, editExisting,
enableInContextSidebar, hideEditor, postId, selectedCohort, topicId, shouldRequireCaptcha,
enableInContextSidebar, hideEditor, postId, selectedCohort, topicId, shouldRequireCaptcha, executeRecaptcha,
]);
useEffect(() => {
@@ -274,9 +273,7 @@ const PostEditor = ({
cohort: Yup.string()
.nullable()
.default(null),
...(shouldRequireCaptcha ? { recaptchaToken: Yup.string().required() } : { }),
...editReasonCodeValidation,
...(shouldRequireCaptcha ? captchaValidation : {}),
});
const handleInContextSelectLabel = (section, subsection) => (
@@ -297,7 +294,6 @@ const PostEditor = ({
handleBlur,
handleChange,
resetForm,
setFieldValue,
}) => (
<Form className="m-4 card p-4 post-form" onSubmit={handleSubmit}>
<h4 className="mb-4 font-style" style={{ lineHeight: '16px' }}>
@@ -523,29 +519,9 @@ const PostEditor = ({
</>
)}
</div>
{/* CAPTCHA Section - Only show for new posts for non-staff users */}
{shouldRequireCaptcha && captchaSettings.siteKey && (
<div className="mb-3">
<Form.Group
isInvalid={isFormikFieldInvalid('recaptchaToken', {
errors,
touched,
})}
>
<Form.Label className="h6">
{intl.formatMessage(messages.verifyHumanLabel)}
</Form.Label>
<div className="d-flex justify-content-start">
<ReCAPTCHA
ref={recaptchaRef}
sitekey={captchaSettings.siteKey}
onChange={(token) => handleCaptchaChange(token, setFieldValue)}
onExpired={() => handleCaptchaExpired(setFieldValue)}
onError={() => handleCaptchaExpired(setFieldValue)}
/>
</div>
<FormikErrorFeedback name="recaptchaToken" />
</Form.Group>
{ shouldRequireCaptcha && captchaSettings.siteKey && captchaError && (
<div className="mb-3 pgn__form-text-invalid pgn__form-text">
{captchaError}
</div>
)}
<div className="d-flex justify-content-end">

View File

@@ -6,6 +6,7 @@ import {
import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter';
import { act } from 'react-dom/test-utils';
import { useGoogleReCaptcha } from 'react-google-recaptcha-v3';
import { IntlProvider } from 'react-intl';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { Factory } from 'rosie';
@@ -25,10 +26,8 @@ import { getCourseConfigApiUrl } from '../../data/api';
import fetchCourseConfig from '../../data/thunks';
import fetchCourseTopics from '../../topics/data/thunks';
import { getThreadsApiUrl } from '../data/api';
import * as selectors from '../data/selectors';
import { fetchThread } from '../data/thunks';
import MockReCAPTCHA, {
mockOnChange, mockOnError, mockOnExpired,
} from './mocksData/react-google-recaptcha';
import PostEditor from './PostEditor';
import '../../cohorts/data/__factories__';
@@ -45,7 +44,11 @@ let store;
let axiosMock;
let container;
jest.mock('react-google-recaptcha', () => MockReCAPTCHA);
jest.mock('react-google-recaptcha-v3', () => ({
useGoogleReCaptcha: jest.fn(),
// eslint-disable-next-line react/prop-types
GoogleReCaptchaProvider: ({ children }) => <div>{children}</div>,
}));
async function renderComponent(editExisting = false, location = `/${courseId}/posts/`) {
const paths = editExisting ? ROUTES.POSTS.EDIT_POST : [ROUTES.POSTS.NEW_POST];
@@ -112,6 +115,8 @@ describe('PostEditor submit Form', () => {
});
test('successfully submits a new post with CAPTCHA', async () => {
const mockExecuteRecaptcha = jest.fn(() => Promise.resolve('mock-token'));
useGoogleReCaptcha.mockReturnValue({ executeRecaptcha: mockExecuteRecaptcha });
const newThread = Factory.build('thread', { id: 'new-thread-1' });
axiosMock.onPost(threadsApiUrl).reply(200, newThread);
@@ -123,11 +128,9 @@ describe('PostEditor submit Form', () => {
});
const postTitle = await screen.findByTestId('post-title-input');
const tinymceEditor = await screen.findByTestId('tinymce-editor');
const solveButton = screen.getByText('Solve CAPTCHA');
fireEvent.change(postTitle, { target: { value: 'Test Post Title' } });
fireEvent.change(tinymceEditor, { target: { value: 'Test Post Content' } });
fireEvent.click(solveButton);
});
await act(async () => {
@@ -152,7 +155,56 @@ describe('PostEditor submit Form', () => {
});
});
test('fails to submit a new post with CAPTCHA if token is missing', async () => {
test('successfully updated a post', async () => {
const mockThread = {
courseId: 'course-v1:edX+DemoX+Demo_Course',
topicId: 'ncw-topic-1',
type: 'discussion',
title: 'Test Post Title',
rawBody: 'Test Post Content',
following: true,
anonymous: false,
anonymousToPeers: false,
enableInContextSidebar: false,
notifyAllLearners: false,
captchaToken: 'mock-token',
};
jest
.spyOn(selectors, 'selectThread')
.mockImplementation(() => jest.fn(() => mockThread));
const newThread = Factory.build('thread', { id: 'new-thread-1' });
axiosMock.onPatch(threadsApiUrl).reply(200, newThread);
await renderComponent(true, `/${courseId}/posts/post1/edit`);
await act(async () => {
fireEvent.change(screen.getByTestId('topic-select'), {
target: { value: 'ncw-topic-1' },
});
const postTitle = await screen.findByTestId('post-title-input');
const tinymceEditor = await screen.findByTestId('tinymce-editor');
fireEvent.change(postTitle, { target: { value: 'Test Post Title' } });
fireEvent.change(tinymceEditor, { target: { value: 'Test Post Content' } });
});
await act(async () => {
fireEvent.click(container.querySelector('[data-testid="show-preview-button"]'));
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
});
await act(async () => {
expect(axiosMock.history.patch).toHaveLength(1);
});
});
test('successfully show captcha error if executeRecaptcha returns null token', async () => {
const mockExecuteRecaptcha = jest.fn().mockResolvedValue(null);
useGoogleReCaptcha.mockReturnValue({ executeRecaptcha: mockExecuteRecaptcha });
const newThread = Factory.build('thread', { id: 'new-thread-1' });
axiosMock.onPost(threadsApiUrl).reply(200, newThread);
@@ -173,9 +225,33 @@ describe('PostEditor submit Form', () => {
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
});
await waitFor(() => {
expect(screen.getByText('Please complete the CAPTCHA verification')).toBeInTheDocument();
expect(screen.getByText('CAPTCHA verification failed.')).toBeInTheDocument();
});
test('successfully show captcha error if executeRecaptcha throws', async () => {
const mockExecuteRecaptcha = jest.fn().mockRejectedValue(new Error('recaptcha failed'));
useGoogleReCaptcha.mockReturnValue({ executeRecaptcha: mockExecuteRecaptcha });
const newThread = Factory.build('thread', { id: 'new-thread-1' });
axiosMock.onPost(threadsApiUrl).reply(200, newThread);
await renderComponent();
await act(async () => {
fireEvent.change(screen.getByTestId('topic-select'), {
target: { value: 'ncw-topic-1' },
});
const postTitle = await screen.findByTestId('post-title-input');
const tinymceEditor = await screen.findByTestId('tinymce-editor');
fireEvent.change(postTitle, { target: { value: 'Test Post Title' } });
fireEvent.change(tinymceEditor, { target: { value: 'Test Post Content' } });
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
});
expect(screen.getByText('CAPTCHA verification failed.')).toBeInTheDocument();
});
});
@@ -339,58 +415,6 @@ describe('PostEditor', () => {
await executeThunk(fetchTab(courseId, 'outline'), store.dispatch, store.getState);
}
test('renders the mocked ReCAPTCHA.', async () => {
await setupData({
captchaSettings: {
enabled: true,
siteKey: 'test-key',
},
hasModerationPrivileges: false,
});
await renderComponent();
expect(screen.getByTestId('mocked-recaptcha')).toBeInTheDocument();
});
test('successfully calls onTokenChange when Solve CAPTCHA button is clicked', async () => {
await setupData({
captchaSettings: {
enabled: true,
siteKey: 'test-key',
},
hasModerationPrivileges: false,
});
await renderComponent();
const solveButton = screen.getByText('Solve CAPTCHA');
fireEvent.click(solveButton);
expect(mockOnChange).toHaveBeenCalled();
});
test('successfully calls onExpired handler when CAPTCHA expires', async () => {
await setupData({
captchaSettings: {
enabled: true,
siteKey: 'test-key',
},
hasModerationPrivileges: false,
});
await renderComponent();
fireEvent.click(screen.getByText('Expire CAPTCHA'));
expect(mockOnExpired).toHaveBeenCalled();
});
test('successfully calls onError handler when CAPTCHA errors', async () => {
await setupData({
captchaSettings: {
enabled: true,
siteKey: 'test-key',
},
hasModerationPrivileges: false,
});
await renderComponent();
fireEvent.click(screen.getByText('Error CAPTCHA'));
expect(mockOnError).toHaveBeenCalled();
});
test('test privileged user', async () => {
await setupData();
await renderComponent();

View File

@@ -177,13 +177,8 @@ const messages = defineMessages({
},
captchaVerificationLabel: {
id: 'discussions.captcha.verification.label',
defaultMessage: 'Please complete the CAPTCHA verification',
description: 'Please complete the CAPTCHA to continue.',
},
verifyHumanLabel: {
id: 'discussions.verify.human.label',
defaultMessage: 'Verify you are human',
description: 'Verify you are human description.',
defaultMessage: 'CAPTCHA verification failed.',
description: 'CAPTCHA verification failed',
},
});

View File

@@ -1,42 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
// Define mock functions
export const mockOnChange = jest.fn();
export const mockOnExpired = jest.fn();
export const mockOnError = jest.fn();
export const mockReset = jest.fn();
const MockReCAPTCHA = React.forwardRef((props, ref) => {
const { onChange, onExpired, onError } = props;
React.useImperativeHandle(ref, () => ({
reset: mockReset,
}));
return (
<div data-testid="mocked-recaptcha" ref={ref}>
<button type="button" onClick={() => { mockOnChange(); onChange?.('mock-token'); }}>
Solve CAPTCHA
</button>
<button type="button" onClick={() => { mockOnExpired(); onExpired?.(); }}>
Expire CAPTCHA
</button>
<button type="button" onClick={() => { mockOnError(); onError?.(); }}>
Error CAPTCHA
</button>
</div>
);
});
MockReCAPTCHA.propTypes = {
onChange: PropTypes.func,
onExpired: PropTypes.func,
onError: PropTypes.func,
};
MockReCAPTCHA.defaultProps = {
onChange: () => {},
onExpired: () => {},
onError: () => {},
};
export default MockReCAPTCHA;