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
@@ -0,0 +1,83 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { REQUEST_PATHNAME_HEADER, REQUEST_SEARCH_HEADER } from "@/i18n/routing";

const mocks = vi.hoisted(() => ({
accessToken: undefined as string | undefined,
refreshToken: undefined as string | undefined,
headers: vi.fn(),
redirect: vi.fn((location: string) => {
throw new Error(`redirect:${location}`);
}),
}));

vi.mock("next/headers", () => ({ headers: mocks.headers }));
vi.mock("next/navigation", () => ({ redirect: mocks.redirect }));
vi.mock("@/lib/spree", () => ({
getAccessToken: () => Promise.resolve(mocks.accessToken),
getRefreshToken: () => Promise.resolve(mocks.refreshToken),
}));
vi.mock("@/components/account/AuthenticatedAccountShell", () => ({
AuthenticatedAccountShell: ({
children,
loginHref,
}: {
children: React.ReactNode;
loginHref: string;
}) => (
<div data-testid="account-shell" data-login-href={loginHref}>
{children}
</div>
),
}));

import { AuthenticatedAccountLayoutContent } from "./layout";

function renderLayout() {
return AuthenticatedAccountLayoutContent({
children: <div>Protected account content</div>,
params: Promise.resolve({ country: "us", locale: "en" }),
});
}

describe("AuthenticatedAccountLayoutContent", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.accessToken = undefined;
mocks.refreshToken = undefined;
mocks.headers.mockResolvedValue(
new Headers({
[REQUEST_PATHNAME_HEADER]: "/us/en/account/orders",
[REQUEST_SEARCH_HEADER]: "?state=complete",
}),
);
});

it("redirects an anonymous request before rendering protected chrome", async () => {
await expect(renderLayout()).rejects.toThrow(
"redirect:/us/en/account?redirect=%2Fus%2Fen%2Faccount%2Forders%3Fstate%3Dcomplete",
);
expect(mocks.redirect).toHaveBeenCalledOnce();
});

it("renders the protected route when an access token is present", async () => {
mocks.accessToken = "access-token";

render(await renderLayout());

expect(screen.getByText("Protected account content")).toBeInTheDocument();
expect(screen.getByTestId("account-shell")).toHaveAttribute(
"data-login-href",
"/us/en/account?redirect=%2Fus%2Fen%2Faccount%2Forders%3Fstate%3Dcomplete",
);
});

it("allows a refresh-only session to recover before client verification", async () => {
mocks.refreshToken = "refresh-token";

render(await renderLayout());

expect(screen.getByText("Protected account content")).toBeInTheDocument();
expect(mocks.redirect).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { AuthenticatedAccountShell } from "@/components/account/AuthenticatedAccountShell";
import { REQUEST_PATHNAME_HEADER, REQUEST_SEARCH_HEADER } from "@/i18n/routing";
import { getAccessToken, getRefreshToken } from "@/lib/spree";
import {
buildAccountLoginHref,
resolveAccountRedirect,
} from "@/lib/utils/account-redirect";

interface AuthenticatedAccountLayoutProps {
children: React.ReactNode;
params: Promise<{ country: string; locale: string }>;
}

export default function AuthenticatedAccountLayout(
props: AuthenticatedAccountLayoutProps,
) {
return (
<Suspense fallback={null}>
<AuthenticatedAccountLayoutContent {...props} />
</Suspense>
);
}

export async function AuthenticatedAccountLayoutContent({
children,
params,
}: AuthenticatedAccountLayoutProps) {
const [{ country, locale }, requestHeaders, accessToken, refreshToken] =
await Promise.all([params, headers(), getAccessToken(), getRefreshToken()]);

const basePath = `/${country}/${locale}`;
const pathname = requestHeaders.get(REQUEST_PATHNAME_HEADER);
const search = requestHeaders.get(REQUEST_SEARCH_HEADER);
const requestedPath = `${pathname ?? ""}${search?.startsWith("?") ? search : ""}`;
const returnTo = resolveAccountRedirect(requestedPath, basePath);
const loginHref = buildAccountLoginHref(basePath, returnTo);

// A refresh token is also a recoverable session credential. Let the client
// session action rotate it in a cookie-writable context before deciding that
// the customer is anonymous.
if (!accessToken && !refreshToken) redirect(loginHref);

return (
<AuthenticatedAccountShell loginHref={loginHref}>
{children}
</AuthenticatedAccountShell>
);
}
214 changes: 0 additions & 214 deletions src/app/[country]/[locale]/(storefront)/account/layout.tsx

This file was deleted.

Loading
Loading