@decocms/blocks 7.0.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.
Files changed (111) hide show
  1. package/package.json +97 -0
  2. package/src/cms/applySectionConventions.ts +128 -0
  3. package/src/cms/blockSource.test.ts +67 -0
  4. package/src/cms/blockSource.ts +124 -0
  5. package/src/cms/index.ts +104 -0
  6. package/src/cms/layoutCacheRace.test.ts +146 -0
  7. package/src/cms/loadDecofileDirectory.test.ts +52 -0
  8. package/src/cms/loadDecofileDirectory.ts +48 -0
  9. package/src/cms/loader.test.ts +184 -0
  10. package/src/cms/loader.ts +284 -0
  11. package/src/cms/registry.test.ts +118 -0
  12. package/src/cms/registry.ts +252 -0
  13. package/src/cms/resolve.test.ts +547 -0
  14. package/src/cms/resolve.ts +2003 -0
  15. package/src/cms/schema.ts +993 -0
  16. package/src/cms/sectionLoaders.test.ts +409 -0
  17. package/src/cms/sectionLoaders.ts +562 -0
  18. package/src/cms/sectionMixins.test.ts +163 -0
  19. package/src/cms/sectionMixins.ts +153 -0
  20. package/src/hooks/LazySection.tsx +121 -0
  21. package/src/hooks/LiveControls.tsx +122 -0
  22. package/src/hooks/RenderSection.test.tsx +81 -0
  23. package/src/hooks/RenderSection.tsx +91 -0
  24. package/src/hooks/SectionErrorFallback.tsx +97 -0
  25. package/src/hooks/index.ts +4 -0
  26. package/src/index.ts +8 -0
  27. package/src/matchers/builtins.test.ts +251 -0
  28. package/src/matchers/builtins.ts +437 -0
  29. package/src/matchers/countryNames.ts +104 -0
  30. package/src/matchers/override.test.ts +205 -0
  31. package/src/matchers/override.ts +136 -0
  32. package/src/matchers/posthog.ts +154 -0
  33. package/src/middleware/decoState.ts +55 -0
  34. package/src/middleware/healthMetrics.ts +133 -0
  35. package/src/middleware/hydrationContext.test.ts +61 -0
  36. package/src/middleware/hydrationContext.ts +79 -0
  37. package/src/middleware/index.ts +88 -0
  38. package/src/middleware/liveness.ts +21 -0
  39. package/src/middleware/observability.test.ts +238 -0
  40. package/src/middleware/observability.ts +620 -0
  41. package/src/middleware/validateSection.test.ts +147 -0
  42. package/src/middleware/validateSection.ts +100 -0
  43. package/src/sdk/abTesting.test.ts +326 -0
  44. package/src/sdk/abTesting.ts +499 -0
  45. package/src/sdk/analytics.ts +77 -0
  46. package/src/sdk/cacheHeaders.test.ts +115 -0
  47. package/src/sdk/cacheHeaders.ts +424 -0
  48. package/src/sdk/cachedLoader.ts +364 -0
  49. package/src/sdk/clx.ts +5 -0
  50. package/src/sdk/cn.test.ts +34 -0
  51. package/src/sdk/cn.ts +28 -0
  52. package/src/sdk/composite.test.ts +121 -0
  53. package/src/sdk/composite.ts +114 -0
  54. package/src/sdk/cookie.test.ts +108 -0
  55. package/src/sdk/cookie.ts +129 -0
  56. package/src/sdk/crypto.ts +185 -0
  57. package/src/sdk/csp.ts +59 -0
  58. package/src/sdk/djb2.ts +20 -0
  59. package/src/sdk/encoding.test.ts +71 -0
  60. package/src/sdk/encoding.ts +47 -0
  61. package/src/sdk/env.ts +31 -0
  62. package/src/sdk/http.test.ts +71 -0
  63. package/src/sdk/http.ts +124 -0
  64. package/src/sdk/index.ts +90 -0
  65. package/src/sdk/inflightTimeout.test.ts +35 -0
  66. package/src/sdk/inflightTimeout.ts +53 -0
  67. package/src/sdk/instrumentedFetch.test.ts +559 -0
  68. package/src/sdk/instrumentedFetch.ts +339 -0
  69. package/src/sdk/invoke.test.ts +115 -0
  70. package/src/sdk/invoke.ts +260 -0
  71. package/src/sdk/logger.test.ts +432 -0
  72. package/src/sdk/logger.ts +304 -0
  73. package/src/sdk/mergeCacheControl.ts +150 -0
  74. package/src/sdk/normalizeUrls.ts +91 -0
  75. package/src/sdk/observability.ts +109 -0
  76. package/src/sdk/otel.test.ts +526 -0
  77. package/src/sdk/otel.ts +981 -0
  78. package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
  79. package/src/sdk/otelAdapters.test.ts +89 -0
  80. package/src/sdk/otelAdapters.ts +144 -0
  81. package/src/sdk/otelHttpLog.test.ts +457 -0
  82. package/src/sdk/otelHttpLog.ts +419 -0
  83. package/src/sdk/otelHttpMeter.test.ts +292 -0
  84. package/src/sdk/otelHttpMeter.ts +506 -0
  85. package/src/sdk/otelHttpTracer.test.ts +474 -0
  86. package/src/sdk/otelHttpTracer.ts +543 -0
  87. package/src/sdk/redirects.ts +225 -0
  88. package/src/sdk/requestContext.ts +281 -0
  89. package/src/sdk/requestContextStorage.browser.test.ts +29 -0
  90. package/src/sdk/requestContextStorage.browser.ts +43 -0
  91. package/src/sdk/requestContextStorage.ts +35 -0
  92. package/src/sdk/retry.ts +45 -0
  93. package/src/sdk/serverTimings.ts +68 -0
  94. package/src/sdk/signal.ts +42 -0
  95. package/src/sdk/sitemap.ts +160 -0
  96. package/src/sdk/urlRedaction.test.ts +73 -0
  97. package/src/sdk/urlRedaction.ts +82 -0
  98. package/src/sdk/urlUtils.ts +134 -0
  99. package/src/sdk/useDevice.test.ts +130 -0
  100. package/src/sdk/useDevice.ts +109 -0
  101. package/src/sdk/useDeviceContext.tsx +108 -0
  102. package/src/sdk/useId.ts +7 -0
  103. package/src/sdk/useScript.test.ts +128 -0
  104. package/src/sdk/useScript.ts +210 -0
  105. package/src/sdk/useSuggestions.test.ts +230 -0
  106. package/src/sdk/useSuggestions.ts +188 -0
  107. package/src/sdk/wrapCaughtErrors.ts +107 -0
  108. package/src/setup.ts +119 -0
  109. package/src/types/index.ts +39 -0
  110. package/src/types/widgets.ts +14 -0
  111. package/tsconfig.json +7 -0
@@ -0,0 +1,147 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ createSectionValidator,
4
+ validateDeferredSectionInput,
5
+ } from "./validateSection";
6
+
7
+ describe("validateDeferredSectionInput", () => {
8
+ it("accepts valid input", () => {
9
+ const result = validateDeferredSectionInput({
10
+ component: "site/sections/ProductShelf.tsx",
11
+ rawProps: { title: "Best Sellers" },
12
+ pagePath: "/",
13
+ });
14
+ expect(result.component).toBe("site/sections/ProductShelf.tsx");
15
+ expect(result.rawProps).toEqual({ title: "Best Sellers" });
16
+ expect(result.pagePath).toBe("/");
17
+ expect(result.pageUrl).toBeUndefined();
18
+ });
19
+
20
+ it("accepts input with optional pageUrl", () => {
21
+ const result = validateDeferredSectionInput({
22
+ component: "site/sections/Hero.tsx",
23
+ rawProps: {},
24
+ pagePath: "/home",
25
+ pageUrl: "https://store.com/home?ref=nav",
26
+ });
27
+ expect(result.pageUrl).toBe("https://store.com/home?ref=nav");
28
+ });
29
+
30
+ it("accepts input with index for SPA navigation ordering", () => {
31
+ const result = validateDeferredSectionInput({
32
+ component: "site/sections/Shelf.tsx",
33
+ rawProps: {},
34
+ pagePath: "/",
35
+ index: 5,
36
+ });
37
+ expect(result.index).toBe(5);
38
+ });
39
+
40
+ it("index is undefined when not provided", () => {
41
+ const result = validateDeferredSectionInput({
42
+ component: "site/sections/Shelf.tsx",
43
+ rawProps: {},
44
+ pagePath: "/",
45
+ });
46
+ expect(result.index).toBeUndefined();
47
+ });
48
+
49
+ it("throws on null input", () => {
50
+ expect(() => validateDeferredSectionInput(null)).toThrow("Expected an object");
51
+ });
52
+
53
+ it("throws on undefined input", () => {
54
+ expect(() => validateDeferredSectionInput(undefined)).toThrow("Expected an object");
55
+ });
56
+
57
+ it("throws on string input", () => {
58
+ expect(() => validateDeferredSectionInput("nope")).toThrow("Expected an object");
59
+ });
60
+
61
+ it("throws when component is missing", () => {
62
+ expect(() =>
63
+ validateDeferredSectionInput({ rawProps: {}, pagePath: "/" }),
64
+ ).toThrow("component");
65
+ });
66
+
67
+ it("throws when component is not a string", () => {
68
+ expect(() =>
69
+ validateDeferredSectionInput({ component: 123, rawProps: {}, pagePath: "/" }),
70
+ ).toThrow("component");
71
+ });
72
+
73
+ it("throws when rawProps is missing", () => {
74
+ expect(() =>
75
+ validateDeferredSectionInput({ component: "X", pagePath: "/" }),
76
+ ).toThrow("rawProps");
77
+ });
78
+
79
+ it("throws when rawProps is an array", () => {
80
+ expect(() =>
81
+ validateDeferredSectionInput({ component: "X", rawProps: [1, 2], pagePath: "/" }),
82
+ ).toThrow("rawProps");
83
+ });
84
+
85
+ it("throws when rawProps is null", () => {
86
+ expect(() =>
87
+ validateDeferredSectionInput({ component: "X", rawProps: null, pagePath: "/" }),
88
+ ).toThrow("rawProps");
89
+ });
90
+
91
+ it("throws when pagePath is missing", () => {
92
+ expect(() =>
93
+ validateDeferredSectionInput({ component: "X", rawProps: {} }),
94
+ ).toThrow("pagePath");
95
+ });
96
+
97
+ it("throws when pageUrl is not a string", () => {
98
+ expect(() =>
99
+ validateDeferredSectionInput({
100
+ component: "X",
101
+ rawProps: {},
102
+ pagePath: "/",
103
+ pageUrl: 42,
104
+ }),
105
+ ).toThrow("pageUrl");
106
+ });
107
+
108
+ it("throws when index is not a number", () => {
109
+ expect(() =>
110
+ validateDeferredSectionInput({
111
+ component: "X",
112
+ rawProps: {},
113
+ pagePath: "/",
114
+ index: "three" as any,
115
+ }),
116
+ ).toThrow("index");
117
+ });
118
+ });
119
+
120
+ describe("createSectionValidator", () => {
121
+ it("passes when all required fields present", () => {
122
+ const validate = createSectionValidator(["title", "maxItems"]);
123
+ const result = validate({ title: "Shelf", maxItems: 8 });
124
+ expect(result).toEqual({ title: "Shelf", maxItems: 8 });
125
+ });
126
+
127
+ it("throws when a required field is missing", () => {
128
+ const validate = createSectionValidator(["title", "maxItems"]);
129
+ expect(() => validate({ title: "Shelf" })).toThrow("maxItems");
130
+ });
131
+
132
+ it("throws on null input", () => {
133
+ const validate = createSectionValidator(["title"]);
134
+ expect(() => validate(null)).toThrow("Expected an object");
135
+ });
136
+
137
+ it("allows extra fields", () => {
138
+ const validate = createSectionValidator(["title"]);
139
+ const result = validate({ title: "Hi", extra: true });
140
+ expect(result).toEqual({ title: "Hi", extra: true });
141
+ });
142
+
143
+ it("empty required fields always passes for objects", () => {
144
+ const validate = createSectionValidator([]);
145
+ expect(validate({})).toEqual({});
146
+ });
147
+ });
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Server function input validation for section-related server functions.
3
+ *
4
+ * Validates that `loadDeferredSection` and similar server functions receive
5
+ * well-formed input before reaching section loaders. Prevents malformed
6
+ * requests from causing obscure runtime errors.
7
+ *
8
+ * @example
9
+ * ```tsx
10
+ * import { validateDeferredSectionInput } from "@decocms/start/middleware/validateSection";
11
+ *
12
+ * // In your storefront's middleware chain:
13
+ * export const loadSection = createServerFn({ method: "POST" })
14
+ * .inputValidator(validateDeferredSectionInput)
15
+ * .handler(async (ctx) => { ... });
16
+ * ```
17
+ */
18
+
19
+ export interface DeferredSectionInput {
20
+ component: string;
21
+ rawProps: Record<string, unknown>;
22
+ pagePath: string;
23
+ pageUrl?: string;
24
+ /** Original position in the page section list — for correct ordering after resolution. */
25
+ index?: number;
26
+ }
27
+
28
+ /**
29
+ * Validates input for deferred section loading server functions.
30
+ * Throws descriptive errors for malformed requests.
31
+ */
32
+ export function validateDeferredSectionInput(data: unknown): DeferredSectionInput {
33
+ if (!data || typeof data !== "object") {
34
+ throw new Error("[validateDeferredSectionInput] Expected an object, got " + typeof data);
35
+ }
36
+ const obj = data as Record<string, unknown>;
37
+
38
+ if (!obj.component || typeof obj.component !== "string") {
39
+ throw new Error(
40
+ "[validateDeferredSectionInput] Missing or invalid 'component' field (expected string)",
41
+ );
42
+ }
43
+
44
+ if (!obj.rawProps || typeof obj.rawProps !== "object" || Array.isArray(obj.rawProps)) {
45
+ throw new Error(
46
+ "[validateDeferredSectionInput] Missing or invalid 'rawProps' field (expected object)",
47
+ );
48
+ }
49
+
50
+ if (!obj.pagePath || typeof obj.pagePath !== "string") {
51
+ throw new Error(
52
+ "[validateDeferredSectionInput] Missing or invalid 'pagePath' field (expected string)",
53
+ );
54
+ }
55
+
56
+ if (obj.pageUrl !== undefined && typeof obj.pageUrl !== "string") {
57
+ throw new Error(
58
+ "[validateDeferredSectionInput] Invalid 'pageUrl' field (expected string or undefined)",
59
+ );
60
+ }
61
+
62
+ if (obj.index !== undefined && typeof obj.index !== "number") {
63
+ throw new Error(
64
+ "[validateDeferredSectionInput] Invalid 'index' field (expected number or undefined)",
65
+ );
66
+ }
67
+
68
+ return {
69
+ component: obj.component as string,
70
+ rawProps: obj.rawProps as Record<string, unknown>,
71
+ pagePath: obj.pagePath as string,
72
+ pageUrl: obj.pageUrl as string | undefined,
73
+ index: obj.index as number | undefined,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Generic section props validator factory.
79
+ * Creates a validator function that checks required fields exist.
80
+ *
81
+ * @example
82
+ * ```tsx
83
+ * const validate = createSectionValidator(["title", "maxItems"]);
84
+ * const props = validate(rawInput); // throws if missing title or maxItems
85
+ * ```
86
+ */
87
+ export function createSectionValidator(requiredFields: string[]) {
88
+ return (data: unknown): Record<string, unknown> => {
89
+ if (!data || typeof data !== "object") {
90
+ throw new Error("[SectionValidator] Expected an object");
91
+ }
92
+ const obj = data as Record<string, unknown>;
93
+ for (const field of requiredFields) {
94
+ if (obj[field] === undefined) {
95
+ throw new Error(`[SectionValidator] Missing required field: "${field}"`);
96
+ }
97
+ }
98
+ return obj;
99
+ };
100
+ }
@@ -0,0 +1,326 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ getStableBucket,
4
+ proxyToFallback,
5
+ type SiteConfig,
6
+ tagBucket,
7
+ type WorkerHandler,
8
+ withABTesting,
9
+ } from "./abTesting";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Test helpers
13
+ // ---------------------------------------------------------------------------
14
+
15
+ const REAL_HOST = "www.bagaggio.com.br";
16
+ const FALLBACK_HOST = "lojabagaggio.deco.site";
17
+
18
+ function makeUrl(path = "/x"): URL {
19
+ return new URL(`https://${REAL_HOST}${path}`);
20
+ }
21
+
22
+ function makeFakeKv(value: SiteConfig | null) {
23
+ return {
24
+ get: vi.fn(async () => value),
25
+ };
26
+ }
27
+
28
+ function makeCtx() {
29
+ return {
30
+ waitUntil: vi.fn(),
31
+ passThroughOnException: vi.fn(),
32
+ };
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // proxyToFallback
37
+ // ---------------------------------------------------------------------------
38
+
39
+ describe("proxyToFallback", () => {
40
+ let fetchSpy: ReturnType<typeof vi.fn>;
41
+
42
+ beforeEach(() => {
43
+ fetchSpy = vi.fn();
44
+ vi.stubGlobal("fetch", fetchSpy);
45
+ });
46
+
47
+ afterEach(() => {
48
+ vi.unstubAllGlobals();
49
+ });
50
+
51
+ it("always sets redirect:'manual' to avoid replaying streamed bodies on 3xx", async () => {
52
+ fetchSpy.mockResolvedValue(new Response("ok", { status: 200 }));
53
+
54
+ const request = new Request(`https://${REAL_HOST}/foo`, {
55
+ method: "POST",
56
+ body: "payload",
57
+ });
58
+ await proxyToFallback(request, makeUrl("/foo"), FALLBACK_HOST);
59
+
60
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
61
+ const [, init] = fetchSpy.mock.calls[0];
62
+ expect(init.redirect).toBe("manual");
63
+ });
64
+
65
+ it("strips hop-by-hop headers + host before forwarding", async () => {
66
+ fetchSpy.mockResolvedValue(new Response(null, { status: 204 }));
67
+
68
+ const request = new Request(`https://${REAL_HOST}/foo`, {
69
+ method: "GET",
70
+ headers: {
71
+ host: REAL_HOST,
72
+ connection: "keep-alive",
73
+ "keep-alive": "timeout=5",
74
+ "transfer-encoding": "chunked",
75
+ upgrade: "websocket",
76
+ "proxy-authorization": "Bearer x",
77
+ "x-real-ip": "1.2.3.4",
78
+ cookie: "session=abc",
79
+ },
80
+ });
81
+ await proxyToFallback(request, makeUrl("/foo"), FALLBACK_HOST);
82
+
83
+ const [, init] = fetchSpy.mock.calls[0];
84
+ const fwd = init.headers as Headers;
85
+ expect(fwd.get("host")).toBeNull();
86
+ expect(fwd.get("connection")).toBeNull();
87
+ expect(fwd.get("keep-alive")).toBeNull();
88
+ expect(fwd.get("transfer-encoding")).toBeNull();
89
+ expect(fwd.get("upgrade")).toBeNull();
90
+ expect(fwd.get("proxy-authorization")).toBeNull();
91
+ // Non-hop-by-hop headers are preserved.
92
+ expect(fwd.get("x-real-ip")).toBe("1.2.3.4");
93
+ expect(fwd.get("cookie")).toBe("session=abc");
94
+ expect(fwd.get("x-forwarded-host")).toBe(REAL_HOST);
95
+ });
96
+
97
+ it("forwards a 302 from the upstream without following it, rewriting Location", async () => {
98
+ fetchSpy.mockResolvedValue(
99
+ new Response(null, {
100
+ status: 302,
101
+ headers: { location: `https://${FALLBACK_HOST}/landing` },
102
+ }),
103
+ );
104
+
105
+ const request = new Request(`https://${REAL_HOST}/go`, {
106
+ method: "POST",
107
+ body: "irrelevant",
108
+ });
109
+ const res = await proxyToFallback(request, makeUrl("/go"), FALLBACK_HOST);
110
+
111
+ expect(res.status).toBe(302);
112
+ expect(res.headers.get("location")).toBe(`https://${REAL_HOST}/landing`);
113
+ });
114
+
115
+ it("does NOT consume the response body on 3xx (passes it through as stream)", async () => {
116
+ // The text-rewrite block must skip non-2xx so streamed/binary 3xx bodies
117
+ // aren't needlessly drained — that was a latent bug paired with the
118
+ // redirect:"manual" fix.
119
+ const upstream = new Response("redirect-body", {
120
+ status: 301,
121
+ headers: {
122
+ "content-type": "text/html",
123
+ location: `https://${FALLBACK_HOST}/elsewhere`,
124
+ },
125
+ });
126
+ const textSpy = vi.spyOn(upstream, "text");
127
+ fetchSpy.mockResolvedValue(upstream);
128
+
129
+ const request = new Request(`https://${REAL_HOST}/r`, { method: "GET" });
130
+ const res = await proxyToFallback(request, makeUrl("/r"), FALLBACK_HOST);
131
+
132
+ expect(textSpy).not.toHaveBeenCalled();
133
+ expect(res.status).toBe(301);
134
+ expect(res.headers.get("location")).toBe(`https://${REAL_HOST}/elsewhere`);
135
+ });
136
+
137
+ it("rewrites the hostname in 2xx text bodies (Fresh partial URLs)", async () => {
138
+ fetchSpy.mockResolvedValue(
139
+ new Response(`<a href="https://${FALLBACK_HOST}/produto">veja</a>`, {
140
+ status: 200,
141
+ headers: { "content-type": "text/html" },
142
+ }),
143
+ );
144
+
145
+ const request = new Request(`https://${REAL_HOST}/p`, { method: "GET" });
146
+ const res = await proxyToFallback(request, makeUrl("/p"), FALLBACK_HOST);
147
+ const body = await res.text();
148
+
149
+ expect(body).toBe(`<a href="https://${REAL_HOST}/produto">veja</a>`);
150
+ });
151
+
152
+ it("does NOT call .text() on 2xx binary responses (image/png passes as stream)", async () => {
153
+ const binary = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a]);
154
+ const upstream = new Response(binary, {
155
+ status: 200,
156
+ headers: { "content-type": "image/png" },
157
+ });
158
+ const textSpy = vi.spyOn(upstream, "text");
159
+ fetchSpy.mockResolvedValue(upstream);
160
+
161
+ const request = new Request(`https://${REAL_HOST}/img.png`, {
162
+ method: "GET",
163
+ });
164
+ const res = await proxyToFallback(request, makeUrl("/img.png"), FALLBACK_HOST);
165
+
166
+ expect(textSpy).not.toHaveBeenCalled();
167
+ expect(res.headers.get("content-type")).toBe("image/png");
168
+ const bytes = new Uint8Array(await res.arrayBuffer());
169
+ expect(Array.from(bytes)).toEqual(Array.from(binary));
170
+ });
171
+
172
+ it("rewrites Set-Cookie Domain from fallback origin to real hostname", async () => {
173
+ const headers = new Headers({ "content-type": "application/json" });
174
+ headers.append("set-cookie", `vtex_segment=abc; Domain=.${FALLBACK_HOST}; Path=/`);
175
+ fetchSpy.mockResolvedValue(new Response("{}", { status: 200, headers }));
176
+
177
+ const request = new Request(`https://${REAL_HOST}/api`, { method: "GET" });
178
+ const res = await proxyToFallback(request, makeUrl("/api"), FALLBACK_HOST);
179
+
180
+ const cookies = res.headers.getSetCookie?.() ?? [];
181
+ expect(cookies).toHaveLength(1);
182
+ expect(cookies[0]).toContain(`Domain=.${REAL_HOST}`);
183
+ expect(cookies[0]).not.toContain(FALLBACK_HOST);
184
+ });
185
+ });
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // withABTesting — clone defense
189
+ // ---------------------------------------------------------------------------
190
+
191
+ describe("withABTesting — outer-catch defense", () => {
192
+ let fetchSpy: ReturnType<typeof vi.fn>;
193
+
194
+ beforeEach(() => {
195
+ fetchSpy = vi.fn();
196
+ vi.stubGlobal("fetch", fetchSpy);
197
+ });
198
+
199
+ afterEach(() => {
200
+ vi.unstubAllGlobals();
201
+ });
202
+
203
+ it("recovers via inner handler with body intact when fallback proxy throws", async () => {
204
+ // Simulate the legacy bug: the first fetch (fallback proxy) blows up
205
+ // *after* the body would have been consumed. The outer catch must be
206
+ // able to read request.body when calling handler.fetch, so withABTesting
207
+ // tees the request with request.clone() before handing it to the proxy.
208
+ fetchSpy.mockRejectedValue(new Error("upstream exploded"));
209
+
210
+ const handler: WorkerHandler = {
211
+ fetch: vi.fn(async (req) => {
212
+ const body = await req.text();
213
+ return new Response(`handler saw: ${body}`, { status: 200 });
214
+ }),
215
+ };
216
+
217
+ const kv = makeFakeKv({
218
+ workerName: "test",
219
+ fallbackOrigin: FALLBACK_HOST,
220
+ abTest: { ratio: 0 }, // ratio 0 → always fallback bucket
221
+ });
222
+
223
+ const wrapped = withABTesting(handler, { kvBinding: "KV" });
224
+ const request = new Request(`https://${REAL_HOST}/recover`, {
225
+ method: "POST",
226
+ body: "payload",
227
+ });
228
+ const res = await wrapped.fetch(
229
+ request,
230
+ { KV: kv } as unknown as Record<string, unknown>,
231
+ makeCtx(),
232
+ );
233
+
234
+ expect(res.status).toBe(200);
235
+ expect(await res.text()).toBe("handler saw: payload");
236
+ expect(handler.fetch).toHaveBeenCalledTimes(1);
237
+ });
238
+ });
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Ratio-fingerprinted cookie
242
+ // ---------------------------------------------------------------------------
243
+
244
+ describe("getStableBucket — ratio fingerprint", () => {
245
+ it("honors the cookie when its ratio fingerprint matches the current ratio", () => {
246
+ const request = new Request(`https://${REAL_HOST}/`, {
247
+ headers: { cookie: "_deco_bucket=fallback:50", "cf-connecting-ip": "1.2.3.4" },
248
+ });
249
+ expect(getStableBucket(request, 0.5, makeUrl("/"))).toBe("fallback");
250
+ });
251
+
252
+ it("ignores the cookie when the KV ratio has changed (fingerprint mismatch)", () => {
253
+ // Cookie remembers an old 50/50 assignment. Operator bumped to 100/0 in
254
+ // KV → cookie is no longer authoritative; bucket must be recomputed
255
+ // against the new threshold (here: 1.0 → always "worker").
256
+ const request = new Request(`https://${REAL_HOST}/`, {
257
+ headers: { cookie: "_deco_bucket=fallback:50", "cf-connecting-ip": "1.2.3.4" },
258
+ });
259
+ expect(getStableBucket(request, 1.0, makeUrl("/"))).toBe("worker");
260
+ });
261
+
262
+ it("ignores legacy unix-timestamp cookies and re-evaluates against current ratio", () => {
263
+ // Pre-fingerprint cookies (bucket:unixTs) parse to null and fall through
264
+ // to the hash. Ratio=0 forces "fallback" regardless of IP.
265
+ const request = new Request(`https://${REAL_HOST}/`, {
266
+ headers: {
267
+ cookie: "_deco_bucket=worker:1711540800",
268
+ "cf-connecting-ip": "1.2.3.4",
269
+ },
270
+ });
271
+ expect(getStableBucket(request, 0, makeUrl("/"))).toBe("fallback");
272
+ });
273
+
274
+ it("query param override beats the cookie even when fingerprint matches", () => {
275
+ const request = new Request(`https://${REAL_HOST}/?_deco_bucket=worker`, {
276
+ headers: { cookie: "_deco_bucket=fallback:50" },
277
+ });
278
+ expect(
279
+ getStableBucket(
280
+ request,
281
+ 0.5,
282
+ new URL(`https://${REAL_HOST}/?_deco_bucket=worker`),
283
+ ),
284
+ ).toBe("worker");
285
+ });
286
+ });
287
+
288
+ describe("tagBucket — ratio fingerprint", () => {
289
+ it("writes the cookie with the current ratio fingerprint when none exists", () => {
290
+ const request = new Request(`https://${REAL_HOST}/`);
291
+ const res = tagBucket(new Response("ok"), "worker", REAL_HOST, request, 0.7);
292
+ const setCookie = res.headers.get("set-cookie") ?? "";
293
+ expect(setCookie).toContain("_deco_bucket=worker:70");
294
+ expect(setCookie).toContain(`Domain=${REAL_HOST}`);
295
+ expect(setCookie).toContain("SameSite=Lax");
296
+ });
297
+
298
+ it("rewrites the cookie when the ratio changes (cookie fingerprint stale)", () => {
299
+ const request = new Request(`https://${REAL_HOST}/`, {
300
+ headers: { cookie: "_deco_bucket=worker:30" },
301
+ });
302
+ const res = tagBucket(new Response("ok"), "worker", REAL_HOST, request, 0.7);
303
+ const setCookie = res.headers.get("set-cookie") ?? "";
304
+ expect(setCookie).toContain("_deco_bucket=worker:70");
305
+ });
306
+
307
+ it("does NOT rewrite the cookie when bucket and ratio still match", () => {
308
+ const request = new Request(`https://${REAL_HOST}/`, {
309
+ headers: { cookie: "_deco_bucket=worker:50" },
310
+ });
311
+ const res = tagBucket(new Response("ok"), "worker", REAL_HOST, request, 0.5);
312
+ expect(res.headers.get("set-cookie")).toBeNull();
313
+ });
314
+
315
+ it("rounds the ratio to an integer percent (0.501 → 50)", () => {
316
+ const request = new Request(`https://${REAL_HOST}/`);
317
+ const res = tagBucket(new Response("ok"), "worker", REAL_HOST, request, 0.501);
318
+ expect(res.headers.get("set-cookie")).toContain("_deco_bucket=worker:50");
319
+ });
320
+
321
+ it("defaults Max-Age to 1 year (cookie expiry is no longer the invalidation mechanism)", () => {
322
+ const request = new Request(`https://${REAL_HOST}/`);
323
+ const res = tagBucket(new Response("ok"), "worker", REAL_HOST, request, 0.5);
324
+ expect(res.headers.get("set-cookie")).toContain(`Max-Age=${60 * 60 * 24 * 365}`);
325
+ });
326
+ });