From e87bc5a374b1008786cc6e2e32e40f56ff3543d7 Mon Sep 17 00:00:00 2001 From: Fernando Frizzatti Date: Tue, 19 May 2026 17:49:52 -0300 Subject: [PATCH] fix(vtex): merge headers Headers-aware so init.headers Cookie survives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a forwarder builds `init` with `headers: new Headers(...)` and that init flows through `getVtexFetch()` (as `createVtexCheckoutProxy` does since 1.15.0), the framework's own header-merge logic silently dropped the cookies. The naive `{ ...authHeaders, ...init?.headers }` spread collapses a `Headers` instance to `{}` (Headers has no own enumerable entries), wiping the browser's full Cookie header on its way to VTEX. Fix: funnel all per-call header merges through a `mergeHeaders` helper backed by the `Headers` constructor, which correctly absorbs every `HeadersInit` shape (Headers / string[][] / Record). Apply the same treatment to `vtexCachedFetch` and `intelligentSearch` so their `_fetch` callsites get vtex_segment forwarding too — covering the last gaps that previously forced sites to wrap `setVtexFetch` with their own (often Headers-unsafe) cookie injectors. Regression test asserts an existing `Cookie` header on a `Headers`- typed init survives the full vtexFetchResponse path verbatim. Two new describe blocks exercise the cached-GET and IS paths. 418/418 tests pass; typecheck clean. Co-authored-by: Cursor --- package-lock.json | 4 +- vtex/__tests__/client-segment-cookie.test.ts | 155 ++++++++++++++++--- vtex/client.ts | 57 ++++++- 3 files changed, 187 insertions(+), 29 deletions(-) diff --git a/package-lock.json b/package-lock.json index c441357..8a5b153 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@decocms/apps", - "version": "1.13.0", + "version": "1.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@decocms/apps", - "version": "1.13.0", + "version": "1.15.0", "license": "MIT", "devDependencies": { "@biomejs/biome": "^2.4.7", diff --git a/vtex/__tests__/client-segment-cookie.test.ts b/vtex/__tests__/client-segment-cookie.test.ts index f0de476..80a69f4 100644 --- a/vtex/__tests__/client-segment-cookie.test.ts +++ b/vtex/__tests__/client-segment-cookie.test.ts @@ -7,7 +7,14 @@ import { RequestContext } from "@decocms/start/sdk/requestContext"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { configureVtex, setVtexFetch, vtexFetchResponse } from "../client"; +import { + configureVtex, + intelligentSearch, + setVtexFetch, + vtexCachedFetch, + vtexFetchResponse, +} from "../client"; +import { clearFetchCache } from "../utils/fetchCache"; function mockResponse(body: unknown = {}, status = 200): Response { return { @@ -43,6 +50,25 @@ function mockResponse(body: unknown = {}, status = 200): Response { * `vi.spyOn`. The spy is restored after `fn` resolves to keep tests * isolated. Nothing here depends on undici or ALS internals. */ +/** + * Read a header from an init in a shape-agnostic way. After the + * `mergeHeaders` refactor, `init.headers` is always a `Headers` + * instance — but the helper handles legacy shapes too so the tests + * stay robust if someone changes the merge implementation again. + */ +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; + const key = Object.keys(rec).find((k) => k.toLowerCase() === name.toLowerCase()); + return key ? rec[key] : undefined; +} + function withRequest(cookieHeader: string | null, fn: () => Promise): Promise { const headers = new Headers(); if (cookieHeader) headers.set("cookie", cookieHeader); @@ -79,8 +105,7 @@ describe("vtexFetchResponse — vtex_segment cookie forwarding", () => { await withRequest("vtex_segment=abc123; other=foo", async () => { await vtexFetchResponse("/api/catalog_system/pub/products/x"); }); - const headers = lastInit?.headers as Record; - expect(headers.cookie).toBe("vtex_segment=abc123"); + expect(headerValue(lastInit, "cookie")).toBe("vtex_segment=abc123"); }); it("does not overwrite a caller-supplied cookie header", async () => { @@ -89,8 +114,7 @@ describe("vtexFetchResponse — vtex_segment cookie forwarding", () => { headers: { cookie: "custom=zzz" }, }); }); - const headers = lastInit?.headers as Record; - expect(headers.cookie).toBe("custom=zzz"); + expect(headerValue(lastInit, "cookie")).toBe("custom=zzz"); }); it("does not overwrite a caller-supplied Cookie header (case-insensitive)", async () => { @@ -99,34 +123,26 @@ describe("vtexFetchResponse — vtex_segment cookie forwarding", () => { headers: { Cookie: "custom=zzz" }, }); }); - const headers = lastInit?.headers as Record; - // The merged headers may contain both keys; the caller's value should win. - const lowered = Object.fromEntries( - Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]), - ); - expect(lowered.cookie).toBe("custom=zzz"); + expect(headerValue(lastInit, "cookie")).toBe("custom=zzz"); }); it("is a no-op when there is no incoming cookie header", async () => { await withRequest(null, async () => { await vtexFetchResponse("/api/x"); }); - const headers = lastInit?.headers as Record; - expect(headers.cookie).toBeUndefined(); + expect(headerValue(lastInit, "cookie")).toBeUndefined(); }); it("is a no-op when there is a cookie header but no vtex_segment", async () => { await withRequest("other=foo; another=bar", async () => { await vtexFetchResponse("/api/x"); }); - const headers = lastInit?.headers as Record; - expect(headers.cookie).toBeUndefined(); + expect(headerValue(lastInit, "cookie")).toBeUndefined(); }); it("does not crash when called outside a RequestContext", async () => { await vtexFetchResponse("/api/x"); - const headers = lastInit?.headers as Record; - expect(headers.cookie).toBeUndefined(); + expect(headerValue(lastInit, "cookie")).toBeUndefined(); }); it("preserves auth headers alongside the forwarded cookie", async () => { @@ -134,9 +150,106 @@ describe("vtexFetchResponse — vtex_segment cookie forwarding", () => { await withRequest("vtex_segment=abc123", async () => { await vtexFetchResponse("/api/x"); }); - const headers = lastInit?.headers as Record; - expect(headers["X-VTEX-API-AppKey"]).toBe("k"); - expect(headers["X-VTEX-API-AppToken"]).toBe("t"); - expect(headers.cookie).toBe("vtex_segment=abc123"); + expect(headerValue(lastInit, "X-VTEX-API-AppKey")).toBe("k"); + expect(headerValue(lastInit, "X-VTEX-API-AppToken")).toBe("t"); + expect(headerValue(lastInit, "cookie")).toBe("vtex_segment=abc123"); + }); + + // Regression: when init.headers is a Headers object (as + // `createVtexCheckoutProxy` passes through `getVtexFetch()`), the + // existing cookie must survive verbatim. The naive + // `{ ...authHeaders, ...init?.headers }` spread collapses a Headers + // instance to `{}` (Headers has no own enumerable entries), which + // silently wipes the browser's full Cookie header — including the + // orderForm cookie any checkout flow depends on. + it("preserves an existing Cookie header when init.headers is a Headers instance", async () => { + await withRequest("vtex_segment=abc123", async () => { + const proxyInit: RequestInit = { + headers: new Headers({ + cookie: "checkout.vtex.com=__ofid=xyz; vtex_segment=originalseg; foo=bar", + }), + }; + await vtexFetchResponse("/api/checkout/pub/orderForm", proxyInit); + }); + expect(headerValue(lastInit, "cookie")).toContain("checkout.vtex.com=__ofid=xyz"); + }); +}); + +// Module-level counter for cache-busting test URLs. `Date.now()` collides +// when two tests run within the same millisecond and the SWR cache in +// fetchWithCache short-circuits the second one — `_fetch` never runs and +// `lastInit` stays `undefined`. Per-test ids are deterministic and +// collision-free. +let testUrlCounter = 0; +const uniqPath = (prefix: string) => `${prefix}/${++testUrlCounter}`; + +describe("vtexCachedFetch — vtex_segment cookie forwarding", () => { + let lastInit: RequestInit | undefined; + + beforeEach(() => { + clearFetchCache(); + configureVtex({ account: "testaccount" }); + lastInit = undefined; + setVtexFetch(((_url: string, init?: RequestInit) => { + lastInit = init; + return Promise.resolve(mockResponse({ ok: true })); + }) as typeof fetch); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("forwards vtex_segment on cached GETs", async () => { + await withRequest("vtex_segment=abc123", async () => { + await vtexCachedFetch(uniqPath("/api/catalog_system/pub/products")); + }); + expect(headerValue(lastInit, "cookie")).toBe("vtex_segment=abc123"); + }); + + it("does not overwrite a caller-supplied cookie header", async () => { + await withRequest("vtex_segment=abc123", async () => { + await vtexCachedFetch(uniqPath("/api/x"), { + headers: { cookie: "custom=zzz" }, + }); + }); + expect(headerValue(lastInit, "cookie")).toBe("custom=zzz"); + }); +}); + +describe("intelligentSearch — vtex_segment cookie forwarding", () => { + let lastInit: RequestInit | undefined; + + beforeEach(() => { + // Reset the SWR cache: otherwise the second test in this block + // can serve the first test's cached body without invoking the + // stub _fetch, leaving lastInit undefined. + clearFetchCache(); + configureVtex({ account: "testaccount" }); + lastInit = undefined; + setVtexFetch(((_url: string, init?: RequestInit) => { + lastInit = init; + return Promise.resolve(mockResponse({ products: [] })); + }) as typeof fetch); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("forwards vtex_segment when caller didn't pass cookieHeader", async () => { + await withRequest("vtex_segment=abc123; other=foo", async () => { + await intelligentSearch(uniqPath("/product_search")); + }); + expect(headerValue(lastInit, "cookie")).toBe("vtex_segment=abc123"); + }); + + it("respects an explicit cookieHeader override", async () => { + await withRequest("vtex_segment=abc123", async () => { + await intelligentSearch(uniqPath("/product_search"), undefined, { + cookieHeader: "custom=zzz", + }); + }); + expect(headerValue(lastInit, "cookie")).toBe("custom=zzz"); }); }); diff --git a/vtex/client.ts b/vtex/client.ts index 0c2e9b3..01d5fef 100644 --- a/vtex/client.ts +++ b/vtex/client.ts @@ -241,11 +241,7 @@ export async function vtexFetchResponse( const response = await _fetch(url, { ...init, - headers: { - ...authHeaders(), - ...(segmentCookie ? { cookie: segmentCookie } : {}), - ...init?.headers, - }, + headers: mergeHeaders(authHeaders(), segmentCookie, init?.headers), }); if (!response.ok) { throw new Error(`VTEX API error: ${response.status} ${response.statusText} - ${url}`); @@ -253,6 +249,37 @@ export async function vtexFetchResponse( return response; } +/** + * Combine framework headers + optional segment cookie + caller headers, + * preserving the precedence "caller wins" regardless of whether the + * caller passed `Headers`, `string[][]`, or `Record`. + * + * Why a helper: the naive `{ ...authHeaders, ...init?.headers }` spread + * silently collapses a `Headers` instance to `{}` (Headers has no own + * enumerable entries), which means any cookies the caller put on a + * Headers object are lost on the wire. The `createVtexCheckoutProxy` + * factory passes init with Headers, which makes this the failure mode + * for every forwarder that relies on browser-supplied cookies reaching + * VTEX. Funneling all merges through the `Headers` constructor (which + * correctly absorbs every HeadersInit shape) keeps the bug from + * sneaking back in. + */ +function mergeHeaders( + auth: Record, + segmentCookie: string | null, + callerHeaders: HeadersInit | undefined, +): Headers { + const merged = new Headers(auth); + if (segmentCookie) merged.set("cookie", segmentCookie); + if (callerHeaders) { + const incoming = new Headers(callerHeaders); + incoming.forEach((value, key) => { + merged.set(key, value); + }); + } + return merged; +} + export async function vtexFetch(path: string, init?: InstrumentedFetchInit): Promise { const response = await vtexFetchResponse(path, init); return response.json(); @@ -282,12 +309,22 @@ export async function vtexCachedFetch( ? { ttl: cacheOpts.cacheTTL } : undefined; + // Mirrors vtexFetchResponse: Legacy Catalog and several other GET + // endpoints gate regional seller availability on the `vtex_segment` + // cookie. Cached GETs (PDP / shelf product lookups) must see the same + // regionalization the rest of the stack does — otherwise sites have + // to wrap _fetch themselves to forward the cookie, which is easy to + // get subtly wrong (especially around HeadersInit shapes). Inline + // here keeps the surface small; if a third callsite appears we + // extract a shared helper. + const segmentCookie = !hasCookieHeader(init?.headers) ? getSegmentCookieHeader() : null; + return fetchWithCache( url, () => _fetch(url, { ...init, - headers: { ...authHeaders(), ...init?.headers }, + headers: mergeHeaders(authHeaders(), segmentCookie, init?.headers), }), opts, ); @@ -383,6 +420,14 @@ export async function intelligentSearch( const headers: Record = { ...authHeaders() }; if (opts?.cookieHeader) { headers.cookie = opts.cookieHeader; + } else { + // IS already gets regionId on the query string above, but some + // internal IS flows (and downstream services it consults) still + // honor the `vtex_segment` cookie — forward it when the caller + // didn't pass an explicit one. See vtexCachedFetch for the same + // rationale. + const segmentCookie = getSegmentCookieHeader(); + if (segmentCookie) headers.cookie = segmentCookie; } const fullUrl = url.toString();