diff --git a/apps/deploy-web/next.config.js b/apps/deploy-web/next.config.js index cd7fab6ab8..f431c78462 100644 --- a/apps/deploy-web/next.config.js +++ b/apps/deploy-web/next.config.js @@ -23,7 +23,7 @@ try { const transpilePackages = ["geist", "@akashnetwork/ui"]; if (process.env.NODE_ENV === "test") { - transpilePackages.push("nanoid", "uint8arrays", "multiformats"); + transpilePackages.push("nanoid", "uint8arrays", "multiformats", "@marsidev/react-turnstile"); } /** diff --git a/apps/deploy-web/package.json b/apps/deploy-web/package.json index 64cd20d053..116ac0a62e 100644 --- a/apps/deploy-web/package.json +++ b/apps/deploy-web/package.json @@ -173,7 +173,8 @@ "prettier": "^3.3.0", "prettier-plugin-tailwindcss": "^0.6.1", "tailwindcss": "^3.4.3", - "typescript": "~5.8.2" + "typescript": "~5.8.2", + "whatwg-fetch": "^3.6.20" }, "overrides": { "@radix-ui/react-dismissable-layer": "^1.0.5", diff --git a/apps/deploy-web/src/components/turnstile/Turnstile.spec.tsx b/apps/deploy-web/src/components/turnstile/Turnstile.spec.tsx new file mode 100644 index 0000000000..9249522cc0 --- /dev/null +++ b/apps/deploy-web/src/components/turnstile/Turnstile.spec.tsx @@ -0,0 +1,212 @@ +import { forwardRef, useEffect } from "react"; +import { act } from "react-dom/test-utils"; +import type { TurnstileInstance } from "@marsidev/react-turnstile"; +import { type TurnstileProps } from "@marsidev/react-turnstile"; +import { mock } from "jest-mock-extended"; +import { setTimeout as wait } from "node:timers/promises"; + +import { COMPONENTS, Turnstile } from "./Turnstile"; + +import { fireEvent, render, screen } from "@testing-library/react"; +import { MockComponents } from "@tests/unit/mocks"; + +describe(Turnstile.name, () => { + it("does not render if turnstile is disabled", async () => { + await setup({ enabled: false }); + + expect(screen.queryByText("Turnstile")).not.toBeInTheDocument(); + }); + + it("does not patch fetch API if turnstile is disabled", async () => { + const originalFetch = window.fetch; + await setup({ enabled: false }); + + expect(window.fetch).toBe(originalFetch); + }); + + it("patches fetch API if turnstile is enabled", async () => { + const originalFetch = window.fetch; + await setup({ enabled: true }); + + expect(window.fetch).not.toBe(originalFetch); + }); + + it("renders turnstile widget", async () => { + await setup({ enabled: true }); + + expect(screen.queryByText("Turnstile")).toBeInTheDocument(); + }); + + it("resets actual widget on error", async () => { + const turnstileInstance = mock(); + const ReactTurnstile = forwardRef((props, ref) => { + useForwardedRef(ref, turnstileInstance); + useEffect(() => { + props.onError?.("test"); + }, []); + return
Turnstile
; + }); + await setup({ enabled: true, components: { ReactTurnstile } }); + + expect(turnstileInstance.remove).toHaveBeenCalled(); + expect(turnstileInstance.render).toHaveBeenCalled(); + expect(turnstileInstance.execute).toHaveBeenCalled(); + expect(screen.queryByText("Some error occurred")).toBeInTheDocument(); + }); + + it('resets actual widget on "Retry" button click', async () => { + const turnstileInstance = mock(); + const ReactTurnstile = forwardRef((props, ref) => { + useForwardedRef(ref, turnstileInstance); + return
Turnstile
; + }); + + await setup({ + enabled: true, + components: { + ReactTurnstile, + Button: forwardRef((props, ref) => ( + + )) + } + }); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + + expect(turnstileInstance.remove).toHaveBeenCalled(); + expect(turnstileInstance.render).toHaveBeenCalled(); + expect(turnstileInstance.execute).toHaveBeenCalled(); + }); + + it('removes actual widget on "Dismiss" button click', async () => { + const turnstileInstance = mock(); + const ReactTurnstile = forwardRef((props, ref) => { + useForwardedRef(ref, turnstileInstance); + return
Turnstile
; + }); + + await setup({ + enabled: true, + components: { + ReactTurnstile, + Button: forwardRef((props, ref) => ( + + )) + } + }); + fireEvent.click(screen.getByRole("button", { name: "Dismiss" })); + + expect(turnstileInstance.remove).toHaveBeenCalled(); + expect(turnstileInstance.render).not.toHaveBeenCalled(); + expect(turnstileInstance.execute).not.toHaveBeenCalled(); + }); + + describe("when CF-Mitigated header is present", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + let amountOfCalls = 0; + globalThis.fetch = jest.fn(async () => { + if (amountOfCalls > 0) { + return new Response("done", { + status: 200 + }); + } + + const response = new Response("", { + status: 403, + headers: new Headers({ "cf-mitigated": "challenge" }) + }); + + amountOfCalls++; + + return response; + }); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("renders turnstile widget", async () => { + const turnstileInstance = mock(); + const ReactTurnstile = forwardRef((props, ref) => { + useForwardedRef(ref, turnstileInstance); + return
Turnstile
; + }); + + await setup({ enabled: true, components: { ReactTurnstile } }); + await fetch("/"); + + expect(turnstileInstance.render).toHaveBeenCalled(); + }); + + it('does not retry request if "Dismiss" button is clicked', async () => { + const fetchMock = globalThis.fetch; + + await setup({ + enabled: true, + components: { + Button: forwardRef((props, ref) => ( + + )) + } + }); + await fetch("/"); + fireEvent.click(screen.getByRole("button", { name: "Dismiss" })); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("retries request if challenge is solved", async () => { + const fetchMock = globalThis.fetch; + const turnstileInstance = mock({ + getResponsePromise: () => Promise.resolve("test response") + }); + const ReactTurnstile = forwardRef((props, ref) => { + useForwardedRef(ref, turnstileInstance); + return
Turnstile
; + }); + + await setup({ enabled: true, components: { ReactTurnstile } }); + await fetch("/"); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + }); + + async function setup(input?: { enabled?: boolean; siteKey?: string; components?: Partial }) { + const result = render( + ((_, ref) => { + useForwardedRef(ref); + return
Turnstile
; + }), + ...input?.components + })} + /> + ); + await act(() => wait(0)); + + return result; + } + + function useForwardedRef(ref: React.ForwardedRef, instance: T = mock()) { + useEffect(() => { + if (typeof ref === "function") { + ref(instance); + } else if (ref) { + ref.current = instance; + } + }, []); + } +}); diff --git a/apps/deploy-web/src/components/turnstile/Turnstile.tsx b/apps/deploy-web/src/components/turnstile/Turnstile.tsx index de6dcc302c..a214ddcd67 100644 --- a/apps/deploy-web/src/components/turnstile/Turnstile.tsx +++ b/apps/deploy-web/src/components/turnstile/Turnstile.tsx @@ -6,11 +6,9 @@ import { MdInfo } from "react-icons/md"; import { Button } from "@akashnetwork/ui/components"; import type { TurnstileInstance } from "@marsidev/react-turnstile"; import { Turnstile as ReactTurnstile } from "@marsidev/react-turnstile"; -import type { AxiosError, AxiosResponse } from "axios"; -import axios from "axios"; +import type { Axios, AxiosError, AxiosResponse } from "axios"; import { motion } from "framer-motion"; import dynamic from "next/dynamic"; -import { firstValueFrom, Subject } from "rxjs"; import { useWhen } from "@src/hooks/useWhen"; import { services } from "@src/services/http/http-browser.service"; @@ -18,11 +16,11 @@ import { managedWalletHttpService } from "@src/services/managed-wallet-http/mana const HTTP_SERVICES = [managedWalletHttpService, services.user, services.stripe, services.tx, services.template, services.auth, services.deploymentSetting]; -const originalFetch = typeof window !== "undefined" && window.fetch; +let originalFetch: typeof fetch | undefined; -const addResponseInterceptor = (interceptor: (value: AxiosError) => AxiosResponse | Promise) => { +const addResponseInterceptor = (intercept: (service: Axios, value: AxiosError) => AxiosResponse | Promise) => { const removes = HTTP_SERVICES.map(service => { - const interceptorId = service.interceptors.response.use(null, interceptor); + const interceptorId = service.interceptors.response.use(null, error => intercept(service, error)); return () => { service.interceptors.response.eject(interceptorId); @@ -38,53 +36,60 @@ type TurnstileStatus = "uninitialized" | "solved" | "interactive" | "expired" | const VISIBILITY_STATUSES: TurnstileStatus[] = ["interactive", "error"]; +export const COMPONENTS = { + ReactTurnstile, + Button, + MdInfo +}; + type TurnstileProps = { enabled: boolean; siteKey: string; + components?: typeof COMPONENTS; }; -export const Turnstile: FC = ({ enabled, siteKey }) => { +export const Turnstile: FC = ({ enabled, siteKey, components: c = COMPONENTS }) => { const turnstileRef = useRef(); const [status, setStatus] = useState("uninitialized"); const isVisible = useMemo(() => enabled && VISIBILITY_STATUSES.includes(status), [enabled, status]); - const dismissedSubject = useRef(new Subject()); + const abortControllerRef = useRef(); - useEffect(() => { - if (!enabled) return; + const resetWidget = useCallback(() => { + turnstileRef.current?.remove(); + turnstileRef.current?.render(); + turnstileRef.current?.execute(); + }, []); - if (originalFetch) { - window.fetch = async (...args) => { - let response = await originalFetch(...args); + const renderTurnstileAndWaitForResponse = useCallback(async () => { + abortControllerRef.current = new AbortController(); + resetWidget(); - if (typeof args[0] === "string" && args[0].startsWith("/") && response.status > 400 && turnstileRef.current) { - turnstileRef.current?.remove(); - turnstileRef.current?.render(); - turnstileRef.current?.execute(); + return Promise.race([ + turnstileRef.current?.getResponsePromise(), + new Promise(resolve => abortControllerRef.current?.signal.addEventListener("abort", () => resolve())) + ]); + }, [resetWidget]); - const turnstileResponse = await Promise.race([turnstileRef.current.getResponsePromise(), firstValueFrom(dismissedSubject.current.asObservable())]); + useEffect(() => { + if (!enabled) return; - if (turnstileResponse) { - response = await originalFetch(...args); - } + if (typeof globalThis.fetch === "function") { + originalFetch = originalFetch || globalThis.fetch; + const fetch = originalFetch; + globalThis.fetch = async (resource, options) => { + const response = await fetch(resource, options); + + if (response.headers.get("cf-mitigated") === "challenge" && turnstileRef.current && (await renderTurnstileAndWaitForResponse())) { + return globalThis.fetch(resource, options); } return response; }; } - const ejectInterceptors = addResponseInterceptor(async error => { - const request = error?.request; - - if ((!request?.status || request?.status > 400) && turnstileRef.current) { - turnstileRef.current?.remove(); - turnstileRef.current?.render(); - turnstileRef.current?.execute(); - - const response = await Promise.race([turnstileRef.current.getResponsePromise(), firstValueFrom(dismissedSubject.current.asObservable())]); - - if (response) { - return axios(error.config!); - } + const ejectInterceptors = addResponseInterceptor(async (service, error) => { + if (error?.response?.headers["cf-mitigated"] === "challenge" && turnstileRef.current && (await renderTurnstileAndWaitForResponse())) { + return service.request(error.config!); } return Promise.reject(error); @@ -92,18 +97,13 @@ export const Turnstile: FC = ({ enabled, siteKey }) => { return () => { ejectInterceptors(); - if (originalFetch) { - window.fetch = originalFetch; + if (typeof originalFetch === "function") { + globalThis.fetch = originalFetch; + originalFetch = undefined; } }; }, [enabled]); - const resetWidget = useCallback(() => { - turnstileRef.current?.remove(); - turnstileRef.current?.render(); - turnstileRef.current?.execute(); - }, []); - useWhen(status === "error", () => { resetWidget(); }); @@ -128,7 +128,7 @@ export const Turnstile: FC = ({ enabled, siteKey }) => {

We are verifying you are a human. This may take a few seconds

Reviewing the security of your connection before proceeding

- = ({ enabled, siteKey }) => { }} >
- - +

- + dismissing the check might result into some features not working properly

diff --git a/apps/deploy-web/tests/unit/setup.ts b/apps/deploy-web/tests/unit/setup.ts index 53082522ba..d8111a55c9 100644 --- a/apps/deploy-web/tests/unit/setup.ts +++ b/apps/deploy-web/tests/unit/setup.ts @@ -1,8 +1,10 @@ import "@testing-library/jest-dom"; +import "whatwg-fetch"; import { TextDecoder, TextEncoder } from "util"; Object.assign(global, { TextDecoder, TextEncoder }); + beforeAll(() => { Object.defineProperty(window, "matchMedia", { writable: true, diff --git a/package-lock.json b/package-lock.json index feb4112a10..565181def8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1304,7 +1304,8 @@ "prettier": "^3.3.0", "prettier-plugin-tailwindcss": "^0.6.1", "tailwindcss": "^3.4.3", - "typescript": "~5.8.2" + "typescript": "~5.8.2", + "whatwg-fetch": "^3.6.20" } }, "apps/deploy-web/node_modules/@cosmjs/encoding": { @@ -51462,6 +51463,13 @@ "node": ">=0.10.0" } }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "dev": true, + "license": "MIT" + }, "node_modules/whatwg-mimetype": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",