From 78c4bacc7530416544c5ece560755b24db4b0503 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:45:08 +0400 Subject: [PATCH 1/3] fix(auth): stop reporting an unsolved captcha as a wedged challenge A visitor who never completes an interactive challenge hit the same 120s deadline as a challenge that genuinely stalled, so both were reported as TURNSTILE_CHALLENGE_WEDGED. The anomaly that report exists to catch ended up drowned out by the ordinary case of nobody clicking. Cloudflare tells us which case we are in: onBeforeInteractive fires before the challenge turns interactive and onAfterInteractive when it leaves, so a deadline reached between the two is an abandoned challenge rather than a wedged one. Those are tracked as captcha_abandoned now, leaving the Sentry report for the case it was built for. The rejection is a real Error as well. As a plain object it escaped the auth mutations into the global MutationCache reporter and filed a second, untagged, error level issue for every one. The five auth mutations opt out of that with a predicate narrow enough to leave genuine auth API failures reportable, and the widget's own message now reaches the visitor in place of "An unexpected error occurred". --- .../auth/EmailCodeStart/EmailCodeStart.tsx | 2 + .../auth/EmailCodeVerify/EmailCodeVerify.tsx | 3 + .../auth/PasswordAuth/PasswordAuth.tsx | 3 + .../RemoteApiError/RemoteApiError.spec.tsx | 6 ++ .../shared/RemoteApiError/RemoteApiError.tsx | 22 +++-- .../turnstile/CaptchaChallengeError.spec.ts | 33 ++++++++ .../turnstile/CaptchaChallengeError.ts | 25 ++++++ .../components/turnstile/Turnstile.spec.tsx | 84 +++++++++++++++++-- .../src/components/turnstile/Turnstile.tsx | 35 ++++++-- .../services/analytics/analytics.service.ts | 1 + 10 files changed, 194 insertions(+), 20 deletions(-) create mode 100644 apps/deploy-web/src/components/turnstile/CaptchaChallengeError.spec.ts create mode 100644 apps/deploy-web/src/components/turnstile/CaptchaChallengeError.ts diff --git a/apps/deploy-web/src/components/auth/EmailCodeStart/EmailCodeStart.tsx b/apps/deploy-web/src/components/auth/EmailCodeStart/EmailCodeStart.tsx index ae6f1e68f4..c45cb6b429 100644 --- a/apps/deploy-web/src/components/auth/EmailCodeStart/EmailCodeStart.tsx +++ b/apps/deploy-web/src/components/auth/EmailCodeStart/EmailCodeStart.tsx @@ -7,6 +7,7 @@ import { useMutation } from "@tanstack/react-query"; import { z } from "zod"; import { RemoteApiError } from "@src/components/shared/RemoteApiError/RemoteApiError"; +import { SKIP_REPORTING_CAPTCHA_OUTCOME } from "@src/components/turnstile/CaptchaChallengeError"; import { useServices } from "@src/context/ServicesProvider"; import { markCodeSent } from "../PasswordlessAuth/withPersistedPasswordlessFlow"; @@ -40,6 +41,7 @@ export function EmailCodeStart({ dependencies: d = DEPENDENCIES, ...props }: Pro const { authService, analyticsService } = useServices(); const startMutation = d.useMutation({ + meta: SKIP_REPORTING_CAPTCHA_OUTCOME, async mutationFn(input: { email: string }) { analyticsService.track("email_login_init"); const captchaToken = await props.getCaptchaToken(); diff --git a/apps/deploy-web/src/components/auth/EmailCodeVerify/EmailCodeVerify.tsx b/apps/deploy-web/src/components/auth/EmailCodeVerify/EmailCodeVerify.tsx index 41ba1d05c9..7fe9d60600 100644 --- a/apps/deploy-web/src/components/auth/EmailCodeVerify/EmailCodeVerify.tsx +++ b/apps/deploy-web/src/components/auth/EmailCodeVerify/EmailCodeVerify.tsx @@ -5,6 +5,7 @@ import { Button, Spinner } from "@akashnetwork/ui/components"; import { useMutation } from "@tanstack/react-query"; import { RemoteApiError } from "@src/components/shared/RemoteApiError/RemoteApiError"; +import { SKIP_REPORTING_CAPTCHA_OUTCOME } from "@src/components/turnstile/CaptchaChallengeError"; import { useServices } from "@src/context/ServicesProvider"; import { markCodeSent, readCodeSentAt } from "../PasswordlessAuth/withPersistedPasswordlessFlow"; import type { VerificationCodeInputRef } from "./VerificationCodeInput"; @@ -39,6 +40,7 @@ export function EmailCodeVerify({ dependencies: d = DEPENDENCIES, ...props }: Pr const [resendCooldownSec, setResendCooldownSec] = useState(() => remainingResendCooldownSec(d.readCodeSentAt())); const verifyMutation = d.useMutation({ + meta: SKIP_REPORTING_CAPTCHA_OUTCOME, async mutationFn(input: { code: string }) { const captchaToken = await props.getCaptchaToken(); await authService.verifyEmailCode({ email: props.email, code: input.code, captchaToken }); @@ -55,6 +57,7 @@ export function EmailCodeVerify({ dependencies: d = DEPENDENCIES, ...props }: Pr }); const resendMutation = d.useMutation({ + meta: SKIP_REPORTING_CAPTCHA_OUTCOME, async mutationFn() { const captchaToken = await props.getCaptchaToken(); await authService.startEmailCode({ email: props.email, captchaToken }); diff --git a/apps/deploy-web/src/components/auth/PasswordAuth/PasswordAuth.tsx b/apps/deploy-web/src/components/auth/PasswordAuth/PasswordAuth.tsx index 4cb21a9844..19a339041a 100644 --- a/apps/deploy-web/src/components/auth/PasswordAuth/PasswordAuth.tsx +++ b/apps/deploy-web/src/components/auth/PasswordAuth/PasswordAuth.tsx @@ -7,6 +7,7 @@ import { useSearchParams } from "next/navigation"; import { useRouter } from "next/router"; import { RemoteApiError } from "@src/components/shared/RemoteApiError/RemoteApiError"; +import { SKIP_REPORTING_CAPTCHA_OUTCOME } from "@src/components/turnstile/CaptchaChallengeError"; import type { TurnstileRef } from "@src/components/turnstile/Turnstile"; import { ClientOnlyTurnstile } from "@src/components/turnstile/Turnstile"; import { useServices } from "@src/context/ServicesProvider"; @@ -53,6 +54,7 @@ export function PasswordAuth({ dependencies: d = DEPENDENCIES }: Props = {}) { const isAuthInFlight = useRef(false); const signInOrSignUp = useMutation({ + meta: SKIP_REPORTING_CAPTCHA_OUTCOME, async mutationFn(input: Tagged<"signin", SignInFormValues> | Tagged<"signup", SignUpFormValues>) { analyticsService.track("password_auth_submit", { type: input.type }); if (!turnstileRef.current) { @@ -85,6 +87,7 @@ export function PasswordAuth({ dependencies: d = DEPENDENCIES }: Props = {}) { ); const forgotPassword = useMutation({ + meta: SKIP_REPORTING_CAPTCHA_OUTCOME, async mutationFn(input: { email: string }) { if (!turnstileRef.current) { throw new Error("Captcha has not been rendered"); diff --git a/apps/deploy-web/src/components/shared/RemoteApiError/RemoteApiError.spec.tsx b/apps/deploy-web/src/components/shared/RemoteApiError/RemoteApiError.spec.tsx index 291ff7e365..86a2dece7d 100644 --- a/apps/deploy-web/src/components/shared/RemoteApiError/RemoteApiError.spec.tsx +++ b/apps/deploy-web/src/components/shared/RemoteApiError/RemoteApiError.spec.tsx @@ -1,6 +1,7 @@ import { AxiosError } from "axios"; import { describe, expect, it } from "vitest"; +import { CaptchaChallengeError } from "@src/components/turnstile/CaptchaChallengeError"; import { DEPENDENCIES, RemoteApiError } from "./RemoteApiError"; import { render } from "@testing-library/react"; @@ -24,6 +25,11 @@ describe(RemoteApiError.name, () => { expect(getByText(/Error message from API/i)).toBeInTheDocument(); }); + it("tells the visitor the captcha is what failed instead of blaming the request", () => { + const { getByText } = setup({ error: new CaptchaChallengeError("abandoned") }); + expect(getByText(/verification wasn't completed/i)).toBeInTheDocument(); + }); + it("does not render anything when error is null or undefined", () => { const result = setup({ error: null }); expect(result.container).toBeEmptyDOMElement(); diff --git a/apps/deploy-web/src/components/shared/RemoteApiError/RemoteApiError.tsx b/apps/deploy-web/src/components/shared/RemoteApiError/RemoteApiError.tsx index aecaf58080..dce342e62b 100644 --- a/apps/deploy-web/src/components/shared/RemoteApiError/RemoteApiError.tsx +++ b/apps/deploy-web/src/components/shared/RemoteApiError/RemoteApiError.tsx @@ -1,6 +1,8 @@ import { isHttpError } from "@akashnetwork/http-sdk"; import { Alert, AlertDescription } from "@akashnetwork/ui/components"; +import { CaptchaChallengeError } from "@src/components/turnstile/CaptchaChallengeError"; + interface Props { error: Error | null | undefined; className?: string; @@ -12,15 +14,25 @@ export const DEPENDENCIES = { AlertDescription }; +const FALLBACK_MESSAGE = "An unexpected error occurred. Please try again or contact support if the issue persists."; + +function describeError(error: Error): string { + if (error instanceof CaptchaChallengeError) { + return error.message; + } + + if (isHttpError(error) && error.response?.data.message) { + return error.response.data.message; + } + + return FALLBACK_MESSAGE; +} + export function RemoteApiError({ error, className, dependencies: d = DEPENDENCIES }: Props) { if (!error) return null; return ( - - {isHttpError(error) && error.response?.data.message - ? error.response.data.message - : "An unexpected error occurred. Please try again or contact support if the issue persists."} - + {describeError(error)} ); } diff --git a/apps/deploy-web/src/components/turnstile/CaptchaChallengeError.spec.ts b/apps/deploy-web/src/components/turnstile/CaptchaChallengeError.spec.ts new file mode 100644 index 0000000000..f1beb3fe52 --- /dev/null +++ b/apps/deploy-web/src/components/turnstile/CaptchaChallengeError.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import type { CaptchaChallengeReason } from "./CaptchaChallengeError"; +import { CaptchaChallengeError, SKIP_REPORTING_CAPTCHA_OUTCOME } from "./CaptchaChallengeError"; + +describe(CaptchaChallengeError.name, () => { + it.each(["abandoned", "timeout", "dismissed", "error"])("carries a message the visitor can read for %s", reason => { + expect(new CaptchaChallengeError(reason).message).toMatch(/^Verification .+\.$/); + }); + + it("survives instanceof against both its own type and Error", () => { + const error = new CaptchaChallengeError("abandoned"); + + expect(error).toBeInstanceOf(CaptchaChallengeError); + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe("CaptchaChallengeError"); + }); + + it("keeps the cloudflare error code when there is one", () => { + expect(new CaptchaChallengeError("error", "300010").code).toBe("300010"); + }); + + describe("SKIP_REPORTING_CAPTCHA_OUTCOME", () => { + it("skips reporting for a captcha outcome", () => { + expect(SKIP_REPORTING_CAPTCHA_OUTCOME.skipErrorReporting(new CaptchaChallengeError("timeout"))).toBe(true); + }); + + it("leaves every other failure reportable", () => { + expect(SKIP_REPORTING_CAPTCHA_OUTCOME.skipErrorReporting(new Error("auth api is down"))).toBe(false); + expect(SKIP_REPORTING_CAPTCHA_OUTCOME.skipErrorReporting({ reason: "timeout" })).toBe(false); + }); + }); +}); diff --git a/apps/deploy-web/src/components/turnstile/CaptchaChallengeError.ts b/apps/deploy-web/src/components/turnstile/CaptchaChallengeError.ts new file mode 100644 index 0000000000..095fa601be --- /dev/null +++ b/apps/deploy-web/src/components/turnstile/CaptchaChallengeError.ts @@ -0,0 +1,25 @@ +export type CaptchaChallengeReason = "abandoned" | "timeout" | "dismissed" | "error"; + +const MESSAGES: Record = { + abandoned: "Verification wasn't completed. Please try again.", + timeout: "Verification didn't finish in time. Please try again.", + dismissed: "Verification was cancelled.", + error: "Verification failed. Please try again." +}; + +export class CaptchaChallengeError extends Error { + readonly name = "CaptchaChallengeError"; + + constructor( + readonly reason: CaptchaChallengeReason, + readonly code?: string + ) { + super(MESSAGES[reason]); + } +} + +/** + * The widget reports its own anomalies with tags the global cache handler cannot know, and a challenge the visitor + * simply never solved is not a fault at all, so neither shape should reach Sentry a second time from a mutation. + */ +export const SKIP_REPORTING_CAPTCHA_OUTCOME = { skipErrorReporting: (error: unknown) => error instanceof CaptchaChallengeError }; diff --git a/apps/deploy-web/src/components/turnstile/Turnstile.spec.tsx b/apps/deploy-web/src/components/turnstile/Turnstile.spec.tsx index 6abfe69ac3..2de066b8be 100644 --- a/apps/deploy-web/src/components/turnstile/Turnstile.spec.tsx +++ b/apps/deploy-web/src/components/turnstile/Turnstile.spec.tsx @@ -5,6 +5,7 @@ import { setTimeout as wait } from "node:timers/promises"; import { describe, expect, it, vi } from "vitest"; import { mock } from "vitest-mock-extended"; +import type { AnalyticsService } from "@src/services/analytics/analytics.service"; import type { ErrorHandlerService } from "@src/services/error-handler/error-handler.service"; import type { TurnstileRef } from "./Turnstile"; import { CHALLENGE_DEADLINE_MS, COMPONENTS, Turnstile } from "./Turnstile"; @@ -237,10 +238,7 @@ describe(Turnstile.name, () => { }); await promise; - expect(rejection).toMatchObject({ - reason: "error", - error: "test-error" - }); + expect(rejection).toMatchObject({ reason: "error", code: "test-error" }); }); it("rejects the pending challenge when the widget is dismissed", async () => { @@ -342,7 +340,7 @@ describe(Turnstile.name, () => { await expect(promise).resolves.toEqual({ token: "refreshed-token" }); }); - it("rejects a challenge that never settles instead of hanging the caller", async () => { + it("reports a wedge when cloudflare goes silent without ever prompting the visitor", async () => { const { turnstileRef, errorHandler } = await setup({ enabled: true }); vi.useFakeTimers(); @@ -363,6 +361,77 @@ describe(Turnstile.name, () => { } }); + it("treats a challenge the visitor never solved as abandoned rather than wedged", async () => { + const { ReactTurnstile, latestProps } = createTurnstileMock(); + const { turnstileRef, errorHandler, analyticsService } = await setup({ enabled: true, components: { ReactTurnstile } }); + vi.useFakeTimers(); + + try { + let rejection: unknown; + const promise = turnstileRef.current!.renderAndWaitResponse().catch(error => { + rejection = error; + }); + await act(async () => { + latestProps.current!.onBeforeInteractive?.(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(CHALLENGE_DEADLINE_MS); + }); + await promise; + + expect(rejection).toMatchObject({ reason: "abandoned" }); + expect(errorHandler.reportError).not.toHaveBeenCalled(); + expect(analyticsService.track).toHaveBeenCalledWith("captcha_abandoned"); + } finally { + vi.useRealTimers(); + } + }); + + it("reports a wedge again once the challenge has left interactive mode", async () => { + const { ReactTurnstile, latestProps } = createTurnstileMock(); + const { turnstileRef, errorHandler, analyticsService } = await setup({ enabled: true, components: { ReactTurnstile } }); + vi.useFakeTimers(); + + try { + const promise = turnstileRef.current!.renderAndWaitResponse().catch(() => undefined); + await act(async () => { + latestProps.current!.onBeforeInteractive?.(); + latestProps.current!.onAfterInteractive?.(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(CHALLENGE_DEADLINE_MS); + }); + await promise; + + expect(errorHandler.reportError).toHaveBeenCalledWith(expect.objectContaining({ tags: { event: "TURNSTILE_CHALLENGE_WEDGED" } })); + expect(analyticsService.track).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("does not carry a previous abandonment into the next challenge", async () => { + const { ReactTurnstile, latestProps } = createTurnstileMock(); + const { turnstileRef, errorHandler } = await setup({ enabled: true, components: { ReactTurnstile } }); + vi.useFakeTimers(); + + try { + const abandoned = turnstileRef.current!.renderAndWaitResponse().catch(() => undefined); + await act(async () => { + latestProps.current!.onBeforeInteractive?.(); + }); + const retried = turnstileRef.current!.renderAndWaitResponse().catch(() => undefined); + await act(async () => { + await vi.advanceTimersByTimeAsync(CHALLENGE_DEADLINE_MS); + }); + await Promise.all([abandoned, retried]); + + expect(errorHandler.reportError).toHaveBeenCalledWith(expect.objectContaining({ tags: { event: "TURNSTILE_CHALLENGE_WEDGED" } })); + } finally { + vi.useRealTimers(); + } + }); + it("drops a pending challenge on unmount without reporting it wedged 2 minutes later", async () => { const { turnstileRef, errorHandler, unmount } = await setup({ enabled: true }); vi.useFakeTimers(); @@ -414,9 +483,10 @@ describe(Turnstile.name, () => { async function setup(input?: { enabled?: boolean; siteKey?: string; onDismissed?: () => void; components?: Partial }) { const turnstileRef = { current: null as TurnstileRef | null }; const errorHandler = mock(); + const analyticsService = mock(); const result = render( - errorHandler }}> + errorHandler, analyticsService: () => analyticsService }}> { ); await act(() => wait(0)); - return { ...result, turnstileRef, errorHandler }; + return { ...result, turnstileRef, errorHandler, analyticsService }; } const ButtonMock = forwardRef>((props, ref) => ( diff --git a/apps/deploy-web/src/components/turnstile/Turnstile.tsx b/apps/deploy-web/src/components/turnstile/Turnstile.tsx index bf6e2874cf..6426eb766f 100644 --- a/apps/deploy-web/src/components/turnstile/Turnstile.tsx +++ b/apps/deploy-web/src/components/turnstile/Turnstile.tsx @@ -13,6 +13,7 @@ import dynamic from "next/dynamic"; import { useServices } from "@src/context/ServicesProvider"; import { useWhen } from "@src/hooks/useWhen"; import { getInjectedConfig } from "@src/utils/getInjectedConfig/getInjectedConfig"; +import { CaptchaChallengeError } from "./CaptchaChallengeError"; type TurnstileStatus = "uninitialized" | "solved" | "interactive" | "expired" | "error" | "dismissed"; @@ -56,7 +57,7 @@ export const Turnstile = forwardRef(function Turns const isVisible = useMemo(() => enabled && VISIBILITY_STATUSES.includes(status), [enabled, status]); const eventBus = useRef(new EventTarget()); const injectedConfig = getInjectedConfig(); - const { errorHandler } = useServices(); + const { errorHandler, analyticsService } = useServices(); const hasReportedFailure = useRef(false); /** Cloudflare keeps retrying every 8s, so only the first anomaly of a run is reported: enough to diagnose, without one Sentry event per retry per stuck visitor. A run ends at the next success or the next challenge the caller asks for. */ @@ -69,6 +70,9 @@ export const Turnstile = forwardRef(function Turns [errorHandler] ); + /** Cloudflare stops calling back once a challenge turns interactive and waits on the visitor, so a deadline reached in that state is an abandoned challenge rather than a wedged one. */ + const isAwaitingInteraction = useRef(false); + const resetWidget = useCallback(() => { turnstileRef.current?.remove(); turnstileRef.current?.render(); @@ -119,6 +123,7 @@ export const Turnstile = forwardRef(function Turns abandonPendingChallenge.current?.(); hasReportedFailure.current = false; + isAwaitingInteraction.current = false; startChallenge(); return new Promise((resolve, reject) => { const stopWaiting = () => { @@ -135,19 +140,26 @@ export const Turnstile = forwardRef(function Turns }; const errorListener = (event: Event) => { stopWaiting(); - const details = (event as CustomEvent<{ reason: string; error?: string }>).detail; - reject({ status, ...details }); + const { code } = (event as CustomEvent<{ code?: string }>).detail; + reject(new CaptchaChallengeError("error", code)); }; abandonPendingChallenge.current = () => { stopWaiting(); - reject({ reason: "dismissed" }); + reject(new CaptchaChallengeError("dismissed")); }; stopWaitingForChallenge.current = stopWaiting; const deadline = setTimeout(() => { stopWaiting(); + + if (isAwaitingInteraction.current) { + analyticsService.track("captcha_abandoned"); + reject(new CaptchaChallengeError("abandoned")); + return; + } + reportChallengeFailure(new Error("Turnstile challenge never settled"), "TURNSTILE_CHALLENGE_WEDGED"); - reject({ reason: "timeout" }); + reject(new CaptchaChallengeError("timeout")); }, CHALLENGE_DEADLINE_MS); eventBus.current.addEventListener("success", successListener); @@ -155,7 +167,7 @@ export const Turnstile = forwardRef(function Turns }); } }), - [startChallenge, enabled, reportChallengeFailure] + [startChallenge, enabled, reportChallengeFailure, analyticsService] ); if (!enabled) { @@ -188,16 +200,23 @@ export const Turnstile = forwardRef(function Turns onError={error => { setStatus("error"); reportChallengeFailure(new Error(`Turnstile challenge failed with code ${error}`), "TURNSTILE_CHALLENGE_FAILED"); - eventBus.current.dispatchEvent(new CustomEvent("error", { detail: { error, reason: "error" } })); + eventBus.current.dispatchEvent(new CustomEvent("error", { detail: { code: error } })); }} onExpire={() => setStatus("expired")} onTimeout={() => reportChallengeFailure(new Error("Turnstile challenge timed out"), "TURNSTILE_CHALLENGE_TIMED_OUT")} onSuccess={token => { setStatus("solved"); hasReportedFailure.current = false; + isAwaitingInteraction.current = false; eventBus.current.dispatchEvent(new CustomEvent("success", { detail: { token } })); }} - onBeforeInteractive={() => setStatus("interactive")} + onBeforeInteractive={() => { + isAwaitingInteraction.current = true; + setStatus("interactive"); + }} + onAfterInteractive={() => { + isAwaitingInteraction.current = false; + }} onWidgetLoad={() => { isWidgetLoaded.current = true; const startPendingChallenge = startChallengeOnWidgetLoad.current; diff --git a/apps/deploy-web/src/services/analytics/analytics.service.ts b/apps/deploy-web/src/services/analytics/analytics.service.ts index f5ac1d5c64..6719055290 100644 --- a/apps/deploy-web/src/services/analytics/analytics.service.ts +++ b/apps/deploy-web/src/services/analytics/analytics.service.ts @@ -38,6 +38,7 @@ export type AnalyticsEvent = | "wrong_email_clk" | "resend_code_clk" | "password_auth_submit" + | "captcha_abandoned" | "connect_wallet" | "connect_managed_wallet" | "disconnect_wallet" From 68ca03f7005ff80a212beeb611fb2ed991279c48 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:08:40 +0400 Subject: [PATCH 2/3] fix(auth): take the captcha overlay down when the challenge deadline passes The overlay is full-bleed at z-101 over an opaque background and stays up for as long as the status says the challenge is interactive. Nothing reset that status when the deadline elapsed, so a visitor who let an interactive challenge lapse kept looking at the captcha while the error explaining it rendered underneath, out of sight. The deadline now moves the widget to a non-visible status before it rejects, on both the abandoned and the wedged path. --- .../turnstile/CaptchaChallengeError.ts | 5 +--- .../components/turnstile/Turnstile.spec.tsx | 27 +++++++++++++++++++ .../src/components/turnstile/Turnstile.tsx | 3 ++- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/apps/deploy-web/src/components/turnstile/CaptchaChallengeError.ts b/apps/deploy-web/src/components/turnstile/CaptchaChallengeError.ts index 095fa601be..20b1f5f3df 100644 --- a/apps/deploy-web/src/components/turnstile/CaptchaChallengeError.ts +++ b/apps/deploy-web/src/components/turnstile/CaptchaChallengeError.ts @@ -18,8 +18,5 @@ export class CaptchaChallengeError extends Error { } } -/** - * The widget reports its own anomalies with tags the global cache handler cannot know, and a challenge the visitor - * simply never solved is not a fault at all, so neither shape should reach Sentry a second time from a mutation. - */ +/** The widget already tags its own anomalies and an unsolved challenge is no fault at all, so neither should reach Sentry again by way of a mutation. */ export const SKIP_REPORTING_CAPTCHA_OUTCOME = { skipErrorReporting: (error: unknown) => error instanceof CaptchaChallengeError }; diff --git a/apps/deploy-web/src/components/turnstile/Turnstile.spec.tsx b/apps/deploy-web/src/components/turnstile/Turnstile.spec.tsx index 2de066b8be..412d6ee522 100644 --- a/apps/deploy-web/src/components/turnstile/Turnstile.spec.tsx +++ b/apps/deploy-web/src/components/turnstile/Turnstile.spec.tsx @@ -432,6 +432,29 @@ describe(Turnstile.name, () => { } }); + it("takes the overlay down when the deadline passes, so the caller's error is not left behind it", async () => { + const { ReactTurnstile, latestProps } = createTurnstileMock(); + const { turnstileRef } = await setup({ enabled: true, components: { ReactTurnstile } }); + vi.useFakeTimers(); + + try { + const promise = turnstileRef.current!.renderAndWaitResponse().catch(() => undefined); + await act(async () => { + latestProps.current!.onBeforeInteractive?.(); + }); + expect(getOverlay()).toHaveStyle({ pointerEvents: "auto" }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(CHALLENGE_DEADLINE_MS); + }); + await promise; + + expect(getOverlay()).toHaveStyle({ pointerEvents: "none" }); + } finally { + vi.useRealTimers(); + } + }); + it("drops a pending challenge on unmount without reporting it wedged 2 minutes later", async () => { const { turnstileRef, errorHandler, unmount } = await setup({ enabled: true }); vi.useFakeTimers(); @@ -507,6 +530,10 @@ describe(Turnstile.name, () => { return { ...result, turnstileRef, errorHandler, analyticsService }; } + function getOverlay() { + return screen.getByText(/we are verifying you are a human/i).closest("[style]"); + } + const ButtonMock = forwardRef>((props, ref) => (