Skip to content

Commit c80e5ad

Browse files
authored
Merge pull request #53 from decocms/fix/headers-aware-cookie-merge
fix(vtex): merge headers Headers-aware so init.headers Cookie survives
2 parents 5f964cb + e87bc5a commit c80e5ad

3 files changed

Lines changed: 187 additions & 29 deletions

File tree

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎vtex/__tests__/client-segment-cookie.test.ts‎

Lines changed: 134 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,14 @@
77

88
import { RequestContext } from "@decocms/start/sdk/requestContext";
99
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10-
import { configureVtex, setVtexFetch, vtexFetchResponse } from "../client";
10+
import {
11+
configureVtex,
12+
intelligentSearch,
13+
setVtexFetch,
14+
vtexCachedFetch,
15+
vtexFetchResponse,
16+
} from "../client";
17+
import { clearFetchCache } from "../utils/fetchCache";
1118

1219
function mockResponse(body: unknown = {}, status = 200): Response {
1320
return {
@@ -43,6 +50,25 @@ function mockResponse(body: unknown = {}, status = 200): Response {
4350
* `vi.spyOn`. The spy is restored after `fn` resolves to keep tests
4451
* isolated. Nothing here depends on undici or ALS internals.
4552
*/
53+
/**
54+
* Read a header from an init in a shape-agnostic way. After the
55+
* `mergeHeaders` refactor, `init.headers` is always a `Headers`
56+
* instance — but the helper handles legacy shapes too so the tests
57+
* stay robust if someone changes the merge implementation again.
58+
*/
59+
function headerValue(init: RequestInit | undefined, name: string): string | undefined {
60+
const headers = init?.headers;
61+
if (!headers) return undefined;
62+
if (headers instanceof Headers) return headers.get(name) ?? undefined;
63+
if (Array.isArray(headers)) {
64+
const found = headers.find(([k]) => k.toLowerCase() === name.toLowerCase());
65+
return found?.[1];
66+
}
67+
const rec = headers as Record<string, string>;
68+
const key = Object.keys(rec).find((k) => k.toLowerCase() === name.toLowerCase());
69+
return key ? rec[key] : undefined;
70+
}
71+
4672
function withRequest<T>(cookieHeader: string | null, fn: () => Promise<T>): Promise<T> {
4773
const headers = new Headers();
4874
if (cookieHeader) headers.set("cookie", cookieHeader);
@@ -79,8 +105,7 @@ describe("vtexFetchResponse — vtex_segment cookie forwarding", () => {
79105
await withRequest("vtex_segment=abc123; other=foo", async () => {
80106
await vtexFetchResponse("/api/catalog_system/pub/products/x");
81107
});
82-
const headers = lastInit?.headers as Record<string, string>;
83-
expect(headers.cookie).toBe("vtex_segment=abc123");
108+
expect(headerValue(lastInit, "cookie")).toBe("vtex_segment=abc123");
84109
});
85110

86111
it("does not overwrite a caller-supplied cookie header", async () => {
@@ -89,8 +114,7 @@ describe("vtexFetchResponse — vtex_segment cookie forwarding", () => {
89114
headers: { cookie: "custom=zzz" },
90115
});
91116
});
92-
const headers = lastInit?.headers as Record<string, string>;
93-
expect(headers.cookie).toBe("custom=zzz");
117+
expect(headerValue(lastInit, "cookie")).toBe("custom=zzz");
94118
});
95119

96120
it("does not overwrite a caller-supplied Cookie header (case-insensitive)", async () => {
@@ -99,44 +123,133 @@ describe("vtexFetchResponse — vtex_segment cookie forwarding", () => {
99123
headers: { Cookie: "custom=zzz" },
100124
});
101125
});
102-
const headers = lastInit?.headers as Record<string, string>;
103-
// The merged headers may contain both keys; the caller's value should win.
104-
const lowered = Object.fromEntries(
105-
Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]),
106-
);
107-
expect(lowered.cookie).toBe("custom=zzz");
126+
expect(headerValue(lastInit, "cookie")).toBe("custom=zzz");
108127
});
109128

110129
it("is a no-op when there is no incoming cookie header", async () => {
111130
await withRequest(null, async () => {
112131
await vtexFetchResponse("/api/x");
113132
});
114-
const headers = lastInit?.headers as Record<string, string>;
115-
expect(headers.cookie).toBeUndefined();
133+
expect(headerValue(lastInit, "cookie")).toBeUndefined();
116134
});
117135

118136
it("is a no-op when there is a cookie header but no vtex_segment", async () => {
119137
await withRequest("other=foo; another=bar", async () => {
120138
await vtexFetchResponse("/api/x");
121139
});
122-
const headers = lastInit?.headers as Record<string, string>;
123-
expect(headers.cookie).toBeUndefined();
140+
expect(headerValue(lastInit, "cookie")).toBeUndefined();
124141
});
125142

126143
it("does not crash when called outside a RequestContext", async () => {
127144
await vtexFetchResponse("/api/x");
128-
const headers = lastInit?.headers as Record<string, string>;
129-
expect(headers.cookie).toBeUndefined();
145+
expect(headerValue(lastInit, "cookie")).toBeUndefined();
130146
});
131147

132148
it("preserves auth headers alongside the forwarded cookie", async () => {
133149
configureVtex({ account: "testaccount", appKey: "k", appToken: "t" });
134150
await withRequest("vtex_segment=abc123", async () => {
135151
await vtexFetchResponse("/api/x");
136152
});
137-
const headers = lastInit?.headers as Record<string, string>;
138-
expect(headers["X-VTEX-API-AppKey"]).toBe("k");
139-
expect(headers["X-VTEX-API-AppToken"]).toBe("t");
140-
expect(headers.cookie).toBe("vtex_segment=abc123");
153+
expect(headerValue(lastInit, "X-VTEX-API-AppKey")).toBe("k");
154+
expect(headerValue(lastInit, "X-VTEX-API-AppToken")).toBe("t");
155+
expect(headerValue(lastInit, "cookie")).toBe("vtex_segment=abc123");
156+
});
157+
158+
// Regression: when init.headers is a Headers object (as
159+
// `createVtexCheckoutProxy` passes through `getVtexFetch()`), the
160+
// existing cookie must survive verbatim. The naive
161+
// `{ ...authHeaders, ...init?.headers }` spread collapses a Headers
162+
// instance to `{}` (Headers has no own enumerable entries), which
163+
// silently wipes the browser's full Cookie header — including the
164+
// orderForm cookie any checkout flow depends on.
165+
it("preserves an existing Cookie header when init.headers is a Headers instance", async () => {
166+
await withRequest("vtex_segment=abc123", async () => {
167+
const proxyInit: RequestInit = {
168+
headers: new Headers({
169+
cookie: "checkout.vtex.com=__ofid=xyz; vtex_segment=originalseg; foo=bar",
170+
}),
171+
};
172+
await vtexFetchResponse("/api/checkout/pub/orderForm", proxyInit);
173+
});
174+
expect(headerValue(lastInit, "cookie")).toContain("checkout.vtex.com=__ofid=xyz");
175+
});
176+
});
177+
178+
// Module-level counter for cache-busting test URLs. `Date.now()` collides
179+
// when two tests run within the same millisecond and the SWR cache in
180+
// fetchWithCache short-circuits the second one — `_fetch` never runs and
181+
// `lastInit` stays `undefined`. Per-test ids are deterministic and
182+
// collision-free.
183+
let testUrlCounter = 0;
184+
const uniqPath = (prefix: string) => `${prefix}/${++testUrlCounter}`;
185+
186+
describe("vtexCachedFetch — vtex_segment cookie forwarding", () => {
187+
let lastInit: RequestInit | undefined;
188+
189+
beforeEach(() => {
190+
clearFetchCache();
191+
configureVtex({ account: "testaccount" });
192+
lastInit = undefined;
193+
setVtexFetch(((_url: string, init?: RequestInit) => {
194+
lastInit = init;
195+
return Promise.resolve(mockResponse({ ok: true }));
196+
}) as typeof fetch);
197+
});
198+
199+
afterEach(() => {
200+
vi.restoreAllMocks();
201+
});
202+
203+
it("forwards vtex_segment on cached GETs", async () => {
204+
await withRequest("vtex_segment=abc123", async () => {
205+
await vtexCachedFetch(uniqPath("/api/catalog_system/pub/products"));
206+
});
207+
expect(headerValue(lastInit, "cookie")).toBe("vtex_segment=abc123");
208+
});
209+
210+
it("does not overwrite a caller-supplied cookie header", async () => {
211+
await withRequest("vtex_segment=abc123", async () => {
212+
await vtexCachedFetch(uniqPath("/api/x"), {
213+
headers: { cookie: "custom=zzz" },
214+
});
215+
});
216+
expect(headerValue(lastInit, "cookie")).toBe("custom=zzz");
217+
});
218+
});
219+
220+
describe("intelligentSearch — vtex_segment cookie forwarding", () => {
221+
let lastInit: RequestInit | undefined;
222+
223+
beforeEach(() => {
224+
// Reset the SWR cache: otherwise the second test in this block
225+
// can serve the first test's cached body without invoking the
226+
// stub _fetch, leaving lastInit undefined.
227+
clearFetchCache();
228+
configureVtex({ account: "testaccount" });
229+
lastInit = undefined;
230+
setVtexFetch(((_url: string, init?: RequestInit) => {
231+
lastInit = init;
232+
return Promise.resolve(mockResponse({ products: [] }));
233+
}) as typeof fetch);
234+
});
235+
236+
afterEach(() => {
237+
vi.restoreAllMocks();
238+
});
239+
240+
it("forwards vtex_segment when caller didn't pass cookieHeader", async () => {
241+
await withRequest("vtex_segment=abc123; other=foo", async () => {
242+
await intelligentSearch(uniqPath("/product_search"));
243+
});
244+
expect(headerValue(lastInit, "cookie")).toBe("vtex_segment=abc123");
245+
});
246+
247+
it("respects an explicit cookieHeader override", async () => {
248+
await withRequest("vtex_segment=abc123", async () => {
249+
await intelligentSearch(uniqPath("/product_search"), undefined, {
250+
cookieHeader: "custom=zzz",
251+
});
252+
});
253+
expect(headerValue(lastInit, "cookie")).toBe("custom=zzz");
141254
});
142255
});

‎vtex/client.ts‎

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -241,18 +241,45 @@ export async function vtexFetchResponse(
241241

242242
const response = await _fetch(url, {
243243
...init,
244-
headers: {
245-
...authHeaders(),
246-
...(segmentCookie ? { cookie: segmentCookie } : {}),
247-
...init?.headers,
248-
},
244+
headers: mergeHeaders(authHeaders(), segmentCookie, init?.headers),
249245
});
250246
if (!response.ok) {
251247
throw new Error(`VTEX API error: ${response.status} ${response.statusText} - ${url}`);
252248
}
253249
return response;
254250
}
255251

252+
/**
253+
* Combine framework headers + optional segment cookie + caller headers,
254+
* preserving the precedence "caller wins" regardless of whether the
255+
* caller passed `Headers`, `string[][]`, or `Record<string, string>`.
256+
*
257+
* Why a helper: the naive `{ ...authHeaders, ...init?.headers }` spread
258+
* silently collapses a `Headers` instance to `{}` (Headers has no own
259+
* enumerable entries), which means any cookies the caller put on a
260+
* Headers object are lost on the wire. The `createVtexCheckoutProxy`
261+
* factory passes init with Headers, which makes this the failure mode
262+
* for every forwarder that relies on browser-supplied cookies reaching
263+
* VTEX. Funneling all merges through the `Headers` constructor (which
264+
* correctly absorbs every HeadersInit shape) keeps the bug from
265+
* sneaking back in.
266+
*/
267+
function mergeHeaders(
268+
auth: Record<string, string>,
269+
segmentCookie: string | null,
270+
callerHeaders: HeadersInit | undefined,
271+
): Headers {
272+
const merged = new Headers(auth);
273+
if (segmentCookie) merged.set("cookie", segmentCookie);
274+
if (callerHeaders) {
275+
const incoming = new Headers(callerHeaders);
276+
incoming.forEach((value, key) => {
277+
merged.set(key, value);
278+
});
279+
}
280+
return merged;
281+
}
282+
256283
export async function vtexFetch<T>(path: string, init?: InstrumentedFetchInit): Promise<T> {
257284
const response = await vtexFetchResponse(path, init);
258285
return response.json();
@@ -282,12 +309,22 @@ export async function vtexCachedFetch<T>(
282309
? { ttl: cacheOpts.cacheTTL }
283310
: undefined;
284311

312+
// Mirrors vtexFetchResponse: Legacy Catalog and several other GET
313+
// endpoints gate regional seller availability on the `vtex_segment`
314+
// cookie. Cached GETs (PDP / shelf product lookups) must see the same
315+
// regionalization the rest of the stack does — otherwise sites have
316+
// to wrap _fetch themselves to forward the cookie, which is easy to
317+
// get subtly wrong (especially around HeadersInit shapes). Inline
318+
// here keeps the surface small; if a third callsite appears we
319+
// extract a shared helper.
320+
const segmentCookie = !hasCookieHeader(init?.headers) ? getSegmentCookieHeader() : null;
321+
285322
return fetchWithCache<T>(
286323
url,
287324
() =>
288325
_fetch(url, {
289326
...init,
290-
headers: { ...authHeaders(), ...init?.headers },
327+
headers: mergeHeaders(authHeaders(), segmentCookie, init?.headers),
291328
}),
292329
opts,
293330
);
@@ -383,6 +420,14 @@ export async function intelligentSearch<T>(
383420
const headers: Record<string, string> = { ...authHeaders() };
384421
if (opts?.cookieHeader) {
385422
headers.cookie = opts.cookieHeader;
423+
} else {
424+
// IS already gets regionId on the query string above, but some
425+
// internal IS flows (and downstream services it consults) still
426+
// honor the `vtex_segment` cookie — forward it when the caller
427+
// didn't pass an explicit one. See vtexCachedFetch for the same
428+
// rationale.
429+
const segmentCookie = getSegmentCookieHeader();
430+
if (segmentCookie) headers.cookie = segmentCookie;
386431
}
387432

388433
const fullUrl = url.toString();

0 commit comments

Comments
 (0)