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
208 changes: 208 additions & 0 deletions vtex/__tests__/client-set-cookie-forward.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/**
* Regression tests for the Set-Cookie propagation chain through
* `vtexFetchWithCookies`. Without this chain, VTEX's `checkout.vtex.com`
* and `CheckoutOrderFormOwnership` cookies never reach the browser via
* `createServerFn` actions, the storefront's local `__orderFormId`
* drifts away from VTEX's server-side orderForm, and the user lands
* on `/checkout` with an empty cart.
*
* Two failure modes covered here:
*
* (1) Inbound capture — VTEX `Set-Cookie` headers must be appended
* to `RequestContext.responseHeaders`, skipping the two IS
* cookies that the middleware owns (`vtex_is_session`,
* `vtex_is_anonymous`), and the `domain=` attribute must be
* stripped so the browser scopes the cookie to the storefront.
*
* (2) Outbound merge — when the caller passes `init.headers` as a
* `Headers` instance (the `createVtexCheckoutProxy` factory does
* this through `getVtexFetch()`), spreading it as a plain object
* collapses to `{}` and silently wipes every other header the
* caller set. The Headers-aware merge in `vtexFetchWithCookies`
* keeps the bug from sneaking back in.
*/

import { RequestContext } from "@decocms/start/sdk/requestContext";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { configureVtex, setVtexFetch, vtexFetchWithCookies } from "../client";

function mockResponse(opts?: { body?: unknown; status?: number; setCookies?: string[] }): Response {
const status = opts?.status ?? 200;
const headers = new Headers();
for (const c of opts?.setCookies ?? []) headers.append("set-cookie", c);
return {
ok: status >= 200 && status < 300,
status,
statusText: status === 200 ? "OK" : "Error",
headers,
json: () => Promise.resolve(opts?.body ?? {}),
} as Response;
}

function headerValue(init: RequestInit | undefined, name: string): string | undefined {
const headers = init?.headers;
if (!headers) return undefined;
if (headers instanceof Headers) return headers.get(name) ?? undefined;
if (Array.isArray(headers)) {
const found = headers.find(([k]) => k.toLowerCase() === name.toLowerCase());
return found?.[1];
}
const rec = headers as Record<string, string>;
const key = Object.keys(rec).find((k) => k.toLowerCase() === name.toLowerCase());
return key ? rec[key] : undefined;
}

function withRequest<T>(
cookieHeader: string | null,
fn: (ctx: { responseHeaders: Headers }) => Promise<T>,
): Promise<T> {
const reqHeaders = new Headers();
if (cookieHeader) reqHeaders.set("cookie", cookieHeader);
const responseHeaders = new Headers();
const fakeCtx = {
request: { headers: reqHeaders } as unknown as Request,
signal: new AbortController().signal,
responseHeaders,
bag: new Map(),
startedAt: Date.now(),
};
const spy = vi
.spyOn(RequestContext, "current", "get")
.mockReturnValue(fakeCtx as unknown as ReturnType<typeof Reflect.get>);
return fn({ responseHeaders }).finally(() => spy.mockRestore());
}

describe("vtexFetchWithCookies — inbound Set-Cookie capture", () => {
let lastInit: RequestInit | undefined;

beforeEach(() => {
configureVtex({ account: "testaccount" });
lastInit = undefined;
});

afterEach(() => {
vi.restoreAllMocks();
});

it("captures upstream Set-Cookie into RequestContext.responseHeaders", async () => {
setVtexFetch(((_url: string, init?: RequestInit) => {
lastInit = init;
return Promise.resolve(
mockResponse({
setCookies: [
"checkout.vtex.com=__ofid=abc123; Path=/; HttpOnly; Secure",
"CheckoutOrderFormOwnership=def456; Path=/; HttpOnly",
],
}),
);
}) as typeof fetch);

const captured = await withRequest("vtex_segment=seg1", async ({ responseHeaders }) => {
await vtexFetchWithCookies("/api/checkout/pub/orderForm");
return responseHeaders.getSetCookie();
});

expect(captured).toHaveLength(2);
expect(captured.some((c) => c.startsWith("checkout.vtex.com="))).toBe(true);
expect(captured.some((c) => c.startsWith("CheckoutOrderFormOwnership="))).toBe(true);
});

it("strips the Domain= attribute so the browser scopes to the storefront host", async () => {
setVtexFetch((() =>
Promise.resolve(
mockResponse({
setCookies: [
"checkout.vtex.com=__ofid=abc; Domain=.vtexcommercestable.com.br; Path=/; HttpOnly",
],
}),
)) as typeof fetch);

const captured = await withRequest("foo=bar", async ({ responseHeaders }) => {
await vtexFetchWithCookies("/api/checkout/pub/orderForm");
return responseHeaders.getSetCookie();
});

expect(captured[0]).not.toMatch(/domain=/i);
expect(captured[0]).toContain("checkout.vtex.com=__ofid=abc");
});

it("skips Intelligent Search cookies (managed by middleware, not actions)", async () => {
setVtexFetch((() =>
Promise.resolve(
mockResponse({
setCookies: [
"checkout.vtex.com=__ofid=abc; Path=/",
"vtex_is_session=ignore-me; Path=/",
"vtex_is_anonymous=ignore-me-too; Path=/",
"CheckoutOrderFormOwnership=def; Path=/",
],
}),
)) as typeof fetch);

const captured = await withRequest("foo=bar", async ({ responseHeaders }) => {
await vtexFetchWithCookies("/api/checkout/pub/orderForm");
return responseHeaders.getSetCookie();
});

expect(captured.some((c) => c.startsWith("checkout.vtex.com="))).toBe(true);
expect(captured.some((c) => c.startsWith("CheckoutOrderFormOwnership="))).toBe(true);
expect(captured.some((c) => c.startsWith("vtex_is_session="))).toBe(false);
expect(captured.some((c) => c.startsWith("vtex_is_anonymous="))).toBe(false);
});

it("does not crash when called outside a RequestContext", async () => {
setVtexFetch((() =>
Promise.resolve(
mockResponse({
setCookies: ["checkout.vtex.com=__ofid=abc; Path=/"],
}),
)) as typeof fetch);
await expect(vtexFetchWithCookies("/api/checkout/pub/orderForm")).resolves.toBeDefined();
});

// Regression for Hole B: when init.headers is a Headers instance,
// the previous Record-cast + spread collapsed it to `{}`, wiping
// every other header (auth, content-type) the caller set. After
// the fix, the Headers-aware merge preserves all of them.
it("preserves other caller headers when init.headers is a Headers instance", async () => {
setVtexFetch(((_url: string, init?: RequestInit) => {
lastInit = init;
return Promise.resolve(mockResponse());
}) as typeof fetch);

await withRequest("vtex_segment=abc; foo=bar", async () => {
await vtexFetchWithCookies("/api/checkout/pub/orderForm", {
headers: new Headers({
"X-Custom-Trace": "trace-id",
"X-VTEX-Operation": "test-op",
}),
});
});

expect(headerValue(lastInit, "x-custom-trace")).toBe("trace-id");
expect(headerValue(lastInit, "x-vtex-operation")).toBe("test-op");
// Caller didn't pass a Cookie — auto-injection picks up the
// request's cookie and forwards it without dropping the
// other headers.
expect(headerValue(lastInit, "cookie")).toBeDefined();
});

it("preserves an existing Cookie header passed via Headers and sanitises it in place", async () => {
setVtexFetch(((_url: string, init?: RequestInit) => {
lastInit = init;
return Promise.resolve(mockResponse());
}) as typeof fetch);

await withRequest("vtex_segment=abc", async () => {
await vtexFetchWithCookies("/api/checkout/pub/orderForm", {
headers: new Headers({
"X-Custom": "keep-me",
cookie: "checkout.vtex.com=__ofid=xyz; vtex_segment=mine",
}),
});
});

expect(headerValue(lastInit, "x-custom")).toBe("keep-me");
expect(headerValue(lastInit, "cookie")).toContain("checkout.vtex.com=__ofid=xyz");
});
});
51 changes: 35 additions & 16 deletions vtex/actions/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* @see https://developers.vtex.com/docs/api-reference/checkout-api
*/

import { getVtexConfig, vtexFetch, vtexFetchWithCookies } from "../client";
import { getVtexConfig, vtexFetchWithCookies } from "../client";
import type { OrderForm } from "../types";

export const DEFAULT_EXPECTED_SECTIONS = [
Expand Down Expand Up @@ -179,7 +179,12 @@ export async function simulateCart(props: SimulateCartProps) {
const { items, postalCode, country, RnbBehavior = 1 } = props;
const config = getVtexConfig();
const params = appendSc(new URLSearchParams({ RnbBehavior: String(RnbBehavior) }));
return vtexFetch<any>(`/api/checkout/pub/orderForms/simulation?${params}`, {
// Uses vtexFetchWithCookies so any Set-Cookie VTEX returns on the
// orderForm-scoped simulation reaches the browser via RequestContext.
// Without this, the segment/ownership cookies VTEX may rotate during
// simulation are dropped, and the storefront's local orderFormId
// drifts away from VTEX's checkout.vtex.com server cookie.
return vtexFetchWithCookies<any>(`/api/checkout/pub/orderForms/simulation?${params}`, {
method: "POST",
body: JSON.stringify({
items,
Expand Down Expand Up @@ -352,7 +357,7 @@ export interface UpdateItemPriceProps {

export async function updateItemPrice(props: UpdateItemPriceProps): Promise<OrderForm> {
const { orderFormId, itemIndex, price } = props;
return vtexFetch<OrderForm>(
return vtexFetchWithCookies<OrderForm>(
`/api/checkout/pub/orderForm/${orderFormId}/items/${itemIndex}/price`,
{ method: "PUT", body: JSON.stringify({ price }) },
);
Expand Down Expand Up @@ -403,7 +408,9 @@ export async function getInstallments(props: GetInstallmentsProps) {
const { orderFormId, paymentSystem } = props;
const params = new URLSearchParams({ paymentSystem: String(paymentSystem) });
appendSc(params);
return vtexFetch<any>(`/api/checkout/pub/orderForm/${orderFormId}/installments?${params}`);
return vtexFetchWithCookies<any>(
`/api/checkout/pub/orderForm/${orderFormId}/installments?${params}`,
);
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -434,7 +441,9 @@ export interface ChangeToAnonymousUserProps {

export async function changeToAnonymousUser(props: ChangeToAnonymousUserProps): Promise<OrderForm> {
const { orderFormId } = props;
return vtexFetch<OrderForm>(`/api/checkout/changeToAnonymousUser/${orderFormId}`);
// This endpoint rotates the orderForm ownership cookies — must use
// vtexFetchWithCookies so the new cookies reach the browser.
return vtexFetchWithCookies<OrderForm>(`/api/checkout/changeToAnonymousUser/${orderFormId}`);
}

export interface ClearOrderFormMessagesProps {
Expand All @@ -445,10 +454,13 @@ export async function clearOrderFormMessages(
props: ClearOrderFormMessagesProps,
): Promise<OrderForm> {
const { orderFormId } = props;
return vtexFetch<OrderForm>(`/api/checkout/pub/orderForm/${orderFormId}/messages/clear`, {
method: "POST",
body: JSON.stringify({}),
});
return vtexFetchWithCookies<OrderForm>(
`/api/checkout/pub/orderForm/${orderFormId}/messages/clear`,
{
method: "POST",
body: JSON.stringify({}),
},
);
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -477,7 +489,7 @@ export async function getSellersByRegion(
const params = new URLSearchParams({ country: "BRA", postalCode });
const sc = salesChannel ?? getVtexConfig().salesChannel;
if (sc) params.set("sc", sc);
const resp = await vtexFetch<RegionResult[]>(`/api/checkout/pub/regions/?${params}`);
const resp = await vtexFetchWithCookies<RegionResult[]>(`/api/checkout/pub/regions/?${params}`);
return resp[0]?.sellers?.length > 0 ? resp[0] : null;
}

Expand All @@ -490,12 +502,19 @@ export interface SetShippingPostalCodeProps {
export async function setShippingPostalCode(props: SetShippingPostalCodeProps): Promise<boolean> {
const { orderFormId, postalCode, country = "BRA" } = props;
try {
await vtexFetch<any>(`/api/checkout/pub/orderForm/${orderFormId}/attachments/shippingData`, {
method: "POST",
body: JSON.stringify({
selectedAddresses: [{ postalCode, country }],
}),
});
// VTEX docs note that /attachments/shippingData can rotate the
// CheckoutOrderFormOwnership cookie. vtexFetchWithCookies ensures
// any such Set-Cookie reaches the browser via RequestContext,
// keeping the storefront and VTEX bound to the same orderForm.
await vtexFetchWithCookies<any>(
`/api/checkout/pub/orderForm/${orderFormId}/attachments/shippingData`,
{
method: "POST",
body: JSON.stringify({
selectedAddresses: [{ postalCode, country }],
}),
},
);
return true;
} catch {
return false;
Expand Down
51 changes: 46 additions & 5 deletions vtex/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,40 @@ function hasCookieHeader(headers: HeadersInit | undefined): boolean {
return Object.keys(headers).some((k) => k.toLowerCase() === "cookie");
}

/**
* Read the cookie header value from any HeadersInit shape.
* Returns undefined when no cookie header is set.
*/
function readCookieHeader(headers: HeadersInit | undefined): string | undefined {
if (!headers) return undefined;
if (headers instanceof Headers) return headers.get("cookie") ?? undefined;
if (Array.isArray(headers)) {
const found = headers.find(([k]) => k.toLowerCase() === "cookie");
return found?.[1];
}
const rec = headers as Record<string, string>;
const key = Object.keys(rec).find((k) => k.toLowerCase() === "cookie");
return key ? rec[key] : undefined;
}

/**
* Return a new Headers instance that copies `headers` and replaces the
* `cookie` value with `cookieValue` (or removes it when undefined).
* Centralises the "merge cookie into existing init.headers" operation so
* we never spread a Headers instance as a plain object — that collapses
* to {} because Headers has no own enumerable entries, and silently
* wipes every other header the caller set. See PR #53.
*/
function withCookieHeader(
headers: HeadersInit | undefined,
cookieValue: string | undefined,
): Headers {
const next = new Headers(headers ?? {});
if (cookieValue) next.set("cookie", cookieValue);
else next.delete("cookie");
return next;
}

export async function vtexFetchResponse(
path: string,
init?: InstrumentedFetchInit,
Expand Down Expand Up @@ -354,22 +388,29 @@ export async function vtexFetchWithCookies<T>(
// otherwise poison every checkout call for the affected user. The drop
// report is emitted via warnDroppedCookies() so we have observability the
// next time a tag misbehaves.
const existingHeaders = init?.headers as Record<string, string> | undefined;
if (!existingHeaders?.cookie) {
//
// Headers normalisation: callers pass either Headers, [name,value][],
// or Record<string,string>. We must NEVER spread a Headers instance as
// a plain object — it collapses to {} and silently drops every other
// header the caller set (auth, content-type, etc.). withCookieHeader()
// funnels every shape through the Headers constructor and is the only
// safe way to rewrite the cookie value.
const callerCookie = readCookieHeader(init?.headers);
if (!callerCookie) {
const ctx = RequestContext.current;
const raw = ctx?.request.headers.get("cookie");
if (raw) {
const { cookies, dropped } = sanitizeOutboundCookieHeader(raw);
if (dropped.length) warnDroppedCookies(dropped, vtexHost());
if (cookies) {
init = { ...init, headers: { ...existingHeaders, cookie: cookies } };
init = { ...init, headers: withCookieHeader(init?.headers, cookies) };
}
}
} else {
// Caller passed an explicit cookie — sanitize it too.
const { cookies, dropped } = sanitizeOutboundCookieHeader(existingHeaders.cookie);
const { cookies, dropped } = sanitizeOutboundCookieHeader(callerCookie);
if (dropped.length) warnDroppedCookies(dropped, vtexHost());
init = { ...init, headers: { ...existingHeaders, cookie: cookies } };
init = { ...init, headers: withCookieHeader(init?.headers, cookies) };
}

const response = await vtexFetchResponse(path, init);
Expand Down
Loading
Loading