Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
Expand All @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 (
<d.Alert variant="destructive" className={className}>
<d.AlertDescription>
{isHttpError(error) && error.response?.data.message
? error.response.data.message
: "An unexpected error occurred. Please try again or contact support if the issue persists."}
</d.AlertDescription>
<d.AlertDescription>{describeError(error)}</d.AlertDescription>
</d.Alert>
);
}
Original file line number Diff line number Diff line change
@@ -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<CaptchaChallengeReason>(["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);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export type CaptchaChallengeReason = "abandoned" | "timeout" | "dismissed" | "error";

const MESSAGES: Record<CaptchaChallengeReason, string> = {
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 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 };
136 changes: 129 additions & 7 deletions apps/deploy-web/src/components/turnstile/Turnstile.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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();

Expand All @@ -363,6 +361,125 @@ 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("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("keeps cloudflare's own interactive timeout quiet once the challenge was abandoned", async () => {
const { ReactTurnstile, latestProps } = createTurnstileMock();
const { turnstileRef, errorHandler } = await setup({ enabled: true, components: { ReactTurnstile } });
vi.useFakeTimers();

try {
const promise = turnstileRef.current!.renderAndWaitResponse().catch(() => undefined);
await act(async () => {
latestProps.current!.onBeforeInteractive?.();
});
await act(async () => {
await vi.advanceTimersByTimeAsync(CHALLENGE_DEADLINE_MS);
});
await promise;

await act(async () => {
latestProps.current!.onTimeout?.();
});

expect(errorHandler.reportError).not.toHaveBeenCalled();
} 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();
Expand Down Expand Up @@ -414,9 +531,10 @@ describe(Turnstile.name, () => {
async function setup(input?: { enabled?: boolean; siteKey?: string; onDismissed?: () => void; components?: Partial<typeof COMPONENTS> }) {
const turnstileRef = { current: null as TurnstileRef | null };
const errorHandler = mock<ErrorHandlerService>();
const analyticsService = mock<AnalyticsService>();

const result = render(
<TestContainerProvider services={{ errorHandler: () => errorHandler }}>
<TestContainerProvider services={{ errorHandler: () => errorHandler, analyticsService: () => analyticsService }}>
<Turnstile
ref={turnstileRef}
enabled={!!input?.enabled}
Expand All @@ -434,7 +552,11 @@ describe(Turnstile.name, () => {
);
await act(() => wait(0));

return { ...result, turnstileRef, errorHandler };
return { ...result, turnstileRef, errorHandler, analyticsService };
}

function getOverlay() {
return screen.getByText(/we are verifying you are a human/i).closest<HTMLElement>("[style]");
}

const ButtonMock = forwardRef<HTMLButtonElement, React.ComponentProps<typeof COMPONENTS.Button>>((props, ref) => (
Expand Down
Loading