@decocms/tanstack 7.34.4 → 7.36.0
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.
- package/package.json +4 -4
- package/src/sdk/draft.test.ts +97 -29
- package/src/sdk/workerEntry.test.ts +183 -50
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/tanstack",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.36.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco framework binding for TanStack Start + Cloudflare Workers",
|
|
6
6
|
"repository": {
|
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
"lint:unused": "knip"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@decocms/blocks": "7.
|
|
28
|
-
"@decocms/blocks-admin": "7.
|
|
29
|
-
"@decocms/blocks-cli": "7.
|
|
27
|
+
"@decocms/blocks": "7.36.0",
|
|
28
|
+
"@decocms/blocks-admin": "7.36.0",
|
|
29
|
+
"@decocms/blocks-cli": "7.36.0",
|
|
30
30
|
"@deco-cx/warp-node": "^0.3.16",
|
|
31
31
|
"fast-json-patch": "^3.1.0",
|
|
32
32
|
"ws": "^8.18.0"
|
package/src/sdk/draft.test.ts
CHANGED
|
@@ -19,11 +19,15 @@ import { DRAFT_POINTER_BAG_KEY } from "./draftShared";
|
|
|
19
19
|
const HOST = "preview.example";
|
|
20
20
|
const DRAFT_BLOCKS = { "pages-home": { title: "draft" } };
|
|
21
21
|
|
|
22
|
-
function req(
|
|
22
|
+
function req(
|
|
23
|
+
opts: { url?: string; host?: string; cookie?: string } = {},
|
|
24
|
+
): Request {
|
|
23
25
|
const headers = new Headers();
|
|
24
26
|
headers.set("host", opts.host ?? HOST);
|
|
25
27
|
if (opts.cookie) headers.set("cookie", opts.cookie);
|
|
26
|
-
return new Request(opts.url ?? `https://${opts.host ?? HOST}/some/page`, {
|
|
28
|
+
return new Request(opts.url ?? `https://${opts.host ?? HOST}/some/page`, {
|
|
29
|
+
headers,
|
|
30
|
+
});
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
/** A fetch stub serving the sandbox decofile; throws if `fail`. */
|
|
@@ -51,41 +55,54 @@ afterEach(() => {
|
|
|
51
55
|
describe("requestCarriesDraft", () => {
|
|
52
56
|
it("is inert when no allowlist is configured", () => {
|
|
53
57
|
setDraftPreviewHosts([]);
|
|
54
|
-
const url = new URL(
|
|
58
|
+
const url = new URL(
|
|
59
|
+
`https://${HOST}/p?__draft=abc.preview-studio.decocms.com@v1`,
|
|
60
|
+
);
|
|
55
61
|
expect(requestCarriesDraft(req({ url: url.toString() }), url)).toBe(false);
|
|
56
62
|
});
|
|
57
63
|
|
|
58
64
|
it("detects the ?__draft= entry param", () => {
|
|
59
|
-
const url = new URL(
|
|
65
|
+
const url = new URL(
|
|
66
|
+
`https://${HOST}/p?__draft=abc.preview-studio.decocms.com@v1`,
|
|
67
|
+
);
|
|
60
68
|
expect(requestCarriesDraft(req({ url: url.toString() }), url)).toBe(true);
|
|
61
69
|
});
|
|
62
70
|
|
|
63
71
|
it("detects the navigation cookie (SPA nav after entry)", () => {
|
|
64
72
|
const url = new URL(`https://${HOST}/p`);
|
|
65
73
|
expect(
|
|
66
|
-
requestCarriesDraft(
|
|
74
|
+
requestCarriesDraft(
|
|
75
|
+
req({ url: url.toString(), cookie: "__deco_draft=abc.x@v1" }),
|
|
76
|
+
url,
|
|
77
|
+
),
|
|
67
78
|
).toBe(true);
|
|
68
79
|
});
|
|
69
80
|
|
|
70
81
|
it("is false on ?__draft=off (leaving preview)", () => {
|
|
71
82
|
const url = new URL(`https://${HOST}/p?__draft=off`);
|
|
72
83
|
expect(
|
|
73
|
-
requestCarriesDraft(
|
|
84
|
+
requestCarriesDraft(
|
|
85
|
+
req({ url: url.toString(), cookie: "__deco_draft=abc.x@v1" }),
|
|
86
|
+
url,
|
|
87
|
+
),
|
|
74
88
|
).toBe(false);
|
|
75
89
|
});
|
|
76
90
|
|
|
77
91
|
it("is structurally inert on a non-preview host, param or not", () => {
|
|
78
92
|
const url = new URL("https://prod.example/p?__draft=abc.x@v1");
|
|
79
|
-
expect(
|
|
80
|
-
|
|
81
|
-
|
|
93
|
+
expect(
|
|
94
|
+
requestCarriesDraft(
|
|
95
|
+
req({ url: url.toString(), host: "prod.example" }),
|
|
96
|
+
url,
|
|
97
|
+
),
|
|
98
|
+
).toBe(false);
|
|
82
99
|
});
|
|
83
100
|
});
|
|
84
101
|
|
|
85
102
|
describe("bindRequestDraft", () => {
|
|
86
103
|
it("binds the resolved draft to the request bag on entry", async () => {
|
|
87
104
|
registerDraftOverride();
|
|
88
|
-
const pointer = "
|
|
105
|
+
const pointer = "studio.decocms.com/api/o/decofile/m/main?token=t@v1";
|
|
89
106
|
const request = req({ url: `https://${HOST}/p?__draft=${pointer}` });
|
|
90
107
|
|
|
91
108
|
const decision = await RequestContext.run(request, async () => {
|
|
@@ -95,20 +112,31 @@ describe("bindRequestDraft", () => {
|
|
|
95
112
|
return d;
|
|
96
113
|
});
|
|
97
114
|
|
|
98
|
-
expect(decision).toEqual({
|
|
115
|
+
expect(decision).toEqual({
|
|
116
|
+
previewing: true,
|
|
117
|
+
setCookie: pointer,
|
|
118
|
+
clearCookie: false,
|
|
119
|
+
});
|
|
99
120
|
});
|
|
100
121
|
|
|
101
122
|
it("carries the draft across navigation via the cookie (no re-set)", async () => {
|
|
102
123
|
registerDraftOverride();
|
|
103
124
|
const pointer = "abc.preview-studio.decocms.com@v1";
|
|
104
|
-
const request = req({
|
|
125
|
+
const request = req({
|
|
126
|
+
url: `https://${HOST}/other`,
|
|
127
|
+
cookie: `__deco_draft=${pointer}`,
|
|
128
|
+
});
|
|
105
129
|
|
|
106
130
|
const decision = await RequestContext.run(request, () =>
|
|
107
131
|
bindRequestDraft(request, sandboxFetch()),
|
|
108
132
|
);
|
|
109
133
|
|
|
110
134
|
// previewing, but setCookie is null — the cookie is already present.
|
|
111
|
-
expect(decision).toEqual({
|
|
135
|
+
expect(decision).toEqual({
|
|
136
|
+
previewing: true,
|
|
137
|
+
setCookie: null,
|
|
138
|
+
clearCookie: false,
|
|
139
|
+
});
|
|
112
140
|
});
|
|
113
141
|
|
|
114
142
|
it("decodes a URL-encoded cookie so the badge pointer round-trips (no double-encode)", async () => {
|
|
@@ -116,7 +144,7 @@ describe("bindRequestDraft", () => {
|
|
|
116
144
|
// pointer stashed for the badge must be the DECODED value, or the badge's
|
|
117
145
|
// share link would re-encode an already-encoded string.
|
|
118
146
|
registerDraftOverride();
|
|
119
|
-
const decoded = "abc.localhost:60534@v7";
|
|
147
|
+
const decoded = "abc.localhost:60534/api/o/decofile/m/main?token=t@v7";
|
|
120
148
|
const request = req({
|
|
121
149
|
url: `https://${HOST}/other`,
|
|
122
150
|
cookie: `__deco_draft=${encodeURIComponent(decoded)}`,
|
|
@@ -131,7 +159,9 @@ describe("bindRequestDraft", () => {
|
|
|
131
159
|
});
|
|
132
160
|
|
|
133
161
|
it("stays previewing (uncacheable) even when the draft fails to resolve", async () => {
|
|
134
|
-
const request = req({
|
|
162
|
+
const request = req({
|
|
163
|
+
url: `https://${HOST}/p?__draft=abc.preview-studio.decocms.com@v1`,
|
|
164
|
+
});
|
|
135
165
|
const decision = await RequestContext.run(request, () =>
|
|
136
166
|
bindRequestDraft(request, sandboxFetch(true)),
|
|
137
167
|
);
|
|
@@ -140,9 +170,18 @@ describe("bindRequestDraft", () => {
|
|
|
140
170
|
});
|
|
141
171
|
|
|
142
172
|
it("clears the cookie on ?__draft=off", async () => {
|
|
143
|
-
const request = req({
|
|
144
|
-
|
|
145
|
-
|
|
173
|
+
const request = req({
|
|
174
|
+
url: `https://${HOST}/p?__draft=off`,
|
|
175
|
+
cookie: "__deco_draft=x@v1",
|
|
176
|
+
});
|
|
177
|
+
const decision = await RequestContext.run(request, () =>
|
|
178
|
+
bindRequestDraft(request),
|
|
179
|
+
);
|
|
180
|
+
expect(decision).toEqual({
|
|
181
|
+
previewing: false,
|
|
182
|
+
setCookie: null,
|
|
183
|
+
clearCookie: true,
|
|
184
|
+
});
|
|
146
185
|
});
|
|
147
186
|
|
|
148
187
|
it("is inert on a non-preview host", async () => {
|
|
@@ -150,8 +189,14 @@ describe("bindRequestDraft", () => {
|
|
|
150
189
|
url: "https://prod.example/p?__draft=abc.x@v1",
|
|
151
190
|
host: "prod.example",
|
|
152
191
|
});
|
|
153
|
-
const decision = await RequestContext.run(request, () =>
|
|
154
|
-
|
|
192
|
+
const decision = await RequestContext.run(request, () =>
|
|
193
|
+
bindRequestDraft(request),
|
|
194
|
+
);
|
|
195
|
+
expect(decision).toEqual({
|
|
196
|
+
previewing: false,
|
|
197
|
+
setCookie: null,
|
|
198
|
+
clearCookie: false,
|
|
199
|
+
});
|
|
155
200
|
});
|
|
156
201
|
});
|
|
157
202
|
|
|
@@ -180,7 +225,11 @@ describe("applyDraftCookieAndHeaders", () => {
|
|
|
180
225
|
|
|
181
226
|
it("expires the cookie when leaving preview", () => {
|
|
182
227
|
const res = new Response("ok");
|
|
183
|
-
applyDraftCookieAndHeaders(res, {
|
|
228
|
+
applyDraftCookieAndHeaders(res, {
|
|
229
|
+
previewing: false,
|
|
230
|
+
setCookie: null,
|
|
231
|
+
clearCookie: true,
|
|
232
|
+
});
|
|
184
233
|
expect(res.headers.get("set-cookie") ?? "").toContain("Max-Age=0");
|
|
185
234
|
// Not previewing → no cache/index headers.
|
|
186
235
|
expect(res.headers.get("cache-control")).toBeNull();
|
|
@@ -188,19 +237,33 @@ describe("applyDraftCookieAndHeaders", () => {
|
|
|
188
237
|
|
|
189
238
|
it("merges Cookie into an existing Vary instead of clobbering it", () => {
|
|
190
239
|
const res = new Response("ok", { headers: { Vary: "Accept-Encoding" } });
|
|
191
|
-
applyDraftCookieAndHeaders(res, {
|
|
240
|
+
applyDraftCookieAndHeaders(res, {
|
|
241
|
+
previewing: true,
|
|
242
|
+
setCookie: null,
|
|
243
|
+
clearCookie: false,
|
|
244
|
+
});
|
|
192
245
|
expect(res.headers.get("vary")).toBe("Accept-Encoding, Cookie");
|
|
193
246
|
});
|
|
194
247
|
|
|
195
248
|
it("leaves a Vary that already lists Cookie untouched (no token dropped)", () => {
|
|
196
|
-
const res = new Response("ok", {
|
|
197
|
-
|
|
249
|
+
const res = new Response("ok", {
|
|
250
|
+
headers: { Vary: "Accept-Encoding, Cookie" },
|
|
251
|
+
});
|
|
252
|
+
applyDraftCookieAndHeaders(res, {
|
|
253
|
+
previewing: true,
|
|
254
|
+
setCookie: null,
|
|
255
|
+
clearCookie: false,
|
|
256
|
+
});
|
|
198
257
|
expect(res.headers.get("vary")).toBe("Accept-Encoding, Cookie");
|
|
199
258
|
});
|
|
200
259
|
|
|
201
260
|
it("does nothing for an ordinary (non-draft) response", () => {
|
|
202
261
|
const res = new Response("ok");
|
|
203
|
-
applyDraftCookieAndHeaders(res, {
|
|
262
|
+
applyDraftCookieAndHeaders(res, {
|
|
263
|
+
previewing: false,
|
|
264
|
+
setCookie: null,
|
|
265
|
+
clearCookie: false,
|
|
266
|
+
});
|
|
204
267
|
expect(res.headers.get("set-cookie")).toBeNull();
|
|
205
268
|
expect(res.headers.get("cache-control")).toBeNull();
|
|
206
269
|
});
|
|
@@ -209,7 +272,9 @@ describe("applyDraftCookieAndHeaders", () => {
|
|
|
209
272
|
describe("installPreviewHostsFromBlocks", () => {
|
|
210
273
|
it("installs previewHosts from the site block (either casing)", async () => {
|
|
211
274
|
setDraftPreviewHosts([]);
|
|
212
|
-
installPreviewHostsFromBlocks({
|
|
275
|
+
installPreviewHostsFromBlocks({
|
|
276
|
+
Site: { previewHosts: ["fila.vtex.app"] },
|
|
277
|
+
});
|
|
213
278
|
|
|
214
279
|
// Verified through the wire: an allowed host now previews.
|
|
215
280
|
const request = req({
|
|
@@ -224,8 +289,11 @@ describe("installPreviewHostsFromBlocks", () => {
|
|
|
224
289
|
setDraftPreviewHosts([]);
|
|
225
290
|
installPreviewHostsFromBlocks({ site: {} });
|
|
226
291
|
const url = new URL("https://fila.vtex.app/p?__draft=x@v1");
|
|
227
|
-
expect(
|
|
228
|
-
|
|
229
|
-
|
|
292
|
+
expect(
|
|
293
|
+
requestCarriesDraft(
|
|
294
|
+
req({ url: url.toString(), host: "fila.vtex.app" }),
|
|
295
|
+
url,
|
|
296
|
+
),
|
|
297
|
+
).toBe(false);
|
|
230
298
|
});
|
|
231
299
|
});
|
|
@@ -59,7 +59,10 @@ describe("injectGeoCookies", () => {
|
|
|
59
59
|
});
|
|
60
60
|
|
|
61
61
|
it("strips cf-ipcity from the outgoing Request headers while preserving the value in __cf_geo_city cookie", () => {
|
|
62
|
-
const req = makeRequest(
|
|
62
|
+
const req = makeRequest(
|
|
63
|
+
{ city: "Brasília", country: "BR" },
|
|
64
|
+
{ "cf-ipcity": "Brasília" },
|
|
65
|
+
);
|
|
63
66
|
|
|
64
67
|
const out = injectGeoCookies(req);
|
|
65
68
|
|
|
@@ -88,7 +91,10 @@ describe("injectGeoCookies", () => {
|
|
|
88
91
|
});
|
|
89
92
|
|
|
90
93
|
it("preserves a pre-existing cookie header", () => {
|
|
91
|
-
const req = makeRequest(
|
|
94
|
+
const req = makeRequest(
|
|
95
|
+
{ region: "São Paulo" },
|
|
96
|
+
{ cookie: "vtex_segment=abc; another=xyz" },
|
|
97
|
+
);
|
|
92
98
|
|
|
93
99
|
const out = injectGeoCookies(req);
|
|
94
100
|
|
|
@@ -143,7 +149,9 @@ describe("buildGeoCacheParam", () => {
|
|
|
143
149
|
|
|
144
150
|
it("omits missing fields gracefully", () => {
|
|
145
151
|
expect(buildGeoCacheParam({ country: "BR" }, "city")).toBe("BR");
|
|
146
|
-
expect(buildGeoCacheParam({ country: "BR", region: "MG" }, "city")).toBe(
|
|
152
|
+
expect(buildGeoCacheParam({ country: "BR", region: "MG" }, "city")).toBe(
|
|
153
|
+
"BR|MG",
|
|
154
|
+
);
|
|
147
155
|
});
|
|
148
156
|
|
|
149
157
|
it("returns undefined when cf has none of country/region/city", () => {
|
|
@@ -214,8 +222,14 @@ describe("CMS redirects", () => {
|
|
|
214
222
|
redirects: [{ from: "/old", to: "/new", type: "permanent" }],
|
|
215
223
|
},
|
|
216
224
|
});
|
|
217
|
-
const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
|
|
218
|
-
|
|
225
|
+
const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
|
|
226
|
+
observability: false,
|
|
227
|
+
});
|
|
228
|
+
const res = await worker.fetch(
|
|
229
|
+
new Request("https://example.com/old"),
|
|
230
|
+
EMPTY_ENV,
|
|
231
|
+
MOCK_CTX,
|
|
232
|
+
);
|
|
219
233
|
expect(res.status).toBe(301);
|
|
220
234
|
expect(res.headers.get("Location")).toBe("/new");
|
|
221
235
|
});
|
|
@@ -227,7 +241,9 @@ describe("CMS redirects", () => {
|
|
|
227
241
|
redirects: [{ from: "/old", to: "/new", type: "permanent" }],
|
|
228
242
|
},
|
|
229
243
|
});
|
|
230
|
-
const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
|
|
244
|
+
const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
|
|
245
|
+
observability: false,
|
|
246
|
+
});
|
|
231
247
|
const res = await worker.fetch(
|
|
232
248
|
new Request("https://example.com/old?asJson"),
|
|
233
249
|
EMPTY_ENV,
|
|
@@ -244,8 +260,14 @@ describe("CMS redirects", () => {
|
|
|
244
260
|
redirects: [{ from: "/promo", to: "/promoção", type: "temporary" }],
|
|
245
261
|
},
|
|
246
262
|
});
|
|
247
|
-
const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
|
|
248
|
-
|
|
263
|
+
const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
|
|
264
|
+
observability: false,
|
|
265
|
+
});
|
|
266
|
+
const res = await worker.fetch(
|
|
267
|
+
new Request("https://example.com/promo"),
|
|
268
|
+
EMPTY_ENV,
|
|
269
|
+
MOCK_CTX,
|
|
270
|
+
);
|
|
249
271
|
expect(res.status).toBe(302);
|
|
250
272
|
expect(res.headers.get("Location")).toBe("/promo%C3%A7%C3%A3o");
|
|
251
273
|
});
|
|
@@ -257,8 +279,14 @@ describe("CMS redirects", () => {
|
|
|
257
279
|
redirects: [{ from: "/promo", to: "/sale", type: "temporary" }],
|
|
258
280
|
},
|
|
259
281
|
});
|
|
260
|
-
const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
|
|
261
|
-
|
|
282
|
+
const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
|
|
283
|
+
observability: false,
|
|
284
|
+
});
|
|
285
|
+
const res = await worker.fetch(
|
|
286
|
+
new Request("https://example.com/promo"),
|
|
287
|
+
EMPTY_ENV,
|
|
288
|
+
MOCK_CTX,
|
|
289
|
+
);
|
|
262
290
|
expect(res.status).toBe(302);
|
|
263
291
|
expect(res.headers.get("Location")).toBe("/sale");
|
|
264
292
|
});
|
|
@@ -270,17 +298,29 @@ describe("CMS redirects", () => {
|
|
|
270
298
|
redirects: [{ from: "/old", to: "/new", type: "permanent" }],
|
|
271
299
|
},
|
|
272
300
|
});
|
|
273
|
-
const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
|
|
274
|
-
|
|
301
|
+
const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
|
|
302
|
+
observability: false,
|
|
303
|
+
});
|
|
304
|
+
const res = await worker.fetch(
|
|
305
|
+
new Request("https://example.com/other"),
|
|
306
|
+
EMPTY_ENV,
|
|
307
|
+
MOCK_CTX,
|
|
308
|
+
);
|
|
275
309
|
expect(res.status).toBe(200);
|
|
276
310
|
});
|
|
277
311
|
|
|
278
312
|
it("picks up new redirects after setBlocks hot-reload", async () => {
|
|
279
313
|
setBlocks({});
|
|
280
|
-
const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
|
|
314
|
+
const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
|
|
315
|
+
observability: false,
|
|
316
|
+
});
|
|
281
317
|
|
|
282
318
|
// First request with no redirects — falls through
|
|
283
|
-
const res1 = await worker.fetch(
|
|
319
|
+
const res1 = await worker.fetch(
|
|
320
|
+
new Request("https://example.com/v1"),
|
|
321
|
+
EMPTY_ENV,
|
|
322
|
+
MOCK_CTX,
|
|
323
|
+
);
|
|
284
324
|
expect(res1.status).toBe(200);
|
|
285
325
|
|
|
286
326
|
// Hot-reload: add a redirect
|
|
@@ -292,7 +332,11 @@ describe("CMS redirects", () => {
|
|
|
292
332
|
});
|
|
293
333
|
|
|
294
334
|
// Same path should now redirect
|
|
295
|
-
const res2 = await worker.fetch(
|
|
335
|
+
const res2 = await worker.fetch(
|
|
336
|
+
new Request("https://example.com/v1"),
|
|
337
|
+
EMPTY_ENV,
|
|
338
|
+
MOCK_CTX,
|
|
339
|
+
);
|
|
296
340
|
expect(res2.status).toBe(301);
|
|
297
341
|
expect(res2.headers.get("Location")).toBe("/v2");
|
|
298
342
|
});
|
|
@@ -313,7 +357,9 @@ describe("degraded origin (anti-cache-poisoning)", () => {
|
|
|
313
357
|
headers: { "X-Deco-Degraded": "true" },
|
|
314
358
|
}),
|
|
315
359
|
};
|
|
316
|
-
const worker = createDecoWorkerEntry(degradedEntry, {
|
|
360
|
+
const worker = createDecoWorkerEntry(degradedEntry, {
|
|
361
|
+
observability: false,
|
|
362
|
+
});
|
|
317
363
|
const res = await worker.fetch(
|
|
318
364
|
new Request("https://example.com/some-category"),
|
|
319
365
|
EMPTY_ENV,
|
|
@@ -327,7 +373,9 @@ describe("degraded origin (anti-cache-poisoning)", () => {
|
|
|
327
373
|
|
|
328
374
|
it("serves a healthy 200 origin without the degraded bypass", async () => {
|
|
329
375
|
setBlocks({});
|
|
330
|
-
const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
|
|
376
|
+
const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
|
|
377
|
+
observability: false,
|
|
378
|
+
});
|
|
331
379
|
const res = await worker.fetch(
|
|
332
380
|
new Request("https://example.com/some-category"),
|
|
333
381
|
EMPTY_ENV,
|
|
@@ -365,8 +413,14 @@ describe("security headers — frame-ancestors default", () => {
|
|
|
365
413
|
|
|
366
414
|
it("applies the default frame-ancestors CSP on HTML responses and drops X-Frame-Options", async () => {
|
|
367
415
|
const worker = createDecoWorkerEntry(HTML_SERVER_ENTRY);
|
|
368
|
-
const res = await worker.fetch(
|
|
369
|
-
|
|
416
|
+
const res = await worker.fetch(
|
|
417
|
+
new Request("https://example.com/"),
|
|
418
|
+
EMPTY_ENV,
|
|
419
|
+
MOCK_CTX,
|
|
420
|
+
);
|
|
421
|
+
expect(res.headers.get("Content-Security-Policy")).toBe(
|
|
422
|
+
DEFAULT_FRAME_ANCESTORS_CSP,
|
|
423
|
+
);
|
|
370
424
|
expect(res.headers.get("X-Frame-Options")).toBeNull();
|
|
371
425
|
// Other defaults are still present.
|
|
372
426
|
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
|
@@ -376,24 +430,42 @@ describe("security headers — frame-ancestors default", () => {
|
|
|
376
430
|
const worker = createDecoWorkerEntry(HTML_SERVER_ENTRY, {
|
|
377
431
|
securityHeaders: { "Content-Security-Policy": "frame-ancestors 'none'" },
|
|
378
432
|
});
|
|
379
|
-
const res = await worker.fetch(
|
|
380
|
-
|
|
433
|
+
const res = await worker.fetch(
|
|
434
|
+
new Request("https://example.com/"),
|
|
435
|
+
EMPTY_ENV,
|
|
436
|
+
MOCK_CTX,
|
|
437
|
+
);
|
|
438
|
+
expect(res.headers.get("Content-Security-Policy")).toBe(
|
|
439
|
+
"frame-ancestors 'none'",
|
|
440
|
+
);
|
|
381
441
|
});
|
|
382
442
|
|
|
383
443
|
it("emits the full site CSP report-only alongside the enforced frame-ancestors", async () => {
|
|
384
444
|
const worker = createDecoWorkerEntry(HTML_SERVER_ENTRY, {
|
|
385
445
|
csp: ["default-src 'self'", "img-src 'self' https:"],
|
|
386
446
|
});
|
|
387
|
-
const res = await worker.fetch(
|
|
388
|
-
|
|
447
|
+
const res = await worker.fetch(
|
|
448
|
+
new Request("https://example.com/"),
|
|
449
|
+
EMPTY_ENV,
|
|
450
|
+
MOCK_CTX,
|
|
451
|
+
);
|
|
452
|
+
expect(res.headers.get("Content-Security-Policy")).toBe(
|
|
453
|
+
DEFAULT_FRAME_ANCESTORS_CSP,
|
|
454
|
+
);
|
|
389
455
|
expect(res.headers.get("Content-Security-Policy-Report-Only")).toBe(
|
|
390
456
|
"default-src 'self'; img-src 'self' https:",
|
|
391
457
|
);
|
|
392
458
|
});
|
|
393
459
|
|
|
394
460
|
it("omits all security headers when securityHeaders is false", async () => {
|
|
395
|
-
const worker = createDecoWorkerEntry(HTML_SERVER_ENTRY, {
|
|
396
|
-
|
|
461
|
+
const worker = createDecoWorkerEntry(HTML_SERVER_ENTRY, {
|
|
462
|
+
securityHeaders: false,
|
|
463
|
+
});
|
|
464
|
+
const res = await worker.fetch(
|
|
465
|
+
new Request("https://example.com/"),
|
|
466
|
+
EMPTY_ENV,
|
|
467
|
+
MOCK_CTX,
|
|
468
|
+
);
|
|
397
469
|
expect(res.headers.get("Content-Security-Policy")).toBeNull();
|
|
398
470
|
expect(res.headers.get("X-Content-Type-Options")).toBeNull();
|
|
399
471
|
});
|
|
@@ -405,10 +477,18 @@ describe("POST /_cache/purge-loaders", () => {
|
|
|
405
477
|
setBlocks({});
|
|
406
478
|
});
|
|
407
479
|
|
|
408
|
-
const purge = (
|
|
409
|
-
|
|
480
|
+
const purge = (
|
|
481
|
+
env: Record<string, unknown>,
|
|
482
|
+
headers: Record<string, string> = {},
|
|
483
|
+
) => {
|
|
484
|
+
const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
|
|
485
|
+
observability: false,
|
|
486
|
+
});
|
|
410
487
|
return worker.fetch(
|
|
411
|
-
new Request("https://example.com/_cache/purge-loaders", {
|
|
488
|
+
new Request("https://example.com/_cache/purge-loaders", {
|
|
489
|
+
method: "POST",
|
|
490
|
+
headers,
|
|
491
|
+
}),
|
|
412
492
|
env,
|
|
413
493
|
MOCK_CTX,
|
|
414
494
|
);
|
|
@@ -420,12 +500,18 @@ describe("POST /_cache/purge-loaders", () => {
|
|
|
420
500
|
});
|
|
421
501
|
|
|
422
502
|
it("401s with the wrong token", async () => {
|
|
423
|
-
const res = await purge(
|
|
503
|
+
const res = await purge(
|
|
504
|
+
{ PURGE_TOKEN: "secret" },
|
|
505
|
+
{ Authorization: "Bearer nope" },
|
|
506
|
+
);
|
|
424
507
|
expect(res.status).toBe(401);
|
|
425
508
|
});
|
|
426
509
|
|
|
427
510
|
it("clears the loader cache with a valid token", async () => {
|
|
428
|
-
const res = await purge(
|
|
511
|
+
const res = await purge(
|
|
512
|
+
{ PURGE_TOKEN: "secret" },
|
|
513
|
+
{ Authorization: "Bearer secret" },
|
|
514
|
+
);
|
|
429
515
|
expect(res.status).toBe(200);
|
|
430
516
|
expect(await res.json()).toEqual({ cleared: true, scope: "isolate" });
|
|
431
517
|
});
|
|
@@ -436,7 +522,9 @@ describe("POST /_cache/purge-loaders", () => {
|
|
|
436
522
|
purgeTokenEnv: false,
|
|
437
523
|
});
|
|
438
524
|
const res = await worker.fetch(
|
|
439
|
-
new Request("https://example.com/_cache/purge-loaders", {
|
|
525
|
+
new Request("https://example.com/_cache/purge-loaders", {
|
|
526
|
+
method: "POST",
|
|
527
|
+
}),
|
|
440
528
|
EMPTY_ENV,
|
|
441
529
|
MOCK_CTX,
|
|
442
530
|
);
|
|
@@ -461,10 +549,20 @@ describe("X-Cache-Segment — custom SegmentKey fields (#284)", () => {
|
|
|
461
549
|
it("includes a custom segment field in X-Cache-Segment instead of dropping it", async () => {
|
|
462
550
|
const worker = createDecoWorkerEntry(HTML_SERVER_ENTRY, {
|
|
463
551
|
observability: false,
|
|
464
|
-
buildSegment: () => ({
|
|
552
|
+
buildSegment: () => ({
|
|
553
|
+
device: "desktop",
|
|
554
|
+
regionId: "RJ",
|
|
555
|
+
delivery: "store-42",
|
|
556
|
+
}),
|
|
465
557
|
});
|
|
466
|
-
const res = await worker.fetch(
|
|
467
|
-
|
|
558
|
+
const res = await worker.fetch(
|
|
559
|
+
new Request("https://example.com/"),
|
|
560
|
+
EMPTY_ENV,
|
|
561
|
+
MOCK_CTX,
|
|
562
|
+
);
|
|
563
|
+
expect(res.headers.get("X-Cache-Segment")).toBe(
|
|
564
|
+
"desktop|r=RJ|delivery=store-42",
|
|
565
|
+
);
|
|
468
566
|
});
|
|
469
567
|
|
|
470
568
|
it("produces different X-Cache-Segment hashes for requests that only differ by a custom field", async () => {
|
|
@@ -474,11 +572,19 @@ describe("X-Cache-Segment — custom SegmentKey fields (#284)", () => {
|
|
|
474
572
|
buildSegment: () => ({ device: "desktop", delivery }),
|
|
475
573
|
});
|
|
476
574
|
|
|
477
|
-
const first = await worker.fetch(
|
|
575
|
+
const first = await worker.fetch(
|
|
576
|
+
new Request("https://example.com/"),
|
|
577
|
+
EMPTY_ENV,
|
|
578
|
+
MOCK_CTX,
|
|
579
|
+
);
|
|
478
580
|
const firstSegment = first.headers.get("X-Cache-Segment");
|
|
479
581
|
|
|
480
582
|
delivery = "store-2";
|
|
481
|
-
const second = await worker.fetch(
|
|
583
|
+
const second = await worker.fetch(
|
|
584
|
+
new Request("https://example.com/"),
|
|
585
|
+
EMPTY_ENV,
|
|
586
|
+
MOCK_CTX,
|
|
587
|
+
);
|
|
482
588
|
const secondSegment = second.headers.get("X-Cache-Segment");
|
|
483
589
|
|
|
484
590
|
expect(firstSegment).not.toBe(secondSegment);
|
|
@@ -487,24 +593,35 @@ describe("X-Cache-Segment — custom SegmentKey fields (#284)", () => {
|
|
|
487
593
|
it("keeps the existing hash format for segments using only known fields (back-compat)", async () => {
|
|
488
594
|
const worker = createDecoWorkerEntry(HTML_SERVER_ENTRY, {
|
|
489
595
|
observability: false,
|
|
490
|
-
buildSegment: () => ({
|
|
596
|
+
buildSegment: () => ({
|
|
597
|
+
device: "desktop",
|
|
598
|
+
salesChannel: "1",
|
|
599
|
+
flags: ["b", "a"],
|
|
600
|
+
}),
|
|
491
601
|
});
|
|
492
|
-
const res = await worker.fetch(
|
|
602
|
+
const res = await worker.fetch(
|
|
603
|
+
new Request("https://example.com/"),
|
|
604
|
+
EMPTY_ENV,
|
|
605
|
+
MOCK_CTX,
|
|
606
|
+
);
|
|
493
607
|
expect(res.headers.get("X-Cache-Segment")).toBe("desktop|sc=1|f=a,b");
|
|
494
608
|
});
|
|
495
609
|
});
|
|
496
610
|
|
|
497
611
|
describe("draft preview (pull-based)", () => {
|
|
498
|
-
const POINTER = "abc.localhost:1234@v1";
|
|
499
|
-
const SANDBOX_URL = "http://abc.localhost:1234/
|
|
500
|
-
const DRAFT_BLOCKS = {
|
|
612
|
+
const POINTER = "abc.localhost:1234/api/o/decofile/m/main?token=t@v1";
|
|
613
|
+
const SANDBOX_URL = "http://abc.localhost:1234/api/o/decofile/m/main?token=t";
|
|
614
|
+
const DRAFT_BLOCKS = {
|
|
615
|
+
"pages-home": { name: "home", path: "/", title: "DRAFTED" },
|
|
616
|
+
};
|
|
501
617
|
|
|
502
618
|
// A serverEntry that reflects the currently-loaded home title, so a test can
|
|
503
619
|
// prove the draft override actually reached the render (not just the headers).
|
|
504
620
|
const REFLECT_ENTRY = {
|
|
505
621
|
fetch: async () => {
|
|
506
622
|
const { loadBlocks } = await import("@decocms/blocks/cms");
|
|
507
|
-
const home =
|
|
623
|
+
const home =
|
|
624
|
+
(loadBlocks()["pages-home"] as { title?: string } | undefined) ?? {};
|
|
508
625
|
return new Response(home.title ?? "PUBLISHED", { status: 200 });
|
|
509
626
|
},
|
|
510
627
|
};
|
|
@@ -512,7 +629,9 @@ describe("draft preview (pull-based)", () => {
|
|
|
512
629
|
let realFetch: typeof fetch;
|
|
513
630
|
afterEach(async () => {
|
|
514
631
|
globalThis.fetch = realFetch;
|
|
515
|
-
const { setDraftPreviewHosts, clearDraftCache } = await import(
|
|
632
|
+
const { setDraftPreviewHosts, clearDraftCache } = await import(
|
|
633
|
+
"@decocms/blocks/cms"
|
|
634
|
+
);
|
|
516
635
|
setDraftPreviewHosts([]);
|
|
517
636
|
clearDraftCache();
|
|
518
637
|
__resetKvHydrationStateForTests();
|
|
@@ -520,8 +639,12 @@ describe("draft preview (pull-based)", () => {
|
|
|
520
639
|
});
|
|
521
640
|
|
|
522
641
|
async function enable() {
|
|
523
|
-
const { setDraftPreviewHosts, clearDraftCache } = await import(
|
|
524
|
-
|
|
642
|
+
const { setDraftPreviewHosts, clearDraftCache } = await import(
|
|
643
|
+
"@decocms/blocks/cms"
|
|
644
|
+
);
|
|
645
|
+
setBlocks({
|
|
646
|
+
"pages-home": { name: "home", path: "/", title: "PUBLISHED" },
|
|
647
|
+
});
|
|
525
648
|
setDraftPreviewHosts(["example.com"]);
|
|
526
649
|
clearDraftCache();
|
|
527
650
|
realFetch = globalThis.fetch;
|
|
@@ -538,7 +661,9 @@ describe("draft preview (pull-based)", () => {
|
|
|
538
661
|
|
|
539
662
|
it("binds the draft, bypasses the edge cache, and stamps the anti-leak headers", async () => {
|
|
540
663
|
await enable();
|
|
541
|
-
const worker = createDecoWorkerEntry(REFLECT_ENTRY, {
|
|
664
|
+
const worker = createDecoWorkerEntry(REFLECT_ENTRY, {
|
|
665
|
+
observability: false,
|
|
666
|
+
});
|
|
542
667
|
const res = await worker.fetch(
|
|
543
668
|
new Request(`https://example.com/?__draft=${POINTER}`, {
|
|
544
669
|
headers: { host: "example.com" },
|
|
@@ -562,7 +687,9 @@ describe("draft preview (pull-based)", () => {
|
|
|
562
687
|
|
|
563
688
|
it("carries the draft across navigation via the cookie alone", async () => {
|
|
564
689
|
await enable();
|
|
565
|
-
const worker = createDecoWorkerEntry(REFLECT_ENTRY, {
|
|
690
|
+
const worker = createDecoWorkerEntry(REFLECT_ENTRY, {
|
|
691
|
+
observability: false,
|
|
692
|
+
});
|
|
566
693
|
const res = await worker.fetch(
|
|
567
694
|
new Request("https://example.com/other", {
|
|
568
695
|
headers: { host: "example.com", cookie: `__deco_draft=${POINTER}` },
|
|
@@ -576,7 +703,9 @@ describe("draft preview (pull-based)", () => {
|
|
|
576
703
|
|
|
577
704
|
it("is structurally inert on a non-preview host", async () => {
|
|
578
705
|
await enable(); // allowlist = example.com only
|
|
579
|
-
const worker = createDecoWorkerEntry(REFLECT_ENTRY, {
|
|
706
|
+
const worker = createDecoWorkerEntry(REFLECT_ENTRY, {
|
|
707
|
+
observability: false,
|
|
708
|
+
});
|
|
580
709
|
const res = await worker.fetch(
|
|
581
710
|
new Request(`https://prod.example/?__draft=${POINTER}`, {
|
|
582
711
|
headers: { host: "prod.example" },
|
|
@@ -590,8 +719,12 @@ describe("draft preview (pull-based)", () => {
|
|
|
590
719
|
});
|
|
591
720
|
|
|
592
721
|
it("stays inert on any host when no allowlist is configured", async () => {
|
|
593
|
-
setBlocks({
|
|
594
|
-
|
|
722
|
+
setBlocks({
|
|
723
|
+
"pages-home": { name: "home", path: "/", title: "PUBLISHED" },
|
|
724
|
+
});
|
|
725
|
+
const worker = createDecoWorkerEntry(REFLECT_ENTRY, {
|
|
726
|
+
observability: false,
|
|
727
|
+
});
|
|
595
728
|
const res = await worker.fetch(
|
|
596
729
|
new Request(`https://example.com/?__draft=${POINTER}`, {
|
|
597
730
|
headers: { host: "example.com" },
|