UNPKG

@hcaptcha/react-native-hcaptcha

Version:

hCaptcha Library for React Native (both Android and iOS)

670 lines (579 loc) 23.2 kB
import React from 'react'; import vm from 'vm'; import { act, render, waitFor } from '@testing-library/react-native'; import { ActivityIndicator, Linking, TouchableWithoutFeedback } from 'react-native'; import Hcaptcha, { HCAPTCHA_READY_EVENT } from '../Hcaptcha'; import { __unsafeResetJourneyRuntime, emitJourneyEvent, initJourneyTracking, peekJourneyEvents } from '../journey'; import { getLastInjectJavaScriptMock, resetWebViewMockState, setWebViewMessageData, } from 'react-native-webview'; const LONG_TOKEN = '10000000-aaaa-bbbb-cccc-000000000001'; describe('Hcaptcha', () => { const getWebView = (component) => component.UNSAFE_getByType('WebView'); const getWebViewHtml = (component) => getWebView(component).props.source.html; const getSerializedConfig = (component) => { const match = getWebViewHtml(component).match(/var hcaptchaConfig = (.*?);\n\s*Object\.entries/s); expect(match).not.toBeNull(); return JSON.parse(match[1]); }; const getApiQueryParams = (component) => Object.fromEntries(new URL(getSerializedConfig(component).apiUrl).searchParams.entries()); const getInlineScripts = (component) => [...getWebViewHtml(component).matchAll(/<script type="text\/javascript">([\s\S]*?)<\/script>/g)] .map((match) => match[1]); beforeEach(() => { jest.restoreAllMocks(); jest.clearAllMocks(); jest.useRealTimers(); resetWebViewMockState(); __unsafeResetJourneyRuntime(); }); it('renders Hcaptcha with minimum props', () => { const component = render(<Hcaptcha url="https://hcaptcha.com" />); expect(component).toMatchSnapshot(); }); it('maps every Hcaptcha prop into WebView props, serialized config, and query params', () => { const style = { borderWidth: 2 }; const debug = { customDebug: 'enabled' }; const onMessage = jest.fn(); const component = render( <Hcaptcha onMessage={onMessage} size="normal" siteKey="00000000-0000-0000-0000-000000000000" style={style} url="https://base.url" languageCode="fr" showLoading={true} closableLoading={true} loadingIndicatorColor="#123456" backgroundColor="rgba(0.1, 0.1, 0.1, 0.4)" theme="contrast" rqdata='{"some":"data"}' sentry={true} jsSrc="https://all.props/api-endpoint" endpoint="https://all.props/endpoint" reportapi="https://all.props/reportapi" assethost="https://all.props/assethost" imghost="https://all.props/imghost" host="all-props-host" debug={debug} orientation="landscape" phonePrefix="44" phoneNumber="+441234567890" /> ); const webView = getWebView(component); const config = getSerializedConfig(component); const query = getApiQueryParams(component); const activityIndicator = component.UNSAFE_getByType(ActivityIndicator); expect(webView.props.source.baseUrl).toBe('https://base.url'); expect(webView.props.style).toEqual([ { backgroundColor: 'transparent', width: '100%' }, style, ]); expect(webView.props.originWhitelist).toEqual(['*']); expect(webView.props.mixedContentMode).toBe('always'); expect(webView.props.javaScriptEnabled).toBe(true); expect(webView.props.automaticallyAdjustContentInsets).toBe(true); expect(webView.props.injectedJavaScript).toContain('window.ReactNativeWebView.postMessage = patchedPostMessage;'); expect(activityIndicator.props.color).toBe('#123456'); expect(config.siteKey).toBe('00000000-0000-0000-0000-000000000000'); expect(config.size).toBe('normal'); expect(config.backgroundColor).toBe('rgba(0.1, 0.1, 0.1, 0.4)'); expect(config.theme).toBe('contrast'); expect(config.rqdata).toBe('{"some":"data"}'); expect(config.phonePrefix).toBe('44'); expect(config.phoneNumber).toBe('+441234567890'); expect(config.verifyData).toBeUndefined(); expect(config.debugInfo).toMatchObject({ customDebug: 'enabled', 'dep_mocked-md5': true, sdk_4_0_0: true, }); expect(query).toMatchObject({ render: 'explicit', onload: 'onloadCallback', host: 'all-props-host', hl: 'fr', sentry: 'true', endpoint: 'https://all.props/endpoint', assethost: 'https://all.props/assethost', imghost: 'https://all.props/imghost', reportapi: 'https://all.props/reportapi', orientation: 'landscape', }); expect(query.custom).toBeUndefined(); }); it('normalizes the legacy checkbox size alias to the JS SDK normal size', () => { const component = render( <Hcaptcha size="checkbox" siteKey="00000000-0000-0000-0000-000000000000" url="https://hcaptcha.com" /> ); expect(getSerializedConfig(component).size).toBe('normal'); }); it('normalizes object and JSON-string themes into object config and custom=true query params', () => { const customTheme = { palette: { mode: 'dark', primary: { main: '#26C6DA' }, }, }; [ customTheme, JSON.stringify(customTheme), ].forEach((theme) => { const component = render( <Hcaptcha siteKey="00000000-0000-0000-0000-000000000000" url="https://hcaptcha.com" languageCode="en" theme={theme} /> ); expect(getSerializedConfig(component).theme).toEqual(customTheme); expect(getApiQueryParams(component).custom).toBe('true'); }); }); it('loads the external api script dynamically and signals RN when the widget is ready', () => { const component = render( <Hcaptcha siteKey="00000000-0000-0000-0000-000000000000" url="https://hcaptcha.com" languageCode="en" theme={JSON.stringify({ palette: { mode: 'dark' } })} rqdata='{"some":"data"}' phonePrefix="44" phoneNumber="+441234567890" /> ); const config = getSerializedConfig(component); const appendedScripts = []; const renderMock = jest.fn(() => 'widget-id'); const executeMock = jest.fn(); const postMessageMock = jest.fn(); const sandbox = { console: { log: jest.fn(), warn: jest.fn(), error: jest.fn(), }, document: { body: { style: {} }, createElement: jest.fn(() => ({})), head: { appendChild: jest.fn((node) => { appendedScripts.push(node); }), }, }, hcaptcha: { execute: executeMock, render: renderMock, setData: jest.fn(), }, window: null, }; sandbox.window = sandbox; sandbox.window.ReactNativeWebView = { postMessage: postMessageMock }; const context = vm.createContext(sandbox); const [bootstrapScript, runtimeScript] = getInlineScripts(component); vm.runInContext(bootstrapScript, context); vm.runInContext(runtimeScript, context); expect(appendedScripts).toHaveLength(1); expect(appendedScripts[0]).toMatchObject({ async: true, defer: true, src: config.apiUrl, }); expect(typeof context.onloadCallback).toBe('function'); expect(new URL(appendedScripts[0].src).searchParams.get('onload')).toBe('onloadCallback'); context.onloadCallback(); expect(renderMock).toHaveBeenCalledWith('hcaptcha-container', expect.objectContaining({ sitekey: '00000000-0000-0000-0000-000000000000', size: 'invisible', theme: { palette: { mode: 'dark' } }, callback: expect.any(Function), 'close-callback': expect.any(Function), 'open-callback': expect.any(Function), 'expired-callback': expect.any(Function), 'chalexpired-callback': expect.any(Function), 'error-callback': expect.any(Function), })); expect(postMessageMock).toHaveBeenCalledWith(HCAPTCHA_READY_EVENT); expect(sandbox.hcaptcha.setData).not.toHaveBeenCalled(); expect(executeMock).not.toHaveBeenCalled(); const renderConfig = renderMock.mock.calls[0][1]; renderConfig['open-callback'](); expect(context.document.body.style.backgroundColor).toBe(config.backgroundColor); expect(postMessageMock).toHaveBeenCalledWith('open'); }); it('serializes every HTML-facing prop safely before embedding it', () => { const theme = { palette: { mode: '</script><script>alert("theme")</script>', }, }; const component = render( <Hcaptcha siteKey={'site"</script><script>alert("site")</script>'} url="https://hcaptcha.com" languageCode={'en"</script><script>alert("lang")</script>'} backgroundColor={'red\';window.ReactNativeWebView.postMessage("bg");//'} theme={theme} rqdata={'";window.ReactNativeWebView.postMessage("rqdata");//</script><script>alert("rqdata")</script>'} sentry={true} jsSrc={'https://example.com/api.js?x=</script><script>alert("src")</script>'} endpoint={'https://example.com/endpoint?</script><script>alert("endpoint")</script>'} reportapi={'https://example.com/reportapi?</script><script>alert("reportapi")</script>'} assethost={'https://example.com/assethost?</script><script>alert("asset")</script>'} imghost={'https://example.com/imghost?</script><script>alert("image")</script>'} host={'host"</script><script>alert("host")</script>'} debug={{ '</script><script>alert("debug")</script>': '</script><script>alert("value")</script>', }} orientation={'landscape"</script><script>alert("orientation")</script>'} phonePrefix={'44";window.ReactNativeWebView.postMessage("prefix");//'} phoneNumber={'+44123\');window.ReactNativeWebView.postMessage("phone");//'} /> ); const html = getWebViewHtml(component); const config = getSerializedConfig(component); const query = getApiQueryParams(component); expect(html).toContain('var hcaptchaConfig = '); expect(html).toContain('hcaptcha.setData(hcaptchaWidgetId, data || {});'); expect(html).toContain(`window.ReactNativeWebView.postMessage("${HCAPTCHA_READY_EVENT}");`); expect(html).not.toContain('<script src='); expect(html).not.toContain('</script><script>alert("site")</script>'); expect(html).toContain('\\u003c/script\\u003e\\u003cscript\\u003ealert(\\"site\\")\\u003c/script\\u003e'); expect(html).toContain('\\u003c/script\\u003e\\u003cscript\\u003ealert(\\"debug\\")\\u003c/script\\u003e'); expect(config.siteKey).toBe('site"</script><script>alert("site")</script>'); expect(config.backgroundColor).toBe('red\';window.ReactNativeWebView.postMessage("bg");//'); expect(config.verifyData).toBeUndefined(); expect(config.theme).toEqual(theme); expect(config.debugInfo['</script><script>alert("debug")</script>']).toBe('</script><script>alert("value")</script>'); expect(query.hl).toBe('en"</script><script>alert("lang")</script>'); expect(query.host).toBe(encodeURIComponent('host"</script><script>alert("host")</script>')); expect(query.endpoint).toBe('https://example.com/endpoint?</script><script>alert("endpoint")</script>'); expect(query.reportapi).toBe('https://example.com/reportapi?</script><script>alert("reportapi")</script>'); expect(query.assethost).toBe('https://example.com/assethost?</script><script>alert("asset")</script>'); expect(query.imghost).toBe('https://example.com/imghost?</script><script>alert("image")</script>'); expect(query.orientation).toBe('landscape"</script><script>alert("orientation")</script>'); expect(query.sentry).toBe('true'); expect(query.custom).toBe('true'); }); it('does not render a loading overlay when showLoading is false', () => { const component = render( <Hcaptcha siteKey="00000000-0000-0000-0000-000000000000" url="https://hcaptcha.com" showLoading={false} /> ); expect(component.UNSAFE_queryByType(TouchableWithoutFeedback)).toBeNull(); expect(component.UNSAFE_queryByType(ActivityIndicator)).toBeNull(); }); it('only allows dismissing the loading overlay when closableLoading is true', () => { const onMessage = jest.fn(); const nonClosable = render( <Hcaptcha siteKey="00000000-0000-0000-0000-000000000000" url="https://hcaptcha.com" showLoading={true} closableLoading={false} onMessage={onMessage} /> ); const nonClosableTouchTarget = nonClosable.UNSAFE_getByType(TouchableWithoutFeedback); act(() => { nonClosableTouchTarget.props.onPress(); }); expect(onMessage).not.toHaveBeenCalled(); const closable = render( <Hcaptcha siteKey="00000000-0000-0000-0000-000000000000" url="https://hcaptcha.com" showLoading={true} closableLoading={true} onMessage={onMessage} /> ); const closableTouchTarget = closable.UNSAFE_getByType(TouchableWithoutFeedback); act(() => { closableTouchTarget.props.onPress(); }); expect(onMessage).toHaveBeenCalledWith({ nativeEvent: { data: 'cancel' } }); }); it('emits a loading timeout while the challenge is still loading', () => { jest.useFakeTimers(); const onMessage = jest.fn(); render( <Hcaptcha siteKey="00000000-0000-0000-0000-000000000000" url="https://hcaptcha.com" onMessage={onMessage} /> ); act(() => { jest.advanceTimersByTime(15000); }); expect(onMessage).toHaveBeenCalledWith({ nativeEvent: { data: 'error', description: 'loading timeout', }, }); }); it('forwards open messages, marks them as successful, and hides the loading overlay', async () => { const onMessage = jest.fn(); setWebViewMessageData('open'); const component = render( <Hcaptcha siteKey="00000000-0000-0000-0000-000000000000" url="https://hcaptcha.com" showLoading={true} onMessage={onMessage} /> ); await waitFor(() => { expect(onMessage).toHaveBeenCalledWith(expect.objectContaining({ success: true, reset: expect.any(Function), nativeEvent: expect.objectContaining({ data: 'open' }), })); }); expect(component.UNSAFE_queryByType(TouchableWithoutFeedback)).toBeNull(); }); it('forwards token messages with reset and markUsed hooks', async () => { jest.useFakeTimers(); const onMessage = jest.fn(); setWebViewMessageData(LONG_TOKEN); const component = render( <Hcaptcha siteKey="00000000-0000-0000-0000-000000000000" url="https://hcaptcha.com" onMessage={onMessage} /> ); await waitFor(() => { expect(onMessage).toHaveBeenCalledWith(expect.objectContaining({ success: true, reset: expect.any(Function), markUsed: expect.any(Function), nativeEvent: expect.objectContaining({ data: LONG_TOKEN }), })); }); const [{ reset, markUsed }] = onMessage.mock.calls[0]; reset(); expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('reset(); setData(')); expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('execute();')); act(() => { getWebView(component).props.onMessage({ nativeEvent: { data: 'open' } }); }); markUsed(); act(() => { jest.advanceTimersByTime(120000); }); expect(onMessage).toHaveBeenCalledTimes(2); expect(onMessage).toHaveBeenNthCalledWith(2, expect.objectContaining({ success: true, nativeEvent: expect.objectContaining({ data: 'open' }), })); }); it('injects fresh verify data only after the widget signals readiness', () => { initJourneyTracking(); emitJourneyEvent('click', 'View', { id: 'before-ready', ac: 'tap', x: 1, y: 2 }); const component = render( <Hcaptcha siteKey="00000000-0000-0000-0000-000000000000" url="https://hcaptcha.com" userJourney={true} /> ); emitJourneyEvent('click', 'View', { id: 'after-mount', ac: 'tap', x: 3, y: 4 }); act(() => { getWebView(component).props.onMessage({ nativeEvent: { data: HCAPTCHA_READY_EVENT } }); }); expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('"id":"before-ready"')); expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('"id":"after-mount"')); expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.not.stringContaining('reset();')); }); it('emits an expired message when a forwarded token is not marked used', async () => { jest.useFakeTimers(); const onMessage = jest.fn(); setWebViewMessageData(LONG_TOKEN); const component = render( <Hcaptcha siteKey="00000000-0000-0000-0000-000000000000" url="https://hcaptcha.com" onMessage={onMessage} /> ); await waitFor(() => { expect(onMessage).toHaveBeenCalledTimes(1); }); act(() => { getWebView(component).props.onMessage({ nativeEvent: { data: 'open' } }); }); act(() => { jest.advanceTimersByTime(120000); }); expect(onMessage).toHaveBeenNthCalledWith(3, { nativeEvent: { data: 'expired' }, success: false, reset: expect.any(Function), }); }); it('marks short non-open messages as errors', async () => { const onMessage = jest.fn(); setWebViewMessageData('webview-error'); render( <Hcaptcha siteKey="00000000-0000-0000-0000-000000000000" url="https://hcaptcha.com" onMessage={onMessage} /> ); await waitFor(() => { expect(onMessage).toHaveBeenCalledWith(expect.objectContaining({ success: false, reset: expect.any(Function), nativeEvent: expect.objectContaining({ data: 'webview-error' }), })); }); }); it('uses verifyParams over legacy props and injects buffered journey data', () => { initJourneyTracking(); emitJourneyEvent('click', 'View', { id: 'screen', ac: 'tap', x: 1, y: 2 }); const component = render( <Hcaptcha siteKey="00000000-0000-0000-0000-000000000000" url="https://hcaptcha.com" rqdata="legacy" phonePrefix="11" phoneNumber="+111" userJourney={true} verifyParams={{ rqdata: 'preferred', phonePrefix: '44', phoneNumber: '+44123', }} /> ); expect(getSerializedConfig(component).verifyData).toBeUndefined(); act(() => { getWebView(component).props.onMessage({ nativeEvent: { data: HCAPTCHA_READY_EVENT } }); }); expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('"rqdata":"preferred"')); expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('"mfa_phoneprefix":"44"')); expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('"mfa_phone":"+44123"')); expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('"userjourney"')); expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('"id":"screen"')); }); it('injects legacy rqdata and phone fields into the final verify payload', () => { const component = render( <Hcaptcha siteKey="00000000-0000-0000-0000-000000000000" url="https://hcaptcha.com" onMessage={jest.fn()} rqdata='{"some":"data"}' phonePrefix="44" phoneNumber="+441234567890" /> ); act(() => { getWebView(component).props.onMessage({ nativeEvent: { data: HCAPTCHA_READY_EVENT } }); }); expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('"rqdata":"{\\"some\\":\\"data\\"}"')); expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('"mfa_phoneprefix":"44"')); expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('"mfa_phone":"+441234567890"')); }); it('preserves buffered journey events after error and cancel messages', () => { initJourneyTracking(); emitJourneyEvent('click', 'View', { id: 'preserved', ac: 'tap' }); const onMessage = jest.fn(); const component = render( <Hcaptcha siteKey="00000000-0000-0000-0000-000000000000" url="https://hcaptcha.com" onMessage={onMessage} userJourney={true} /> ); act(() => { getWebView(component).props.onMessage({ nativeEvent: { data: 'challenge-closed' } }); getWebView(component).props.onMessage({ nativeEvent: { data: 'webview-error' } }); }); expect(peekJourneyEvents()).toEqual([ expect.objectContaining({ k: 'click', v: 'View', m: { id: 'preserved', ac: 'tap' }, }), ]); }); it('opens hcaptcha links externally and blocks navigation in the WebView', () => { const openURL = jest.spyOn(Linking, 'openURL').mockResolvedValue(true); const component = render( <Hcaptcha siteKey="00000000-0000-0000-0000-000000000000" url="https://hcaptcha.com" /> ); const shouldStart = getWebView(component).props.onShouldStartLoadWithRequest({ url: 'https://www.hcaptcha.com/privacy', }); expect(shouldStart).toBe(false); expect(openURL).toHaveBeenCalledWith('https://www.hcaptcha.com/privacy'); }); it('opens sms links externally and reports failures back through onMessage', async () => { const openURL = jest.spyOn(Linking, 'openURL'); const onMessage = jest.fn(); const component = render( <Hcaptcha siteKey="00000000-0000-0000-0000-000000000000" url="https://hcaptcha.com" onMessage={onMessage} /> ); openURL.mockResolvedValueOnce(true); const successfulSms = getWebView(component).props.onShouldStartLoadWithRequest({ url: 'sms:+15551234567', }); expect(successfulSms).toBe(false); expect(openURL).toHaveBeenCalledWith('sms:+15551234567'); openURL.mockRejectedValueOnce(new Error('sms unavailable')); const failedSms = getWebView(component).props.onShouldStartLoadWithRequest({ url: 'sms:+15557654321', }); expect(failedSms).toBe(false); await waitFor(() => { expect(onMessage).toHaveBeenCalledWith({ nativeEvent: { data: 'sms-open-failed', description: 'sms unavailable', }, success: false, }); }); }); it('allows non-hcaptcha, non-sms navigations to continue inside the WebView', () => { const openURL = jest.spyOn(Linking, 'openURL').mockResolvedValue(true); const component = render( <Hcaptcha siteKey="00000000-0000-0000-0000-000000000000" url="https://hcaptcha.com" /> ); const shouldStart = getWebView(component).props.onShouldStartLoadWithRequest({ url: 'https://example.com/path', }); expect(shouldStart).toBe(true); expect(openURL).not.toHaveBeenCalled(); }); });