diff --git a/.changeset/captcha-inline-spotlight.md b/.changeset/captcha-inline-spotlight.md new file mode 100644 index 00000000000..ef75d4751cb --- /dev/null +++ b/.changeset/captcha-inline-spotlight.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +When an interactive bot-protection challenge appears during sign-in or sign-up, the card now brings the challenge to the foreground — hiding the other fields and buttons until it's solved — so it's clear the "Verify you are human" check must be completed. Invisible challenges are unaffected. diff --git a/.changeset/captcha-spotlight-compat.md b/.changeset/captcha-spotlight-compat.md new file mode 100644 index 00000000000..1f1f82608f7 --- /dev/null +++ b/.changeset/captcha-spotlight-compat.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Fix the inline captcha spotlight to expand correctly when an older clerk-js runtime is paired with a newer UI that expects the `data-cl-interactive` attribute — the challenge now falls back to the `maxHeight` heuristic so the spotlight still fires across version skews. diff --git a/packages/clerk-js/sandbox/app.ts b/packages/clerk-js/sandbox/app.ts index 3c345812ff2..2e661a84de5 100644 --- a/packages/clerk-js/sandbox/app.ts +++ b/packages/clerk-js/sandbox/app.ts @@ -186,6 +186,31 @@ function assertClerkIsLoaded(c: ClerkType | undefined): asserts c is ClerkType { } } +/** + * Sandbox-only demo aid: force an interactive Cloudflare Turnstile challenge so the inline captcha + * "spotlight" can be exercised without changing instance settings. Enable with `?captcha=interactive`. + * + * Overrides the loaded environment (never `src/`) to use Cloudflare's documented test sitekey + * `3x00000000000000000000FF` (which always escalates to interactive), render it inline, and disable + * the OAuth bypass so the "Continue with …" path also hits the challenge. Remove the query param to + * get the normal (mostly invisible) behavior back. + */ +function applyCaptchaSandboxOverrides() { + if (new URL(window.location.href).searchParams.get('captcha') !== 'interactive') { + return; + } + const env = (Clerk as unknown as { __internal_environment?: any })?.__internal_environment; + if (!env) { + console.warn('[sandbox] captcha=interactive: environment not loaded, skipping override'); + return; + } + env.displayConfig.captchaPublicKey = '3x00000000000000000000FF'; // forces an interactive challenge + env.displayConfig.captchaWidgetType = 'smart'; // render inline into #clerk-captcha + env.displayConfig.captchaOauthBypass = []; // also exercise the OAuth ("Continue with …") path + env.userSettings.signUp.captcha_enabled = true; // ensure the widget actually runs + console.info('[sandbox] Forcing interactive captcha (test sitekey 3x…FF).'); +} + function mountIndex(element: HTMLDivElement) { assertClerkIsLoaded(Clerk); const user = Clerk.user; @@ -507,6 +532,7 @@ void (async () => { }, localization: l[initialLocale as keyof typeof l], }); + applyCaptchaSandboxOverrides(); renderCurrentRoute(); const { pane } = await initControls(); diff --git a/packages/clerk-js/src/utils/captcha/__tests__/turnstile.test.ts b/packages/clerk-js/src/utils/captcha/__tests__/turnstile.test.ts new file mode 100644 index 00000000000..d49164ce99d --- /dev/null +++ b/packages/clerk-js/src/utils/captcha/__tests__/turnstile.test.ts @@ -0,0 +1,64 @@ +import { CAPTCHA_ELEMENT_ID, CAPTCHA_INVISIBLE_CLASSNAME } from '@clerk/shared/internal/clerk-js/constants'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getTurnstileToken } from '../turnstile'; + +/** + * Regression guard for the inline "spotlight": the spotlight keys off + * `#clerk-captcha`'s `style.maxHeight !== '0'`. The common invisible flow (~99% of + * challenges) must never create or mutate `#clerk-captcha`, so its `maxHeight` stays + * `'0'` and no spotlight is ever triggered. This pins that existing behavior. + */ +describe('getTurnstileToken — invisible flow', () => { + let renderConfig: Record | undefined; + + beforeEach(() => { + renderConfig = undefined; + (window as any).turnstile = { + render: vi.fn((_selector: string, config: Record) => { + renderConfig = config; + // Real Turnstile returns the widget id, then fires the callback asynchronously. + // Invisible challenges resolve without ever escalating to interactive, so the + // `before-interactive-callback` (the only code that expands #clerk-captcha) never fires. + setTimeout(() => config.callback('mock_token'), 0); + return 'mock_widget_id'; + }), + remove: vi.fn(), + reset: vi.fn(), + }; + }); + + afterEach(() => { + document.body.innerHTML = ''; + delete (window as any).turnstile; + vi.restoreAllMocks(); + }); + + it('renders into its own throwaway container, never #clerk-captcha', async () => { + // An inline #clerk-captcha may coexist on the page (e.g. a Start card is mounted), + // but an invisible challenge must never touch it. + const inline = document.createElement('div'); + inline.id = CAPTCHA_ELEMENT_ID; + inline.style.maxHeight = '0'; + document.body.appendChild(inline); + + const result = await getTurnstileToken({ + captchaProvider: 'turnstile', + siteKey: 'visible-key', + invisibleSiteKey: 'invisible-key', + widgetType: 'invisible', + }); + + expect(result).toEqual({ captchaToken: 'mock_token', captchaWidgetType: 'invisible' }); + + // The invisible flow targets its own `.clerk-invisible-captcha` div with the invisible key. + expect((window as any).turnstile.render).toHaveBeenCalledWith(`.${CAPTCHA_INVISIBLE_CLASSNAME}`, expect.anything()); + expect(renderConfig?.sitekey).toBe('invisible-key'); + + // The spotlight signal on #clerk-captcha is untouched throughout → no spotlight. + expect(inline.style.maxHeight || '0').toBe('0'); + + // The temporary invisible container is created then cleaned up in `finally`. + expect(document.querySelector(`.${CAPTCHA_INVISIBLE_CLASSNAME}`)).toBeNull(); + }); +}); diff --git a/packages/clerk-js/src/utils/captcha/turnstile.ts b/packages/clerk-js/src/utils/captcha/turnstile.ts index 728f6b30094..228bfd85d8c 100644 --- a/packages/clerk-js/src/utils/captcha/turnstile.ts +++ b/packages/clerk-js/src/utils/captcha/turnstile.ts @@ -177,6 +177,7 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => { // and then expands to the correct height visibleWidget.style.minHeight = captchaSize === 'compact' ? '140px' : '68px'; visibleWidget.style.marginBottom = '1.5rem'; + visibleWidget.dataset.clInteractive = 'true'; } } }, @@ -242,6 +243,7 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => { if (captchaTypeUsed === 'smart') { const visibleWidget = document.getElementById(CAPTCHA_ELEMENT_ID); if (visibleWidget) { + delete visibleWidget.dataset.clInteractive; visibleWidget.style.maxHeight = '0'; visibleWidget.style.minHeight = 'unset'; visibleWidget.style.marginBottom = 'unset'; diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx index 15b83cd4b0b..9d4c66fbe6e 100644 --- a/packages/ui/src/components/SignIn/SignInStart.tsx +++ b/packages/ui/src/components/SignIn/SignInStart.tsx @@ -158,6 +158,9 @@ function SignInStartInternal(): JSX.Element { const hasSocialOrWeb3Buttons = !!authenticatableSocialStrategies.length || !!web3FirstFactors.length || !!alternativePhoneCodeChannels.length; const [shouldAutofocus, setShouldAutofocus] = useState(!isMobileDevice() && !hasSocialOrWeb3Buttons); + // When the captcha escalates to an interactive challenge, spotlight it by collapsing/inerting the + // rest of the card (see the descriptors.main column below). + const [captchaIsInteractive, setCaptchaIsInteractive] = useState(false); const textIdentifierField = useFormControl('identifier', initialValues[identifierAttribute] || '', { ...currentIdentifier, isRequired: true, @@ -597,6 +600,13 @@ function SignInStartInternal(): JSX.Element { {hasSocialOrWeb3Buttons && ( @@ -629,24 +639,27 @@ function SignInStartInternal(): JSX.Element { /> - ) : null} - {!standardFormAttributes.length && } - {userSettings.attributes.passkey?.enabled && - userSettings.passkeySettings.show_sign_in_button && - isWebSupported && ( - - authenticateWithPasskey({ flow: 'discoverable' })} - /> - - )} + + {/* Kept outside descriptors.main so the spotlight's `inert` leaves this alternative action reachable. */} + {userSettings.attributes.passkey?.enabled && + userSettings.passkeySettings.show_sign_in_button && + isWebSupported && ( + + authenticateWithPasskey({ flow: 'discoverable' })} + /> + + )} {userSettings.signUp.mode === SIGN_UP_MODES.PUBLIC && !isCombinedFlow && ( diff --git a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx index 993884a6dab..28cbbb00f82 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx @@ -1,9 +1,11 @@ import { ClerkAPIResponseError } from '@clerk/shared/error'; +import { CAPTCHA_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants'; import { OAUTH_PROVIDERS } from '@clerk/shared/oauth'; import type { SignInResource } from '@clerk/shared/types'; import { waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { simulateCaptchaInteractive, simulateCaptchaResolved } from '@/test/captcha'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { fireEvent, mockWebAuthn, render, screen } from '@/test/utils'; import { CardStateProvider } from '@/ui/elements/contexts'; @@ -761,4 +763,67 @@ describe('SignInStart', () => { ); }); }); + + describe('Captcha', () => { + it('renders the captcha widget in the form (email) config', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + }); + render(, { wrapper }); + expect(document.getElementById(CAPTCHA_ELEMENT_ID)).not.toBeNull(); + }); + + it('renders the captcha widget in the social-only (no form) config', async () => { + const { wrapper } = await createFixtures(f => { + f.withSocialProvider({ provider: 'google' }); + }); + render(, { wrapper }); + expect(document.getElementById(CAPTCHA_ELEMENT_ID)).not.toBeNull(); + }); + }); + + describe('Captcha spotlight', () => { + const getCaptcha = () => document.getElementById(CAPTCHA_ELEMENT_ID) as HTMLElement; + + it('inerts the form/social subtree while interactive — never the captcha or header — and restores', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withSocialProvider({ provider: 'google' }); + }); + render(, { wrapper }); + + const google = screen.getByText(/continue with google/i); + expect(google.closest('[inert]')).toBeNull(); + + simulateCaptchaInteractive(getCaptcha()); + + await waitFor(() => expect(google.closest('[inert]')).not.toBeNull()); + // The captcha widget and the header stay outside the inert subtree. + expect(getCaptcha().closest('[inert]')).toBeNull(); + expect(screen.getByRole('heading').closest('[inert]')).toBeNull(); + + simulateCaptchaResolved(getCaptcha()); + await waitFor(() => expect(google.closest('[inert]')).toBeNull()); + }); + + mockWebAuthn(() => { + it('keeps the passkey action available (outside the inert subtree) during the spotlight', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withPasskey(); + f.withPasskeySettings({ + allow_autofill: false, + show_sign_in_button: true, + }); + }); + render(, { wrapper }); + + simulateCaptchaInteractive(getCaptcha()); + // Spotlight is active (the form is inert)... + await waitFor(() => expect(screen.getByText(/email address/i).closest('[inert]')).not.toBeNull()); + // ...but the passkey action remains reachable. + expect(screen.getByText('Use passkey instead').closest('[inert]')).toBeNull(); + }); + }); + }); }); diff --git a/packages/ui/src/components/SignUp/SignUpForm.tsx b/packages/ui/src/components/SignUp/SignUpForm.tsx index 0bfffecf701..e36a979e464 100644 --- a/packages/ui/src/components/SignUp/SignUpForm.tsx +++ b/packages/ui/src/components/SignUp/SignUpForm.tsx @@ -5,7 +5,6 @@ import { LegalCheckbox } from '@/ui/elements/LegalConsentCheckbox'; import type { FormControlState } from '@/ui/utils/useFormControl'; import { Col, localizationKeys, useAppearance } from '../../customizables'; -import { CaptchaElement } from '../../elements/CaptchaElement'; import { mqu } from '../../styledSystem'; import type { ActiveIdentifier, Fields } from './signUpFormHelpers'; @@ -116,7 +115,6 @@ export const SignUpForm = (props: SignUpFormProps) => { )} - {(showOauthProviders || showWeb3Providers || showAlternativePhoneCodeProviders) && ( @@ -464,8 +474,11 @@ function SignUpStartInternal(): JSX.Element { /> )} - {!shouldShowForm && } + diff --git a/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx b/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx index 2e6f6c26f92..a4984f49263 100644 --- a/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx +++ b/packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx @@ -1,8 +1,10 @@ import { ClerkAPIResponseError } from '@clerk/shared/error'; +import { CAPTCHA_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants'; import { OAUTH_PROVIDERS } from '@clerk/shared/oauth'; import type { SignUpResource } from '@clerk/shared/types'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { simulateCaptchaInteractive, simulateCaptchaResolved } from '@/test/captcha'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { fireEvent, render, screen, waitFor } from '@/test/utils'; import { CardStateProvider } from '@/ui/elements/contexts'; @@ -523,4 +525,55 @@ describe('SignUpStart', () => { await waitFor(() => screen.getByText(/create your account/i)); }); }); + + describe('Captcha', () => { + // The ticket tests above mutate window.location and don't restore it (no afterEach in this + // suite); reset to a clean URL so the ticket flow doesn't fire during these renders. + beforeEach(() => { + Object.defineProperty(window, 'location', { + writable: true, + value: new URL('http://localhost/sign-up'), + }); + }); + + it('renders the captcha widget in the form (email) config', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress({ required: true }); + }); + render(, { wrapper }); + expect(document.getElementById(CAPTCHA_ELEMENT_ID)).not.toBeNull(); + }); + + it('renders the captcha widget in the social-only (no form) config', async () => { + const { wrapper } = await createFixtures(f => { + f.withSocialProvider({ provider: 'google' }); + }); + render(, { wrapper }); + expect(document.getElementById(CAPTCHA_ELEMENT_ID)).not.toBeNull(); + }); + + describe('spotlight', () => { + const getCaptcha = () => document.getElementById(CAPTCHA_ELEMENT_ID) as HTMLElement; + + it('inerts the form/social subtree while interactive — never the captcha or header — and restores', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress({ required: true }); + f.withSocialProvider({ provider: 'google' }); + }); + render(, { wrapper }); + + const google = screen.getByText(/continue with google/i); + expect(google.closest('[inert]')).toBeNull(); + + simulateCaptchaInteractive(getCaptcha()); + + await waitFor(() => expect(google.closest('[inert]')).not.toBeNull()); + expect(getCaptcha().closest('[inert]')).toBeNull(); + expect(screen.getByRole('heading').closest('[inert]')).toBeNull(); + + simulateCaptchaResolved(getCaptcha()); + await waitFor(() => expect(google.closest('[inert]')).toBeNull()); + }); + }); + }); }); diff --git a/packages/ui/src/elements/CaptchaElement.tsx b/packages/ui/src/elements/CaptchaElement.tsx index 86e2e93853b..0b765952cdf 100644 --- a/packages/ui/src/elements/CaptchaElement.tsx +++ b/packages/ui/src/elements/CaptchaElement.tsx @@ -1,19 +1,41 @@ import { CAPTCHA_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants'; -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Box, useAppearance, useLocalizations } from '../customizables'; /** * This component uses a MutationObserver to listen for DOM changes made by our Turnstile logic, - * which operates outside the React lifecycle. It stores the observed state in ref to ensure that + * which operates outside the React lifecycle. It stores the observed state in refs to ensure that * any external style changes, such as updates to max-height, min-height, or margin-bottom persist across re-renders, * preventing unwanted layout resets. + * + * When Turnstile escalates to an interactive "Verify you are human" challenge it sets + * `data-cl-interactive="true"` on the element (removed on resolve/error). `onInteractiveChange` + * surfaces that signal so a parent can react (e.g. spotlight the challenge); it never fires on mount. */ -export const CaptchaElement = () => { - const elementRef = useRef(null); +export const CaptchaElement = ({ + onInteractiveChange, + gapless, +}: { + onInteractiveChange?: (interactive: boolean) => void; + /** + * When true, the element is removed from flow (`position:absolute`) while collapsed so it adds no + * gap gutter to a flex parent, switching to `position:static` while interactive. Opt-in so the + * other (non-spotlight) render sites keep their current positioning. + */ + gapless?: boolean; +}) => { + const elementRef = useRef(null); const maxHeightValueRef = useRef('0'); const minHeightValueRef = useRef('unset'); const marginBottomValueRef = useRef('unset'); + // State forces a re-render on the interactive transition, which re-applies the ref-held styles + // above (preserving Turnstile's injected values) and drives the `gapless` position toggle. + const [isInteractive, setIsInteractive] = useState(false); + // The observer is set up once (`[]` deps), so it reads the latest callback through a ref. + const onInteractiveChangeRef = useRef(onInteractiveChange); + onInteractiveChangeRef.current = onInteractiveChange; + const isInteractiveRef = useRef(false); const { parsedCaptcha } = useAppearance(); const { locale } = useLocalizations(); const captchaTheme = parsedCaptcha?.theme; @@ -30,17 +52,46 @@ export const CaptchaElement = () => { const observer = new MutationObserver(mutations => { mutations.forEach(mutation => { const target = mutation.target as HTMLDivElement; - if (mutation.type === 'attributes' && mutation.attributeName === 'style' && elementRef.current) { + if (mutation.type !== 'attributes' || !elementRef.current) { + return; + } + if (mutation.attributeName === 'style') { + // Keep refs in sync so Turnstile's injected styles survive React re-renders. maxHeightValueRef.current = target.style.maxHeight || '0'; minHeightValueRef.current = target.style.minHeight || 'unset'; marginBottomValueRef.current = target.style.marginBottom || 'unset'; + // Fallback for old clerk-js that never writes data-cl-interactive: infer + // interactive state from maxHeight. When the MutationObserver callback fires, + // the DOM already reflects all mutations from the same microtask, so + // `target.dataset.clInteractive` is up-to-date — new clerk-js (which sets + // the attribute alongside the style) passes the guard and is handled below. + if (!('clInteractive' in target.dataset)) { + const mh = target.style.maxHeight; + const nowInteractive = mh !== '' && mh !== '0' && mh !== '0px'; + if (nowInteractive !== isInteractiveRef.current) { + isInteractiveRef.current = nowInteractive; + setIsInteractive(nowInteractive); + onInteractiveChangeRef.current?.(nowInteractive); + } + } + } + if (mutation.attributeName === 'data-cl-interactive') { + // ORDERING IS LOAD-BEARING: style mutations from the same turnstile.ts call are + // delivered before this one (DOM mutations are batched and replayed in order), so + // the refs above are already up-to-date when the re-render triggered below runs. + const nowInteractive = target.dataset.clInteractive === 'true'; + if (nowInteractive !== isInteractiveRef.current) { + isInteractiveRef.current = nowInteractive; + setIsInteractive(nowInteractive); + onInteractiveChangeRef.current?.(nowInteractive); + } } }); }); observer.observe(elementRef.current, { attributes: true, - attributeFilter: ['style'], + attributeFilter: ['style', 'data-cl-interactive'], }); return () => observer.disconnect(); @@ -56,6 +107,9 @@ export const CaptchaElement = () => { maxHeight: maxHeightValueRef.current, minHeight: minHeightValueRef.current, marginBottom: marginBottomValueRef.current, + // When `gapless`, drop out of flow while collapsed so the element contributes no gap gutter + // to its flex parent; rejoin flow once the interactive challenge expands it. + position: gapless ? (isInteractive ? 'static' : 'absolute') : undefined, }} data-cl-theme={captchaTheme} data-cl-size={captchaSize} diff --git a/packages/ui/src/elements/__tests__/CaptchaElement.test.tsx b/packages/ui/src/elements/__tests__/CaptchaElement.test.tsx new file mode 100644 index 00000000000..6b49f425cf3 --- /dev/null +++ b/packages/ui/src/elements/__tests__/CaptchaElement.test.tsx @@ -0,0 +1,92 @@ +import { CAPTCHA_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants'; +import { describe, expect, it, vi } from 'vitest'; + +import { + simulateCaptchaInteractive, + simulateCaptchaInteractiveLegacy, + simulateCaptchaResolved, + simulateCaptchaResolvedLegacy, +} from '@/test/captcha'; +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, waitFor } from '@/test/utils'; + +import { CaptchaElement } from '../CaptchaElement'; + +const { createFixtures } = bindCreateFixtures('SignIn'); + +const getCaptcha = () => document.getElementById(CAPTCHA_ELEMENT_ID) as HTMLElement; + +describe('CaptchaElement', () => { + it('renders the captcha element collapsed by default', async () => { + const { wrapper } = await createFixtures(); + render(, { wrapper }); + + const el = getCaptcha(); + expect(el).not.toBeNull(); + expect(el.style.maxHeight || '0').toBe('0'); + }); + + it('does not call onInteractiveChange on mount', async () => { + const { wrapper } = await createFixtures(); + const onInteractiveChange = vi.fn(); + render(, { wrapper }); + + // Give the observer a tick to (incorrectly) fire if it were going to. + await new Promise(resolve => setTimeout(resolve, 0)); + expect(onInteractiveChange).not.toHaveBeenCalled(); + }); + + it('calls onInteractiveChange(true) when the challenge becomes interactive', async () => { + const { wrapper } = await createFixtures(); + const onInteractiveChange = vi.fn(); + render(, { wrapper }); + + simulateCaptchaInteractive(getCaptcha()); + + await waitFor(() => expect(onInteractiveChange).toHaveBeenLastCalledWith(true)); + }); + + it('calls onInteractiveChange(false) when the challenge resolves', async () => { + const { wrapper } = await createFixtures(); + const onInteractiveChange = vi.fn(); + render(, { wrapper }); + + simulateCaptchaInteractive(getCaptcha()); + await waitFor(() => expect(onInteractiveChange).toHaveBeenLastCalledWith(true)); + + simulateCaptchaResolved(getCaptcha()); + await waitFor(() => expect(onInteractiveChange).toHaveBeenLastCalledWith(false)); + }); + + it('does not throw when onInteractiveChange is omitted', async () => { + const { wrapper } = await createFixtures(); + render(, { wrapper }); + + expect(() => simulateCaptchaInteractive(getCaptcha())).not.toThrow(); + await waitFor(() => expect(getCaptcha().dataset.clInteractive).toBe('true')); + }); + + describe('legacy clerk-js fallback (no data-cl-interactive attribute)', () => { + it('calls onInteractiveChange(true) when maxHeight expands without the attribute', async () => { + const { wrapper } = await createFixtures(); + const onInteractiveChange = vi.fn(); + render(, { wrapper }); + + simulateCaptchaInteractiveLegacy(getCaptcha()); + + await waitFor(() => expect(onInteractiveChange).toHaveBeenLastCalledWith(true)); + }); + + it('calls onInteractiveChange(false) when maxHeight collapses without the attribute', async () => { + const { wrapper } = await createFixtures(); + const onInteractiveChange = vi.fn(); + render(, { wrapper }); + + simulateCaptchaInteractiveLegacy(getCaptcha()); + await waitFor(() => expect(onInteractiveChange).toHaveBeenLastCalledWith(true)); + + simulateCaptchaResolvedLegacy(getCaptcha()); + await waitFor(() => expect(onInteractiveChange).toHaveBeenLastCalledWith(false)); + }); + }); +}); diff --git a/packages/ui/src/test/captcha.ts b/packages/ui/src/test/captcha.ts new file mode 100644 index 00000000000..7568831ea6d --- /dev/null +++ b/packages/ui/src/test/captcha.ts @@ -0,0 +1,75 @@ +/** + * Test-only helpers for driving Cloudflare Turnstile captcha flows. + * + * Not bundled: this module lives under `src/test/` (the `@/test/*` alias resolves + * only during test runs) and is never exported from the package entry. + * + * The interactive signal: our Turnstile logic (`turnstile.ts`) sets + * `data-cl-interactive="true"` on `#clerk-captcha` when an interactive challenge + * is showing and removes it on resolve/error. `CaptchaElement` observes this via a + * MutationObserver. These helpers reproduce that transition so tests don't hand-roll + * the DOM mutation inline. + */ + +/** Simulate Turnstile escalating to an interactive challenge (widget expands). */ +export const simulateCaptchaInteractive = (el: HTMLElement) => { + el.dataset.clInteractive = 'true'; +}; + +/** Simulate the interactive challenge resolving (widget collapses back). */ +export const simulateCaptchaResolved = (el: HTMLElement) => { + delete el.dataset.clInteractive; +}; + +/** + * Simulate old clerk-js escalating to interactive via maxHeight only (no data-cl-interactive). + * Uses a concrete pixel value so the mutation fires reliably in JSDOM. + */ +export const simulateCaptchaInteractiveLegacy = (el: HTMLElement) => { + el.style.maxHeight = '68px'; + el.style.minHeight = '68px'; + el.style.marginBottom = '1.5rem'; +}; + +/** + * Simulate old clerk-js collapsing after resolve via maxHeight only (no data-cl-interactive). + */ +export const simulateCaptchaResolvedLegacy = (el: HTMLElement) => { + el.style.maxHeight = '0'; + el.style.minHeight = ''; + el.style.marginBottom = ''; +}; + +/** + * Cloudflare's documented dummy sitekeys for testing Turnstile. + * @see https://developers.cloudflare.com/turnstile/troubleshooting/testing/ + * + * Force the interactive path locally by pointing the instance at + * `FORCES_INTERACTIVE` and disabling the OAuth captcha bypass — LOCAL ONLY, + * never commit these into instance config. + */ +export const TEST_SITEKEYS = { + /** Always passes, visible widget. */ + ALWAYS_PASSES_VISIBLE: '1x00000000000000000000AA', + /** Always fails, visible widget. */ + ALWAYS_FAILS_VISIBLE: '2x00000000000000000000AB', + /** Always passes, invisible widget. */ + ALWAYS_PASSES_INVISIBLE: '1x00000000000000000000BB', + /** Always fails, invisible widget. */ + ALWAYS_FAILS_INVISIBLE: '2x00000000000000000000BB', + /** Forces an interactive challenge, visible widget. */ + FORCES_INTERACTIVE: '3x00000000000000000000FF', +} as const; + +/** + * Cloudflare's documented dummy secret keys (server-side verification). + * @see https://developers.cloudflare.com/turnstile/troubleshooting/testing/ + */ +export const TEST_SECRET_KEYS = { + /** Always passes validation. */ + ALWAYS_PASSES: '1x0000000000000000000000000000000AA', + /** Always fails validation. */ + ALWAYS_FAILS: '2x0000000000000000000000000000000AA', + /** Returns a "token already spent" error. */ + TOKEN_ALREADY_SPENT: '3x0000000000000000000000000000000AA', +} as const;