import * as React from 'react'; import { createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import ChallengeModal from '../challenge-modal'; import getShortAddress from '../../../lib/get-short-address'; import useTrustedBoardUrlPermissionsStore from '../../../stores/use-trusted-board-url-permissions-store'; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; const testState = vi.hoisted(() => ({ abandonCurrentChallengeMock: vi.fn().mockResolvedValue(undefined), account: { author: { address: '0xabc123', }, } as Record, challenges: [] as Array<{ challenge: any; id: number }>, commentsByCid: {} as Record, isMobile: false, publicationPreview: 'preview body', publicationType: 'post', removeChallengeMock: vi.fn(), springStartMock: vi.fn(), theme: 'dark', votePreview: 'upvote', })); vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string, options?: Record) => { if (key === 'challenge_counter') { return `${options?.index}/${options?.total}`; } if (key === 'iframe_challenge_confirm') { return `${options?.board} wants to open ${options?.site}.\n\nFor {{publicationType}}: {{excerpt}}`; } if (key === 'trusted_board_link_checkbox') { return 'Always allow boards to open this website'; } return key; }, }), })); vi.mock('@bitsocial/bitsocial-react-hooks', () => ({ useAccount: () => testState.account, useComment: ({ commentCid }: { commentCid?: string }) => (commentCid ? testState.commentsByCid[commentCid] : undefined), })); vi.mock('../../../lib/utils/challenge-utils', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getPublicationPreview: () => testState.publicationPreview, getPublicationType: () => testState.publicationType, getVotePreview: () => testState.votePreview, }; }); vi.mock('../../../hooks/use-is-mobile', () => ({ default: () => testState.isMobile, })); vi.mock('../../../hooks/use-theme', () => ({ default: () => [testState.theme], })); vi.mock('../../../stores/use-challenges-store', () => ({ default: () => ({ abandonCurrentChallenge: testState.abandonCurrentChallengeMock, challenges: testState.challenges, removeChallenge: testState.removeChallengeMock, }), })); vi.mock('@react-spring/web', async () => { const React = await vi.importActual('react'); const createSpringValue = (value: number) => ({ get: () => value, to: (mapper: (input: number) => unknown) => mapper(value), }); return { animated: { div: React.forwardRef(({ children, style, ...props }: any, ref) => createElement('div', { ...props, ref, style: { touchAction: style?.touchAction } }, children)), }, useSpring: () => [ { x: createSpringValue(120), y: createSpringValue(60), }, { start: testState.springStartMock }, ], }; }); vi.mock('@use-gesture/react', () => ({ useDrag: () => () => ({}), })); let alertSpy: ReturnType; let confirmSpy: ReturnType; let container: HTMLDivElement; let consoleErrorSpy: ReturnType; let nextStoredChallengeId = 1; let postMessageMock: ReturnType; let root: Root; const createPublication = (): Record => ({ author: { displayName: 'Alice' }, content: 'Publication content', link: 'https://example.com/link', parentCid: 'parent-1', publishChallengeAnswers: vi.fn(), shortCommunityAddress: 'mu', communityAddress: 'music-posting.eth', title: 'Subject', }); const createStoredChallenge = (challenge: any, publication = createPublication(), publicationTarget?: Record) => ({ challenge: [{ challenges: Array.isArray(challenge) ? challenge : [challenge] }, publication, publicationTarget], id: nextStoredChallengeId++, }); const renderModal = async () => { await act(async () => { root.render(createElement(ChallengeModal)); }); await act(async () => { await new Promise((resolve) => queueMicrotask(resolve)); }); }; const dispatchInput = async (element: HTMLInputElement, value: string) => { await act(async () => { const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value'); descriptor?.set?.call(element, value); element.dispatchEvent(new Event('input', { bubbles: true })); element.dispatchEvent(new Event('change', { bubbles: true })); }); }; const clickButton = async (text: string) => { const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === text); await act(async () => { button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); }; describe('ChallengeModal', () => { beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); useTrustedBoardUrlPermissionsStore.setState({ trustedOrigins: {} }); testState.abandonCurrentChallengeMock.mockReset().mockResolvedValue(undefined); testState.account = { author: { address: '0xabc123', }, }; testState.challenges = []; testState.commentsByCid = { 'parent-1': { author: { shortAddress: '0xparent', }, }, }; testState.isMobile = false; testState.publicationPreview = 'preview body'; testState.publicationType = 'post'; testState.removeChallengeMock.mockReset(); testState.springStartMock.mockReset(); testState.theme = 'dark'; testState.votePreview = 'upvote'; alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined); confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true); consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); postMessageMock = vi.fn(); Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', { configurable: true, get: () => ({ postMessage: postMessageMock, }), }); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); }); afterEach(() => { act(() => root.unmount()); container.remove(); alertSpy.mockRestore(); confirmSpy.mockRestore(); consoleErrorSpy.mockRestore(); }); it('renders nothing when there are no queued challenges', async () => { await renderModal(); expect(container.innerHTML).toBe(''); }); it('submits a text challenge answer on Enter and closes the modal', async () => { const publication = createPublication(); testState.publicationType = 'reply'; testState.challenges = [ createStoredChallenge( { challenge: '2 + 2', type: 'text/plain', }, publication, ), ]; await renderModal(); expect(container.textContent).toContain('Challenge for reply'); expect(container.textContent).toContain('1/1'); expect(container.querySelector('textarea')?.textContent ?? container.textContent).toContain('Publication content'); const input = container.querySelector('input[placeholder*="TYPE THE ANSWER HERE"]'); expect(input).not.toBeNull(); await dispatchInput(input as HTMLInputElement, '4'); await act(async () => { input?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' })); }); expect(publication.publishChallengeAnswers).toHaveBeenCalledWith({ challengeAnswers: ['4'] }); expect(testState.removeChallengeMock).toHaveBeenCalledOnce(); }); it('centers the mobile modal from the viewport instead of its static position', async () => { testState.isMobile = true; Object.defineProperty(window, 'innerWidth', { configurable: true, value: 375 }); Object.defineProperty(window, 'innerHeight', { configurable: true, value: 667 }); testState.publicationType = 'reply'; testState.challenges = [ createStoredChallenge({ challenge: 'https://spamblocker.bitsocial.net/api/v1/iframe/session-123', type: 'url/iframe', }), ]; await renderModal(); const dialog = container.querySelector('[role="dialog"]'); expect(container.textContent).toContain('wants to open spamblocker.bitsocial.net.'); expect(dialog?.style.left).toBe('50%'); expect(dialog?.style.top).toBe('0px'); expect(dialog?.style.transform).toBe('translate3d(-50%, 134px, 0)'); }); it('redacts generated fortune BBCode from challenge publication details', async () => { const publication = { ...createPublication(), content: 'body[fortune color=#fd4d32]Excellent Luck[/fortune]', }; testState.publicationPreview = 'body'; testState.publicationType = 'post'; testState.challenges = [ createStoredChallenge( { challenge: '2 + 2', type: 'text/plain', }, publication, ), ]; await renderModal(); expect(container.querySelector('textarea')?.value).toBe('body'); expect(container.textContent).not.toContain('Excellent Luck'); expect(container.textContent).not.toContain('[fortune'); }); it('supports multi-step image challenges with next and previous navigation', async () => { const publication = createPublication(); testState.challenges = [ createStoredChallenge( [ { challenge: 'first answer', type: 'text/plain', }, { challenge: 'YmFzZTY0LWltYWdl', type: 'image/png', }, ], publication, ), ]; await renderModal(); expect(container.textContent).toContain('1/2'); const input = container.querySelector('input[placeholder*="TYPE THE ANSWER HERE"]'); await dispatchInput(input as HTMLInputElement, 'step one'); await act(async () => { input?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' })); }); expect(container.textContent).toContain('2/2'); expect(container.querySelector('img')?.getAttribute('src')).toBe('data:image/png;base64,YmFzZTY0LWltYWdl'); await clickButton('previous'); expect(container.textContent).toContain('1/2'); await clickButton('next'); await dispatchInput(container.querySelector('input[placeholder*="TYPE THE ANSWER HERE"]') as HTMLInputElement, 'step two'); await clickButton('submit'); expect(publication.publishChallengeAnswers).toHaveBeenCalledWith({ challengeAnswers: ['step one', 'step two'] }); expect(testState.removeChallengeMock).toHaveBeenCalledOnce(); }); it('opens iframe challenges through the custom modal, injects the theme, and completes them', async () => { const publication = createPublication(); testState.challenges = [ createStoredChallenge( { challenge: 'https://mintpass.org/auth?user={userAddress}', type: 'url/iframe', }, publication, ), ]; await renderModal(); expect(confirmSpy).not.toHaveBeenCalled(); expect(container.querySelector('#challenge-modal-title')?.textContent).toContain('Challenge for post'); expect(container.textContent).toContain('mintpass.org'); expect(container.textContent).toContain('mu wants to open mintpass.org.'); expect(container.textContent).not.toContain('For post:'); expect(container.textContent).not.toContain('For {{publicationType}}: {{excerpt}}'); expect(container.textContent).toContain('Open'); expect(container.textContent).toContain('Always allow boards to open this website'); expect(container.textContent).not.toContain('Always allow boards to open mintpass.org'); expect(container.textContent).not.toContain('Cancel'); const publicationFields = Array.from(container.querySelectorAll('input[type="text"]')); expect(publicationFields.map((input) => input.value)).toEqual(['Alice', 'Subject', 'https://example.com/link']); expect(publicationFields.every((input) => input.disabled)).toBe(true); const publicationTextarea = container.querySelector('textarea'); expect(publicationTextarea?.value).toBe('Publication content'); expect(publicationTextarea?.disabled).toBe(true); expect(container.querySelector('iframe')).toBeNull(); await clickButton('Open'); const iframe = container.querySelector('iframe'); expect(iframe).not.toBeNull(); expect(iframe?.getAttribute('src')).toContain('https://mintpass.org/auth?user=0xabc123&theme=dark'); expect(iframe?.getAttribute('sandbox')).toBe('allow-scripts allow-forms allow-popups allow-same-origin allow-top-navigation-by-user-activation'); await act(async () => { iframe?.dispatchEvent(new Event('load', { bubbles: true })); }); expect(postMessageMock).toHaveBeenCalledWith( { source: 'plebbit-5chan', theme: 'dark', type: 'plebbit-theme', }, 'https://mintpass.org', ); const doneButton = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'Finish Challenge'); expect(doneButton?.getAttribute('aria-label')).toBe('Finish challenge'); await clickButton('Finish Challenge'); expect(publication.publishChallengeAnswers).toHaveBeenCalledWith({ challengeAnswers: [''] }); expect(testState.removeChallengeMock).toHaveBeenCalledOnce(); }); it('remembers a trusted iframe origin when the checkbox is selected', async () => { testState.challenges = [ createStoredChallenge({ challenge: 'https://spamblocker.bitsocial.net/api/v1/iframe/session-123', type: 'url/iframe', }), ]; await renderModal(); const checkbox = container.querySelector('input[type="checkbox"]'); await act(async () => { checkbox?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); await clickButton('Open'); expect(useTrustedBoardUrlPermissionsStore.getState().isOriginTrusted('https://spamblocker.bitsocial.net')).toBe(true); expect(useTrustedBoardUrlPermissionsStore.getState().getTrustedOrigins()).toEqual([ expect.objectContaining({ origin: 'https://spamblocker.bitsocial.net', site: 'spamblocker.bitsocial.net', }), ]); }); it('opens trusted iframe origins without asking again', async () => { useTrustedBoardUrlPermissionsStore.getState().trustOrigin('https://spamblocker.bitsocial.net', 'spamblocker.bitsocial.net'); testState.challenges = [ createStoredChallenge({ challenge: 'https://spamblocker.bitsocial.net/api/v1/iframe/session-123', type: 'url/iframe', }), ]; await renderModal(); expect(confirmSpy).not.toHaveBeenCalled(); expect(container.textContent).not.toContain('wants to open'); expect(container.querySelector('iframe')?.getAttribute('src')).toContain('https://spamblocker.bitsocial.net/api/v1/iframe/session-123?theme=dark'); }); it('allows localhost http iframe challenges for local spam blocker testing', async () => { const publication = createPublication(); testState.challenges = [ createStoredChallenge( { challenge: 'http://localhost:3000/api/v1/iframe/session-123?foo=bar', type: 'url/iframe', }, publication, ), ]; await renderModal(); expect(confirmSpy).not.toHaveBeenCalled(); expect(container.textContent).toContain('mu wants to open localhost:3000.'); expect(container.textContent).not.toContain('For post:'); expect(container.textContent).not.toContain('For {{publicationType}}: {{excerpt}}'); await clickButton('Open'); const iframe = container.querySelector('iframe'); expect(iframe).not.toBeNull(); expect(iframe?.getAttribute('src')).toContain('http://localhost:3000/api/v1/iframe/session-123?foo=bar&theme=dark'); await act(async () => { iframe?.dispatchEvent(new Event('load', { bubbles: true })); }); expect(postMessageMock).toHaveBeenCalledWith( { source: 'plebbit-5chan', theme: 'dark', type: 'plebbit-theme', }, 'http://localhost:3000', ); }); it('auto-submits iframe challenges when the iframe posts a completion message', async () => { const publication = createPublication(); testState.challenges = [ createStoredChallenge( { challenge: 'https://spamblocker.bitsocial.net/api/v1/iframe/session-123', type: 'url/iframe', }, publication, ), ]; await renderModal(); await clickButton('Open'); await act(async () => { window.dispatchEvent( new MessageEvent('message', { data: { type: 'challengeAnswer', challengeAnswers: [''], sessionId: 'session-123', }, origin: 'https://spamblocker.bitsocial.net', }), ); }); expect(publication.publishChallengeAnswers).toHaveBeenCalledWith({ challengeAnswers: [''] }); expect(testState.removeChallengeMock).toHaveBeenCalledOnce(); }); it('advances through multiple iframe challenges before publishing answers', async () => { const publication = createPublication(); testState.challenges = [ createStoredChallenge( [ { challenge: 'https://spamblocker.bitsocial.net/api/v1/iframe/session-123', type: 'url/iframe', }, { challenge: 'https://flags.5chan.app/iframe/session-flag', type: 'url/iframe', }, ], publication, ), ]; await renderModal(); await clickButton('Open'); await act(async () => { window.dispatchEvent( new MessageEvent('message', { data: { type: 'challengeAnswer', challengeAnswers: [''], sessionId: 'session-123', }, origin: 'https://spamblocker.bitsocial.net', }), ); }); expect(publication.publishChallengeAnswers).not.toHaveBeenCalled(); expect(testState.removeChallengeMock).not.toHaveBeenCalled(); expect(container.textContent).toContain('mu wants to open flags.5chan.app.'); await clickButton('Open'); await act(async () => { window.dispatchEvent( new MessageEvent('message', { data: { type: 'challengeAnswer', challengeAnswers: [''], sessionId: 'session-flag', }, origin: 'https://flags.5chan.app', }), ); }); expect(publication.publishChallengeAnswers).toHaveBeenCalledWith({ challengeAnswers: ['', ''] }); expect(testState.removeChallengeMock).toHaveBeenCalledOnce(); }); it('ignores iframe completion messages with the wrong session id', async () => { const publication = createPublication(); testState.challenges = [ createStoredChallenge( { challenge: 'https://spamblocker.bitsocial.net/api/v1/iframe/session-123', type: 'url/iframe', }, publication, ), ]; await renderModal(); await clickButton('Open'); await act(async () => { window.dispatchEvent( new MessageEvent('message', { data: { type: 'challengeAnswer', challengeAnswers: [''], sessionId: 'session-999', }, origin: 'https://spamblocker.bitsocial.net', }), ); }); expect(publication.publishChallengeAnswers).not.toHaveBeenCalled(); expect(testState.removeChallengeMock).not.toHaveBeenCalled(); }); it('uses the shortened community address when shortCommunityAddress is unavailable', async () => { const longCommunityAddress = '12D3KooWS6yKc5N7o6JcAYHZpaQwAwyh1VddYatarU75Se3HXEeD'; const publication = { ...createPublication(), shortCommunityAddress: undefined, communityAddress: longCommunityAddress, }; testState.challenges = [ createStoredChallenge( { challenge: 'http://localhost:3000/api/v1/iframe/session-123?foo=bar', type: 'url/iframe', }, publication, ), ]; await renderModal(); expect(confirmSpy).not.toHaveBeenCalled(); expect(container.textContent).toContain(`${getShortAddress(longCommunityAddress)} wants to open localhost:3000.`); expect(container.textContent).not.toContain('For post:'); expect(container.textContent).not.toContain('For {{publicationType}}: {{excerpt}}'); expect(container.textContent).not.toContain(longCommunityAddress); }); it('shows reply content in disabled fields instead of the iframe confirmation message', async () => { const publication = { ...createPublication(), content: '>>17\nA reply body with enough context', title: '', }; testState.publicationType = 'reply'; testState.challenges = [ createStoredChallenge( { challenge: 'https://spamblocker.bitsocial.net/api/v1/iframe/session-123', type: 'url/iframe', }, publication, ), ]; await renderModal(); expect(confirmSpy).not.toHaveBeenCalled(); expect(container.querySelector('#challenge-modal-title')?.textContent).toContain('Challenge for reply'); expect(container.textContent).toContain('mu wants to open spamblocker.bitsocial.net.'); expect(container.textContent).not.toContain('For reply:'); expect(container.textContent).not.toContain('For {{publicationType}}: {{excerpt}}'); expect(container.querySelector('textarea')?.value).toBe('>>17\nA reply body with enough context'); }); it('abandons iframe challenges when the custom confirmation is closed', async () => { testState.challenges = [ createStoredChallenge({ challenge: 'https://spamblocker.bitsocial.net/api/v1/iframe/session-123', type: 'url/iframe', }), ]; await renderModal(); await clickButton('close'); expect(confirmSpy).not.toHaveBeenCalled(); expect(testState.abandonCurrentChallengeMock).toHaveBeenCalledOnce(); expect(container.querySelector('iframe')).toBeNull(); }); it('shows iframe challenge errors inline instead of relying on alert', async () => { testState.account = { author: { address: '' } }; testState.challenges = [ createStoredChallenge({ challenge: 'https://mintpass.org/auth?user={userAddress}', type: 'url/iframe', }), ]; await renderModal(); expect(alertSpy).not.toHaveBeenCalled(); expect(container.textContent).toContain('Error: Unable to load challenge without your address. Please sign in and try again.'); expect(container.textContent).not.toContain('Open'); expect(container.querySelector('iframe')).toBeNull(); await clickButton('close'); expect(testState.abandonCurrentChallengeMock).toHaveBeenCalledOnce(); }); it('shows invalid iframe challenge errors inline and still responds to Escape', async () => { testState.challenges = [ createStoredChallenge({ challenge: 'http://example.com/unsafe', type: 'url/iframe', }), ]; await renderModal(); expect(alertSpy).not.toHaveBeenCalled(); expect(confirmSpy).not.toHaveBeenCalled(); expect(container.textContent).toContain('Error: Only HTTPS iframe challenges or localhost HTTP challenges are supported'); expect(testState.abandonCurrentChallengeMock).not.toHaveBeenCalled(); await act(async () => { document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); }); expect(testState.abandonCurrentChallengeMock).toHaveBeenCalledOnce(); }); });