import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { createRoutesStub } from "react-router"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nextProvider } from "react-i18next"; import i18n from "i18next"; import OnboardingForm, { clientLoader, sanitizeReturnTo, } from "#/routes/onboarding-form"; import AuthService from "#/api/auth-service/auth-service.api"; import { onboardingService } from "#/api/onboarding-service/onboarding-service.api"; const mockMutate = vi.fn(); const mockNavigate = vi.fn(); const mockUseMe = vi.fn(); // Loader data set in beforeEach for each test suite let loaderData: { config: { app_mode: string; feature_flags: { deployment_mode: string } }; }; vi.mock("react-router", async (importOriginal) => { const original = await importOriginal(); return { ...original, useNavigate: () => mockNavigate, }; }); vi.mock("#/hooks/mutation/use-submit-onboarding", () => ({ useSubmitOnboarding: () => ({ mutate: mockMutate, }), })); vi.mock("#/hooks/query/use-me", () => ({ useMe: () => mockUseMe(), })); // Mocks for clientLoader tests const mockQueryClientGetData = vi.fn(); const mockQueryClientSetData = vi.fn(); vi.mock("#/query-client-config", () => ({ queryClient: { getQueryData: (...args: unknown[]) => mockQueryClientGetData(...args), setQueryData: (...args: unknown[]) => mockQueryClientSetData(...args), }, })); const mockGetConfig = vi.fn(); vi.mock("#/api/option-service/option-service.api", () => ({ default: { getConfig: () => mockGetConfig(), }, })); const renderOnboardingForm = async (initialEntry: string = "/") => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); const RouterStub = createRoutesStub([ { path: "/", Component: OnboardingForm, loader: () => loaderData, }, ]); const result = render( , ); // Wait for the component to render await screen.findByTestId("onboarding-form"); return result; }; describe("OnboardingForm - Cloud Mode", () => { beforeEach(() => { mockMutate.mockClear(); mockNavigate.mockClear(); loaderData = { config: { app_mode: "saas", feature_flags: { deployment_mode: "cloud" }, }, }; // Cloud mode tracks all users, role doesn't matter mockUseMe.mockReturnValue({ data: { role: "member" } }); }); it("should render with the correct test id", async () => { await renderOnboardingForm(); expect(screen.getByTestId("onboarding-form")).toBeInTheDocument(); }); it("should render the first step initially", async () => { await renderOnboardingForm(); expect(screen.getByTestId("step-header")).toBeInTheDocument(); expect(screen.getByTestId("step-content")).toBeInTheDocument(); expect(screen.getByTestId("step-actions")).toBeInTheDocument(); }); it("should display step progress indicator with 3 bars for cloud mode", async () => { await renderOnboardingForm(); const stepHeader = screen.getByTestId("step-header"); const progressBars = stepHeader.querySelectorAll(".rounded-full"); expect(progressBars).toHaveLength(3); }); it("should have the Next button disabled when no option is selected", async () => { await renderOnboardingForm(); const nextButton = screen.getByRole("button", { name: /next/i }); expect(nextButton).toBeDisabled(); }); it("should enable the Next button when an option is selected", async () => { const user = userEvent.setup(); await renderOnboardingForm(); await user.click(screen.getByTestId("step-option-solo")); const nextButton = screen.getByRole("button", { name: /next/i }); expect(nextButton).not.toBeDisabled(); }); it("should advance to the next step when Next is clicked", async () => { const user = userEvent.setup(); await renderOnboardingForm(); // On step 1, first progress bar should be filled (bg-white) const stepHeader = screen.getByTestId("step-header"); let progressBars = stepHeader.querySelectorAll(".bg-white"); expect(progressBars).toHaveLength(1); await user.click(screen.getByTestId("step-option-solo")); await user.click(screen.getByRole("button", { name: /next/i })); // On step 2, first two progress bars should be filled progressBars = stepHeader.querySelectorAll(".bg-white"); expect(progressBars).toHaveLength(2); }); it("should disable Next button again on new step until option is selected", async () => { const user = userEvent.setup(); await renderOnboardingForm(); await user.click(screen.getByTestId("step-option-solo")); await user.click(screen.getByRole("button", { name: /next/i })); const nextButton = screen.getByRole("button", { name: /next/i }); expect(nextButton).toBeDisabled(); }); it("should call submitOnboarding with selections when finishing the last step", async () => { const user = userEvent.setup(); await renderOnboardingForm(); // Step 1 - select org size (first step in saas mode - single select) await user.click(screen.getByTestId("step-option-org_2_10")); await user.click(screen.getByRole("button", { name: /next/i })); // Step 2 - select use case (multi-select) await user.click(screen.getByTestId("step-option-new_features")); await user.click(screen.getByRole("button", { name: /next/i })); // Step 3 - select role (last step in saas mode - single select) await user.click(screen.getByTestId("step-option-software_engineer")); await user.click(screen.getByRole("button", { name: /finish/i })); expect(mockMutate).toHaveBeenCalledTimes(1); expect(mockMutate).toHaveBeenCalledWith( expect.objectContaining({ selections: { org_size: "org_2_10", use_case: ["new_features"], role: "software_engineer", }, }), ); }); it("should render 5 options on step 1 (org size question)", async () => { await renderOnboardingForm(); const options = screen .getAllByRole("button") .filter((btn) => btn.getAttribute("data-testid")?.startsWith("step-option-"), ); expect(options).toHaveLength(5); }); it("should preserve selections when navigating through steps", async () => { const user = userEvent.setup(); await renderOnboardingForm(); // Select org size on step 1 (single select) await user.click(screen.getByTestId("step-option-solo")); await user.click(screen.getByRole("button", { name: /next/i })); // Select use case on step 2 (multi-select) await user.click(screen.getByTestId("step-option-fixing_bugs")); await user.click(screen.getByRole("button", { name: /next/i })); // Select role on step 3 (single select) await user.click(screen.getByTestId("step-option-cto_founder")); await user.click(screen.getByRole("button", { name: /finish/i })); // Verify all selections were preserved expect(mockMutate).toHaveBeenCalledWith( expect.objectContaining({ selections: { org_size: "solo", use_case: ["fixing_bugs"], role: "cto_founder", }, }), ); }); it("should allow selecting multiple options on multi-select steps", async () => { const user = userEvent.setup(); await renderOnboardingForm(); // Step 1 - select org size (single select) await user.click(screen.getByTestId("step-option-solo")); await user.click(screen.getByRole("button", { name: /next/i })); // Step 2 - select multiple use cases (multi-select) await user.click(screen.getByTestId("step-option-new_features")); await user.click(screen.getByTestId("step-option-fixing_bugs")); await user.click(screen.getByTestId("step-option-refactoring")); await user.click(screen.getByRole("button", { name: /next/i })); // Step 3 - select role (single select) await user.click(screen.getByTestId("step-option-software_engineer")); await user.click(screen.getByRole("button", { name: /finish/i })); expect(mockMutate).toHaveBeenCalledWith( expect.objectContaining({ selections: { org_size: "solo", use_case: ["new_features", "fixing_bugs", "refactoring"], role: "software_engineer", }, }), ); }); it("should allow deselecting options on multi-select steps", async () => { const user = userEvent.setup(); await renderOnboardingForm(); // Step 1 - select org size await user.click(screen.getByTestId("step-option-solo")); await user.click(screen.getByRole("button", { name: /next/i })); // Step 2 - select and deselect use cases await user.click(screen.getByTestId("step-option-new_features")); await user.click(screen.getByTestId("step-option-fixing_bugs")); await user.click(screen.getByTestId("step-option-new_features")); // Deselect await user.click(screen.getByRole("button", { name: /next/i })); // Step 3 - select role await user.click(screen.getByTestId("step-option-software_engineer")); await user.click(screen.getByRole("button", { name: /finish/i })); expect(mockMutate).toHaveBeenCalledWith( expect.objectContaining({ selections: { org_size: "solo", use_case: ["fixing_bugs"], role: "software_engineer", }, }), ); }); it("should show all progress bars filled on the last step", async () => { const user = userEvent.setup(); await renderOnboardingForm(); // Navigate to step 3 await user.click(screen.getByTestId("step-option-solo")); await user.click(screen.getByRole("button", { name: /next/i })); await user.click(screen.getByTestId("step-option-new_features")); await user.click(screen.getByRole("button", { name: /next/i })); // On step 3, all three progress bars should be filled const stepHeader = screen.getByTestId("step-header"); const progressBars = stepHeader.querySelectorAll(".bg-white"); expect(progressBars).toHaveLength(3); }); it("should not render the Back button on the first step", async () => { await renderOnboardingForm(); const backButton = screen.queryByRole("button", { name: /back/i }); expect(backButton).not.toBeInTheDocument(); }); it("should render the Back button on step 2", async () => { const user = userEvent.setup(); await renderOnboardingForm(); await user.click(screen.getByTestId("step-option-solo")); await user.click(screen.getByRole("button", { name: /next/i })); const backButton = screen.getByRole("button", { name: /back/i }); expect(backButton).toBeInTheDocument(); }); it("should go back to the previous step when Back is clicked", async () => { const user = userEvent.setup(); await renderOnboardingForm(); // Navigate to step 2 await user.click(screen.getByTestId("step-option-solo")); await user.click(screen.getByRole("button", { name: /next/i })); // Verify we're on step 2 (2 progress bars filled) const stepHeader = screen.getByTestId("step-header"); let progressBars = stepHeader.querySelectorAll(".bg-white"); expect(progressBars).toHaveLength(2); // Click Back await user.click(screen.getByRole("button", { name: /back/i })); // Verify we're back on step 1 (1 progress bar filled) progressBars = stepHeader.querySelectorAll(".bg-white"); expect(progressBars).toHaveLength(1); }); }); describe("OnboardingForm - Self-Hosted Mode", () => { // Self-hosted mode has 3 steps: org_name, org_size, use_case // The role question is cloud-only and not shown in self-hosted mode beforeEach(() => { mockMutate.mockClear(); mockNavigate.mockClear(); loaderData = { config: { app_mode: "saas", feature_flags: { deployment_mode: "self_hosted" }, }, }; // Self-hosted mode only tracks org owners mockUseMe.mockReturnValue({ data: { role: "owner" } }); }); it("should render with the correct test id", async () => { await renderOnboardingForm(); expect(screen.getByTestId("onboarding-form")).toBeInTheDocument(); }); it("should display step progress indicator with 3 bars for self-hosted mode", async () => { await renderOnboardingForm(); // Self-hosted has 3 steps: org_name, org_size, use_case (role is cloud-only) const stepHeader = screen.getByTestId("step-header"); const progressBars = stepHeader.querySelectorAll(".rounded-full"); expect(progressBars).toHaveLength(3); }); it("should start with org_name question as first step with two input fields", async () => { await renderOnboardingForm(); // The first step in self-hosted mode should be org_name with two inputs const orgNameInput = screen.getByTestId("form-input-org_name"); const orgDomainInput = screen.getByTestId("form-input-org_domain"); expect(orgNameInput).toBeInTheDocument(); expect(orgDomainInput).toBeInTheDocument(); }); it("should call submitOnboarding with all selections including org_name when finishing", async () => { const user = userEvent.setup(); await renderOnboardingForm(); // Step 1 - enter org name and domain (input fields) const orgNameInput = screen.getByTestId("form-input-org_name"); const orgDomainInput = screen.getByTestId("form-input-org_domain"); await user.type(orgNameInput, "Acme Corp"); await user.type(orgDomainInput, "acme.com"); await user.click(screen.getByRole("button", { name: /next/i })); // Step 2 - select org size (single select) await user.click(screen.getByTestId("step-option-org_2_10")); await user.click(screen.getByRole("button", { name: /next/i })); // Step 3 - select use case (multi-select) - this is the last step in self-hosted mode await user.click(screen.getByTestId("step-option-new_features")); await user.click(screen.getByRole("button", { name: /finish/i })); expect(mockMutate).toHaveBeenCalledTimes(1); expect(mockMutate).toHaveBeenCalledWith( expect.objectContaining({ selections: { org_name: "Acme Corp", org_domain: "acme.com", org_size: "org_2_10", use_case: ["new_features"], }, }), ); }); it("should show all 3 progress bars filled on the last step", async () => { const user = userEvent.setup(); await renderOnboardingForm(); // Navigate through all 3 steps const orgNameInput = screen.getByTestId("form-input-org_name"); const orgDomainInput = screen.getByTestId("form-input-org_domain"); await user.type(orgNameInput, "Test Company"); await user.type(orgDomainInput, "test.com"); await user.click(screen.getByRole("button", { name: /next/i })); await user.click(screen.getByTestId("step-option-org_2_10")); await user.click(screen.getByRole("button", { name: /next/i })); // On step 3, all three progress bars should be filled const stepHeader = screen.getByTestId("step-header"); const progressBars = stepHeader.querySelectorAll(".bg-white"); expect(progressBars).toHaveLength(3); }); it("should have Next button disabled when both org_name inputs are empty", async () => { await renderOnboardingForm(); const nextButton = screen.getByRole("button", { name: /next/i }); expect(nextButton).toBeDisabled(); }); it("should enable Next button when both org_name and org_domain are entered", async () => { const user = userEvent.setup(); await renderOnboardingForm(); const orgNameInput = screen.getByTestId("form-input-org_name"); const orgDomainInput = screen.getByTestId("form-input-org_domain"); await user.type(orgNameInput, "My Company"); await user.type(orgDomainInput, "mycompany.com"); const nextButton = screen.getByRole("button", { name: /next/i }); expect(nextButton).not.toBeDisabled(); }); }); describe("OnboardingForm - redirect when already onboarded", () => { beforeEach(() => { mockMutate.mockClear(); mockNavigate.mockClear(); mockUseMe.mockReturnValue({ data: { role: "member" } }); loaderData = { config: { app_mode: "saas", feature_flags: { deployment_mode: "cloud" }, }, }; mockGetConfig.mockResolvedValue({ app_mode: "saas", feature_flags: { deployment_mode: "cloud" }, }); vi.spyOn(AuthService, "authenticate").mockResolvedValue(true); }); it("should navigate to / when the backend reports onboarding is already complete", async () => { // Arrange vi.spyOn(onboardingService, "getStatus").mockResolvedValue({ should_complete_onboarding: false, }); // Act await renderOnboardingForm(); // Assert await vi.waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true }); }); }); it("should restore the returnTo destination when onboarding is already complete", async () => { // Regression: a stale ``/onboarding`` link must still respect // a ``returnTo`` query param so post-login deep links survive. vi.spyOn(onboardingService, "getStatus").mockResolvedValue({ should_complete_onboarding: false, }); await renderOnboardingForm( `/?returnTo=${encodeURIComponent("/conversations/abc?foo=bar")}`, ); await vi.waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith("/conversations/abc?foo=bar", { replace: true, }); }); expect(mockNavigate).not.toHaveBeenCalledWith("/", { replace: true }); }); it("should reject absolute URL returnTo and redirect to / when onboarding is already complete", async () => { // Security: a hand-crafted ``?returnTo=https://evil.example`` must // never turn the component redirect into an open-redirect vector. vi.spyOn(onboardingService, "getStatus").mockResolvedValue({ should_complete_onboarding: false, }); await renderOnboardingForm( `/?returnTo=${encodeURIComponent("https://evil.example.com/pwn")}`, ); await vi.waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true }); }); }); it("should reject protocol-relative returnTo and redirect to / when onboarding is already complete", async () => { // Security: protocol-relative URLs (``//evil.example.com``) are // also open-redirect vectors and must be rejected. vi.spyOn(onboardingService, "getStatus").mockResolvedValue({ should_complete_onboarding: false, }); await renderOnboardingForm( `/?returnTo=${encodeURIComponent("//evil.example.com/pwn")}`, ); await vi.waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true }); }); }); it("should forward returnTo to submitOnboarding so the post-submit redirect respects it", async () => { // Regression: ``OnboardingGuard`` saves the originally requested // URL as ``?returnTo=...``. ``OnboardingForm`` must thread that // value through the submit mutation so the post-submit fallback // (when the server response has no ``redirect_url``) sends the // user back to where they started. const user = userEvent.setup(); await renderOnboardingForm( `/?returnTo=${encodeURIComponent("/conversations/abc?foo=bar")}`, ); // Step 1 - org size await user.click(screen.getByTestId("step-option-solo")); await user.click(screen.getByRole("button", { name: /next/i })); // Step 2 - use case await user.click(screen.getByTestId("step-option-new_features")); await user.click(screen.getByRole("button", { name: /next/i })); // Step 3 - role await user.click(screen.getByTestId("step-option-software_engineer")); await user.click(screen.getByRole("button", { name: /finish/i })); expect(mockMutate).toHaveBeenCalledWith( expect.objectContaining({ returnTo: "/conversations/abc?foo=bar", }), ); }); }); describe("onboarding-form clientLoader", () => { // The loader takes a ``{ request }`` arg from react-router. Most of // the existing tests don't care about the URL, so build a default // request pointing at bare ``/onboarding`` and let returnTo-aware // tests override it. const makeArgs = (url = "https://app.example.com/onboarding") => ({ request: new Request(url), }); beforeEach(() => { mockQueryClientGetData.mockReset(); mockQueryClientSetData.mockReset(); mockGetConfig.mockReset(); }); describe("redirect behavior", () => { it("should redirect to / when enable_onboarding feature flag is false", async () => { const saasConfig = { app_mode: "saas", feature_flags: { deployment_mode: "cloud", enable_onboarding: false }, }; mockQueryClientGetData.mockReturnValue(saasConfig); const result = await clientLoader(makeArgs()); expect(result).toBeDefined(); expect((result as Response).status).toBe(302); expect((result as Response).headers.get("Location")).toBe("/"); }); it("should redirect to / when app_mode is oss", async () => { const ossConfig = { app_mode: "oss", feature_flags: { deployment_mode: undefined, enable_onboarding: true }, }; mockQueryClientGetData.mockReturnValue(ossConfig); const result = await clientLoader(makeArgs()); expect(result).toBeDefined(); expect((result as Response).status).toBe(302); expect((result as Response).headers.get("Location")).toBe("/"); }); it("should redirect to / when app_mode is undefined", async () => { const undefinedConfig = { app_mode: undefined, feature_flags: { deployment_mode: "cloud", enable_onboarding: true }, }; mockQueryClientGetData.mockReturnValue(undefinedConfig); const result = await clientLoader(makeArgs()); expect(result).toBeDefined(); expect((result as Response).status).toBe(302); expect((result as Response).headers.get("Location")).toBe("/"); }); it("should redirect to / when config is null", async () => { mockQueryClientGetData.mockReturnValue(null); mockGetConfig.mockResolvedValue(null); const result = await clientLoader(makeArgs()); expect(result).toBeDefined(); expect((result as Response).status).toBe(302); expect((result as Response).headers.get("Location")).toBe("/"); }); it("should allow access and return config when app_mode is saas with cloud deployment and enable_onboarding is true", async () => { const saasCloudConfig = { app_mode: "saas", feature_flags: { deployment_mode: "cloud", enable_onboarding: true }, }; mockQueryClientGetData.mockReturnValue(saasCloudConfig); const result = await clientLoader(makeArgs()); expect(result).toEqual({ config: saasCloudConfig }); }); it("should allow access and return config when app_mode is saas with self_hosted deployment and enable_onboarding is true", async () => { const saasSelfHostedConfig = { app_mode: "saas", feature_flags: { deployment_mode: "self_hosted", enable_onboarding: true, }, }; mockQueryClientGetData.mockReturnValue(saasSelfHostedConfig); const result = await clientLoader(makeArgs()); expect(result).toEqual({ config: saasSelfHostedConfig }); }); }); describe("returnTo handling on redirect", () => { // The frontend can disagree with the backend about whether // onboarding applies (e.g. the backend gates on // ``DEPLOYMENT_MODE='cloud'`` while the frontend gates on // ``feature_flags.enable_onboarding``). When the frontend // redirects users away from /onboarding because the flag is // off, it must still honor the ``?returnTo=`` query parameter // so deep links survive the disagreement. it("should honor returnTo when enable_onboarding is false", async () => { const saasConfig = { app_mode: "saas", feature_flags: { deployment_mode: "cloud", enable_onboarding: false }, }; mockQueryClientGetData.mockReturnValue(saasConfig); const result = await clientLoader( makeArgs( "https://app.example.com/onboarding?returnTo=%2Fsettings%2Fuser", ), ); expect((result as Response).status).toBe(302); expect((result as Response).headers.get("Location")).toBe( "/settings/user", ); }); it("should honor returnTo with query string when app_mode is oss", async () => { const ossConfig = { app_mode: "oss", feature_flags: { deployment_mode: undefined, enable_onboarding: true }, }; mockQueryClientGetData.mockReturnValue(ossConfig); const result = await clientLoader( makeArgs( "https://app.example.com/onboarding" + "?returnTo=%2Fconversations%2Fabc%3Ffoo%3Dbar", ), ); expect((result as Response).headers.get("Location")).toBe( "/conversations/abc?foo=bar", ); }); it("should reject absolute URL returnTo and fall back to /", async () => { // Safety: never let a hand-crafted ``?returnTo=https://evil.example`` // turn the loader's redirect into an open-redirect vector. const ossConfig = { app_mode: "oss", feature_flags: { deployment_mode: undefined, enable_onboarding: true }, }; mockQueryClientGetData.mockReturnValue(ossConfig); const result = await clientLoader( makeArgs( "https://app.example.com/onboarding" + "?returnTo=https%3A%2F%2Fevil.example.com%2Fpwn", ), ); expect((result as Response).headers.get("Location")).toBe("/"); }); it("should reject protocol-relative returnTo and fall back to /", async () => { const ossConfig = { app_mode: "oss", feature_flags: { deployment_mode: undefined, enable_onboarding: true }, }; mockQueryClientGetData.mockReturnValue(ossConfig); const result = await clientLoader( makeArgs( "https://app.example.com/onboarding" + "?returnTo=%2F%2Fevil.example.com%2Fpwn", ), ); expect((result as Response).headers.get("Location")).toBe("/"); }); it("should fall back to / when returnTo is missing", async () => { const ossConfig = { app_mode: "oss", feature_flags: { deployment_mode: undefined, enable_onboarding: true }, }; mockQueryClientGetData.mockReturnValue(ossConfig); const result = await clientLoader( makeArgs("https://app.example.com/onboarding"), ); expect((result as Response).headers.get("Location")).toBe("/"); }); }); describe("config fetching", () => { it("should use cached config from queryClient when available", async () => { const cachedConfig = { app_mode: "saas", feature_flags: { deployment_mode: "cloud", enable_onboarding: true }, }; mockQueryClientGetData.mockReturnValue(cachedConfig); await clientLoader(makeArgs()); expect(mockQueryClientGetData).toHaveBeenCalledWith([ "web-client-config", ]); expect(mockGetConfig).not.toHaveBeenCalled(); }); it("should fetch config from OptionService when not cached", async () => { const fetchedConfig = { app_mode: "saas", feature_flags: { deployment_mode: "cloud", enable_onboarding: true }, }; mockQueryClientGetData.mockReturnValue(null); mockGetConfig.mockResolvedValue(fetchedConfig); const result = await clientLoader(makeArgs()); expect(mockGetConfig).toHaveBeenCalled(); expect(mockQueryClientSetData).toHaveBeenCalledWith( ["web-client-config"], fetchedConfig, ); expect(result).toEqual({ config: fetchedConfig }); }); }); }); describe("sanitizeReturnTo", () => { it("should return / for null", () => { expect(sanitizeReturnTo(null)).toBe("/"); }); it("should return / for empty string", () => { expect(sanitizeReturnTo("")).toBe("/"); }); it("should allow same-origin absolute paths", () => { expect(sanitizeReturnTo("/conversations/abc")).toBe("/conversations/abc"); }); it("should allow paths with query strings", () => { expect(sanitizeReturnTo("/conversations/abc?foo=bar")).toBe( "/conversations/abc?foo=bar", ); }); it("should prepend / to relative paths that lack one", () => { expect(sanitizeReturnTo("conversations/abc")).toBe("/conversations/abc"); }); it("should reject http:// URLs and fall back to /", () => { expect(sanitizeReturnTo("http://evil.example.com/pwn")).toBe("/"); }); it("should reject https:// URLs and fall back to /", () => { expect(sanitizeReturnTo("https://evil.example.com/pwn")).toBe("/"); }); it("should reject protocol-relative URLs and fall back to /", () => { expect(sanitizeReturnTo("//evil.example.com/pwn")).toBe("/"); }); });