From 4f3b770c4ee9274a0a5299e02ce34d842e678f87 Mon Sep 17 00:00:00 2001 From: Suvam-paul145 Date: Tue, 24 Feb 2026 20:31:21 +0530 Subject: [PATCH 1/6] fix: sanitize dangerouslySetInnerHTML with DOMPurify to prevent XSS --- src/common/Testimonial/TestimonialCard.jsx | 3 ++- src/common/badges-dashboard/BadgeDetails.jsx | 3 ++- src/common/utils/sanitizeHTML.js | 12 ++++++++++++ .../SelectionSortVisualizer.js | 2 ++ src/plays/devblog/Pages/Article.jsx | 3 ++- src/plays/fun-quiz/EndScreen.jsx | 7 ++++--- src/plays/fun-quiz/QuizScreen.jsx | 5 +++-- src/plays/markdown-editor/Output.jsx | 3 ++- src/plays/text-to-speech/TextToSpeech.jsx | 5 +---- src/plays/tube2tunes/Tube2tunes.jsx | 9 +++++++-- 10 files changed, 37 insertions(+), 15 deletions(-) create mode 100644 src/common/utils/sanitizeHTML.js diff --git a/src/common/Testimonial/TestimonialCard.jsx b/src/common/Testimonial/TestimonialCard.jsx index c2e0c29ce0..9bf94226a0 100644 --- a/src/common/Testimonial/TestimonialCard.jsx +++ b/src/common/Testimonial/TestimonialCard.jsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { format } from 'date-fns'; import * as allLocales from 'date-fns/locale'; import { email2Slug } from 'common/services/string'; +import sanitizeHTML from 'common/utils/sanitizeHTML'; const TestimonialCard = ({ home, quote, name, avatarUrl, category, created_at, email }) => { const [formattedDate] = useState(() => { @@ -59,7 +60,7 @@ const TestimonialCard = ({ home, quote, name, avatarUrl, category, created_at, e >

diff --git a/src/common/badges-dashboard/BadgeDetails.jsx b/src/common/badges-dashboard/BadgeDetails.jsx index 893bf60b19..ff78258d9f 100644 --- a/src/common/badges-dashboard/BadgeDetails.jsx +++ b/src/common/badges-dashboard/BadgeDetails.jsx @@ -1,4 +1,5 @@ import Badge from './Badge'; +import sanitizeHTML from 'common/utils/sanitizeHTML'; import './badge.css'; const BadgeDetails = ({ badge, onClose }) => { @@ -9,7 +10,7 @@ const BadgeDetails = ({ badge, onClose }) => { return `${name}`; }); - return ; + return ; }; return ( diff --git a/src/common/utils/sanitizeHTML.js b/src/common/utils/sanitizeHTML.js new file mode 100644 index 0000000000..b07766de01 --- /dev/null +++ b/src/common/utils/sanitizeHTML.js @@ -0,0 +1,12 @@ +import DOMPurify from 'dompurify'; + +/** + * Sanitizes an HTML string using DOMPurify to prevent XSS attacks. + * Use this utility whenever you need to render dynamic HTML via dangerouslySetInnerHTML. + * + * @param {string} html - The raw HTML string to sanitize. + * @returns {string} - A sanitized HTML string safe to use with dangerouslySetInnerHTML. + */ +const sanitizeHTML = (html) => DOMPurify.sanitize(html ?? ''); + +export default sanitizeHTML; diff --git a/src/plays/Selection-Sort-Visualizer/SelectionSortVisualizer.js b/src/plays/Selection-Sort-Visualizer/SelectionSortVisualizer.js index 951f4380cb..1eb8241875 100644 --- a/src/plays/Selection-Sort-Visualizer/SelectionSortVisualizer.js +++ b/src/plays/Selection-Sort-Visualizer/SelectionSortVisualizer.js @@ -23,6 +23,8 @@ function SelectionSortVisualizer() { const handleSort = async () => { const arrCopy = [...arr]; const outputElements = document.getElementById('output-visualizer'); + // Safe: clears the container to empty string (no user data injected). + // All subsequent DOM mutations use createElement/createTextNode (XSS-safe). outputElements.innerHTML = ''; for (let i = 0; i < arrCopy.length - 1; i++) { diff --git a/src/plays/devblog/Pages/Article.jsx b/src/plays/devblog/Pages/Article.jsx index 8f7c70e7da..ef2ea2d2b0 100644 --- a/src/plays/devblog/Pages/Article.jsx +++ b/src/plays/devblog/Pages/Article.jsx @@ -1,6 +1,7 @@ import axios from 'axios'; import { useState, useEffect } from 'react'; import { useParams } from 'react-router-dom'; +import sanitizeHTML from 'common/utils/sanitizeHTML'; import Loading from '../components/Loading'; const Article = () => { @@ -50,7 +51,7 @@ const Article = () => {

) : ( diff --git a/src/plays/fun-quiz/EndScreen.jsx b/src/plays/fun-quiz/EndScreen.jsx index a8f337760f..5398009a8b 100644 --- a/src/plays/fun-quiz/EndScreen.jsx +++ b/src/plays/fun-quiz/EndScreen.jsx @@ -1,5 +1,6 @@ // vendors import { Fragment, useState } from 'react'; +import sanitizeHTML from 'common/utils/sanitizeHTML'; // css import './FrontScreen.scss'; @@ -16,17 +17,17 @@ const EndScreen = ({ quizSummary, redirectHome }) => {
Question: {currentQuestion?.qNo}
  • Ans: ${currentQuestion?.correct_answer}
    ` + __html: sanitizeHTML(`
    Ans: ${currentQuestion?.correct_answer}
    `) }} /> Your Answer: ${currentQuestion?.your_answer}` + __html: sanitizeHTML(`Your Answer: ${currentQuestion?.your_answer}`) }} /> diff --git a/src/plays/fun-quiz/QuizScreen.jsx b/src/plays/fun-quiz/QuizScreen.jsx index cc150234f8..571946985a 100644 --- a/src/plays/fun-quiz/QuizScreen.jsx +++ b/src/plays/fun-quiz/QuizScreen.jsx @@ -1,4 +1,5 @@ import { useEffect, useState, useCallback, useRef } from 'react'; +import sanitizeHTML from 'common/utils/sanitizeHTML'; import './QuizScreen.scss'; @@ -149,7 +150,7 @@ function QuizScreen({ category, getQuizSummary }) {
    {timer}
    Question: {questionNumber + 1}
    -

    +

    {currentQuestion?.options?.map((option, index) => { @@ -157,7 +158,7 @@ function QuizScreen({ category, getQuizSummary }) {
    diff --git a/src/plays/markdown-editor/Output.jsx b/src/plays/markdown-editor/Output.jsx index 9228271430..8f7ca65314 100644 --- a/src/plays/markdown-editor/Output.jsx +++ b/src/plays/markdown-editor/Output.jsx @@ -1,10 +1,11 @@ import React from 'react'; +import sanitizeHTML from 'common/utils/sanitizeHTML'; const Output = ({ md, text, mdPreviewBox }) => { return (
    ); diff --git a/src/plays/text-to-speech/TextToSpeech.jsx b/src/plays/text-to-speech/TextToSpeech.jsx index 3286a6d0f9..e400bc4fde 100644 --- a/src/plays/text-to-speech/TextToSpeech.jsx +++ b/src/plays/text-to-speech/TextToSpeech.jsx @@ -158,10 +158,7 @@ function TextToSpeech(props) {
    {convertedText ? ( <> -

    +

    {convertedText}

    + )} + +
    +
    + ); + } + + return this.props.children; + } +} + +const styles = { + container: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + padding: '3rem 1.5rem', + textAlign: 'center', + minHeight: '50vh' + }, + image: { + width: '200px', + height: 'auto', + marginBottom: '1.5rem', + opacity: 0.8 + }, + title: { + fontSize: '1.5rem', + fontWeight: 600, + color: '#333', + margin: '0 0 0.75rem' + }, + message: { + fontSize: '1rem', + color: '#666', + maxWidth: '500px', + lineHeight: 1.5, + margin: '0 0 1.5rem' + }, + actions: { + display: 'flex', + gap: '1rem' + }, + retryButton: { + padding: '0.6rem 1.5rem', + fontSize: '0.95rem', + fontWeight: 600, + border: 'none', + borderRadius: '6px', + cursor: 'pointer', + background: '#00f2fe', + color: '#fff', + transition: 'opacity 0.2s' + }, + backButton: { + padding: '0.6rem 1.5rem', + fontSize: '0.95rem', + fontWeight: 600, + border: '2px solid #00f2fe', + borderRadius: '6px', + cursor: 'pointer', + background: 'transparent', + color: '#00f2fe', + transition: 'opacity 0.2s' + } +}; + +export default PlayErrorBoundary; diff --git a/src/common/playlists/PlayMeta.jsx b/src/common/playlists/PlayMeta.jsx index d5dbc2f17b..33158038d9 100644 --- a/src/common/playlists/PlayMeta.jsx +++ b/src/common/playlists/PlayMeta.jsx @@ -10,6 +10,7 @@ import { PageNotFound } from 'common'; import thumbPlay from 'images/thumb-play.png'; import { getProdUrl } from 'common/utils/commonUtils'; import { loadCoverImage } from 'common/utils/coverImageUtil'; +import PlayErrorBoundary from 'common/playlists/PlayErrorBoundary'; function PlayMeta() { const [loading, setLoading] = useState(true); @@ -87,6 +88,10 @@ function PlayMeta() { const renderPlayComponent = () => { const Comp = plays[play.component || toSanitized(play.title_name)]; + if (!Comp) { + return ; + } + return ; }; @@ -103,7 +108,11 @@ function PlayMeta() { - }>{renderPlayComponent()} + } + > + {renderPlayComponent()} + ); } From a08892180134bb950ffc74269186a7c853c050e5 Mon Sep 17 00:00:00 2001 From: Suvam-paul145 Date: Wed, 25 Feb 2026 21:19:18 +0530 Subject: [PATCH 5/6] unusual files are reverted from this branch --- .husky/pre-commit | 2 +- src/common/Testimonial/TestimonialCard.jsx | 3 +- src/common/badges-dashboard/BadgeDetails.jsx | 3 +- .../hooks/__tests__/useCacheResponse.test.ts | 42 -------- .../hooks/__tests__/useContributors.test.js | 78 --------------- .../hooks/__tests__/useFeaturedPlays.test.js | 53 ---------- src/common/hooks/__tests__/useFetch.test.js | 58 ----------- src/common/hooks/__tests__/useGitHub.test.js | 49 --------- .../hooks/__tests__/useLikePlays.test.js | 56 ----------- .../hooks/__tests__/useLocalStorage.test.js | 81 --------------- .../search/__tests__/search-helper.test.js | 93 ----------------- src/common/services/__tests__/plays.test.js | 99 ------------------- .../utils/__tests__/commonUtils.test.js | 68 ------------- .../utils/__tests__/coverImageUtil.test.js | 60 ----------- .../utils/__tests__/formatCount.test.ts | 45 --------- src/common/utils/sanitizeHTML.js | 12 --- .../SelectionSortVisualizer.js | 2 - src/plays/devblog/Pages/Article.jsx | 3 +- src/plays/fun-quiz/EndScreen.jsx | 7 +- src/plays/fun-quiz/QuizScreen.jsx | 5 +- src/plays/markdown-editor/Output.jsx | 3 +- src/plays/text-to-speech/TextToSpeech.jsx | 5 +- src/plays/tube2tunes/Tube2tunes.jsx | 9 +- 23 files changed, 16 insertions(+), 820 deletions(-) delete mode 100644 src/common/hooks/__tests__/useCacheResponse.test.ts delete mode 100644 src/common/hooks/__tests__/useContributors.test.js delete mode 100644 src/common/hooks/__tests__/useFeaturedPlays.test.js delete mode 100644 src/common/hooks/__tests__/useFetch.test.js delete mode 100644 src/common/hooks/__tests__/useGitHub.test.js delete mode 100644 src/common/hooks/__tests__/useLikePlays.test.js delete mode 100644 src/common/hooks/__tests__/useLocalStorage.test.js delete mode 100644 src/common/search/__tests__/search-helper.test.js delete mode 100644 src/common/services/__tests__/plays.test.js delete mode 100644 src/common/utils/__tests__/commonUtils.test.js delete mode 100644 src/common/utils/__tests__/coverImageUtil.test.js delete mode 100644 src/common/utils/__tests__/formatCount.test.ts delete mode 100644 src/common/utils/sanitizeHTML.js diff --git a/.husky/pre-commit b/.husky/pre-commit index 0312b76025..7e2936624c 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" -npx lint-staged \ No newline at end of file +yarn pre-commit \ No newline at end of file diff --git a/src/common/Testimonial/TestimonialCard.jsx b/src/common/Testimonial/TestimonialCard.jsx index 9bf94226a0..c2e0c29ce0 100644 --- a/src/common/Testimonial/TestimonialCard.jsx +++ b/src/common/Testimonial/TestimonialCard.jsx @@ -2,7 +2,6 @@ import React, { useState } from 'react'; import { format } from 'date-fns'; import * as allLocales from 'date-fns/locale'; import { email2Slug } from 'common/services/string'; -import sanitizeHTML from 'common/utils/sanitizeHTML'; const TestimonialCard = ({ home, quote, name, avatarUrl, category, created_at, email }) => { const [formattedDate] = useState(() => { @@ -60,7 +59,7 @@ const TestimonialCard = ({ home, quote, name, avatarUrl, category, created_at, e >

    diff --git a/src/common/badges-dashboard/BadgeDetails.jsx b/src/common/badges-dashboard/BadgeDetails.jsx index ff78258d9f..893bf60b19 100644 --- a/src/common/badges-dashboard/BadgeDetails.jsx +++ b/src/common/badges-dashboard/BadgeDetails.jsx @@ -1,5 +1,4 @@ import Badge from './Badge'; -import sanitizeHTML from 'common/utils/sanitizeHTML'; import './badge.css'; const BadgeDetails = ({ badge, onClose }) => { @@ -10,7 +9,7 @@ const BadgeDetails = ({ badge, onClose }) => { return `${name}`; }); - return ; + return ; }; return ( diff --git a/src/common/hooks/__tests__/useCacheResponse.test.ts b/src/common/hooks/__tests__/useCacheResponse.test.ts deleted file mode 100644 index e6ad169f9a..0000000000 --- a/src/common/hooks/__tests__/useCacheResponse.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { renderHook } from '@testing-library/react'; -import useCacheResponse from '../useCacheResponse'; - -describe('useCacheResponse', () => { - it('stores and retrieves cached data', () => { - const { result } = renderHook(() => useCacheResponse()); - const [retrieveCache, createCache] = result.current; - - createCache('testKey', { data: 'hello' }); - expect(retrieveCache('testKey')).toEqual({ data: 'hello' }); - }); - - it('returns null for a cache key that does not exist', () => { - const { result } = renderHook(() => useCacheResponse()); - const [retrieveCache] = result.current; - - expect(retrieveCache('nonExistentKey')).toBeNull(); - }); - - it('overwrites existing cache entries', () => { - const { result } = renderHook(() => useCacheResponse()); - const [retrieveCache, createCache] = result.current; - - createCache('key', 'first'); - createCache('key', 'second'); - expect(retrieveCache('key')).toBe('second'); - }); - - it('supports different data types', () => { - const { result } = renderHook(() => useCacheResponse()); - const [retrieveCache, createCache] = result.current; - - createCache('number', 42); - createCache('array', [1, 2, 3]); - createCache('null', null); - - expect(retrieveCache('number')).toBe(42); - expect(retrieveCache('array')).toEqual([1, 2, 3]); - // null is falsy, so the hook returns null for it too - expect(retrieveCache('null')).toBeNull(); - }); -}); diff --git a/src/common/hooks/__tests__/useContributors.test.js b/src/common/hooks/__tests__/useContributors.test.js deleted file mode 100644 index b6cf94303d..0000000000 --- a/src/common/hooks/__tests__/useContributors.test.js +++ /dev/null @@ -1,78 +0,0 @@ -import { renderHook, waitFor } from '@testing-library/react'; -import useContributors from '../useContributors'; - -beforeEach(() => { - global.fetch = jest.fn(); - process.env.REACT_APP_PLAY_API_URL = 'https://api.test.com'; -}); - -afterEach(() => { - jest.restoreAllMocks(); - delete process.env.REACT_APP_PLAY_API_URL; -}); - -const mockContributors = [ - { login: 'user1', type: 'User', contributions: 50 }, - { login: 'dependabot', type: 'Bot', contributions: 100 }, - { login: 'user2', type: 'User', contributions: 30 }, - { login: 'user3', type: 'User', contributions: 80 } -]; - -describe('useContributors', () => { - it('fetches contributors and filters out bots', async () => { - global.fetch.mockResolvedValueOnce({ - json: () => Promise.resolve(mockContributors) - }); - - const { result } = renderHook(() => useContributors(false)); - - expect(result.current.isLoading).toBe(true); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.data).toHaveLength(3); - expect(result.current.data.every((c) => c.type !== 'Bot')).toBe(true); - expect(result.current.error).toBeUndefined(); - }); - - it('sorts contributors by contributions when sorted=true', async () => { - global.fetch.mockResolvedValueOnce({ - json: () => Promise.resolve(mockContributors) - }); - - const { result } = renderHook(() => useContributors(true)); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - const contributions = result.current.data.map((c) => c.contributions); - expect(contributions).toEqual([80, 50, 30]); // descending order, bot excluded - }); - - it('sets error on fetch failure', async () => { - const mockError = new Error('Network error'); - global.fetch.mockRejectedValueOnce(mockError); - - const { result } = renderHook(() => useContributors(false)); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.error).toBe(mockError); - expect(result.current.data).toBeUndefined(); - }); - - it('fetches from the correct API URL', async () => { - global.fetch.mockResolvedValueOnce({ - json: () => Promise.resolve([]) - }); - - renderHook(() => useContributors(false)); - - expect(global.fetch).toHaveBeenCalledWith('https://api.test.com/react-play/contributors'); - }); -}); diff --git a/src/common/hooks/__tests__/useFeaturedPlays.test.js b/src/common/hooks/__tests__/useFeaturedPlays.test.js deleted file mode 100644 index 1e325f2c4c..0000000000 --- a/src/common/hooks/__tests__/useFeaturedPlays.test.js +++ /dev/null @@ -1,53 +0,0 @@ -import { renderHook, waitFor } from '@testing-library/react'; -import useFeaturedPlays from '../useFeaturedPlays'; -import { submit } from 'common/services/request'; - -// Mock the services/request module -jest.mock('common/services/request', () => ({ - submit: jest.fn() -})); - -jest.mock('common/services/request/query/fetch-plays-filter', () => ({ - FetchPlaysFilter: { - getAllFeaturedPlays: jest.fn(() => 'mock-query') - } -})); - -describe('useFeaturedPlays', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - it('returns featured plays data on success', async () => { - const mockPlays = [ - { id: 1, name: 'Play 1' }, - { id: 2, name: 'Play 2' } - ]; - submit.mockResolvedValueOnce(mockPlays); - - const { result } = renderHook(() => useFeaturedPlays()); - - // [loading, error, data] - expect(result.current[0]).toBe(true); // loading - - await waitFor(() => { - expect(result.current[0]).toBe(false); // loading done - }); - - expect(result.current[1]).toBeNull(); // no error - expect(result.current[2]).toEqual(mockPlays); // data - }); - - it('sets error on failure', async () => { - const mockError = { message: 'GraphQL error' }; - submit.mockRejectedValueOnce([mockError]); - - const { result } = renderHook(() => useFeaturedPlays()); - - await waitFor(() => { - expect(result.current[0]).toBe(false); - }); - - expect(result.current[1]).toEqual(mockError); // error - }); -}); diff --git a/src/common/hooks/__tests__/useFetch.test.js b/src/common/hooks/__tests__/useFetch.test.js deleted file mode 100644 index 6364a36938..0000000000 --- a/src/common/hooks/__tests__/useFetch.test.js +++ /dev/null @@ -1,58 +0,0 @@ -import { renderHook, waitFor } from '@testing-library/react'; -import useFetch from '../useFetch'; - -// Mock global fetch -beforeEach(() => { - global.fetch = jest.fn(); -}); - -afterEach(() => { - jest.restoreAllMocks(); -}); - -describe('useFetch', () => { - it('returns data on successful fetch', async () => { - const mockData = [{ id: 1, name: 'Play 1' }]; - global.fetch.mockResolvedValueOnce({ - json: () => Promise.resolve(mockData) - }); - - const { result } = renderHook(() => useFetch('https://api.example.com/data')); - - // Initially loading - expect(result.current.loading).toBe(true); - expect(result.current.data).toEqual([]); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - expect(result.current.data).toEqual(mockData); - expect(result.current.error).toBeNull(); - }); - - it('sets error on fetch failure', async () => { - const mockError = new Error('Network error'); - global.fetch.mockRejectedValueOnce(mockError); - - const { result } = renderHook(() => useFetch('https://api.example.com/fail')); - - await waitFor(() => { - expect(result.current.loading).toBe(false); - }); - - expect(result.current.error).toBe(mockError); - expect(result.current.data).toEqual([]); - }); - - it('passes options to fetch', async () => { - global.fetch.mockResolvedValueOnce({ - json: () => Promise.resolve({}) - }); - - const options = { method: 'POST', body: JSON.stringify({ test: true }) }; - renderHook(() => useFetch('https://api.example.com/data', options)); - - expect(global.fetch).toHaveBeenCalledWith('https://api.example.com/data', options); - }); -}); diff --git a/src/common/hooks/__tests__/useGitHub.test.js b/src/common/hooks/__tests__/useGitHub.test.js deleted file mode 100644 index 383ee469fd..0000000000 --- a/src/common/hooks/__tests__/useGitHub.test.js +++ /dev/null @@ -1,49 +0,0 @@ -import { renderHook, waitFor } from '@testing-library/react'; -import useGitHub from '../useGitHub'; - -beforeEach(() => { - global.fetch = jest.fn(); -}); - -afterEach(() => { - jest.restoreAllMocks(); -}); - -describe('useGitHub', () => { - it('fetches GitHub user data successfully', async () => { - const mockUser = { - login: 'octocat', - name: 'The Octocat', - public_repos: 8 - }; - global.fetch.mockResolvedValueOnce({ - json: () => Promise.resolve(mockUser) - }); - - const { result } = renderHook(() => useGitHub('octocat')); - - expect(result.current.isLoading).toBe(true); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.data).toEqual(mockUser); - expect(result.current.error).toBeUndefined(); - expect(global.fetch).toHaveBeenCalledWith('https://api.github.com/users/octocat'); - }); - - it('handles fetch errors', async () => { - const mockError = new Error('Rate limited'); - global.fetch.mockRejectedValueOnce(mockError); - - const { result } = renderHook(() => useGitHub('unknown-user')); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.error).toBe(mockError); - expect(result.current.data).toBeUndefined(); - }); -}); diff --git a/src/common/hooks/__tests__/useLikePlays.test.js b/src/common/hooks/__tests__/useLikePlays.test.js deleted file mode 100644 index 8e819d312f..0000000000 --- a/src/common/hooks/__tests__/useLikePlays.test.js +++ /dev/null @@ -1,56 +0,0 @@ -import { renderHook } from '@testing-library/react'; -import useLikePlays from '../useLikePlays'; -import { submit } from 'common/services/request'; - -jest.mock('common/services/request', () => ({ - submit: jest.fn() -})); - -jest.mock('common/services/request/query/like-play', () => ({ - likeIndividualPlay: jest.fn((obj) => ({ type: 'LIKE', ...obj })), - unlikeIndividualPlay: jest.fn((obj) => ({ type: 'UNLIKE', ...obj })) -})); - -describe('useLikePlays', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - it('likePlay calls submit and resolves on success', async () => { - const mockResponse = { id: 1, liked: true }; - submit.mockResolvedValueOnce(mockResponse); - - const { result } = renderHook(() => useLikePlays()); - const response = await result.current.likePlay({ play_id: 'abc' }); - - expect(response).toEqual(mockResponse); - expect(submit).toHaveBeenCalledTimes(1); - }); - - it('likePlay rejects on submit failure', async () => { - const mockError = new Error('Mutation failed'); - submit.mockRejectedValueOnce(mockError); - - const { result } = renderHook(() => useLikePlays()); - - await expect(result.current.likePlay({ play_id: 'abc' })).rejects.toThrow('Mutation failed'); - }); - - it('unLikePlay calls submit and resolves on success', async () => { - const mockResponse = { id: 1, liked: false }; - submit.mockResolvedValueOnce(mockResponse); - - const { result } = renderHook(() => useLikePlays()); - const response = await result.current.unLikePlay({ play_id: 'abc' }); - - expect(response).toEqual(mockResponse); - }); - - it('unLikePlay rejects on submit failure', async () => { - submit.mockRejectedValueOnce(new Error('Delete failed')); - - const { result } = renderHook(() => useLikePlays()); - - await expect(result.current.unLikePlay({ play_id: 'abc' })).rejects.toThrow('Delete failed'); - }); -}); diff --git a/src/common/hooks/__tests__/useLocalStorage.test.js b/src/common/hooks/__tests__/useLocalStorage.test.js deleted file mode 100644 index cd74e9837c..0000000000 --- a/src/common/hooks/__tests__/useLocalStorage.test.js +++ /dev/null @@ -1,81 +0,0 @@ -import { renderHook, act } from '@testing-library/react'; -import useLocalStorage from '../useLocalStorage'; - -// Mock localStorage -const localStorageMock = (() => { - let store = {}; - - return { - getItem: jest.fn((key) => store[key] || null), - setItem: jest.fn((key, value) => { - store[key] = value; - }), - clear: () => { - store = {}; - } - }; -})(); - -Object.defineProperty(window, 'localStorage', { value: localStorageMock }); - -describe('useLocalStorage', () => { - beforeEach(() => { - localStorageMock.clear(); - jest.clearAllMocks(); - }); - - it('returns initial value when localStorage is empty', () => { - const { result } = renderHook(() => useLocalStorage('testKey', 'default')); - expect(result.current[0]).toBe('default'); - }); - - it('reads existing value from localStorage', () => { - localStorageMock.getItem.mockReturnValueOnce(JSON.stringify('stored-value')); - const { result } = renderHook(() => useLocalStorage('testKey', 'default')); - expect(result.current[0]).toBe('stored-value'); - }); - - it('updates localStorage when setValue is called', () => { - const { result } = renderHook(() => useLocalStorage('testKey', 'initial')); - - act(() => { - result.current[1]('new-value'); - }); - - expect(result.current[0]).toBe('new-value'); - expect(window.localStorage.setItem).toHaveBeenCalledWith( - 'testKey', - JSON.stringify('new-value') - ); - }); - - it('supports function updater pattern', () => { - const { result } = renderHook(() => useLocalStorage('counter', 0)); - - act(() => { - result.current[1]((prev) => prev + 1); - }); - - expect(result.current[0]).toBe(1); - }); - - it('handles JSON parse errors gracefully', () => { - localStorageMock.getItem.mockReturnValueOnce('invalid-json{{{'); - const { result } = renderHook(() => useLocalStorage('badKey', 'fallback')); - expect(result.current[0]).toBe('fallback'); - }); - - it('stores objects correctly', () => { - const { result } = renderHook(() => useLocalStorage('objKey', {})); - - act(() => { - result.current[1]({ name: 'test', count: 5 }); - }); - - expect(result.current[0]).toEqual({ name: 'test', count: 5 }); - expect(window.localStorage.setItem).toHaveBeenCalledWith( - 'objKey', - JSON.stringify({ name: 'test', count: 5 }) - ); - }); -}); diff --git a/src/common/search/__tests__/search-helper.test.js b/src/common/search/__tests__/search-helper.test.js deleted file mode 100644 index a4eb67a2e3..0000000000 --- a/src/common/search/__tests__/search-helper.test.js +++ /dev/null @@ -1,93 +0,0 @@ -import { ParseQuery, QueryExtractValue, QueryDBTranslator } from '../search-helper'; - -describe('ParseQuery', () => { - it('parses a query string into an object', () => { - expect(ParseQuery('?levels=1&tags=react')).toEqual({ - levels: '1', - tags: 'react' - }); - }); - - it('handles query strings without leading ?', () => { - expect(ParseQuery('levels=1&tags=react')).toEqual({ - levels: '1', - tags: 'react' - }); - }); - - it('decodes URI-encoded values', () => { - expect(ParseQuery('text=hello%20world')).toEqual({ - text: 'hello world' - }); - }); - - it('handles empty value for a key', () => { - expect(ParseQuery('key=')).toEqual({ key: '' }); - }); - - it('returns undefined for falsy input', () => { - expect(ParseQuery('')).toBeUndefined(); - expect(ParseQuery(null)).toBeUndefined(); - expect(ParseQuery(undefined)).toBeUndefined(); - }); -}); - -describe('QueryExtractValue', () => { - it('splits comma-separated values into arrays', () => { - const query = { levels: '1,2,3', tags: 'react,javascript' }; - expect(QueryExtractValue(query)).toEqual({ - levels: ['1', '2', '3'], - tags: ['react', 'javascript'] - }); - }); - - it('wraps single values in an array', () => { - expect(QueryExtractValue({ levels: '1' })).toEqual({ - levels: ['1'] - }); - }); -}); - -describe('QueryDBTranslator', () => { - it('translates datafield keys to dbfield keys', () => { - const query = { levels: '1,2' }; - const result = QueryDBTranslator(query); - // levels maps to level_id per FIELD_TEMPLATE - expect(result).toHaveProperty('level_id', ['1', '2']); - }); - - it('keeps datafield key when no dbfield mapping exists', () => { - const query = { tags: 'react-tag-id' }; - const result = QueryDBTranslator(query); - // tags has no dbfield in FIELD_TEMPLATE - expect(result).toHaveProperty('tags', ['react-tag-id']); - }); - - it('handles text field specially as a single string', () => { - const query = { text: 'my search' }; - const result = QueryDBTranslator(query); - expect(result.text).toBe('my search'); - }); - - it('ignores query keys not in FIELD_TEMPLATE', () => { - const query = { unknownField: 'value' }; - const result = QueryDBTranslator(query); - expect(Object.keys(result)).toHaveLength(0); - }); - - it('handles combined query with multiple fields', () => { - const query = { - levels: '1,2', - creators: 'user-id-1', - languages: 'js', - text: 'todo app' - }; - const result = QueryDBTranslator(query); - expect(result).toEqual({ - level_id: ['1', '2'], - owner_user_id: ['user-id-1'], - language: ['js'], - text: 'todo app' - }); - }); -}); diff --git a/src/common/services/__tests__/plays.test.js b/src/common/services/__tests__/plays.test.js deleted file mode 100644 index ec4789912a..0000000000 --- a/src/common/services/__tests__/plays.test.js +++ /dev/null @@ -1,99 +0,0 @@ -import { deleteATag, getPlaysByFilter } from '../plays'; -import { submit } from '../request'; - -jest.mock('../request', () => ({ - submit: jest.fn(), - submitMutation: jest.fn() -})); - -jest.mock('../request/query', () => ({ - deleteATagQuery: jest.fn((params) => ({ type: 'DELETE_TAG', ...params })) -})); - -jest.mock('../request/query/play', () => ({ - associatePlayWithTagQuery: 'ASSOCIATE_TAG_QUERY', - createPlayQuery: 'CREATE_PLAY_QUERY' -})); - -jest.mock('common/services/request/query/fetch-plays', () => ({ - FetchPlaysByFilter: jest.fn(() => 'FETCH_PLAYS_PAYLOAD') -})); - -jest.mock('common/services/string', () => ({ - toTitleCaseTrimmed: jest.fn((str) => str.replace(/\s/g, '')), - toKebabCase: jest.fn((str) => str.toLowerCase().replace(/\s+/g, '-')), - toSlug: jest.fn((str) => str.toLowerCase().replace(/\s+/g, '-')) -})); - -describe('deleteATag', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - it('returns an array of deletion promises for removed tags', () => { - submit.mockReturnValue(Promise.resolve()); - - const actualTags = [ - { id: 'tag-1', name: 'React' }, - { id: 'tag-2', name: 'JavaScript' }, - { id: 'tag-3', name: 'CSS' } - ]; - const newTags = [{ id: 'tag-1', name: 'React' }]; // tag-2 and tag-3 removed - - const result = deleteATag('play-1', actualTags, newTags); - - expect(result).toHaveLength(2); - expect(submit).toHaveBeenCalledTimes(2); - }); - - it('returns empty array when no tags are removed', () => { - const actualTags = [{ id: 'tag-1', name: 'React' }]; - const newTags = [{ id: 'tag-1', name: 'React' }]; - - const result = deleteATag('play-1', actualTags, newTags); - - expect(result).toHaveLength(0); - expect(submit).not.toHaveBeenCalled(); - }); - - it('returns empty array when there are no actual tags', () => { - const result = deleteATag('play-1', [], [{ id: 'tag-1' }]); - expect(result).toHaveLength(0); - }); -}); - -describe('getPlaysByFilter', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - it('returns plays with title_name appended', async () => { - const mockPlays = [ - { name: 'todo app', play_tags: [] }, - { name: 'calculator', play_tags: [] } - ]; - submit.mockResolvedValueOnce(mockPlays); - - const result = await getPlaysByFilter(null, 'newest'); - - expect(result).toHaveLength(2); - expect(result[0]).toHaveProperty('title_name'); - expect(result[1]).toHaveProperty('title_name'); - }); - - it('filters by tags when tags filter is provided', async () => { - const mockPlays = [ - { name: 'play1', play_tags: [{ tag_id: 'tag-1' }] }, - { name: 'play2', play_tags: [{ tag_id: 'tag-2' }] }, - { name: 'play3', play_tags: [{ tag_id: 'tag-3' }] } - ]; - submit.mockResolvedValueOnce(mockPlays); - - const filter = { tags: ['tag-1', 'tag-3'] }; - const result = await getPlaysByFilter(filter, 'newest'); - - // Only play1 and play3 match the tags - expect(result).toHaveLength(2); - expect(result.map((p) => p.name)).toEqual(['play1', 'play3']); - }); -}); diff --git a/src/common/utils/__tests__/commonUtils.test.js b/src/common/utils/__tests__/commonUtils.test.js deleted file mode 100644 index 78550cbd2d..0000000000 --- a/src/common/utils/__tests__/commonUtils.test.js +++ /dev/null @@ -1,68 +0,0 @@ -import { compareTextValue, getProdUrl, formatDate } from '../commonUtils'; -import countByProp from '../commonUtils'; - -describe('compareTextValue', () => { - it('returns true when string option contains search text (case-insensitive)', () => { - expect(compareTextValue('Hello World', 'hello')).toBe(true); - expect(compareTextValue('Hello World', 'WORLD')).toBe(true); - }); - - it('returns false when string option does not contain search text', () => { - expect(compareTextValue('Hello World', 'xyz')).toBe(false); - }); - - it('handles React element with props.children array', () => { - const element = { props: { children: ['icon', 'JavaScript'] } }; - expect(compareTextValue(element, 'java')).toBe(true); - expect(compareTextValue(element, 'python')).toBe(false); - }); -}); - -describe('getProdUrl', () => { - it('prepends the reactplay.io domain to a path', () => { - expect(getProdUrl('/plays/my-play')).toBe('https://reactplay.io/plays/my-play'); - }); - - it('handles root path', () => { - expect(getProdUrl('/')).toBe('https://reactplay.io/'); - }); - - it('handles empty string', () => { - expect(getProdUrl('')).toBe('https://reactplay.io'); - }); -}); - -describe('countByProp', () => { - const items = [ - { type: 'play', name: 'A' }, - { type: 'idea', name: 'B' }, - { type: 'play', name: 'C' }, - { type: 'play', name: 'D' } - ]; - - it('counts items matching the given key-value pair', () => { - expect(countByProp(items, 'type', 'play')).toBe(3); - expect(countByProp(items, 'type', 'idea')).toBe(1); - }); - - it('returns 0 when no items match', () => { - expect(countByProp(items, 'type', 'missing')).toBe(0); - }); - - it('returns 0 for an empty array', () => { - expect(countByProp([], 'type', 'play')).toBe(0); - }); -}); - -describe('formatDate', () => { - it('formats an ISO date string into "Joined DD Mon YYYY"', () => { - const result = formatDate('2023-06-15T10:00:00Z'); - expect(result).toMatch(/^Joined 15 \w+ 2023$/); - }); - - it('returns empty string for falsy input', () => { - expect(formatDate('')).toBe(''); - expect(formatDate(null)).toBe(''); - expect(formatDate(undefined)).toBe(''); - }); -}); diff --git a/src/common/utils/__tests__/coverImageUtil.test.js b/src/common/utils/__tests__/coverImageUtil.test.js deleted file mode 100644 index e643fa270e..0000000000 --- a/src/common/utils/__tests__/coverImageUtil.test.js +++ /dev/null @@ -1,60 +0,0 @@ -// We need to mock the dynamic import used inside coverImageUtil -// and also the fallback image import. -import { loadCoverImage } from '../coverImageUtil'; - -const MOCK_FALLBACK = 'fallback-image.png'; -const MOCK_COVER = 'cover-image.png'; - -jest.mock('images/play-fallback-cover.png', () => 'fallback-image.png', { virtual: true }); - -// Mock the dynamic import by overriding the loadImageForExtension behavior -// through mocking the entire module and re-implementing loadCoverImage with -// a controllable import mock. -let mockImport; - -jest.mock('../coverImageUtil', () => { - const { IMAGE_EXTENSIONS, FULFILLED_STATUS } = jest.requireActual('../utilsConstants'); - - return { - loadCoverImage: async (playSlug) => { - const imagePromises = IMAGE_EXTENSIONS.map((extension) => mockImport(playSlug, extension)); - const results = await Promise.allSettled(imagePromises); - const image = results.find( - (result) => result.status === FULFILLED_STATUS && result.value?.default - ); - - return image?.value.default || 'fallback-image.png'; - } - }; -}); - -describe('loadCoverImage', () => { - beforeEach(() => { - mockImport = jest.fn(); - }); - - it('returns the first successfully loaded image', async () => { - // png fails, jpg succeeds - mockImport - .mockRejectedValueOnce(new Error('not found')) // png - .mockResolvedValueOnce({ default: MOCK_COVER }) // jpg - .mockRejectedValueOnce(new Error('not found')); // jpeg - - const result = await loadCoverImage('my-play'); - expect(result).toBe(MOCK_COVER); - }); - - it('returns fallback image when all extensions fail', async () => { - mockImport.mockRejectedValue(new Error('not found')); - - const result = await loadCoverImage('missing-play'); - expect(result).toBe(MOCK_FALLBACK); - }); - - it('returns fallback when images resolve without default property', async () => { - mockImport.mockResolvedValue({ noDefault: true }); - - const result = await loadCoverImage('bad-module'); - expect(result).toBe(MOCK_FALLBACK); - }); -}); diff --git a/src/common/utils/__tests__/formatCount.test.ts b/src/common/utils/__tests__/formatCount.test.ts deleted file mode 100644 index 83045626a9..0000000000 --- a/src/common/utils/__tests__/formatCount.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { formatDurationCount, formatViewCount } from '../formatCount'; - -describe('formatDurationCount', () => { - it('formats seconds-only durations as MM:SS', () => { - expect(formatDurationCount(0)).toBe('00:00'); - expect(formatDurationCount(5)).toBe('00:05'); - expect(formatDurationCount(59)).toBe('00:59'); - }); - - it('formats minute durations as MM:SS', () => { - expect(formatDurationCount(60)).toBe('01:00'); - expect(formatDurationCount(125)).toBe('02:05'); - expect(formatDurationCount(3599)).toBe('59:59'); - }); - - it('includes hours when duration >= 3600', () => { - expect(formatDurationCount(3600)).toBe('1:00:00'); - expect(formatDurationCount(3661)).toBe('1:01:01'); - expect(formatDurationCount(7384)).toBe('2:03:04'); - }); -}); - -describe('formatViewCount', () => { - it('returns the original string for counts below 1000', () => { - expect(formatViewCount('0')).toBe('0'); - expect(formatViewCount('999')).toBe('999'); - expect(formatViewCount('42')).toBe('42'); - }); - - it('formats thousands with K suffix', () => { - expect(formatViewCount('1000')).toBe('1.0K'); - expect(formatViewCount('1500')).toBe('1.5K'); - expect(formatViewCount('999999')).toBe('1000.0K'); - }); - - it('formats millions with M suffix', () => { - expect(formatViewCount('1000000')).toBe('1.0M'); - expect(formatViewCount('2500000')).toBe('2.5M'); - }); - - it('formats billions with B suffix', () => { - expect(formatViewCount('1000000000')).toBe('1.0B'); - expect(formatViewCount('3700000000')).toBe('3.7B'); - }); -}); diff --git a/src/common/utils/sanitizeHTML.js b/src/common/utils/sanitizeHTML.js deleted file mode 100644 index b07766de01..0000000000 --- a/src/common/utils/sanitizeHTML.js +++ /dev/null @@ -1,12 +0,0 @@ -import DOMPurify from 'dompurify'; - -/** - * Sanitizes an HTML string using DOMPurify to prevent XSS attacks. - * Use this utility whenever you need to render dynamic HTML via dangerouslySetInnerHTML. - * - * @param {string} html - The raw HTML string to sanitize. - * @returns {string} - A sanitized HTML string safe to use with dangerouslySetInnerHTML. - */ -const sanitizeHTML = (html) => DOMPurify.sanitize(html ?? ''); - -export default sanitizeHTML; diff --git a/src/plays/Selection-Sort-Visualizer/SelectionSortVisualizer.js b/src/plays/Selection-Sort-Visualizer/SelectionSortVisualizer.js index 1eb8241875..951f4380cb 100644 --- a/src/plays/Selection-Sort-Visualizer/SelectionSortVisualizer.js +++ b/src/plays/Selection-Sort-Visualizer/SelectionSortVisualizer.js @@ -23,8 +23,6 @@ function SelectionSortVisualizer() { const handleSort = async () => { const arrCopy = [...arr]; const outputElements = document.getElementById('output-visualizer'); - // Safe: clears the container to empty string (no user data injected). - // All subsequent DOM mutations use createElement/createTextNode (XSS-safe). outputElements.innerHTML = ''; for (let i = 0; i < arrCopy.length - 1; i++) { diff --git a/src/plays/devblog/Pages/Article.jsx b/src/plays/devblog/Pages/Article.jsx index ef2ea2d2b0..8f7c70e7da 100644 --- a/src/plays/devblog/Pages/Article.jsx +++ b/src/plays/devblog/Pages/Article.jsx @@ -1,7 +1,6 @@ import axios from 'axios'; import { useState, useEffect } from 'react'; import { useParams } from 'react-router-dom'; -import sanitizeHTML from 'common/utils/sanitizeHTML'; import Loading from '../components/Loading'; const Article = () => { @@ -51,7 +50,7 @@ const Article = () => {
    ) : ( diff --git a/src/plays/fun-quiz/EndScreen.jsx b/src/plays/fun-quiz/EndScreen.jsx index 5398009a8b..a8f337760f 100644 --- a/src/plays/fun-quiz/EndScreen.jsx +++ b/src/plays/fun-quiz/EndScreen.jsx @@ -1,6 +1,5 @@ // vendors import { Fragment, useState } from 'react'; -import sanitizeHTML from 'common/utils/sanitizeHTML'; // css import './FrontScreen.scss'; @@ -17,17 +16,17 @@ const EndScreen = ({ quizSummary, redirectHome }) => {
    Question: {currentQuestion?.qNo}
  • Ans: ${currentQuestion?.correct_answer}
    `) + __html: `
    Ans: ${currentQuestion?.correct_answer}
    ` }} /> Your Answer: ${currentQuestion?.your_answer}`) + __html: `Your Answer: ${currentQuestion?.your_answer}` }} /> diff --git a/src/plays/fun-quiz/QuizScreen.jsx b/src/plays/fun-quiz/QuizScreen.jsx index 571946985a..cc150234f8 100644 --- a/src/plays/fun-quiz/QuizScreen.jsx +++ b/src/plays/fun-quiz/QuizScreen.jsx @@ -1,5 +1,4 @@ import { useEffect, useState, useCallback, useRef } from 'react'; -import sanitizeHTML from 'common/utils/sanitizeHTML'; import './QuizScreen.scss'; @@ -150,7 +149,7 @@ function QuizScreen({ category, getQuizSummary }) {
    {timer}
    Question: {questionNumber + 1}
    -

    +

    {currentQuestion?.options?.map((option, index) => { @@ -158,7 +157,7 @@ function QuizScreen({ category, getQuizSummary }) {
    diff --git a/src/plays/markdown-editor/Output.jsx b/src/plays/markdown-editor/Output.jsx index 8f7ca65314..9228271430 100644 --- a/src/plays/markdown-editor/Output.jsx +++ b/src/plays/markdown-editor/Output.jsx @@ -1,11 +1,10 @@ import React from 'react'; -import sanitizeHTML from 'common/utils/sanitizeHTML'; const Output = ({ md, text, mdPreviewBox }) => { return (
    ); diff --git a/src/plays/text-to-speech/TextToSpeech.jsx b/src/plays/text-to-speech/TextToSpeech.jsx index e400bc4fde..3286a6d0f9 100644 --- a/src/plays/text-to-speech/TextToSpeech.jsx +++ b/src/plays/text-to-speech/TextToSpeech.jsx @@ -158,7 +158,10 @@ function TextToSpeech(props) {
    {convertedText ? ( <> -

    {convertedText}

    +