From f4786e970acaf07b461e16e1433a1eaadf4abcc1 Mon Sep 17 00:00:00 2001 From: Andil Date: Sun, 20 Sep 2026 02:09:54 +0000 Subject: [PATCH] fix: properly cleanup turnstile on unmount --- frontend/src/components/Captcha.jsx | 84 ++++++-------- frontend/src/test/unit/Captcha.test.jsx | 139 ++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 48 deletions(-) create mode 100644 frontend/src/test/unit/Captcha.test.jsx diff --git a/frontend/src/components/Captcha.jsx b/frontend/src/components/Captcha.jsx index d87d826..b817688 100644 --- a/frontend/src/components/Captcha.jsx +++ b/frontend/src/components/Captcha.jsx @@ -15,6 +15,7 @@ export default function Captcha({ onToken }) { } let cancelled = false + let localWidgetId = null const renderWidget = () => { if ( @@ -25,55 +26,43 @@ export default function Captcha({ onToken }) { return } - if (widgetIdRef.current !== null) { + // Nettoyage d'un éventuel widget précédent. + if (localWidgetId !== null) { try { - window.turnstile.remove(widgetIdRef.current) + window.turnstile.remove(localWidgetId) } catch { - // Widget déjà supprimé + // Widget déjà supprimé. } + localWidgetId = null widgetIdRef.current = null } containerRef.current.innerHTML = '' - window.turnstile.ready(() => { - if ( - cancelled || - !containerRef.current || - !window.turnstile - ) { - return + localWidgetId = window.turnstile.render( + containerRef.current, + { + sitekey: TURNSTILE_SITE_KEY, + language: 'fr', + size: 'flexible', + appearance: 'always', + + callback: (token) => { + onToken(token) + }, + + 'expired-callback': () => { + onToken('') + }, + + 'error-callback': () => { + onToken('') + }, } + ) - widgetIdRef.current = window.turnstile.render( - containerRef.current, - { - sitekey: TURNSTILE_SITE_KEY, - language: 'fr', - size: 'flexible', - appearance: 'always', - - callback: (token) => { - if (!cancelled) { - onToken(token) - } - }, - - 'expired-callback': () => { - if (!cancelled) { - onToken('') - } - }, - - 'error-callback': () => { - if (!cancelled) { - onToken('') - } - }, - } - ) - }) + widgetIdRef.current = localWidgetId } const existingScript = document.getElementById( @@ -104,22 +93,21 @@ export default function Captcha({ onToken }) { return () => { cancelled = true - if ( - widgetIdRef.current !== null && - window.turnstile - ) { + const widgetId = + localWidgetId !== null + ? localWidgetId + : widgetIdRef.current + + if (widgetId !== null && window.turnstile) { try { - window.turnstile.remove(widgetIdRef.current) + window.turnstile.remove(widgetId) } catch { - // Widget déjà supprimé + // Widget déjà supprimé. } } + localWidgetId = null widgetIdRef.current = null - - if (containerRef.current) { - containerRef.current.innerHTML = '' - } } }, [onToken]) diff --git a/frontend/src/test/unit/Captcha.test.jsx b/frontend/src/test/unit/Captcha.test.jsx new file mode 100644 index 0000000..1b232af --- /dev/null +++ b/frontend/src/test/unit/Captcha.test.jsx @@ -0,0 +1,139 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, cleanup, waitFor } from '@testing-library/react' + +vi.mock('../../config/captcha', () => ({ + CAPTCHA_ENABLED: true, + TURNSTILE_SITE_KEY: '0x4AAAAAAE0AF9mR2Y1luOeG', +})) + +import Captcha from '../../components/Captcha' + +describe('Captcha', () => { + let originalTurnstile + + beforeEach(() => { + originalTurnstile = window.turnstile + + document.head + .querySelectorAll('#cf-turnstile-script') + .forEach((script) => script.remove()) + + window.turnstile = undefined + }) + + afterEach(() => { + cleanup() + window.turnstile = originalTurnstile + vi.restoreAllMocks() + }) + + it('rend le widget lorsque Turnstile est déjà disponible', async () => { + const onToken = vi.fn() + + window.turnstile = { + render: vi.fn((container, options) => { + options.callback('token-test') + return 'widget-1' + }), + remove: vi.fn(), + } + + render() + + await waitFor(() => { + expect(window.turnstile.render).toHaveBeenCalled() + }) + + expect(onToken).toHaveBeenCalledWith('token-test') + }) + + it('gère expiration et erreur du widget', async () => { + const onToken = vi.fn() + let options + + window.turnstile = { + render: vi.fn((container, opts) => { + options = opts + return 'widget-2' + }), + remove: vi.fn(), + } + + render() + + await waitFor(() => { + expect(options).toBeDefined() + }) + + options['expired-callback']() + options['error-callback']() + + expect(onToken).toHaveBeenNthCalledWith(1, '') + expect(onToken).toHaveBeenNthCalledWith(2, '') + }) + + it('charge Turnstile lorsque le script existe déjà', async () => { + const onToken = vi.fn() + + const script = document.createElement('script') + script.id = 'cf-turnstile-script' + document.head.appendChild(script) + + render() + + window.turnstile = { + render: vi.fn(() => 'widget-3'), + remove: vi.fn(), + } + + script.dispatchEvent(new Event('load')) + + await waitFor(() => { + expect(window.turnstile.render).toHaveBeenCalled() + }) + }) + + it('supprime le widget au démontage', async () => { + const onToken = vi.fn() + const remove = vi.fn() + + window.turnstile = { + render: vi.fn(() => 'widget-4'), + remove, + } + + const { unmount } = render() + + await waitFor(() => { + expect(window.turnstile.render).toHaveBeenCalledTimes(1) + }) + + // Le widget est bien créé avant le démontage. + unmount() + + expect(remove).toHaveBeenCalledWith('widget-4') + }) + + it('tolère une erreur lors de la suppression du widget', async () => { + const onToken = vi.fn() + const remove = vi.fn(() => { + throw new Error('widget déjà supprimé') + }) + + window.turnstile = { + render: vi.fn(() => 'widget-5'), + remove, + } + + const { unmount } = render() + + await waitFor(() => { + expect(window.turnstile.render).toHaveBeenCalledTimes(1) + }) + + expect(() => unmount()).not.toThrow() + + expect(remove).toHaveBeenCalledWith('widget-5') + }) + +})