@decocms/start 6.1.0 → 6.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Regression tests for /deco/invoke Set-Cookie propagation.
3
+ *
4
+ * The historical bug: the single- and batch-invoke paths copied
5
+ * `RequestContext.responseHeaders` to the HTTP response via
6
+ * `headers.entries()`, which collapses multiple `Set-Cookie` values
7
+ * into a single comma-joined string. Browsers silently discard those,
8
+ * so every VTEX cart action lost its session cookies and the user
9
+ * ended up at /checkout with an empty cart.
10
+ *
11
+ * These tests pin the fix: when a handler appends multiple
12
+ * Set-Cookie values to `RequestContext.responseHeaders`, the response
13
+ * returned by `handleInvoke` must surface them as N distinct
14
+ * Set-Cookie headers (readable via `response.headers.getSetCookie()`).
15
+ */
16
+
17
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
18
+ import { RequestContext } from "../sdk/requestContext";
19
+ import {
20
+ clearInvokeHandlers,
21
+ handleInvoke,
22
+ registerInvokeHandlers,
23
+ } from "./invoke";
24
+
25
+ const COOKIE_A = "checkout.vtex.com__orderFormId=of-123; Path=/; HttpOnly";
26
+ const COOKIE_B = "segment=eyJjYW1wYWlnbnMiOiJ4In0=; Path=/; HttpOnly";
27
+ const COOKIE_C = "sc=1; Path=/; HttpOnly";
28
+
29
+ function makeInvokeRequest(key: string, body: unknown = {}): Request {
30
+ return new Request(`http://localhost/deco/invoke/${key}`, {
31
+ method: "POST",
32
+ headers: { "content-type": "application/json" },
33
+ body: JSON.stringify(body),
34
+ });
35
+ }
36
+
37
+ function makeBatchRequest(body: Record<string, unknown>): Request {
38
+ return new Request("http://localhost/deco/invoke", {
39
+ method: "POST",
40
+ headers: { "content-type": "application/json" },
41
+ body: JSON.stringify(body),
42
+ });
43
+ }
44
+
45
+ describe("handleInvoke — Set-Cookie propagation (single)", () => {
46
+ beforeEach(() => clearInvokeHandlers());
47
+ afterEach(() => clearInvokeHandlers());
48
+
49
+ it("forwards multiple Set-Cookie values as distinct headers", async () => {
50
+ registerInvokeHandlers({
51
+ "vtex/actions/addItemsToCart": async () => {
52
+ RequestContext.responseHeaders.append("set-cookie", COOKIE_A);
53
+ RequestContext.responseHeaders.append("set-cookie", COOKIE_B);
54
+ RequestContext.responseHeaders.append("set-cookie", COOKIE_C);
55
+ return { orderFormId: "of-123" };
56
+ },
57
+ });
58
+
59
+ const request = makeInvokeRequest("vtex/actions/addItemsToCart");
60
+ const response = await RequestContext.run(request, () => handleInvoke(request));
61
+
62
+ const cookies = response.headers.getSetCookie();
63
+ expect(cookies).toHaveLength(3);
64
+ expect(cookies).toContain(COOKIE_A);
65
+ expect(cookies).toContain(COOKIE_B);
66
+ expect(cookies).toContain(COOKIE_C);
67
+ });
68
+
69
+ it("does not collapse cookies into a single Set-Cookie entry", async () => {
70
+ registerInvokeHandlers({
71
+ "vtex/actions/foo": async () => {
72
+ RequestContext.responseHeaders.append("set-cookie", COOKIE_A);
73
+ RequestContext.responseHeaders.append("set-cookie", COOKIE_B);
74
+ return {};
75
+ },
76
+ });
77
+
78
+ const request = makeInvokeRequest("vtex/actions/foo");
79
+ const response = await RequestContext.run(request, () => handleInvoke(request));
80
+
81
+ // The regressed bug appended a single comma-joined string, so
82
+ // `getSetCookie()` returned a 1-element array. The fix appends each
83
+ // value individually — verifying the count alone catches the regression.
84
+ expect(response.headers.getSetCookie()).toHaveLength(2);
85
+ });
86
+
87
+ it("forwards non-cookie headers unchanged", async () => {
88
+ registerInvokeHandlers({
89
+ "vtex/actions/withHeader": async () => {
90
+ RequestContext.responseHeaders.append("x-vtex-trace-id", "abc-123");
91
+ return {};
92
+ },
93
+ });
94
+
95
+ const request = makeInvokeRequest("vtex/actions/withHeader");
96
+ const response = await RequestContext.run(request, () => handleInvoke(request));
97
+ expect(response.headers.get("x-vtex-trace-id")).toBe("abc-123");
98
+ });
99
+
100
+ it("does not forward Set-Cookie when handler writes none", async () => {
101
+ registerInvokeHandlers({
102
+ "vtex/loaders/productList": async () => ({ items: [] }),
103
+ });
104
+
105
+ const request = makeInvokeRequest("vtex/loaders/productList");
106
+ const response = await RequestContext.run(request, () => handleInvoke(request));
107
+ expect(response.headers.getSetCookie()).toEqual([]);
108
+ });
109
+ });
110
+
111
+ describe("handleInvoke — Set-Cookie propagation (batch)", () => {
112
+ beforeEach(() => clearInvokeHandlers());
113
+ afterEach(() => clearInvokeHandlers());
114
+
115
+ it("forwards cookies that batch handlers append to the shared context", async () => {
116
+ registerInvokeHandlers({
117
+ "vtex/actions/addItemsToCart": async () => {
118
+ RequestContext.responseHeaders.append("set-cookie", COOKIE_A);
119
+ RequestContext.responseHeaders.append("set-cookie", COOKIE_B);
120
+ return { orderFormId: "of-123" };
121
+ },
122
+ "vtex/loaders/productList": async () => {
123
+ // Loader writes its own cookie (e.g. segment) — must also propagate.
124
+ RequestContext.responseHeaders.append("set-cookie", COOKIE_C);
125
+ return { items: [] };
126
+ },
127
+ });
128
+
129
+ const request = makeBatchRequest({
130
+ "vtex/actions/addItemsToCart": { orderFormId: "x" },
131
+ "vtex/loaders/productList": {},
132
+ });
133
+ const response = await RequestContext.run(request, () => handleInvoke(request));
134
+
135
+ const cookies = response.headers.getSetCookie();
136
+ expect(cookies).toHaveLength(3);
137
+ expect(cookies).toContain(COOKIE_A);
138
+ expect(cookies).toContain(COOKIE_B);
139
+ expect(cookies).toContain(COOKIE_C);
140
+ });
141
+ });
@@ -58,6 +58,41 @@ export function clearInvokeHandlers(): void {
58
58
 
59
59
  const JSON_HEADERS = { "Content-Type": "application/json" } as const;
60
60
 
61
+ /**
62
+ * Copy headers that handlers wrote to `RequestContext.responseHeaders`
63
+ * onto an outgoing Response.
64
+ *
65
+ * Why this exists and is not a `for…of headers.entries()` one-liner:
66
+ * `Headers.entries()` (and `forEach`) collapses multiple `Set-Cookie`
67
+ * values into a single comma-joined string (per the Fetch spec).
68
+ * Browsers silently discard cookies whose value contains an unescaped
69
+ * comma, so every VTEX cart action that returns multiple cookies
70
+ * (`checkout.vtex.com__orderFormId`, `segment`, `sc`, `vtex_session`…)
71
+ * loses them in transit. The next request creates a fresh empty
72
+ * orderForm and the user lands on /checkout with an empty cart.
73
+ *
74
+ * `Headers.getSetCookie()` is the spec-blessed way to read the
75
+ * un-collapsed list. We append each value individually onto the
76
+ * response so the browser sees N distinct `Set-Cookie` headers, and
77
+ * use `forEach` to copy any non-cookie headers as-is.
78
+ */
79
+ function forwardCtxHeadersTo(response: Response): void {
80
+ const ctx = RequestContext.current;
81
+ if (!ctx) return;
82
+ const cookies =
83
+ typeof ctx.responseHeaders.getSetCookie === "function"
84
+ ? ctx.responseHeaders.getSetCookie()
85
+ : [];
86
+ for (const cookie of cookies) {
87
+ response.headers.append("set-cookie", cookie);
88
+ }
89
+ ctx.responseHeaders.forEach((value, key) => {
90
+ if (key.toLowerCase() !== "set-cookie") {
91
+ response.headers.append(key, value);
92
+ }
93
+ });
94
+ }
95
+
61
96
  const isDev =
62
97
  typeof globalThis.process !== "undefined" && globalThis.process.env?.NODE_ENV === "development";
63
98
 
@@ -171,17 +206,7 @@ export async function handleInvoke(request: Request): Promise<Response> {
171
206
  }
172
207
  const filtered = selectFields(result, select);
173
208
  const response = new Response(JSON.stringify(filtered), { status: 200, headers: JSON_HEADERS });
174
-
175
- // Copy any headers that handlers wrote to RequestContext.responseHeaders
176
- // (e.g., Set-Cookie from proxySetCookie). This mirrors deco-cx/deco's
177
- // ctx.response.headers → HTTP Response forwarding.
178
- const ctx = RequestContext.current;
179
- if (ctx) {
180
- for (const [key, value] of ctx.responseHeaders.entries()) {
181
- response.headers.append(key, value);
182
- }
183
- }
184
-
209
+ forwardCtxHeadersTo(response);
185
210
  return response;
186
211
  } catch (error) {
187
212
  return errorResponse((error as Error).message, 500, error);
@@ -202,8 +227,11 @@ export async function handleInvoke(request: Request): Promise<Response> {
202
227
  try {
203
228
  let result = await found.handler(payload, request);
204
229
  // If a loader returns a Response, extract its JSON body for batching.
205
- // Set-Cookie headers from batch items are not forwarded individually
206
- // (use single invoke for auth loaders that need cookie passthrough).
230
+ // Set-Cookie values from a handler-returned Response are *not*
231
+ // forwarded those leave the AsyncLocalStorage scope. Handlers
232
+ // that need cookie passthrough must write to
233
+ // RequestContext.responseHeaders (which forwardCtxHeadersTo()
234
+ // below propagates onto the batch response).
207
235
  if (result instanceof Response) {
208
236
  try { result = await result.json(); } catch { result = null; }
209
237
  }
@@ -217,7 +245,12 @@ export async function handleInvoke(request: Request): Promise<Response> {
217
245
  }),
218
246
  );
219
247
 
220
- return new Response(JSON.stringify(results), { status: 200, headers: JSON_HEADERS });
248
+ const response = new Response(JSON.stringify(results), { status: 200, headers: JSON_HEADERS });
249
+ // All batch handlers share the same RequestContext, so any Set-Cookie
250
+ // they appended (e.g. from VTEX vtexFetchWithCookies) is in
251
+ // `responseHeaders` by now. Forward it as N distinct Set-Cookie headers.
252
+ forwardCtxHeadersTo(response);
253
+ return response;
221
254
  }
222
255
 
223
256
  return errorResponse("No invoke key specified", 400);