From 2241575cc0879978d77e0db31cbb597ee4b0f6ee Mon Sep 17 00:00:00 2001 From: Ahtisham Shahid Date: Thu, 17 Jul 2025 18:55:16 +0500 Subject: [PATCH] feat: added captcha to discussion post creation (#785) * feat: added captcha to discussion post creation * feat: added captcha for comment and response * fix: removed learner check * test: fixed test cases * fix: removed comment and added check for empty sitekey * fix: fixed translation issue * test: added test cases for recaptcha * test: should allow posting a comment with CAPTCHA * test: added submit post test cases * test: test edge cases for api * test: added test cases for react-google-recaptcha * test: added test case for default values for captcha * fix: removed unused catch --------- Co-authored-by: sundasnoreen12 Co-authored-by: Awais Ansari --- package-lock.json | 25 +++ package.json | 1 + src/discussions/data/selectors.js | 2 + src/discussions/data/slices.js | 4 + .../post-comments/PostCommentsView.test.jsx | 141 +++++++++++++- .../comments/comment/CommentEditor.jsx | 56 +++++- src/discussions/post-comments/data/api.js | 10 +- .../post-comments/data/api.test.js | 83 +++++++- src/discussions/post-comments/data/thunks.js | 5 +- src/discussions/post-comments/messages.js | 10 + src/discussions/posts/data/api.js | 4 + src/discussions/posts/data/thunks.js | 3 + .../posts/post-editor/PostEditor.jsx | 59 +++++- .../posts/post-editor/PostEditor.test.jsx | 178 +++++++++++++++++- src/discussions/posts/post-editor/messages.js | 10 + .../mocksData/react-google-recaptcha.jsx | 42 +++++ 16 files changed, 614 insertions(+), 19 deletions(-) create mode 100644 src/discussions/posts/post-editor/mocksData/react-google-recaptcha.jsx diff --git a/package-lock.json b/package-lock.json index 6bfd2d19..a016ef4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "raw-loader": "4.0.2", "react": "18.3.1", "react-dom": "18.3.1", + "react-google-recaptcha": "^3.1.0", "react-helmet": "6.1.0", "react-redux": "7.2.9", "react-router": "6.18.0", @@ -21630,6 +21631,18 @@ "node": ">=0.10.0" } }, + "node_modules/react-async-script": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-1.2.0.tgz", + "integrity": "sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==", + "dependencies": { + "hoist-non-react-statics": "^3.3.0", + "prop-types": "^15.5.0" + }, + "peerDependencies": { + "react": ">=16.4.1" + } + }, "node_modules/react-bootstrap": { "version": "1.6.8", "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-1.6.8.tgz", @@ -21887,6 +21900,18 @@ } } }, + "node_modules/react-google-recaptcha": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz", + "integrity": "sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==", + "dependencies": { + "prop-types": "^15.5.0", + "react-async-script": "^1.2.0" + }, + "peerDependencies": { + "react": ">=16.4.1" + } + }, "node_modules/react-helmet": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-6.1.0.tgz", diff --git a/package.json b/package.json index 30439134..0ba12e7c 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "raw-loader": "4.0.2", "react": "18.3.1", "react-dom": "18.3.1", + "react-google-recaptcha": "^3.1.0", "react-helmet": "6.1.0", "react-redux": "7.2.9", "react-router": "6.18.0", diff --git a/src/discussions/data/selectors.js b/src/discussions/data/selectors.js index 5e0474d5..b82201e0 100644 --- a/src/discussions/data/selectors.js +++ b/src/discussions/data/selectors.js @@ -33,6 +33,8 @@ export const selectIsPostingEnabled = state => state.config.isPostingEnabled; export const selectIsNotifyAllLearnersEnabled = state => state.config.isNotifyAllLearnersEnabled; +export const selectCaptchaSettings = state => state.config.captchaSettings; + export const selectModerationSettings = state => ({ postCloseReasons: state.config.postCloseReasons, editReasons: state.config.editReasons, diff --git a/src/discussions/data/slices.js b/src/discussions/data/slices.js index 071fc02e..f895c3f2 100644 --- a/src/discussions/data/slices.js +++ b/src/discussions/data/slices.js @@ -22,6 +22,10 @@ const configSlice = createSlice({ dividedInlineDiscussions: [], dividedCourseWideDiscussions: [], }, + captchaSettings: { + enabled: false, + siteKey: '', + }, editReasons: [], postCloseReasons: [], enableInContext: false, diff --git a/src/discussions/post-comments/PostCommentsView.test.jsx b/src/discussions/post-comments/PostCommentsView.test.jsx index 5c31c15c..e1fa23e8 100644 --- a/src/discussions/post-comments/PostCommentsView.test.jsx +++ b/src/discussions/post-comments/PostCommentsView.test.jsx @@ -1,3 +1,5 @@ +import React, { useRef } from 'react'; + import { act, fireEvent, render, screen, waitFor, within, } from '@testing-library/react'; @@ -23,6 +25,12 @@ 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'; @@ -51,6 +59,8 @@ let testLocation; let container; let unmount; +jest.mock('react-google-recaptcha', () => MockReCAPTCHA); + async function mockAxiosReturnPagedComments(threadId, threadType = ThreadType.DISCUSSION, page = 1, count = 2) { axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult', { can_delete: true }, { threadId, @@ -215,7 +225,13 @@ describe('ThreadView', () => { endorsed: false, })]; }); - axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, { isPostingEnabled: true }); + axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, { + isPostingEnabled: true, + captchaSettings: { + enabled: true, + siteKey: 'test-key', + }, + }); window.HTMLElement.prototype.scrollIntoView = jest.fn(); await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState); @@ -292,6 +308,29 @@ describe('ThreadView', () => { expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument(); }); + it('should allow posting a comment with CAPTCHA', async () => { + 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('Solve CAPTCHA')); }); + await act(async () => { fireEvent.click(screen.getByText(/submit/i)); }); + + await waitFor(() => { + expect(axiosMock.history.post).toHaveLength(1); + expect(JSON.parse(axiosMock.history.post[0].data)).toMatchObject({ + captcha_token: 'm', + enable_in_context_sidebar: false, + parent_id: 'comment-1', + raw_body: 'New comment with CAPTCHA', + thread_id: 'thread-1', + }); + expect(mockOnChange).toHaveBeenCalled(); + }); + }); + it('should allow posting a comment', async () => { await waitFor(() => renderComponent(discussionPostId)); @@ -302,7 +341,6 @@ describe('ThreadView', () => { await act(async () => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } }); }); await act(async () => { fireEvent.click(screen.getByText(/submit/i)); }); - expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument(); await waitFor(async () => expect(await screen.findByTestId('comment-1')).toBeInTheDocument()); }); @@ -323,7 +361,6 @@ describe('ThreadView', () => { await act(async () => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } }); }); await act(async () => { fireEvent.click(screen.getByText(/submit/i)); }); - expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument(); await waitFor(async () => expect(await screen.findByTestId('reply-comment-2')).toBeInTheDocument()); }); @@ -581,6 +618,42 @@ describe('ThreadView', () => { describe('for discussion thread', () => { const findLoadMoreCommentsButton = () => screen.findByTestId('load-more-comments'); + it('renders the mocked ReCAPTCHA.', async () => { + 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 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 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 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 })) @@ -749,7 +822,7 @@ describe('ThreadView', () => { fireEvent.click(screen.queryAllByText('Add comment')[0]); }); - expect(screen.queryByTestId('tinymce-editor').value).toBe(''); + expect(screen.queryByTestId('tinymce-editor').value).toBe('Draft comment 123!'); }); it('successfully added response in the draft.', async () => { @@ -793,7 +866,7 @@ describe('ThreadView', () => { fireEvent.click(screen.queryByText('Add response')); }); - expect(screen.queryByTestId('tinymce-editor').value).toBe(''); + expect(screen.queryByTestId('tinymce-editor').value).toBe('Draft Response!'); }); it('successfully maintain response for the specific post in the draft.', async () => { @@ -975,3 +1048,61 @@ describe('ThreadView', () => { }); }); }); + +describe('MockReCAPTCHA', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('uses defaultProps when props are not provided', () => { + render(); + + 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 ( +
+ + +
+ ); + }; + + const { getByText, getByTestId } = render(); + + 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(); + }); +}); diff --git a/src/discussions/post-comments/comments/comment/CommentEditor.jsx b/src/discussions/post-comments/comments/comment/CommentEditor.jsx index cfa2b9a0..aa3b16cf 100644 --- a/src/discussions/post-comments/comments/comment/CommentEditor.jsx +++ b/src/discussions/post-comments/comments/comment/CommentEditor.jsx @@ -5,6 +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 { useSelector } from 'react-redux'; import * as Yup from 'yup'; @@ -17,6 +18,7 @@ import PostPreviewPanel from '../../../../components/PostPreviewPanel'; import useDispatchWithState from '../../../../data/hooks'; import DiscussionContext from '../../../common/context'; import { + selectCaptchaSettings, selectModerationSettings, selectUserHasModerationPrivileges, selectUserIsGroupTa, @@ -40,6 +42,7 @@ const CommentEditor = ({ const intl = useIntl(); const editorRef = useRef(null); const formRef = useRef(null); + const recaptchaRef = useRef(null); const { authenticatedUser } = useContext(AppContext); const { enableInContextSidebar } = useContext(DiscussionContext); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); @@ -49,6 +52,13 @@ const CommentEditor = ({ const [submitting, dispatch] = useDispatchWithState(); const [editorContent, setEditorContent] = useState(); const { addDraftContent, getDraftContent, removeDraftContent } = useDraftContent(); + const captchaSettings = useSelector(selectCaptchaSettings); + + const shouldRequireCaptcha = !id && captchaSettings.enabled; + + const captchaValidation = { + recaptchaToken: Yup.string().required(intl.formatMessage(messages.captchaVerificationLabel)), + }; const canDisplayEditReason = (edit && (userHasModerationPrivileges || userIsGroupTa || userIsStaff) @@ -62,16 +72,31 @@ 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: '', }; + 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(); + } onCloseEditor(); }, [onCloseEditor, initialValues]); @@ -92,7 +117,7 @@ const CommentEditor = ({ }; await dispatch(editComment(id, payload)); } else { - await dispatch(addComment(values.comment, threadId, parentId, enableInContextSidebar)); + await dispatch(addComment(values.comment, threadId, parentId, enableInContextSidebar, ...(shouldRequireCaptcha ? values.recaptchaToken : ''))); } /* istanbul ignore if: TinyMCE is mocked so this cannot be easily tested */ if (editorRef.current) { @@ -100,7 +125,7 @@ const CommentEditor = ({ } handleCloseEditor(resetForm); deleteEditorContent(); - }, [id, threadId, parentId, enableInContextSidebar, handleCloseEditor]); + }, [id, threadId, parentId, enableInContextSidebar, handleCloseEditor, shouldRequireCaptcha]); // 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}`; @@ -147,6 +172,7 @@ const CommentEditor = ({ handleBlur, handleChange, resetForm, + setFieldValue, }) => (
{canDisplayEditReason && ( @@ -202,6 +228,32 @@ const CommentEditor = ({ )} + {/* CAPTCHA Section - Only show for new posts from non-staff users */} + { shouldRequireCaptcha && captchaSettings.siteKey && ( +
+ + + {intl.formatMessage(messages.verifyHumanLabel)} + +
+ handleCaptchaChange(token, setFieldValue)} + onExpired={() => handleCaptchaExpired(setFieldValue)} + onError={() => handleCaptchaExpired(setFieldValue)} + /> +
+ +
+
+ ) } +
+ {/* CAPTCHA Section - Only show for new posts for non-staff users */} + {shouldRequireCaptcha && captchaSettings.siteKey && ( +
+ + + {intl.formatMessage(messages.verifyHumanLabel)} + +
+ handleCaptchaChange(token, setFieldValue)} + onExpired={() => handleCaptchaExpired(setFieldValue)} + onError={() => handleCaptchaExpired(setFieldValue)} + /> +
+ +
+
+ )}
+ + +
+ ); +}); + +MockReCAPTCHA.propTypes = { + onChange: PropTypes.func, + onExpired: PropTypes.func, + onError: PropTypes.func, +}; +MockReCAPTCHA.defaultProps = { + onChange: () => {}, + onExpired: () => {}, + onError: () => {}, +}; + +export default MockReCAPTCHA;