diff --git a/apps/deploy-web/env/.env.sample b/apps/deploy-web/env/.env.sample index 153e5fe069..4eeaa86baa 100644 --- a/apps/deploy-web/env/.env.sample +++ b/apps/deploy-web/env/.env.sample @@ -45,3 +45,9 @@ NEXT_PUBLIC_UNLEASH_FRONTEND_API_TOKEN= UNLEASH_SERVER_API_URL= UNLEASH_SERVER_API_TOKEN= + +NEXT_PUBLIC_TURNSTILE_ENABLED=true +# https://developers.cloudflare.com/turnstile/troubleshooting/testing/#dummy-sitekeys-and-secret-keys +NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA +TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA +E2E_TESTING_CLIENT_TOKEN=any-random-string diff --git a/apps/deploy-web/playwright.config.ts b/apps/deploy-web/playwright.config.ts index 6e701812e5..f179b66140 100644 --- a/apps/deploy-web/playwright.config.ts +++ b/apps/deploy-web/playwright.config.ts @@ -4,6 +4,12 @@ import path from "path"; dotenv.config({ path: path.resolve(__dirname, "env/.env.test") }); +if (!process.env.E2E_TESTING_CLIENT_TOKEN && !process.env.BASE_URL?.includes("localhost")) { + throw new Error( + "E2E_TESTING_CLIENT_TOKEN is a required env variable. Without it, tests will be blocked by captcha verification. Should be set to the same value as app's E2E_TESTING_CLIENT_TOKEN env variable." + ); +} + /** * See https://playwright.dev/docs/test-configuration. */ @@ -26,7 +32,10 @@ export default defineConfig({ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: "retain-on-failure", video: "retain-on-failure", - actionTimeout: 15_000 + actionTimeout: 15_000, + extraHTTPHeaders: { + "X-Testing-Client-Token": process.env.E2E_TESTING_CLIENT_TOKEN || "" + } }, /* Configure projects for major browsers */ diff --git a/apps/deploy-web/src/components/auth/AuthPage/AuthPage.spec.tsx b/apps/deploy-web/src/components/auth/AuthPage/AuthPage.spec.tsx index 0936692989..a4e167b9bd 100644 --- a/apps/deploy-web/src/components/auth/AuthPage/AuthPage.spec.tsx +++ b/apps/deploy-web/src/components/auth/AuthPage/AuthPage.spec.tsx @@ -1,8 +1,10 @@ +import { type RefObject, useState } from "react"; import type { Tabs } from "@akashnetwork/ui/components"; import { mock } from "jest-mock-extended"; import type { ReadonlyURLSearchParams } from "next/navigation"; import type { NextRouter } from "next/router"; +import type { TurnstileRef } from "@src/components/turnstile/Turnstile"; import type { AuthService } from "@src/services/auth/auth/auth.service"; import type { SignInForm, SignInFormValues } from "../SignInForm/SignInForm"; import type { SignUpForm, SignUpFormValues } from "../SignUpForm/SignUpForm"; @@ -110,7 +112,10 @@ describe(AuthPage.name, () => { }); await waitFor(() => { - expect(authService.login).toHaveBeenCalledWith(credentials); + expect(authService.login).toHaveBeenCalledWith({ + ...credentials, + captchaToken: "test-captcha-token" + }); }); expect(authService.signup).not.toHaveBeenCalled(); await waitFor(() => { @@ -168,7 +173,10 @@ describe(AuthPage.name, () => { }); await waitFor(() => { - expect(authService.signup).toHaveBeenCalledWith(credentials); + expect(authService.signup).toHaveBeenCalledWith({ + ...credentials, + captchaToken: "test-captcha-token" + }); }); expect(authService.login).not.toHaveBeenCalled(); await waitFor(() => { @@ -205,6 +213,48 @@ describe(AuthPage.name, () => { }); }); + describe("when ForgotPassword view is open", () => { + it("renders forgot password form when SignInForm notifies about its request", async () => { + const SignInFormMock = jest.fn((() => SignInForm) as typeof SignInForm); + const ForgotPasswordFormMock = jest.fn(() => ForgotPasswordForm); + const { router } = setup({ + dependencies: { + SignInForm: SignInFormMock, + ForgotPasswordForm: ForgotPasswordFormMock + } + }); + + await act(() => { + SignInFormMock.mock.calls[0][0].onForgotPasswordClick?.(); + }); + + await waitFor(() => { + expect(router.replace).toHaveBeenCalledTimes(1); + expect(screen.getByText("ForgotPasswordForm")).toBeInTheDocument(); + }); + }); + + it("submits forgot password form and displays success message", async () => { + const ForgotPasswordFormMock = jest.fn(ComponentMock as typeof DEPENDENCIES.ForgotPasswordForm); + const { authService } = setup({ + searchParams: { + tab: "forgot-password" + }, + dependencies: { + ForgotPasswordForm: ForgotPasswordFormMock + } + }); + + await act(() => { + ForgotPasswordFormMock.mock.calls[0][0].onSubmit({ email: "test@example.com" }); + }); + + await waitFor(() => { + expect(authService.sendPasswordResetEmail).toHaveBeenCalledWith({ email: "test@example.com", captchaToken: "test-captcha-token" }); + }); + }); + }); + function setup(input: { searchParams?: { tab?: "login" | "signup" | "forgot-password"; @@ -214,7 +264,13 @@ describe(AuthPage.name, () => { dependencies?: Partial; }) { const authService = mock(); - const router = mock(); + let setRouterPageParams: (params: URLSearchParams) => void = () => {}; + const router = mock({ + replace: jest.fn(url => { + setRouterPageParams?.(new URL(url as string, "http://localunittest:8080").searchParams); + return Promise.resolve(true); + }) + }); const checkSession = jest.fn(async () => undefined); const useUserMock: typeof DEPENDENCIES.useUser = () => ({ checkSession, @@ -231,7 +287,20 @@ describe(AuthPage.name, () => { if (input.searchParams?.returnTo) { params.set("returnTo", input.searchParams.returnTo); } - const useSearchParamsMock = () => params as ReadonlyURLSearchParams; + const useSearchParamsMock = () => { + const [pageParams, setPageParams] = useState(params); + setRouterPageParams = setPageParams; + return pageParams as ReadonlyURLSearchParams; + }; + + const TurnstileMock = jest.fn(({ turnstileRef }: { turnstileRef?: RefObject }) => { + if (turnstileRef) { + (turnstileRef as { current: TurnstileRef }).current = { + renderAndWaitResponse: jest.fn().mockResolvedValue({ token: "test-captcha-token" }) + }; + } + return null; + }); render( authService }}> @@ -241,6 +310,7 @@ describe(AuthPage.name, () => { useUser: useUserMock, useSearchParams: useSearchParamsMock, useRouter: () => router, + Turnstile: TurnstileMock, ...input.dependencies }} /> diff --git a/apps/deploy-web/src/components/auth/AuthPage/AuthPage.tsx b/apps/deploy-web/src/components/auth/AuthPage/AuthPage.tsx index 57d1832f12..069aa366e6 100644 --- a/apps/deploy-web/src/components/auth/AuthPage/AuthPage.tsx +++ b/apps/deploy-web/src/components/auth/AuthPage/AuthPage.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { Separator, Tabs, TabsContent, TabsList, TabsTrigger } from "@akashnetwork/ui/components"; import { useMutation } from "@tanstack/react-query"; import { DollarSignIcon, RocketIcon, ZapIcon } from "lucide-react"; @@ -10,6 +10,8 @@ import { NextSeo } from "next-seo"; import { AkashConsoleLogo } from "@src/components/icons/AkashConsoleLogo"; import { RemoteApiError } from "@src/components/shared/RemoteApiError/RemoteApiError"; +import type { TurnstileRef } from "@src/components/turnstile/Turnstile"; +import { ClientOnlyTurnstile } from "@src/components/turnstile/Turnstile"; import { useServices } from "@src/context/ServicesProvider"; import { useUser } from "@src/hooks/useUser"; import { AuthLayout } from "../AuthLayout/AuthLayout"; @@ -28,10 +30,16 @@ export const DEPENDENCIES = { SignUpForm, RemoteApiError, ForgotPasswordForm, + Turnstile: ClientOnlyTurnstile, Tabs, TabsContent, TabsTrigger, TabsList, + DollarSignIcon, + RocketIcon, + ZapIcon, + AkashConsoleLogo, + Separator, useUser, useSearchParams, useRouter @@ -42,11 +50,12 @@ interface Props { } export function AuthPage({ dependencies: d = DEPENDENCIES }: Props = {}) { - const { authService } = useServices(); + const { authService, publicConfig } = useServices(); const router = d.useRouter(); const searchParams = d.useSearchParams(); const { checkSession } = d.useUser(); const [email, setEmail] = useState(""); + const turnstileRef = useRef(null); const redirectToSocialLogin = useCallback( async (provider: "github" | "google-oauth2") => { @@ -57,11 +66,17 @@ export function AuthPage({ dependencies: d = DEPENDENCIES }: Props = {}) { ); const signInOrSignUp = useMutation({ async mutationFn(input: Tagged<"signin", SignInFormValues> | Tagged<"signup", SignUpFormValues>) { + if (!turnstileRef.current) { + throw new Error("Captcha has not been rendered"); + } const returnUrl = searchParams.get("from") || searchParams.get("returnTo") || "/"; + + const { token: captchaToken } = await turnstileRef.current.renderAndWaitResponse(); + if (input.type === "signin") { - await authService.login(input.value); + await authService.login({ ...input.value, captchaToken }); } else { - await authService.signup(input.value); + await authService.signup({ ...input.value, captchaToken }); } await checkSession(); router.push(returnUrl); @@ -74,17 +89,24 @@ export function AuthPage({ dependencies: d = DEPENDENCIES }: Props = {}) { const tabId = value !== "login" && value !== "signup" && value !== "forgot-password" ? "login" : value; const newSearchParams = new URLSearchParams(searchParams); newSearchParams.set("tab", tabId); - signInOrSignUp.reset(); - forgotPassword.reset(); + resetMutations(); router.replace(`?${newSearchParams.toString()}`, undefined, { shallow: true }); }, [searchParams, router] ); const forgotPassword = useMutation({ async mutationFn(input: { email: string }) { - await authService.sendPasswordResetEmail({ email: input.email }); + if (!turnstileRef.current) { + throw new Error("Captcha has not been rendered"); + } + const { token: captchaToken } = await turnstileRef.current.renderAndWaitResponse(); + await authService.sendPasswordResetEmail({ email: input.email, captchaToken }); } }); + const resetMutations = useCallback(() => { + signInOrSignUp.reset(); + forgotPassword.reset(); + }, [signInOrSignUp, forgotPassword]); return ( The fastest way to deploy an application on Akash.Network

- +
Generous Free Trial
@@ -103,7 +125,7 @@ export function AuthPage({ dependencies: d = DEPENDENCIES }: Props = {}) {
- +
Optimized for AI/ML
@@ -112,7 +134,7 @@ export function AuthPage({ dependencies: d = DEPENDENCIES }: Props = {}) {
- +
Cost Savings
@@ -126,7 +148,7 @@ export function AuthPage({ dependencies: d = DEPENDENCIES }: Props = {}) {
- +

{(activeView === "forgot-password" && "Reset your password") || (
@@ -142,9 +164,10 @@ export function AuthPage({ dependencies: d = DEPENDENCIES }: Props = {}) {

-
+
{(activeView === "forgot-password" && ( <> + test me here?
- +
Or continue with
@@ -210,6 +233,12 @@ export function AuthPage({ dependencies: d = DEPENDENCIES }: Props = {}) { )} +
diff --git a/apps/deploy-web/src/components/auth/SignInForm/SignInForm.tsx b/apps/deploy-web/src/components/auth/SignInForm/SignInForm.tsx index c176b5b1b2..f00e2639ce 100644 --- a/apps/deploy-web/src/components/auth/SignInForm/SignInForm.tsx +++ b/apps/deploy-web/src/components/auth/SignInForm/SignInForm.tsx @@ -87,7 +87,13 @@ export function SignInForm({ dependencies: d = DEPENDENCIES, ...props }: Props) <>
Password
- + Forgot password?
diff --git a/apps/deploy-web/src/components/billing-usage/TrendIndicator/TrendIndicator.spec.tsx b/apps/deploy-web/src/components/billing-usage/TrendIndicator/TrendIndicator.spec.tsx index 930e73cca2..f5f097b79c 100644 --- a/apps/deploy-web/src/components/billing-usage/TrendIndicator/TrendIndicator.spec.tsx +++ b/apps/deploy-web/src/components/billing-usage/TrendIndicator/TrendIndicator.spec.tsx @@ -115,8 +115,8 @@ describe(TrendIndicator.name, () => { function setup(props: TrendIndicatorProps<"totalUsdSpent", UsageHistory>) { const defaultProps: TrendIndicatorProps<"totalUsdSpent", UsageHistory> = { components: { - GraphUp: () =>
Graph Up
, - GraphDown: () =>
Graph Down
+ GraphUp: (() => Graph Up) as unknown as Required>["components"]["GraphUp"], + GraphDown: (() => Graph Down) as unknown as Required>["components"]["GraphDown"] }, isFetching: props.isFetching ?? false, data: props.data ?? [ diff --git a/apps/deploy-web/src/components/turnstile/Turnstile.spec.tsx b/apps/deploy-web/src/components/turnstile/Turnstile.spec.tsx index 9249522cc0..38c0608ca3 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 { type TurnstileProps } from "@marsidev/react-turnstile"; import { mock } from "jest-mock-extended"; import { setTimeout as wait } from "node:timers/promises"; +import type { TurnstileRef } from "./Turnstile"; import { COMPONENTS, Turnstile } from "./Turnstile"; import { fireEvent, render, screen } from "@testing-library/react"; @@ -17,20 +18,6 @@ describe(Turnstile.name, () => { 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 }); @@ -72,22 +59,24 @@ describe(Turnstile.name, () => { )) } }); - fireEvent.click(screen.getByRole("button", { name: "Retry" })); + fireEvent.click(screen.getAllByRole("button")[0]); expect(turnstileInstance.remove).toHaveBeenCalled(); expect(turnstileInstance.render).toHaveBeenCalled(); expect(turnstileInstance.execute).toHaveBeenCalled(); }); - it('removes actual widget on "Dismiss" button click', async () => { + it('removes actual widget on "Go Back" button click', async () => { const turnstileInstance = mock(); const ReactTurnstile = forwardRef((props, ref) => { useForwardedRef(ref, turnstileInstance); return
Turnstile
; }); + const onDismissed = jest.fn(); await setup({ enabled: true, + onDismissed, components: { ReactTurnstile, Button: forwardRef((props, ref) => ( @@ -97,95 +86,81 @@ describe(Turnstile.name, () => { )) } }); - fireEvent.click(screen.getByRole("button", { name: "Dismiss" })); + fireEvent.click(screen.getAllByRole("button")[1]); expect(turnstileInstance.remove).toHaveBeenCalled(); expect(turnstileInstance.render).not.toHaveBeenCalled(); expect(turnstileInstance.execute).not.toHaveBeenCalled(); + expect(onDismissed).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" }) - }); + describe("renderAndWaitResponse", () => { + it("resolves with token when challenge is solved", async () => { + const turnstileInstance = mock(); + let triggerSuccess: ((token: string) => void) | undefined; + const ReactTurnstile = forwardRef((props, ref) => { + useForwardedRef(ref, turnstileInstance); + triggerSuccess = (token: string) => props.onSuccess?.(token); + return
Turnstile
; + }); - amountOfCalls++; + const { turnstileRef } = await setup({ + enabled: true, + components: { ReactTurnstile } + }); - return response; + const promise = turnstileRef.current!.renderAndWaitResponse(); + await act(async () => { + triggerSuccess?.("test-token"); + await wait(0); }); - }); - afterEach(() => { - globalThis.fetch = originalFetch; + await expect(promise).resolves.toEqual({ token: "test-token" }); + expect(turnstileInstance.remove).toHaveBeenCalled(); + expect(turnstileInstance.render).toHaveBeenCalled(); + expect(turnstileInstance.execute).toHaveBeenCalled(); }); - it("renders turnstile widget", async () => { + it("rejects with error when challenge fails", async () => { const turnstileInstance = mock(); + let triggerError: ((error: string) => void) | undefined; const ReactTurnstile = forwardRef((props, ref) => { useForwardedRef(ref, turnstileInstance); + triggerError = (error: string) => props.onError?.(error); 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({ + const { turnstileRef } = await setup({ enabled: true, - components: { - Button: forwardRef((props, ref) => ( - - )) - } + components: { ReactTurnstile } }); - 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") + let rejection: unknown; + const promise = turnstileRef.current!.renderAndWaitResponse().catch(error => { + rejection = error; }); - const ReactTurnstile = forwardRef((props, ref) => { - useForwardedRef(ref, turnstileInstance); - return
Turnstile
; + await act(async () => { + triggerError?.("test-error"); + await wait(0); }); + await promise; - await setup({ enabled: true, components: { ReactTurnstile } }); - await fetch("/"); - - expect(fetchMock).toHaveBeenCalledTimes(2); + expect(rejection).toMatchObject({ + reason: "error", + error: "test-error" + }); }); }); - async function setup(input?: { enabled?: boolean; siteKey?: string; components?: Partial }) { + async function setup(input?: { enabled?: boolean; siteKey?: string; onDismissed?: () => void; components?: Partial }) { + const turnstileRef = { current: null as TurnstileRef | null }; + const result = render( ((_, ref) => { useForwardedRef(ref); @@ -197,7 +172,7 @@ describe(Turnstile.name, () => { ); await act(() => wait(0)); - return result; + return { ...result, turnstileRef }; } function useForwardedRef(ref: React.ForwardedRef, instance: T = mock()) { diff --git a/apps/deploy-web/src/components/turnstile/Turnstile.tsx b/apps/deploy-web/src/components/turnstile/Turnstile.tsx index 6208570ef1..b4b6d4ac25 100644 --- a/apps/deploy-web/src/components/turnstile/Turnstile.tsx +++ b/apps/deploy-web/src/components/turnstile/Turnstile.tsx @@ -1,17 +1,17 @@ "use client"; -import type { FC } from "react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { RefObject } from "react"; +import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from "react"; 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 { motion } from "framer-motion"; +import { RefreshCwIcon, Undo2 } from "lucide-react"; import dynamic from "next/dynamic"; import { useWhen } from "@src/hooks/useWhen"; - -let originalFetch: typeof fetch | undefined; +import { getInjectedConfig } from "@src/utils/getInjectedConfig/getInjectedConfig"; type TurnstileStatus = "uninitialized" | "solved" | "interactive" | "expired" | "error" | "dismissed"; @@ -23,68 +23,71 @@ export const COMPONENTS = { MdInfo }; +export type TurnstileRef = { + renderAndWaitResponse: () => Promise<{ token: string }>; +}; + type TurnstileProps = { enabled: boolean; siteKey: string; + onDismissed?: () => void; + turnstileRef?: RefObject; components?: typeof COMPONENTS; }; -export const Turnstile: FC = ({ enabled, siteKey, components: c = COMPONENTS }) => { +export const Turnstile = forwardRef(function Turnstile( + { enabled, siteKey, onDismissed, turnstileRef: externalTurnstileRef, components: c = COMPONENTS }, + ref +) { const turnstileRef = useRef(); const [status, setStatus] = useState("uninitialized"); const isVisible = useMemo(() => enabled && VISIBILITY_STATUSES.includes(status), [enabled, status]); - const abortControllerRef = useRef(); + const eventBus = useRef(new EventTarget()); + const injectedConfig = getInjectedConfig(); const resetWidget = useCallback(() => { turnstileRef.current?.remove(); turnstileRef.current?.render(); turnstileRef.current?.execute(); }, []); + const hideWidget = useCallback(() => { + setStatus("dismissed"); + onDismissed?.(); + }, [onDismissed]); - const renderTurnstileAndWaitForResponse = useCallback(async () => { - abortControllerRef.current = new AbortController(); + useWhen(status === "error" || status === "expired", () => { resetWidget(); + }); + useWhen(status === "dismissed", () => { + turnstileRef.current?.remove(); + }); - return Promise.race([ - turnstileRef.current?.getResponsePromise(), - new Promise(resolve => abortControllerRef.current?.signal.addEventListener("abort", () => resolve())) - ]); - }, [resetWidget]); - - useEffect(() => { - if (!enabled) { - if (typeof originalFetch === "function") { - globalThis.fetch = originalFetch; - originalFetch = undefined; - } - return; - } - - 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; - }; - } - - return () => { - if (typeof originalFetch === "function") { - globalThis.fetch = originalFetch; - originalFetch = undefined; + useImperativeHandle( + ref || externalTurnstileRef, + () => ({ + renderAndWaitResponse() { + resetWidget(); + return new Promise((resolve, reject) => { + eventBus.current.addEventListener( + "success", + event => { + resolve((event as CustomEvent<{ token: string }>).detail); + }, + { once: true } + ); + eventBus.current.addEventListener( + "error", + event => { + const details = (event as CustomEvent<{ reason: string; error?: string }>).detail; + reject({ status, ...details }); + }, + { once: true } + ); + }); } - }; - }, [enabled]); - - useWhen(status === "error", () => { - resetWidget(); - }); + }), + [resetWidget] + ); if (!enabled) { return null; @@ -93,7 +96,7 @@ export const Turnstile: FC = ({ enabled, siteKey, components: c return ( <> = ({ enabled, siteKey, components: c delay: isVisible ? 0 : status === "dismissed" ? 0 : 1 }} > -
-

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

-

Reviewing the security of your connection before proceeding

-
+
+
+

We are verifying you are a human. This may take a moment

+

Reviewing the security of your connection before proceeding

+
+
setStatus("error")} - onExpire={() => setStatus("expired")} - onSuccess={() => setStatus("solved")} + onError={error => { + setStatus("error"); + eventBus.current.dispatchEvent(new CustomEvent("error", { detail: { error, reason: "error" } })); + }} + onExpire={() => { + setStatus("expired"); + eventBus.current.dispatchEvent(new CustomEvent("expired", { detail: { reason: "expired" } })); + }} + onSuccess={token => { + setStatus("solved"); + eventBus.current.dispatchEvent(new CustomEvent("success", { detail: { token } })); + }} onBeforeInteractive={() => setStatus("interactive")} /> + +
+ + + + + + +
+
{status === "error" &&

Some error occurred

} - -
- - Retry - - { - setStatus("dismissed"); - abortControllerRef.current?.abort(); - turnstileRef.current?.remove(); - }} - variant="link" - > - Dismiss - -
- -

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

-
); -}; +}); -export const ClientOnlyTurnstile = dynamic(() => Promise.resolve(Turnstile), { ssr: false }); +export const ClientOnlyTurnstile = dynamic(async () => Turnstile, { ssr: false }); diff --git a/apps/deploy-web/src/config/env-config.schema.ts b/apps/deploy-web/src/config/env-config.schema.ts index 36242f235b..b320fbb902 100644 --- a/apps/deploy-web/src/config/env-config.schema.ts +++ b/apps/deploy-web/src/config/env-config.schema.ts @@ -64,7 +64,15 @@ export const serverEnvSchema = browserEnvSchema.extend({ NEXT_PUBLIC_UNLEASH_ENABLE_ALL: coercedBoolean().optional().default("false"), NEXT_PUBLIC_MANAGED_WALLET_NETWORK_ID: networkId.optional().default("mainnet"), NEXT_PUBLIC_DEFAULT_NETWORK_ID: networkId.optional().default("mainnet"), - NODE_ENV: z.enum(["development", "production", "test"]).optional().default("development") + NODE_ENV: z.enum(["development", "production", "test"]).optional().default("development"), + TURNSTILE_SECRET_KEY: z.string(), + /** + * @see https://developers.cloudflare.com/turnstile/troubleshooting/testing/#test-secret-keys + */ + TURNSTILE_BYPASS_SECRET_KEY: z.string().default("1x0000000000000000000000000000000AA"), + E2E_TESTING_CLIENT_TOKEN: z.string({ + required_error: "This token is used to adjust configuration of the app for e2e testing. Can be any random string." + }) }); export type BrowserEnvConfig = z.infer; diff --git a/apps/deploy-web/src/hooks/useInjectedConfig.spec.ts b/apps/deploy-web/src/hooks/useInjectedConfig.spec.ts deleted file mode 100644 index e9d4231063..0000000000 --- a/apps/deploy-web/src/hooks/useInjectedConfig.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { setTimeout as wait } from "node:timers/promises"; - -import { useInjectedConfig } from "./useInjectedConfig"; - -import { act, renderHook } from "@testing-library/react"; - -describe(useInjectedConfig.name, () => { - it("returns null config and isLoaded=true when no injected config exists", () => { - const hasConfig = jest.fn().mockReturnValue(false); - const decodeConfig = jest.fn().mockResolvedValue(null); - const { result } = renderHook(() => - useInjectedConfig({ - hasConfig, - decodeConfig - }) - ); - - expect(result.current).toEqual({ config: null, isLoaded: true }); - expect(decodeConfig).not.toHaveBeenCalled(); - }); - - it("returns decoded config and isLoaded=true when injected config exists", async () => { - const mockConfig = { NEXT_PUBLIC_TURNSTILE_ENABLED: true }; - const { result } = renderHook(() => - useInjectedConfig({ - hasConfig: () => true, - decodeConfig: () => Promise.resolve(mockConfig) - }) - ); - expect(result.current).toEqual({ config: null, isLoaded: false }); - - await act(() => wait(0)); - - expect(result.current).toEqual({ config: mockConfig, isLoaded: true }); - }); -}); diff --git a/apps/deploy-web/src/hooks/useInjectedConfig.ts b/apps/deploy-web/src/hooks/useInjectedConfig.ts deleted file mode 100644 index 661738b238..0000000000 --- a/apps/deploy-web/src/hooks/useInjectedConfig.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useEffect, useState } from "react"; - -import type { BrowserEnvConfig } from "@src/config/env-config.schema"; -import { decodeInjectedConfig, hasInjectedConfig } from "@src/services/decodeInjectedConfig/decodeInjectedConfig"; - -/** - * This hook is used to get the injected and verified config from the window object. - */ -export function useInjectedConfig({ - decodeConfig = decodeInjectedConfig, - hasConfig = hasInjectedConfig -}: InjectedConfigHookProps = {}): InjectedConfigHookResult { - const [isLoaded, setIsLoaded] = useState(false); - const [config, setConfig] = useState | null>(null); - - useEffect(() => { - if (hasConfig()) { - decodeConfig() - .then(setConfig) - .finally(() => setIsLoaded(true)); - } else { - setIsLoaded(true); - } - }, []); - - return { config, isLoaded }; -} - -/** - * Cannot use DI in this hook directly because we need to use it at the very beginning of the app - */ -export interface InjectedConfigHookProps { - decodeConfig?: typeof decodeInjectedConfig; - hasConfig?: typeof hasInjectedConfig; -} - -export interface InjectedConfigHookResult { - config: Partial | null; - isLoaded: boolean; -} diff --git a/apps/deploy-web/src/lib/nextjs/defineApiHandler/defineApiHandler.ts b/apps/deploy-web/src/lib/nextjs/defineApiHandler/defineApiHandler.ts index fede5c542b..0f7a84160d 100644 --- a/apps/deploy-web/src/lib/nextjs/defineApiHandler/defineApiHandler.ts +++ b/apps/deploy-web/src/lib/nextjs/defineApiHandler/defineApiHandler.ts @@ -15,6 +15,10 @@ export function defineApiHandler | u ): NextApiHandler { return wrapApiHandlerWithSentry( (async (req, res) => { + if (options.method && req.method !== options.method) { + return res.status(405).json({ message: "Method not allowed" } as TResponse); + } + const requestServices = (req as NextApiRequestWithServices)[REQ_SERVICES_KEY] || services; const session = await requestServices.getSession(req, res); @@ -57,6 +61,7 @@ export interface ApiHandlerOptions | * The parametrized route of the API handler. */ route: string; + method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS" | "HEAD"; schema?: TSchema; handler(context: ApiHandlerContext): Promise | void; } diff --git a/apps/deploy-web/src/lib/nextjs/pages/api/proxy/path.spec.ts b/apps/deploy-web/src/lib/nextjs/pages/api/proxy/path.spec.ts index 623e8d7e56..4381a82e14 100644 --- a/apps/deploy-web/src/lib/nextjs/pages/api/proxy/path.spec.ts +++ b/apps/deploy-web/src/lib/nextjs/pages/api/proxy/path.spec.ts @@ -30,7 +30,7 @@ describe("proxy API handler", () => { const mockGetSession = jest.fn(); const mockUserTracker = mock(); const mockApiUrlService = mock(); - const mockConfig = mock(); + const mockConfig = mock(); const mockProxy = { once: jest.fn().mockReturnThis(), @@ -74,9 +74,9 @@ describe("proxy API handler", () => { getSession: mockGetSession, httpProxy: mockHttpProxy, apiUrlService: mockApiUrlService, - config: mockConfig, + privateConfig: mockConfig, userTracker: mockUserTracker - } as unknown as typeof services; + } satisfies typeof services; const handlerPromise = proxyHandler(req, res); diff --git a/apps/deploy-web/src/middleware/verify-captcha/verify-captcha.spec.ts b/apps/deploy-web/src/middleware/verify-captcha/verify-captcha.spec.ts new file mode 100644 index 0000000000..f92971ba62 --- /dev/null +++ b/apps/deploy-web/src/middleware/verify-captcha/verify-captcha.spec.ts @@ -0,0 +1,186 @@ +import type { LoggerService } from "@akashnetwork/logging"; +import { mock } from "jest-mock-extended"; +import type { GetServerSidePropsContext } from "next"; +import { Err, Ok } from "ts-results"; + +import type { AppTypedContext } from "@src/lib/nextjs/defineServerSideProps/defineServerSideProps"; +import type { TurnstileVerifierService } from "@src/services/turnstile-verifier/turnstile-verifier.service"; +import { verifyCaptcha } from "./verify-captcha"; + +describe(verifyCaptcha.name, () => { + it("returns Ok when turnstile is disabled", async () => { + const { result } = await setup({ + turnstileEnabled: false, + captchaToken: "test-token" + }); + + expect(result.ok).toBe(true); + }); + + it("returns Ok when captcha verification succeeds", async () => { + const { result, captchaVerifier } = await setup({ + turnstileEnabled: true, + captchaToken: "valid-token", + verificationResult: Ok(undefined) + }); + + expect(result.ok).toBe(true); + expect(captchaVerifier.verify).toHaveBeenCalledWith("valid-token", { + remoteIp: "127.0.0.1", + bypassVerificationToken: undefined + }); + }); + + it("returns Err when captcha verification fails", async () => { + const { result, logger } = await setup({ + turnstileEnabled: true, + captchaToken: "invalid-token", + verificationResult: Err({ + code: "verification_failed" as const, + errorCodes: ["invalid-input-response" as const] + }) + }); + + expect(result.err).toBe(true); + if (result.err) { + expect(result.val).toEqual({ + code: "captcha_verification_failed", + message: "Captcha verification failed. Please try again." + }); + } + expect(logger.warn).toHaveBeenCalledWith({ + event: "CAPTCHA_VERIFICATION_FAILED", + cause: { + code: "verification_failed", + errorCodes: ["invalid-input-response"] + } + }); + }); + + it("passes bypass verification token from headers", async () => { + const { captchaVerifier } = await setup({ + turnstileEnabled: true, + captchaToken: "test-token", + verificationResult: Ok(undefined), + headers: { + "x-testing-client-token": "bypass-token" + } + }); + + expect(captchaVerifier.verify).toHaveBeenCalledWith("test-token", { + remoteIp: "127.0.0.1", + bypassVerificationToken: "bypass-token" + }); + }); + + describe("when extracting remote IP", () => { + it("uses cf-connecting-ip header when available", async () => { + const { captchaVerifier } = await setup({ + turnstileEnabled: true, + captchaToken: "test-token", + verificationResult: Ok(undefined), + headers: { + "cf-connecting-ip": "1.2.3.4", + "x-real-ip": "5.6.7.8", + "x-forwarded-for": "9.10.11.12" + } + }); + + expect(captchaVerifier.verify).toHaveBeenCalledWith("test-token", { + remoteIp: "1.2.3.4", + bypassVerificationToken: undefined + }); + }); + + it("uses x-real-ip header when cf-connecting-ip is not available", async () => { + const { captchaVerifier } = await setup({ + turnstileEnabled: true, + captchaToken: "test-token", + verificationResult: Ok(undefined), + headers: { + "x-real-ip": "5.6.7.8", + "x-forwarded-for": "9.10.11.12" + } + }); + + expect(captchaVerifier.verify).toHaveBeenCalledWith("test-token", { + remoteIp: "5.6.7.8", + bypassVerificationToken: undefined + }); + }); + + it("uses first IP from x-forwarded-for header when other headers are not available", async () => { + const { captchaVerifier } = await setup({ + turnstileEnabled: true, + captchaToken: "test-token", + verificationResult: Ok(undefined), + headers: { + "x-forwarded-for": "9.10.11.12, 13.14.15.16" + } + }); + + expect(captchaVerifier.verify).toHaveBeenCalledWith("test-token", { + remoteIp: "9.10.11.12", + bypassVerificationToken: undefined + }); + }); + + it("uses socket remoteAddress when no headers are available", async () => { + const { captchaVerifier } = await setup({ + turnstileEnabled: true, + captchaToken: "test-token", + verificationResult: Ok(undefined), + headers: {}, + socketRemoteAddress: "192.168.1.1" + }); + + expect(captchaVerifier.verify).toHaveBeenCalledWith("test-token", { + remoteIp: "192.168.1.1", + bypassVerificationToken: undefined + }); + }); + }); + + async function setup(input: { + turnstileEnabled: boolean; + captchaToken: string; + verificationResult?: Awaited>; + headers?: Record; + socketRemoteAddress?: string; + }) { + const captchaVerifier = mock(); + const logger = mock(); + + captchaVerifier.verify.mockResolvedValue(input.verificationResult ?? Ok(undefined)); + + const headers = input.headers ?? { + "x-forwarded-for": "127.0.0.1" + }; + const req = { + headers, + socket: { + remoteAddress: input.socketRemoteAddress ?? "127.0.0.1" + } + } as GetServerSidePropsContext["req"]; + + const context = { + req, + services: mock({ + publicConfig: { + NEXT_PUBLIC_TURNSTILE_ENABLED: input.turnstileEnabled + } as AppTypedContext["services"]["publicConfig"], + captchaVerifier, + logger + }) + }; + + const result = await verifyCaptcha(input.captchaToken, context); + + return { + result, + captchaVerifier, + logger, + req + }; + } +}); diff --git a/apps/deploy-web/src/middleware/verify-captcha/verify-captcha.ts b/apps/deploy-web/src/middleware/verify-captcha/verify-captcha.ts new file mode 100644 index 0000000000..372d3dfe08 --- /dev/null +++ b/apps/deploy-web/src/middleware/verify-captcha/verify-captcha.ts @@ -0,0 +1,36 @@ +import type { IncomingMessage } from "http"; +import { Err, Ok } from "ts-results"; + +import type { AppTypedContext } from "@src/lib/nextjs/defineServerSideProps/defineServerSideProps"; + +export async function verifyCaptcha(captchaToken: string, { services, req }: Pick) { + if (!services.publicConfig.NEXT_PUBLIC_TURNSTILE_ENABLED) { + return Ok(undefined); + } + + const remoteIp = getRemoteIp(req); + const captchaVerification = await services.captchaVerifier.verify(captchaToken, { + remoteIp, + bypassVerificationToken: req.headers["x-testing-client-token"] as string | undefined + }); + if (captchaVerification.err) { + services.logger.warn({ + event: "CAPTCHA_VERIFICATION_FAILED", + cause: captchaVerification.val + }); + + return Err({ + code: "captcha_verification_failed", + message: "Captcha verification failed. Please try again." + }); + } + + return Ok(undefined); +} + +function getRemoteIp(req: IncomingMessage) { + if (req.headers["cf-connecting-ip"]) return req.headers["cf-connecting-ip"] as string; + if (req.headers["x-real-ip"]) return req.headers["x-real-ip"] as string; + if (typeof req.headers["x-forwarded-for"] === "string") return req.headers["x-forwarded-for"].split(",")[0]?.trim(); + return req.socket?.remoteAddress; +} diff --git a/apps/deploy-web/src/pages/_app.tsx b/apps/deploy-web/src/pages/_app.tsx index 9142431406..cb4bf9cbbd 100644 --- a/apps/deploy-web/src/pages/_app.tsx +++ b/apps/deploy-web/src/pages/_app.tsx @@ -19,11 +19,8 @@ import NProgress from "nprogress"; import GoogleAnalytics from "@src/components/layout/CustomGoogleAnalytics"; import { CustomIntlProvider } from "@src/components/layout/CustomIntlProvider"; -import { Loading } from "@src/components/layout/Layout"; import { PageHead } from "@src/components/layout/PageHead"; -import { ClientOnlyTurnstile } from "@src/components/turnstile/Turnstile"; import { UserProviders } from "@src/components/user/UserProviders/UserProviders"; -import { browserEnvConfig } from "@src/config/browser-env.config"; import { CertificateProvider } from "@src/context/CertificateProvider"; import { CustomChainProvider } from "@src/context/CustomChainProvider"; import { ColorModeProvider } from "@src/context/CustomThemeContext"; @@ -34,7 +31,6 @@ import { ServicesProvider } from "@src/context/ServicesProvider"; import { RootContainerProvider, useRootContainer } from "@src/context/ServicesProvider/RootContainerProvider"; import { SettingsProvider } from "@src/context/SettingsProvider"; import { WalletProvider } from "@src/context/WalletProvider"; -import { useInjectedConfig } from "@src/hooks/useInjectedConfig"; import { store } from "@src/store/global-store"; interface Props extends AppProps { @@ -52,24 +48,10 @@ Router.events.on("routeChangeError", () => NProgress.done()); const App: React.FunctionComponent = props => { const { Component, pageProps } = props; - const { config, isLoaded: isLoadedInjectedConfig } = useInjectedConfig(); - - if (!isLoadedInjectedConfig) { - return ( - - - - ); - } return ( <> - - diff --git a/apps/deploy-web/src/pages/api/auth/[...auth0].ts b/apps/deploy-web/src/pages/api/auth/[...auth0].ts index 310e831d54..6c751d3b31 100644 --- a/apps/deploy-web/src/pages/api/auth/[...auth0].ts +++ b/apps/deploy-web/src/pages/api/auth/[...auth0].ts @@ -24,8 +24,8 @@ const authHandler = once((services: AppServices) => handleAuth({ async login(req: NextApiRequest, res: NextApiResponse) { const returnUrl = decodeURIComponent((req.query.from as string) ?? "/"); - if (services.config.AUTH0_LOCAL_ENABLED && services.config.AUTH0_REDIRECT_BASE_URL) { - rewriteLocalRedirect(res, services.config); + if (services.privateConfig.AUTH0_LOCAL_ENABLED && services.privateConfig.AUTH0_REDIRECT_BASE_URL) { + rewriteLocalRedirect(res, services.privateConfig); } await handleLogin(req, res, { @@ -44,7 +44,7 @@ const authHandler = once((services: AppServices) => try { const userSettings = await services.sessionService.createLocalUser(session); session.user = { ...session.user, ...userSettings }; - const isSecure = services.config.NODE_ENV === "production"; + const isSecure = services.privateConfig.NODE_ENV === "production"; res.setHeader( "Set-Cookie", `${ANONYMOUS_HEADER_COOKIE_NAME}=; Path=/api/auth/callback; HttpOnly; ${isSecure ? "Secure;" : ""} SameSite=Lax; Expires=Thu, 01 Jan 1970 00:00:00 GMT` @@ -61,7 +61,7 @@ const authHandler = once((services: AppServices) => throw error; } }, - logout: services.config.AUTH0_LOCAL_ENABLED + logout: services.privateConfig.AUTH0_LOCAL_ENABLED ? async function (req: NextApiRequest, res: NextApiResponse) { const cookies = req.cookies; const expiredCookies = Object.keys(cookies) diff --git a/apps/deploy-web/src/pages/api/auth/password-login.ts b/apps/deploy-web/src/pages/api/auth/password-login.ts index ce4ef22e1e..17dea9df82 100644 --- a/apps/deploy-web/src/pages/api/auth/password-login.ts +++ b/apps/deploy-web/src/pages/api/auth/password-login.ts @@ -2,16 +2,24 @@ import { z } from "zod"; import { setSession } from "@src/lib/auth0/setSession/setSession"; import { defineApiHandler } from "@src/lib/nextjs/defineApiHandler/defineApiHandler"; +import { verifyCaptcha } from "@src/middleware/verify-captcha/verify-captcha"; export default defineApiHandler({ route: "/api/auth/password-login", + method: "POST", schema: z.object({ body: z.object({ email: z.string().email(), - password: z.string() + password: z.string(), + captchaToken: z.string() }) }), - async handler({ res, req, services }) { + async handler(ctx) { + const { res, req, services, body } = ctx; + + const verification = await verifyCaptcha(body.captchaToken, ctx); + if (verification.err) return res.status(400).json(verification.val); + const result = await services.sessionService.signIn({ email: req.body.email, password: req.body.password diff --git a/apps/deploy-web/src/pages/api/auth/password-signup.ts b/apps/deploy-web/src/pages/api/auth/password-signup.ts index b0e19ca476..74e25026ca 100644 --- a/apps/deploy-web/src/pages/api/auth/password-signup.ts +++ b/apps/deploy-web/src/pages/api/auth/password-signup.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { setSession } from "@src/lib/auth0/setSession/setSession"; import { defineApiHandler } from "@src/lib/nextjs/defineApiHandler/defineApiHandler"; +import { verifyCaptcha } from "@src/middleware/verify-captcha/verify-captcha"; const LOWER_LETTER_REGEX = /\p{Ll}/u; const UPPER_LETTER_REGEX = /\p{Lu}/u; @@ -10,6 +11,7 @@ const SPECIAL_CHAR_REGEX = /[^\p{L}\p{N}]/u; export default defineApiHandler({ route: "/api/auth/password-signup", + method: "POST", schema: z.object({ body: z.object({ email: z.string().email(), @@ -30,10 +32,16 @@ export default defineApiHandler({ ), termsAndConditions: z.boolean().refine(value => value, { message: "Please accept the terms and conditions" - }) + }), + captchaToken: z.string() }) }), - async handler({ res, req, services }) { + async handler(ctx) { + const { res, req, services, body } = ctx; + + const verification = await verifyCaptcha(body.captchaToken, ctx); + if (verification.err) return res.status(400).json(verification.val); + const result = await services.sessionService.signUp({ email: req.body.email, password: req.body.password diff --git a/apps/deploy-web/src/pages/api/auth/send-password-reset-email.ts b/apps/deploy-web/src/pages/api/auth/send-password-reset-email.ts index af459e8e26..cf297e8e60 100644 --- a/apps/deploy-web/src/pages/api/auth/send-password-reset-email.ts +++ b/apps/deploy-web/src/pages/api/auth/send-password-reset-email.ts @@ -1,20 +1,24 @@ import { z } from "zod"; import { defineApiHandler } from "@src/lib/nextjs/defineApiHandler/defineApiHandler"; +import { verifyCaptcha } from "@src/middleware/verify-captcha/verify-captcha"; export default defineApiHandler({ route: "/api/auth/send-password-reset-email", + method: "POST", schema: z.object({ body: z.object({ - email: z.string().email() + email: z.string().email(), + captchaToken: z.string() }) }), - async handler({ res, req, services }) { - if (req.method !== "POST") { - return res.status(405).json({ message: "Method not allowed" }); - } + async handler(ctx) { + const { res, req, services, body } = ctx; try { + const verification = await verifyCaptcha(body.captchaToken, ctx); + if (verification.err) return res.status(400).json(verification.val); + const result = await services.sessionService.sendPasswordResetEmail({ email: req.body.email }); if (result.ok) { res.status(204).end(); diff --git a/apps/deploy-web/src/pages/api/auth/signup.ts b/apps/deploy-web/src/pages/api/auth/signup.ts index d9bedc885b..4d16c28e13 100644 --- a/apps/deploy-web/src/pages/api/auth/signup.ts +++ b/apps/deploy-web/src/pages/api/auth/signup.ts @@ -9,8 +9,8 @@ export default defineApiHandler({ route: "/api/auth/signup", async handler({ res, req, services }) { try { - if (services.config.AUTH0_LOCAL_ENABLED && services.config.AUTH0_REDIRECT_BASE_URL) { - rewriteLocalRedirect(res, services.config); + if (services.privateConfig.AUTH0_LOCAL_ENABLED && services.privateConfig.AUTH0_REDIRECT_BASE_URL) { + rewriteLocalRedirect(res, services.privateConfig); } const returnUrl = decodeURIComponent((req.query.returnTo as string) ?? "/"); @@ -20,7 +20,7 @@ export default defineApiHandler({ // then we set cookie and return 204 status, the actual call will be made by in-browser redirect if (token) { const lifetime = 5 * 60; // 5 minutes - const isSecure = services.config.NODE_ENV === "production"; + const isSecure = services.privateConfig.NODE_ENV === "production"; res.setHeader( "Set-Cookie", `${ANONYMOUS_HEADER_COOKIE_NAME}=${encodeURIComponent(token.replace(/^Bearer\s+/i, ""))}; HttpOnly; ${isSecure ? "Secure;" : ""} SameSite=Lax; Path=/api/auth/callback; Max-Age=${lifetime}` diff --git a/apps/deploy-web/src/pages/api/bitbucket/authenticate.ts b/apps/deploy-web/src/pages/api/bitbucket/authenticate.ts index d0dd4946ae..cc9cf5229a 100644 --- a/apps/deploy-web/src/pages/api/bitbucket/authenticate.ts +++ b/apps/deploy-web/src/pages/api/bitbucket/authenticate.ts @@ -11,7 +11,7 @@ export default defineApiHandler({ }) }), async handler({ body, res, services }) { - const { NEXT_PUBLIC_BITBUCKET_CLIENT_ID, BITBUCKET_CLIENT_SECRET } = services.config; + const { NEXT_PUBLIC_BITBUCKET_CLIENT_ID, BITBUCKET_CLIENT_SECRET } = services.privateConfig; const bitbucketAuth = new BitbucketAuth(NEXT_PUBLIC_BITBUCKET_CLIENT_ID!, BITBUCKET_CLIENT_SECRET, services.externalApiHttpClient); const tokens = await bitbucketAuth.exchangeAuthorizationCodeForTokens(body.code); diff --git a/apps/deploy-web/src/pages/api/bitbucket/refresh.ts b/apps/deploy-web/src/pages/api/bitbucket/refresh.ts index 137455c562..a68edbb042 100644 --- a/apps/deploy-web/src/pages/api/bitbucket/refresh.ts +++ b/apps/deploy-web/src/pages/api/bitbucket/refresh.ts @@ -11,7 +11,7 @@ export default defineApiHandler({ }) }), async handler({ body, res, services }) { - const { NEXT_PUBLIC_BITBUCKET_CLIENT_ID, BITBUCKET_CLIENT_SECRET } = services.config; + const { NEXT_PUBLIC_BITBUCKET_CLIENT_ID, BITBUCKET_CLIENT_SECRET } = services.privateConfig; const bitbucketAuth = new BitbucketAuth(NEXT_PUBLIC_BITBUCKET_CLIENT_ID!, BITBUCKET_CLIENT_SECRET, services.externalApiHttpClient); const tokens = await bitbucketAuth.refreshTokensUsingRefreshToken(body.refreshToken); diff --git a/apps/deploy-web/src/pages/api/github/authenticate.ts b/apps/deploy-web/src/pages/api/github/authenticate.ts index 07cfbdbd1c..20461452bb 100644 --- a/apps/deploy-web/src/pages/api/github/authenticate.ts +++ b/apps/deploy-web/src/pages/api/github/authenticate.ts @@ -11,7 +11,7 @@ export default defineApiHandler({ }) }), async handler({ body, res, services }) { - const { NEXT_PUBLIC_GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, NEXT_PUBLIC_REDIRECT_URI } = services.config; + const { NEXT_PUBLIC_GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, NEXT_PUBLIC_REDIRECT_URI } = services.privateConfig; const gitHubAuth = new GitHubAuth( NEXT_PUBLIC_GITHUB_CLIENT_ID as string, GITHUB_CLIENT_SECRET as string, diff --git a/apps/deploy-web/src/pages/api/gitlab/authenticate.ts b/apps/deploy-web/src/pages/api/gitlab/authenticate.ts index 9a2e2105c0..2f9729c2f4 100644 --- a/apps/deploy-web/src/pages/api/gitlab/authenticate.ts +++ b/apps/deploy-web/src/pages/api/gitlab/authenticate.ts @@ -11,7 +11,7 @@ export default defineApiHandler({ }) }), async handler({ body, res, services }) { - const { NEXT_PUBLIC_GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET, NEXT_PUBLIC_REDIRECT_URI } = services.config; + const { NEXT_PUBLIC_GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET, NEXT_PUBLIC_REDIRECT_URI } = services.privateConfig; const gitlabAuth = new GitlabAuth( NEXT_PUBLIC_GITLAB_CLIENT_ID as string, GITLAB_CLIENT_SECRET as string, diff --git a/apps/deploy-web/src/pages/api/gitlab/refresh.ts b/apps/deploy-web/src/pages/api/gitlab/refresh.ts index d12044bd6a..379145d6a4 100644 --- a/apps/deploy-web/src/pages/api/gitlab/refresh.ts +++ b/apps/deploy-web/src/pages/api/gitlab/refresh.ts @@ -11,7 +11,7 @@ export default defineApiHandler({ }) }), async handler({ body, res, services }) { - const { NEXT_PUBLIC_GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET } = services.config; + const { NEXT_PUBLIC_GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET } = services.privateConfig; const gitlabAuth = new GitlabAuth(NEXT_PUBLIC_GITLAB_CLIENT_ID as string, GITLAB_CLIENT_SECRET as string, undefined, services.externalApiHttpClient); const tokens = await gitlabAuth.refreshTokensUsingRefreshToken(body.refreshToken); diff --git a/apps/deploy-web/src/pages/api/proxy/[...path].ts b/apps/deploy-web/src/pages/api/proxy/[...path].ts index 0d434f3553..663faa11a1 100644 --- a/apps/deploy-web/src/pages/api/proxy/[...path].ts +++ b/apps/deploy-web/src/pages/api/proxy/[...path].ts @@ -38,7 +38,7 @@ export default defineApiHandler({ const proxy = services.httpProxy.createProxyServer({ changeOrigin: true, - target: services.apiUrlService.getBaseApiUrlFor(services.config.NEXT_PUBLIC_MANAGED_WALLET_NETWORK_ID), + target: services.apiUrlService.getBaseApiUrlFor(services.privateConfig.NEXT_PUBLIC_MANAGED_WALLET_NETWORK_ID), secure: false, autoRewrite: false, headers diff --git a/apps/deploy-web/src/pages/profile/[username]/index.tsx b/apps/deploy-web/src/pages/profile/[username]/index.tsx index 5875f0eecf..6371bd68ad 100644 --- a/apps/deploy-web/src/pages/profile/[username]/index.tsx +++ b/apps/deploy-web/src/pages/profile/[username]/index.tsx @@ -25,7 +25,7 @@ export const getServerSideProps = defineServerSideProps({ }), async handler({ params, services }): Promise> { const { data: user } = await services.consoleApiHttpClient.get( - `${services.apiUrlService.getBaseApiUrlFor(services.config.NEXT_PUBLIC_MANAGED_WALLET_NETWORK_ID)}/user/byUsername/${params.username}` + `${services.apiUrlService.getBaseApiUrlFor(services.privateConfig.NEXT_PUBLIC_MANAGED_WALLET_NETWORK_ID)}/user/byUsername/${params.username}` ); return { diff --git a/apps/deploy-web/src/pages/template/[id]/index.tsx b/apps/deploy-web/src/pages/template/[id]/index.tsx index 6f66590104..58a8a9342c 100644 --- a/apps/deploy-web/src/pages/template/[id]/index.tsx +++ b/apps/deploy-web/src/pages/template/[id]/index.tsx @@ -35,7 +35,7 @@ export const getServerSideProps = defineServerSideProps({ }; } const response = await services.consoleApiHttpClient.get( - `${services.apiUrlService.getBaseApiUrlFor(services.config.NEXT_PUBLIC_MANAGED_WALLET_NETWORK_ID)}/user/template/${params.id}`, + `${services.apiUrlService.getBaseApiUrlFor(services.privateConfig.NEXT_PUBLIC_MANAGED_WALLET_NETWORK_ID)}/user/template/${params.id}`, config ); diff --git a/apps/deploy-web/src/services/app-di-container/server-di-container.service.ts b/apps/deploy-web/src/services/app-di-container/server-di-container.service.ts index 539abbc274..612d1dd572 100644 --- a/apps/deploy-web/src/services/app-di-container/server-di-container.service.ts +++ b/apps/deploy-web/src/services/app-di-container/server-di-container.service.ts @@ -10,6 +10,7 @@ import { clientIpForwardingInterceptor } from "../client-ip-forwarding/client-ip import { createChildContainer } from "../container/createContainer"; import { FeatureFlagService } from "../feature-flag/feature-flag.service"; import { SessionService } from "../session/session.service"; +import { TurnstileVerifierService } from "../turnstile-verifier/turnstile-verifier.service"; import { createAppRootContainer } from "./app-di-container"; const rootContainer = createAppRootContainer({ @@ -28,16 +29,16 @@ export const services = createChildContainer(rootContainer, { notificationsApi: () => createAPIClient({ requestFn, - baseUrl: services.apiUrlService.getBaseApiUrlFor(services.config.NEXT_PUBLIC_MANAGED_WALLET_NETWORK_ID) + baseUrl: services.apiUrlService.getBaseApiUrlFor(services.privateConfig.NEXT_PUBLIC_MANAGED_WALLET_NETWORK_ID) }), - config: () => serverEnvConfig, + privateConfig: () => Object.freeze(serverEnvConfig), consoleApiHttpClient: () => services.applyAxiosInterceptors(services.createAxios()), sessionService: () => new SessionService( services.externalApiHttpClient, services.applyAxiosInterceptors( services.createAxios({ - baseURL: services.apiUrlService.getBaseApiUrlFor(services.config.NEXT_PUBLIC_MANAGED_WALLET_NETWORK_ID), + baseURL: services.apiUrlService.getBaseApiUrlFor(services.privateConfig.NEXT_PUBLIC_MANAGED_WALLET_NETWORK_ID), headers: { "Content-Type": "application/json; charset=utf-8", Accept: "application/json" @@ -45,12 +46,18 @@ export const services = createChildContainer(rootContainer, { }) ), { - ISSUER_BASE_URL: services.config.AUTH0_ISSUER_BASE_URL, - CLIENT_ID: services.config.AUTH0_CLIENT_ID, - CLIENT_SECRET: services.config.AUTH0_CLIENT_SECRET, - AUDIENCE: services.config.AUTH0_AUDIENCE + ISSUER_BASE_URL: services.privateConfig.AUTH0_ISSUER_BASE_URL, + CLIENT_ID: services.privateConfig.AUTH0_CLIENT_ID, + CLIENT_SECRET: services.privateConfig.AUTH0_CLIENT_SECRET, + AUDIENCE: services.privateConfig.AUTH0_AUDIENCE } - ) + ), + captchaVerifier: () => + new TurnstileVerifierService(services.externalApiHttpClient, { + secretKey: services.privateConfig.TURNSTILE_SECRET_KEY, + turnstileBypassSecretKey: services.privateConfig.TURNSTILE_BYPASS_SECRET_KEY, + bypassSecretKeyVerificationToken: services.privateConfig.E2E_TESTING_CLIENT_TOKEN + }) }); export type AppServices = typeof services; diff --git a/apps/deploy-web/src/services/auth/auth/auth.service.ts b/apps/deploy-web/src/services/auth/auth/auth.service.ts index 5571be2d10..b39df929f8 100644 --- a/apps/deploy-web/src/services/auth/auth/auth.service.ts +++ b/apps/deploy-web/src/services/auth/auth/auth.service.ts @@ -29,24 +29,27 @@ export class AuthService { this.location.assign(this.urlService.signup() + (queryParams ? `?${queryParams}` : "")); } - async login(input: { email: string; password: string }): Promise { + async login(input: { email: string; password: string; captchaToken: string }): Promise { await this.internalApiHttpClient.post("/api/auth/password-login", { email: input.email, - password: input.password + password: input.password, + captchaToken: input.captchaToken }); } - async signup(input: { email: string; password: string; termsAndConditions: boolean }): Promise { + async signup(input: { email: string; password: string; termsAndConditions: boolean; captchaToken: string }): Promise { await this.internalApiHttpClient.post("/api/auth/password-signup", { email: input.email, password: input.password, - termsAndConditions: input.termsAndConditions + termsAndConditions: input.termsAndConditions, + captchaToken: input.captchaToken }); } - async sendPasswordResetEmail(input: { email: string }): Promise { + async sendPasswordResetEmail(input: { email: string; captchaToken: string }): Promise { await this.internalApiHttpClient.post("/api/auth/send-password-reset-email", { - email: input.email + email: input.email, + captchaToken: input.captchaToken }); } diff --git a/apps/deploy-web/src/services/decodeInjectedConfig/decodeInjectedConfig.spec.ts b/apps/deploy-web/src/services/decodeInjectedConfig/decodeInjectedConfig.spec.ts deleted file mode 100644 index 3f86f62a98..0000000000 --- a/apps/deploy-web/src/services/decodeInjectedConfig/decodeInjectedConfig.spec.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { createSign, generateKeyPairSync, webcrypto } from "crypto"; - -import type { BrowserEnvConfig } from "@src/config/env-config.schema"; -import { decodeInjectedConfig } from "./decodeInjectedConfig"; - -describe(decodeInjectedConfig.name, () => { - beforeAll(() => { - if (!window.crypto.subtle) { - Object.defineProperty(globalThis, "crypto", { - value: webcrypto as any, - writable: true - }); - } - }); - - it("returns null if public key is not provided", async () => { - let config = await decodeInjectedConfig(undefined); - expect(config).toBeNull(); - - config = await decodeInjectedConfig(); - expect(config).toBeNull(); - }); - - it("returns null if __AK_INJECTED_CONFIG__ is not a string", async () => { - (window as any).__AK_INJECTED_CONFIG__ = 1; - let config = await decodeInjectedConfig("test pem"); - expect(config).toBeNull(); - - (window as any).__AK_INJECTED_CONFIG__ = undefined; - config = await decodeInjectedConfig("test pem"); - expect(config).toBeNull(); - }); - - it("return null if __AK_INJECTED_CONFIG__ is of invalid format", async () => { - (window as any).__AK_INJECTED_CONFIG__ = "test config"; - let config = await decodeInjectedConfig("test pem"); - expect(config).toBeNull(); - - (window as any).__AK_INJECTED_CONFIG__ = "test config."; - config = await decodeInjectedConfig("test pem"); - expect(config).toBeNull(); - - (window as any).__AK_INJECTED_CONFIG__ = ".test signature"; - config = await decodeInjectedConfig("test pem"); - expect(config).toBeNull(); - }); - - it("returns null if signature is invalid", async () => { - const config = { - NEXT_PUBLIC_TURNSTILE_SITE_KEY: "test site key" - }; - const { signedConfig } = signConfig(config); - const { publicKey } = generateKeyPairSync("rsa", { - modulusLength: 2048 - }); - (window as any).__AK_INJECTED_CONFIG__ = signedConfig; - const decodedConfig = await decodeInjectedConfig(publicKey.export({ type: "spki", format: "pem" }) as string); - expect(decodedConfig).toBeNull(); - }); - - it("returns null if config is not a valid JSON", async () => { - const { signedConfig, publicKey } = signConfig({}); - const [, signature] = signedConfig.split(".", 2); - (window as any).__AK_INJECTED_CONFIG__ = `not valid json.${signature}`; - - const decodedConfig = await decodeInjectedConfig(publicKey.export({ type: "spki", format: "pem" }) as string); - expect(decodedConfig).toBeNull(); - }); - - it("returns config if signature is valid", async () => { - const config = { - NEXT_PUBLIC_TURNSTILE_SITE_KEY: "test site key" - }; - const { signedConfig, publicKey } = signConfig(config); - - (window as any).__AK_INJECTED_CONFIG__ = signedConfig; - const decodedConfig = await decodeInjectedConfig(publicKey.export({ type: "spki", format: "pem" }) as string); - expect(decodedConfig).toEqual(config); - }); - - function signConfig(config: Partial) { - const { publicKey, privateKey } = generateKeyPairSync("rsa", { - modulusLength: 2048 - }); - const serializedConfig = JSON.stringify(config); - - const sign = createSign("SHA256"); - sign.update(JSON.stringify(config)); - sign.end(); - const signature = sign.sign(privateKey, "base64"); - - return { - signedConfig: `${serializedConfig}.${signature}`, - privateKey, - publicKey - }; - } -}); diff --git a/apps/deploy-web/src/services/decodeInjectedConfig/decodeInjectedConfig.ts b/apps/deploy-web/src/services/decodeInjectedConfig/decodeInjectedConfig.ts deleted file mode 100644 index 959a5619f1..0000000000 --- a/apps/deploy-web/src/services/decodeInjectedConfig/decodeInjectedConfig.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { BrowserEnvConfig } from "@src/config/env-config.schema"; - -export function hasInjectedConfig(): boolean { - return typeof window !== "undefined" && !!(window as any)?.__AK_INJECTED_CONFIG__; -} - -export async function decodeInjectedConfig(publicPem = process.env.NEXT_PUBLIC_UI_CONFIG_PUBLIC_KEY): Promise | null> { - if (!publicPem) return null; - - const signedConfig = (window as any).__AK_INJECTED_CONFIG__; - if (!signedConfig || typeof signedConfig !== "string") return null; - - const [config, signature] = signedConfig.split(".", 2); - if (!config || !signature) return null; - - const publicKey = await crypto.subtle.importKey( - "spki", - base64ToArrayBuffer(publicPem.replace(/-----[^-]+-----/g, "").replace(/\s+/g, "")), - { - name: "RSASSA-PKCS1-v1_5", - hash: "SHA-256" - }, - false, - ["verify"] - ); - const isValidSignature = await crypto.subtle.verify("RSASSA-PKCS1-v1_5", publicKey, base64ToArrayBuffer(signature), new TextEncoder().encode(config)); - if (!isValidSignature) return null; - - try { - return JSON.parse(config); - } catch { - return null; - } -} - -function base64ToArrayBuffer(base64: string) { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes; -} diff --git a/apps/deploy-web/src/services/turnstile-verifier/turnstile-verifier.service.spec.ts b/apps/deploy-web/src/services/turnstile-verifier/turnstile-verifier.service.spec.ts new file mode 100644 index 0000000000..2e16199b74 --- /dev/null +++ b/apps/deploy-web/src/services/turnstile-verifier/turnstile-verifier.service.spec.ts @@ -0,0 +1,87 @@ +import type { HttpClient } from "@akashnetwork/http-sdk"; +import type { MockProxy } from "jest-mock-extended"; +import { mock } from "jest-mock-extended"; + +import { TurnstileVerifierService } from "./turnstile-verifier.service"; + +describe(TurnstileVerifierService.name, () => { + describe("verify", () => { + it("returns Ok when verification succeeds", async () => { + const { service, httpClient, config } = setup(); + httpClient.post.mockResolvedValue({ data: { success: true } }); + + const result = await service.verify("valid-token"); + + expect(result.ok).toBe(true); + expect(httpClient.post).toHaveBeenCalledWith( + expect.any(String), + { secret: config.secretKey, response: "valid-token" }, + expect.objectContaining({ + headers: { "Content-Type": "application/json" } + }) + ); + }); + + it("includes remoteIp in payload when provided", async () => { + const { service, httpClient, config } = setup(); + httpClient.post.mockResolvedValue({ data: { success: true } }); + + await service.verify("test-token", { remoteIp: "192.168.1.1" }); + + expect(httpClient.post).toHaveBeenCalledWith( + expect.any(String), + { secret: config.secretKey, response: "test-token", remoteip: "192.168.1.1" }, + expect.any(Object) + ); + }); + + it("returns Err with error codes when verification fails", async () => { + const { service, httpClient } = setup(); + httpClient.post.mockResolvedValue({ + data: { success: false, "error-codes": ["invalid-input-response"] } + }); + + const result = await service.verify("invalid-token"); + + expect(result.ok).toBe(false); + expect(result.val).toEqual({ + code: "verification_failed", + errorCodes: ["invalid-input-response"] + }); + }); + + it("uses bypass secret key when bypass verification token matches", async () => { + const { service, httpClient, config } = setup(); + httpClient.post.mockResolvedValue({ data: { success: true } }); + + await service.verify("test-token", { + bypassVerificationToken: config.bypassSecretKeyVerificationToken + }); + + expect(httpClient.post).toHaveBeenCalledWith(expect.any(String), { secret: config.turnstileBypassSecretKey, response: "test-token" }, expect.any(Object)); + }); + + it("uses regular secret key when bypass verification token does not match", async () => { + const { service, httpClient, config } = setup(); + httpClient.post.mockResolvedValue({ data: { success: true } }); + + await service.verify("test-token", { + bypassVerificationToken: "wrong-bypass-token" + }); + + expect(httpClient.post).toHaveBeenCalledWith(expect.any(String), { secret: config.secretKey, response: "test-token" }, expect.any(Object)); + }); + }); + + function setup() { + const httpClient: MockProxy = mock(); + const config = { + secretKey: "test-secret-key", + turnstileBypassSecretKey: "test-bypass-secret-key", + bypassSecretKeyVerificationToken: "test-bypass-verification-token" + }; + const service = new TurnstileVerifierService(httpClient, config); + + return { service, httpClient, config }; + } +}); diff --git a/apps/deploy-web/src/services/turnstile-verifier/turnstile-verifier.service.ts b/apps/deploy-web/src/services/turnstile-verifier/turnstile-verifier.service.ts new file mode 100644 index 0000000000..3ea221c12b --- /dev/null +++ b/apps/deploy-web/src/services/turnstile-verifier/turnstile-verifier.service.ts @@ -0,0 +1,84 @@ +import type { HttpClient } from "@akashnetwork/http-sdk"; +import type { Result } from "ts-results"; +import { Err, Ok } from "ts-results"; + +export class TurnstileVerifierService { + readonly #config: TurnstileVerifierConfig; + /** + * @see https://developers.cloudflare.com/turnstile/get-started/server-side-validation/ + */ + readonly #verificationUrl = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; + readonly #externalApiHttpClient: HttpClient; + + constructor(externalApiHttpClient: HttpClient, config: TurnstileVerifierConfig) { + this.#externalApiHttpClient = externalApiHttpClient; + this.#config = config; + } + + async verify( + token: string, + options?: { + remoteIp?: string; + bypassVerificationToken?: string; + } + ): Promise< + Result< + void, + { + code: "verification_failed"; + errorCodes: Required["error-codes"]; + } + > + > { + const canUseBypassSecretKey = + this.#config.bypassSecretKeyVerificationToken && options?.bypassVerificationToken === this.#config.bypassSecretKeyVerificationToken; + const payload: Record = { + secret: canUseBypassSecretKey ? this.#config.turnstileBypassSecretKey : this.#config.secretKey, + response: token + }; + if (options?.remoteIp) { + payload.remoteip = options.remoteIp; + } + + const { data } = await this.#externalApiHttpClient.post(this.#verificationUrl, payload, { + headers: { + "Content-Type": "application/json" + }, + validateStatus: status => status < 500 + }); + + if (data.success) return Ok(undefined); + + return Err({ + code: "verification_failed", + errorCodes: data["error-codes"] || [] + }); + } +} + +interface TurnstileVerifyResponse { + success: boolean; + "error-codes"?: Array< + | "missing-input-secret" + | "invalid-input-secret" + | "missing-input-response" + | "invalid-input-response" + | "bad-request" + | "timeout-or-duplicate" + | "internal-error" + >; + challenge_ts?: string; + hostname?: string; +} + +interface TurnstileVerifierConfig { + secretKey: string; + /** + * @see https://developers.cloudflare.com/turnstile/troubleshooting/testing/#test-secret-keys + */ + turnstileBypassSecretKey: string; + /** + * Token used to enable bypassing Turnstile's secret key verification + */ + bypassSecretKeyVerificationToken: string; +} diff --git a/apps/deploy-web/src/utils/getInjectedConfig/getInjectedConfig.ts b/apps/deploy-web/src/utils/getInjectedConfig/getInjectedConfig.ts new file mode 100644 index 0000000000..2ff0672b9a --- /dev/null +++ b/apps/deploy-web/src/utils/getInjectedConfig/getInjectedConfig.ts @@ -0,0 +1,5 @@ +import type { BrowserEnvConfig } from "@src/config/env-config.schema"; + +export function getInjectedConfig(): Partial | undefined { + return typeof window !== "undefined" ? (window as any).__AK_INJECTED_CONFIG__ : undefined; +} diff --git a/apps/deploy-web/tests/ui/fixture/base-test.ts b/apps/deploy-web/tests/ui/fixture/base-test.ts index 06ae86ddc7..a2b5d60d91 100644 --- a/apps/deploy-web/tests/ui/fixture/base-test.ts +++ b/apps/deploy-web/tests/ui/fixture/base-test.ts @@ -1,9 +1,6 @@ import type { Page } from "@playwright/test"; import { test as baseTest } from "@playwright/test"; -import type { BrowserEnvConfig } from "@src/config/browser-env.config"; -import { testEnvConfig } from "./test-env.config"; - export * from "@playwright/test"; export const test = baseTest.extend({ @@ -13,47 +10,13 @@ export const test = baseTest.extend({ } }); -export async function injectUIConfig(page: Page) { - if (!testEnvConfig.UI_CONFIG_SIGNATURE_PRIVATE_KEY) { - return; - } - - const uiConfig = await getSignedConfig(testEnvConfig.UI_CONFIG_SIGNATURE_PRIVATE_KEY); - await page.addInitScript(stringifiedConfig => { - (window as any).__AK_INJECTED_CONFIG__ = stringifiedConfig; - }, uiConfig); -} - -const signedConfigCache = new Map(); -async function getSignedConfig(privateKeyPem: string) { - if (signedConfigCache.has(privateKeyPem)) { - return signedConfigCache.get(privateKeyPem); - } - - const config: Partial = { - // always pass token: https://deelopers.cloudflare.com/turnstile/troubleshooting/testing/#dummy-sitekeys-and-secret-keys - NEXT_PUBLIC_TURNSTILE_SITE_KEY: "1x00000000000000000000AA" - }; - const serializedConfig = JSON.stringify(config); - const privateKey = await importPrivateKey(privateKeyPem); - const signatureBuffer = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", privateKey, Buffer.from(serializedConfig)); - - const result = `${serializedConfig}.${Buffer.from(signatureBuffer).toString("base64")}`; - signedConfigCache.set(privateKeyPem, result); - return result; -} - -async function importPrivateKey(pem: string) { - const der = Buffer.from(pem.replace(/-----[^-]+-----/g, "").replace(/\s+/g, ""), "base64"); +export const expect = test.expect; - return await crypto.subtle.importKey( - "pkcs8", - der, - { - name: "RSASSA-PKCS1-v1_5", - hash: "SHA-256" - }, - false, - ["sign"] - ); +export async function injectUIConfig(page: Page) { + await page.addInitScript(() => { + (window as any).__AK_INJECTED_CONFIG__ = Object.freeze({ + // always pass turnstile site key: https://developers.cloudflare.com/turnstile/troubleshooting/testing/#dummy-sitekeys-and-secret-keys + NEXT_PUBLIC_TURNSTILE_SITE_KEY: "1x00000000000000000000AA" + }); + }); } diff --git a/apps/deploy-web/tests/ui/fixture/test-env.config.ts b/apps/deploy-web/tests/ui/fixture/test-env.config.ts index 628f4809b9..6e2aeb1cb8 100644 --- a/apps/deploy-web/tests/ui/fixture/test-env.config.ts +++ b/apps/deploy-web/tests/ui/fixture/test-env.config.ts @@ -8,7 +8,6 @@ export const testEnvSchema = z.object({ .default("http://localhost:3000") .transform(url => url.replace(/\/+$/, "")), TEST_WALLET_MNEMONIC: z.string(), - UI_CONFIG_SIGNATURE_PRIVATE_KEY: z.string().optional(), NETWORK_ID: z.enum(["mainnet", "sandbox", "testnet"]).default("sandbox"), USER_DATA_DIR: z.string().default(path.join(tmpdir(), "akash-console-web-ui-tests", crypto.randomUUID())) }); @@ -16,7 +15,6 @@ export const testEnvSchema = z.object({ export const testEnvConfig = testEnvSchema.parse({ BASE_URL: process.env.BASE_URL, TEST_WALLET_MNEMONIC: process.env.TEST_WALLET_MNEMONIC, - UI_CONFIG_SIGNATURE_PRIVATE_KEY: process.env.UI_CONFIG_SIGNATURE_PRIVATE_KEY, USER_DATA_DIR: process.env.USER_DATA_DIR });