@dotpress/testing 2.0.0-alpha.4 → 2.0.0-alpha.5
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/README.md +1 -1
- package/dist/chunk-MUWNILMA.js +51 -0
- package/dist/chunk-MUWNILMA.js.map +1 -0
- package/dist/conformance.d.ts +28 -0
- package/dist/conformance.js +1266 -0
- package/dist/conformance.js.map +1 -0
- package/dist/index.d.ts +17 -52
- package/dist/index.js +43 -1310
- package/dist/index.js.map +1 -1
- package/dist/target-BZC-J2SE.d.ts +25 -0
- package/package.json +7 -3
|
@@ -0,0 +1,1266 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createFetchTarget,
|
|
3
|
+
createHttpTarget,
|
|
4
|
+
createMemoryLogger
|
|
5
|
+
} from "./chunk-MUWNILMA.js";
|
|
6
|
+
|
|
7
|
+
// src/conformance.ts
|
|
8
|
+
import {
|
|
9
|
+
createRouter,
|
|
10
|
+
file,
|
|
11
|
+
json,
|
|
12
|
+
notFoundError,
|
|
13
|
+
redirect,
|
|
14
|
+
sse,
|
|
15
|
+
stream,
|
|
16
|
+
text,
|
|
17
|
+
unauthorizedError
|
|
18
|
+
} from "@dotpress/core";
|
|
19
|
+
import { DEFAULT_BODY_LIMIT } from "@dotpress/core/adapter";
|
|
20
|
+
import { describe, expect, it, vi } from "vitest";
|
|
21
|
+
var TIMEOUT = 2e3;
|
|
22
|
+
var within = (promise, label) => Promise.race([
|
|
23
|
+
promise,
|
|
24
|
+
new Promise(
|
|
25
|
+
(_, reject) => setTimeout(
|
|
26
|
+
() => reject(new Error(`Timed out waiting for: ${label}`)),
|
|
27
|
+
TIMEOUT
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
]);
|
|
31
|
+
var deferred = () => {
|
|
32
|
+
let resolve;
|
|
33
|
+
const promise = new Promise((done) => {
|
|
34
|
+
resolve = done;
|
|
35
|
+
});
|
|
36
|
+
return { promise, resolve };
|
|
37
|
+
};
|
|
38
|
+
var waitForAbort = (signal) => new Promise((resolve) => {
|
|
39
|
+
if (signal.aborted) resolve();
|
|
40
|
+
signal.addEventListener("abort", () => resolve(), { once: true });
|
|
41
|
+
});
|
|
42
|
+
var readChunk = async (reader) => {
|
|
43
|
+
const { done, value } = await reader.read();
|
|
44
|
+
return done ? void 0 : new TextDecoder().decode(value);
|
|
45
|
+
};
|
|
46
|
+
var runConformanceSuite = (options) => {
|
|
47
|
+
const serve = async (define, appOptions = {}) => {
|
|
48
|
+
const router = createRouter();
|
|
49
|
+
define(router);
|
|
50
|
+
return options.setup({
|
|
51
|
+
logger: createMemoryLogger(),
|
|
52
|
+
...appOptions,
|
|
53
|
+
routers: [router]
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
const withApp = async (define, run, appOptions) => {
|
|
57
|
+
const target = await serve(define, appOptions);
|
|
58
|
+
try {
|
|
59
|
+
await run(target);
|
|
60
|
+
} finally {
|
|
61
|
+
await target.close();
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const postJson = (body) => ({
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: { "content-type": "application/json" },
|
|
67
|
+
body: JSON.stringify(body)
|
|
68
|
+
});
|
|
69
|
+
describe(`${options.name} adapter conformance`, () => {
|
|
70
|
+
describe("routing", () => {
|
|
71
|
+
it("serves JSON results", () => withApp(
|
|
72
|
+
(router) => {
|
|
73
|
+
router.defineRoute({ path: "/hello", handler: () => ({ hi: 1 }) });
|
|
74
|
+
},
|
|
75
|
+
async ({ fetch }) => {
|
|
76
|
+
const res = await fetch("/hello");
|
|
77
|
+
expect(res.status).toBe(200);
|
|
78
|
+
expect(res.headers.get("content-type")).toBe(
|
|
79
|
+
"application/json; charset=utf-8"
|
|
80
|
+
);
|
|
81
|
+
expect(await res.json()).toEqual({ hi: 1 });
|
|
82
|
+
}
|
|
83
|
+
));
|
|
84
|
+
it("routes every HTTP method", () => withApp(
|
|
85
|
+
(router) => {
|
|
86
|
+
for (const method of [
|
|
87
|
+
"get",
|
|
88
|
+
"post",
|
|
89
|
+
"put",
|
|
90
|
+
"patch",
|
|
91
|
+
"delete",
|
|
92
|
+
"options"
|
|
93
|
+
]) {
|
|
94
|
+
router.defineRoute({
|
|
95
|
+
method,
|
|
96
|
+
path: "/resource",
|
|
97
|
+
handler: ({ req }) => ({ method: req.method })
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
router.defineRoute({
|
|
101
|
+
method: "head",
|
|
102
|
+
path: "/resource",
|
|
103
|
+
handler: ({ res }) => {
|
|
104
|
+
res.setHeader("x-head", "yes");
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
async ({ fetch }) => {
|
|
109
|
+
for (const method of [
|
|
110
|
+
"GET",
|
|
111
|
+
"POST",
|
|
112
|
+
"PUT",
|
|
113
|
+
"PATCH",
|
|
114
|
+
"DELETE",
|
|
115
|
+
"OPTIONS"
|
|
116
|
+
]) {
|
|
117
|
+
const res = await fetch("/resource", { method });
|
|
118
|
+
expect(await res.json()).toEqual({ method });
|
|
119
|
+
}
|
|
120
|
+
const head = await fetch("/resource", { method: "HEAD" });
|
|
121
|
+
expect(head.headers.get("x-head")).toBe("yes");
|
|
122
|
+
}
|
|
123
|
+
));
|
|
124
|
+
it("extracts decoded path parameters", () => withApp(
|
|
125
|
+
(router) => {
|
|
126
|
+
router.defineRoute({
|
|
127
|
+
path: "/orgs/:orgId/files/:name",
|
|
128
|
+
handler: ({ req }) => req.params
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
async ({ fetch }) => {
|
|
132
|
+
const res = await fetch("/orgs/acme/files/a%20b%2Fc%C3%A9");
|
|
133
|
+
expect(await res.json()).toEqual({ orgId: "acme", name: "a b/c\xE9" });
|
|
134
|
+
}
|
|
135
|
+
));
|
|
136
|
+
it("gives static segments priority over parameters", () => withApp(
|
|
137
|
+
(router) => {
|
|
138
|
+
router.defineRoute({
|
|
139
|
+
path: "/users/:id",
|
|
140
|
+
handler: ({ req }) => ({ id: req.params.id })
|
|
141
|
+
});
|
|
142
|
+
router.defineRoute({ path: "/users/me", handler: () => "me" });
|
|
143
|
+
},
|
|
144
|
+
async ({ fetch }) => {
|
|
145
|
+
expect(await (await fetch("/users/me")).json()).toBe("me");
|
|
146
|
+
expect(await (await fetch("/users/42")).json()).toEqual({
|
|
147
|
+
id: "42"
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
));
|
|
151
|
+
it("ignores trailing slashes and matches case-sensitively", () => withApp(
|
|
152
|
+
(router) => {
|
|
153
|
+
router.defineRoute({ path: "/users", handler: () => "users" });
|
|
154
|
+
},
|
|
155
|
+
async ({ fetch }) => {
|
|
156
|
+
expect((await fetch("/users/")).status).toBe(200);
|
|
157
|
+
expect((await fetch("/Users")).status).toBe(404);
|
|
158
|
+
}
|
|
159
|
+
));
|
|
160
|
+
it("returns the standard 404 error for unknown paths", () => withApp(
|
|
161
|
+
(router) => {
|
|
162
|
+
router.defineRoute({ path: "/items", handler: () => [] });
|
|
163
|
+
},
|
|
164
|
+
async ({ fetch }) => {
|
|
165
|
+
for (const method of ["GET", "POST"]) {
|
|
166
|
+
const res = await fetch("/missing", { method });
|
|
167
|
+
expect(res.status).toBe(404);
|
|
168
|
+
expect(res.headers.get("x-request-id")).toBeTruthy();
|
|
169
|
+
expect(await res.json()).toEqual({
|
|
170
|
+
status: 404,
|
|
171
|
+
error: "NOT_FOUND",
|
|
172
|
+
message: "Route not found"
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
));
|
|
177
|
+
it("returns 405 with an Allow header for unsupported methods", () => withApp(
|
|
178
|
+
(router) => {
|
|
179
|
+
router.defineRoute({ path: "/items/:id", handler: () => ({}) });
|
|
180
|
+
router.defineRoute({
|
|
181
|
+
method: "delete",
|
|
182
|
+
path: "/items/:id",
|
|
183
|
+
handler: () => {
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
},
|
|
187
|
+
async ({ fetch }) => {
|
|
188
|
+
for (const method of ["POST", "OPTIONS"]) {
|
|
189
|
+
const res = await fetch("/items/1", { method });
|
|
190
|
+
expect(res.status).toBe(405);
|
|
191
|
+
expect(res.headers.get("allow")).toBe("GET, HEAD, DELETE");
|
|
192
|
+
expect(res.headers.get("x-request-id")).toBeTruthy();
|
|
193
|
+
expect(await res.json()).toEqual({
|
|
194
|
+
status: 405,
|
|
195
|
+
error: "METHOD_NOT_ALLOWED",
|
|
196
|
+
message: "Method not allowed"
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
));
|
|
201
|
+
it("answers HEAD requests with the GET route, without body", () => withApp(
|
|
202
|
+
(router) => {
|
|
203
|
+
router.defineRoute({ path: "/data", handler: () => ({ a: 1 }) });
|
|
204
|
+
router.defineRoute({
|
|
205
|
+
path: "/stream",
|
|
206
|
+
handler: () => stream(["never sent"])
|
|
207
|
+
});
|
|
208
|
+
},
|
|
209
|
+
async ({ fetch }) => {
|
|
210
|
+
const res = await fetch("/data", { method: "HEAD" });
|
|
211
|
+
expect(res.status).toBe(200);
|
|
212
|
+
expect(res.headers.get("content-type")).toBe(
|
|
213
|
+
"application/json; charset=utf-8"
|
|
214
|
+
);
|
|
215
|
+
expect(await res.text()).toBe("");
|
|
216
|
+
const streamed = await fetch("/stream", { method: "HEAD" });
|
|
217
|
+
expect(streamed.status).toBe(200);
|
|
218
|
+
expect(await streamed.text()).toBe("");
|
|
219
|
+
}
|
|
220
|
+
));
|
|
221
|
+
it("mounts groups with their prefix and middlewares", () => {
|
|
222
|
+
const calls = [];
|
|
223
|
+
const track = (name) => () => {
|
|
224
|
+
calls.push(name);
|
|
225
|
+
};
|
|
226
|
+
return withApp(
|
|
227
|
+
(router) => {
|
|
228
|
+
const api = router.createGroup("/api", [track("api")]);
|
|
229
|
+
api.createGroup("/admin", [track("admin")]).defineRoute({
|
|
230
|
+
path: "/stats",
|
|
231
|
+
middlewares: [track("route")],
|
|
232
|
+
handler: () => ({ ok: true })
|
|
233
|
+
});
|
|
234
|
+
},
|
|
235
|
+
async ({ fetch }) => {
|
|
236
|
+
expect(await (await fetch("/api/admin/stats")).json()).toEqual({
|
|
237
|
+
ok: true
|
|
238
|
+
});
|
|
239
|
+
expect(calls).toEqual(["global", "api", "admin", "route"]);
|
|
240
|
+
},
|
|
241
|
+
{ middlewares: [track("global")] }
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
it("serves the routes under the base path only", () => withApp(
|
|
245
|
+
(router) => {
|
|
246
|
+
router.defineRoute({
|
|
247
|
+
path: "/users/:id",
|
|
248
|
+
handler: ({ req }) => ({ id: req.params.id, path: req.path })
|
|
249
|
+
});
|
|
250
|
+
},
|
|
251
|
+
async ({ fetch }) => {
|
|
252
|
+
const res = await fetch("/my-app/api/users/42");
|
|
253
|
+
expect(res.status).toBe(200);
|
|
254
|
+
expect(await res.json()).toEqual({
|
|
255
|
+
id: "42",
|
|
256
|
+
path: "/my-app/api/users/42"
|
|
257
|
+
});
|
|
258
|
+
for (const path of ["/users/42", "/my-app/users/42"]) {
|
|
259
|
+
const outside = await fetch(path);
|
|
260
|
+
expect(outside.status, path).toBe(404);
|
|
261
|
+
expect(await outside.json()).toMatchObject({
|
|
262
|
+
error: "NOT_FOUND"
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
{ basePath: "/my-app/api" }
|
|
267
|
+
));
|
|
268
|
+
});
|
|
269
|
+
describe("request", () => {
|
|
270
|
+
it("parses the query string", () => withApp(
|
|
271
|
+
(router) => {
|
|
272
|
+
router.defineRoute({
|
|
273
|
+
path: "/search",
|
|
274
|
+
handler: ({ req }) => req.query
|
|
275
|
+
});
|
|
276
|
+
},
|
|
277
|
+
async ({ fetch }) => {
|
|
278
|
+
const res = await fetch(
|
|
279
|
+
"/search?q=hello+world&tag=a&tag=b&name=%C3%A9&empty="
|
|
280
|
+
);
|
|
281
|
+
expect(await res.json()).toEqual({
|
|
282
|
+
q: "hello world",
|
|
283
|
+
tag: ["a", "b"],
|
|
284
|
+
name: "\xE9",
|
|
285
|
+
empty: ""
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
));
|
|
289
|
+
it("exposes the method, path, URL, headers and cookies", () => withApp(
|
|
290
|
+
(router) => {
|
|
291
|
+
router.defineRoute({
|
|
292
|
+
method: "post",
|
|
293
|
+
path: "/inspect",
|
|
294
|
+
handler: ({ req }) => ({
|
|
295
|
+
method: req.method,
|
|
296
|
+
path: req.path,
|
|
297
|
+
url: req.url && new URL(req.url).pathname + new URL(req.url).search,
|
|
298
|
+
custom: req.header("X-Custom"),
|
|
299
|
+
lowercase: req.headers["x-custom"],
|
|
300
|
+
cookies: req.cookies
|
|
301
|
+
})
|
|
302
|
+
});
|
|
303
|
+
},
|
|
304
|
+
async ({ fetch }) => {
|
|
305
|
+
const res = await fetch("/inspect?a=1", {
|
|
306
|
+
method: "POST",
|
|
307
|
+
headers: {
|
|
308
|
+
"X-Custom": "value",
|
|
309
|
+
cookie: "session=abc; theme=dark%20blue"
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
expect(await res.json()).toEqual({
|
|
313
|
+
method: "POST",
|
|
314
|
+
path: "/inspect",
|
|
315
|
+
url: "/inspect?a=1",
|
|
316
|
+
custom: "value",
|
|
317
|
+
lowercase: "value",
|
|
318
|
+
cookies: { session: "abc", theme: "dark blue" }
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
));
|
|
322
|
+
it("exposes the client IP address", () => withApp(
|
|
323
|
+
(router) => {
|
|
324
|
+
router.defineRoute({
|
|
325
|
+
path: "/ip",
|
|
326
|
+
handler: ({ req }) => ({ ip: req.ip })
|
|
327
|
+
});
|
|
328
|
+
},
|
|
329
|
+
async ({ fetch }) => {
|
|
330
|
+
const { ip } = await (await fetch("/ip")).json();
|
|
331
|
+
expect(ip).toMatch(/^((::ffff:)?127\.0\.0\.1|::1)$/);
|
|
332
|
+
}
|
|
333
|
+
));
|
|
334
|
+
it("parses JSON bodies", () => withApp(
|
|
335
|
+
(router) => {
|
|
336
|
+
router.defineRoute({
|
|
337
|
+
method: "post",
|
|
338
|
+
path: "/echo",
|
|
339
|
+
handler: ({ req }) => ({ body: req.body })
|
|
340
|
+
});
|
|
341
|
+
},
|
|
342
|
+
async ({ fetch }) => {
|
|
343
|
+
const res = await fetch("/echo", postJson({ a: [1, "\xE9"], b: null }));
|
|
344
|
+
expect(await res.json()).toEqual({ body: { a: [1, "\xE9"], b: null } });
|
|
345
|
+
}
|
|
346
|
+
));
|
|
347
|
+
it("rejects malformed JSON bodies", () => withApp(
|
|
348
|
+
(router) => {
|
|
349
|
+
router.defineRoute({
|
|
350
|
+
method: "post",
|
|
351
|
+
path: "/echo",
|
|
352
|
+
handler: () => ({ unreachable: true })
|
|
353
|
+
});
|
|
354
|
+
},
|
|
355
|
+
async ({ fetch }) => {
|
|
356
|
+
const res = await fetch("/echo", {
|
|
357
|
+
method: "POST",
|
|
358
|
+
headers: { "content-type": "application/json" },
|
|
359
|
+
body: '{"broken":'
|
|
360
|
+
});
|
|
361
|
+
expect(res.status).toBe(400);
|
|
362
|
+
expect(res.headers.get("x-request-id")).toBeTruthy();
|
|
363
|
+
expect(await res.json()).toEqual({
|
|
364
|
+
status: 400,
|
|
365
|
+
error: "MALFORMED_BODY",
|
|
366
|
+
message: "Malformed request body"
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
));
|
|
370
|
+
it("parses URL-encoded, text and binary bodies", () => withApp(
|
|
371
|
+
(router) => {
|
|
372
|
+
router.defineRoute({
|
|
373
|
+
method: "post",
|
|
374
|
+
path: "/echo",
|
|
375
|
+
handler: ({ req }) => req.body instanceof Uint8Array ? { bytes: [...req.body] } : { body: req.body }
|
|
376
|
+
});
|
|
377
|
+
},
|
|
378
|
+
async ({ fetch }) => {
|
|
379
|
+
const form = await fetch("/echo", {
|
|
380
|
+
method: "POST",
|
|
381
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
382
|
+
body: "name=Ada+Lovelace&tag=a&tag=b"
|
|
383
|
+
});
|
|
384
|
+
expect(await form.json()).toEqual({
|
|
385
|
+
body: { name: "Ada Lovelace", tag: ["a", "b"] }
|
|
386
|
+
});
|
|
387
|
+
const plain = await fetch("/echo", {
|
|
388
|
+
method: "POST",
|
|
389
|
+
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
390
|
+
body: "h\xE9llo"
|
|
391
|
+
});
|
|
392
|
+
expect(await plain.json()).toEqual({ body: "h\xE9llo" });
|
|
393
|
+
const binary = await fetch("/echo", {
|
|
394
|
+
method: "POST",
|
|
395
|
+
headers: { "content-type": "application/octet-stream" },
|
|
396
|
+
body: new Uint8Array([0, 1, 254, 255])
|
|
397
|
+
});
|
|
398
|
+
expect(await binary.json()).toEqual({ bytes: [0, 1, 254, 255] });
|
|
399
|
+
}
|
|
400
|
+
));
|
|
401
|
+
it("exposes the raw body on rawBody routes", () => withApp(
|
|
402
|
+
(router) => {
|
|
403
|
+
router.defineRoute({
|
|
404
|
+
method: "post",
|
|
405
|
+
path: "/webhooks",
|
|
406
|
+
rawBody: true,
|
|
407
|
+
schema: (z) => ({ body: z.object({ type: z.string() }) }),
|
|
408
|
+
handler: ({ req }) => ({
|
|
409
|
+
raw: new TextDecoder().decode(req.rawBody),
|
|
410
|
+
type: req.body.type
|
|
411
|
+
})
|
|
412
|
+
});
|
|
413
|
+
router.defineRoute({
|
|
414
|
+
method: "post",
|
|
415
|
+
path: "/ping",
|
|
416
|
+
rawBody: true,
|
|
417
|
+
handler: ({ req }) => ({ length: req.rawBody.byteLength })
|
|
418
|
+
});
|
|
419
|
+
},
|
|
420
|
+
async ({ fetch }) => {
|
|
421
|
+
const payload = '{ "type" : "invoice.paid", "id": "\xE9" }';
|
|
422
|
+
const res = await fetch("/webhooks", {
|
|
423
|
+
method: "POST",
|
|
424
|
+
headers: { "content-type": "application/json" },
|
|
425
|
+
body: payload
|
|
426
|
+
});
|
|
427
|
+
expect(await res.json()).toEqual({
|
|
428
|
+
raw: payload,
|
|
429
|
+
type: "invoice.paid"
|
|
430
|
+
});
|
|
431
|
+
const empty = await fetch("/ping", { method: "POST" });
|
|
432
|
+
expect(await empty.json()).toEqual({ length: 0 });
|
|
433
|
+
}
|
|
434
|
+
));
|
|
435
|
+
it("gives an undefined body when there is none", () => withApp(
|
|
436
|
+
(router) => {
|
|
437
|
+
router.defineRoute({
|
|
438
|
+
method: "post",
|
|
439
|
+
path: "/empty",
|
|
440
|
+
handler: ({ req }) => ({ hasBody: req.body !== void 0 })
|
|
441
|
+
});
|
|
442
|
+
},
|
|
443
|
+
async ({ fetch }) => {
|
|
444
|
+
const res = await fetch("/empty", { method: "POST" });
|
|
445
|
+
expect(await res.json()).toEqual({ hasBody: false });
|
|
446
|
+
}
|
|
447
|
+
));
|
|
448
|
+
it("rejects bodies larger than the default limit", () => withApp(
|
|
449
|
+
(router) => {
|
|
450
|
+
router.defineRoute({
|
|
451
|
+
method: "post",
|
|
452
|
+
path: "/upload",
|
|
453
|
+
handler: () => ({ unreachable: true })
|
|
454
|
+
});
|
|
455
|
+
},
|
|
456
|
+
async ({ fetch }) => {
|
|
457
|
+
const payload = new Uint8Array(DEFAULT_BODY_LIMIT + 1);
|
|
458
|
+
const declared = await fetch("/upload", {
|
|
459
|
+
method: "POST",
|
|
460
|
+
headers: { "content-type": "application/octet-stream" },
|
|
461
|
+
body: payload
|
|
462
|
+
});
|
|
463
|
+
expect(declared.status).toBe(413);
|
|
464
|
+
expect(await declared.json()).toEqual({
|
|
465
|
+
status: 413,
|
|
466
|
+
error: "PAYLOAD_TOO_LARGE",
|
|
467
|
+
message: "Request body too large"
|
|
468
|
+
});
|
|
469
|
+
const chunked = await fetch("/upload", {
|
|
470
|
+
method: "POST",
|
|
471
|
+
headers: { "content-type": "application/octet-stream" },
|
|
472
|
+
body: new ReadableStream({
|
|
473
|
+
start(controller) {
|
|
474
|
+
controller.enqueue(payload.subarray(0, DEFAULT_BODY_LIMIT));
|
|
475
|
+
controller.enqueue(new Uint8Array(1));
|
|
476
|
+
controller.close();
|
|
477
|
+
}
|
|
478
|
+
}),
|
|
479
|
+
duplex: "half"
|
|
480
|
+
});
|
|
481
|
+
expect(chunked.status).toBe(413);
|
|
482
|
+
}
|
|
483
|
+
));
|
|
484
|
+
it("accepts bodies up to the default limit", () => withApp(
|
|
485
|
+
(router) => {
|
|
486
|
+
router.defineRoute({
|
|
487
|
+
method: "post",
|
|
488
|
+
path: "/upload",
|
|
489
|
+
handler: ({ req }) => ({
|
|
490
|
+
size: req.body.byteLength
|
|
491
|
+
})
|
|
492
|
+
});
|
|
493
|
+
},
|
|
494
|
+
async ({ fetch }) => {
|
|
495
|
+
const res = await fetch("/upload", {
|
|
496
|
+
method: "POST",
|
|
497
|
+
headers: { "content-type": "application/octet-stream" },
|
|
498
|
+
body: new Uint8Array(DEFAULT_BODY_LIMIT)
|
|
499
|
+
});
|
|
500
|
+
expect(await res.json()).toEqual({ size: DEFAULT_BODY_LIMIT });
|
|
501
|
+
}
|
|
502
|
+
));
|
|
503
|
+
it("rejects compressed request bodies", () => withApp(
|
|
504
|
+
(router) => {
|
|
505
|
+
router.defineRoute({
|
|
506
|
+
method: "post",
|
|
507
|
+
path: "/echo",
|
|
508
|
+
handler: () => ({ unreachable: true })
|
|
509
|
+
});
|
|
510
|
+
},
|
|
511
|
+
async ({ fetch }) => {
|
|
512
|
+
const res = await fetch("/echo", {
|
|
513
|
+
method: "POST",
|
|
514
|
+
headers: {
|
|
515
|
+
"content-type": "application/json",
|
|
516
|
+
"content-encoding": "gzip"
|
|
517
|
+
},
|
|
518
|
+
body: new Uint8Array([31, 139, 8, 0])
|
|
519
|
+
});
|
|
520
|
+
expect(res.status).toBe(415);
|
|
521
|
+
expect(await res.json()).toMatchObject({
|
|
522
|
+
error: "UNSUPPORTED_MEDIA_TYPE"
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
));
|
|
526
|
+
});
|
|
527
|
+
describe("uploads", () => {
|
|
528
|
+
const formWith = (entries) => {
|
|
529
|
+
const form = new FormData();
|
|
530
|
+
for (const [name, value] of entries) form.append(name, value);
|
|
531
|
+
return form;
|
|
532
|
+
};
|
|
533
|
+
it("parses multipart bodies into fields and files", () => {
|
|
534
|
+
const bytes = Uint8Array.from({ length: 256 }, (_, index) => index);
|
|
535
|
+
return withApp(
|
|
536
|
+
(router) => {
|
|
537
|
+
router.defineRoute({
|
|
538
|
+
method: "post",
|
|
539
|
+
path: "/documents",
|
|
540
|
+
schema: (z) => ({
|
|
541
|
+
body: z.object({ title: z.string() }),
|
|
542
|
+
files: z.object({
|
|
543
|
+
document: z.file().mime(["application/pdf"])
|
|
544
|
+
})
|
|
545
|
+
}),
|
|
546
|
+
handler: async ({ req }) => ({
|
|
547
|
+
title: req.body.title,
|
|
548
|
+
name: req.files.document.name,
|
|
549
|
+
type: req.files.document.type,
|
|
550
|
+
bytes: [
|
|
551
|
+
...new Uint8Array(await req.files.document.arrayBuffer())
|
|
552
|
+
]
|
|
553
|
+
})
|
|
554
|
+
});
|
|
555
|
+
router.defineRoute({
|
|
556
|
+
method: "post",
|
|
557
|
+
path: "/gallery",
|
|
558
|
+
handler: ({ req }) => ({
|
|
559
|
+
fields: req.body,
|
|
560
|
+
files: Object.fromEntries(
|
|
561
|
+
Object.entries(req.files).map(([name, value]) => [
|
|
562
|
+
name,
|
|
563
|
+
[value].flat().map((file2) => file2.name)
|
|
564
|
+
])
|
|
565
|
+
)
|
|
566
|
+
})
|
|
567
|
+
});
|
|
568
|
+
},
|
|
569
|
+
async ({ fetch }) => {
|
|
570
|
+
const res = await fetch("/documents", {
|
|
571
|
+
method: "POST",
|
|
572
|
+
body: formWith([
|
|
573
|
+
["title", "Contrat sign\xE9"],
|
|
574
|
+
[
|
|
575
|
+
"document",
|
|
576
|
+
new File([bytes], "contrat \xE9t\xE9.pdf", {
|
|
577
|
+
type: "application/pdf"
|
|
578
|
+
})
|
|
579
|
+
]
|
|
580
|
+
])
|
|
581
|
+
});
|
|
582
|
+
expect(await res.json()).toEqual({
|
|
583
|
+
title: "Contrat sign\xE9",
|
|
584
|
+
name: "contrat \xE9t\xE9.pdf",
|
|
585
|
+
type: "application/pdf",
|
|
586
|
+
bytes: [...bytes]
|
|
587
|
+
});
|
|
588
|
+
const gallery = await fetch("/gallery", {
|
|
589
|
+
method: "POST",
|
|
590
|
+
body: formWith([
|
|
591
|
+
["tag", "sea"],
|
|
592
|
+
["tag", "sun"],
|
|
593
|
+
["photos", new File(["a"], "a.jpg")],
|
|
594
|
+
["photos", new File(["b"], "b.jpg")],
|
|
595
|
+
["cover", new File(["c"], "c.jpg")]
|
|
596
|
+
])
|
|
597
|
+
});
|
|
598
|
+
expect(await gallery.json()).toEqual({
|
|
599
|
+
fields: { tag: ["sea", "sun"] },
|
|
600
|
+
files: { photos: ["a.jpg", "b.jpg"], cover: ["c.jpg"] }
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
);
|
|
604
|
+
});
|
|
605
|
+
it("validates files", () => withApp(
|
|
606
|
+
(router) => {
|
|
607
|
+
router.defineRoute({
|
|
608
|
+
method: "post",
|
|
609
|
+
path: "/avatar",
|
|
610
|
+
schema: (z) => ({
|
|
611
|
+
files: z.object({
|
|
612
|
+
avatar: z.file().max(10).mime(["image/png"])
|
|
613
|
+
})
|
|
614
|
+
}),
|
|
615
|
+
handler: () => ({ ok: true })
|
|
616
|
+
});
|
|
617
|
+
},
|
|
618
|
+
async ({ fetch }) => {
|
|
619
|
+
const res = await fetch("/avatar", {
|
|
620
|
+
method: "POST",
|
|
621
|
+
body: formWith([
|
|
622
|
+
[
|
|
623
|
+
"avatar",
|
|
624
|
+
new File([new Uint8Array(11)], "a.gif", {
|
|
625
|
+
type: "image/gif"
|
|
626
|
+
})
|
|
627
|
+
]
|
|
628
|
+
])
|
|
629
|
+
});
|
|
630
|
+
expect(res.status).toBe(400);
|
|
631
|
+
expect(await res.json()).toMatchObject({
|
|
632
|
+
error: "VALIDATION_FAILED",
|
|
633
|
+
data: {
|
|
634
|
+
issues: [
|
|
635
|
+
{ source: "files", path: ["avatar"], code: "too_big" },
|
|
636
|
+
{ source: "files", path: ["avatar"], code: "invalid_value" }
|
|
637
|
+
]
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
));
|
|
642
|
+
it("applies the route body limit", () => withApp(
|
|
643
|
+
(router) => {
|
|
644
|
+
router.defineRoute({
|
|
645
|
+
method: "post",
|
|
646
|
+
path: "/large",
|
|
647
|
+
bodyLimit: 3 * DEFAULT_BODY_LIMIT,
|
|
648
|
+
handler: ({ req }) => ({
|
|
649
|
+
size: req.files.file.size
|
|
650
|
+
})
|
|
651
|
+
});
|
|
652
|
+
router.defineRoute({
|
|
653
|
+
method: "post",
|
|
654
|
+
path: "/small",
|
|
655
|
+
bodyLimit: 100,
|
|
656
|
+
handler: () => ({ unreachable: true })
|
|
657
|
+
});
|
|
658
|
+
},
|
|
659
|
+
async ({ fetch }) => {
|
|
660
|
+
const size = 2 * DEFAULT_BODY_LIMIT;
|
|
661
|
+
const large = await fetch("/large", {
|
|
662
|
+
method: "POST",
|
|
663
|
+
body: formWith([
|
|
664
|
+
["file", new File([new Uint8Array(size)], "big.bin")]
|
|
665
|
+
])
|
|
666
|
+
});
|
|
667
|
+
expect(await large.json()).toEqual({ size });
|
|
668
|
+
const small = await fetch("/small", {
|
|
669
|
+
method: "POST",
|
|
670
|
+
body: formWith([
|
|
671
|
+
["file", new File([new Uint8Array(200)], "a.bin")]
|
|
672
|
+
])
|
|
673
|
+
});
|
|
674
|
+
expect(small.status).toBe(413);
|
|
675
|
+
}
|
|
676
|
+
));
|
|
677
|
+
it("rejects malformed multipart bodies", () => withApp(
|
|
678
|
+
(router) => {
|
|
679
|
+
router.defineRoute({
|
|
680
|
+
method: "post",
|
|
681
|
+
path: "/upload",
|
|
682
|
+
handler: () => ({ unreachable: true })
|
|
683
|
+
});
|
|
684
|
+
},
|
|
685
|
+
async ({ fetch }) => {
|
|
686
|
+
const res = await fetch("/upload", {
|
|
687
|
+
method: "POST",
|
|
688
|
+
headers: { "content-type": "multipart/form-data; boundary=xyz" },
|
|
689
|
+
body: "not a multipart body"
|
|
690
|
+
});
|
|
691
|
+
expect(res.status).toBe(400);
|
|
692
|
+
expect(await res.json()).toMatchObject({ error: "MALFORMED_BODY" });
|
|
693
|
+
}
|
|
694
|
+
));
|
|
695
|
+
});
|
|
696
|
+
describe("validation", () => {
|
|
697
|
+
it("returns VALIDATION_FAILED errors and parsed values", () => withApp(
|
|
698
|
+
(router) => {
|
|
699
|
+
router.defineRoute({
|
|
700
|
+
method: "post",
|
|
701
|
+
path: "/users/:id",
|
|
702
|
+
schema: (z) => ({
|
|
703
|
+
params: z.object({ id: z.coerce.number().int() }),
|
|
704
|
+
query: z.object({ notify: z.enum(["yes", "no"]) }),
|
|
705
|
+
body: z.object({ name: z.string().trim() })
|
|
706
|
+
}),
|
|
707
|
+
handler: ({ req }) => ({
|
|
708
|
+
id: req.params.id,
|
|
709
|
+
notify: req.query.notify,
|
|
710
|
+
name: req.body.name
|
|
711
|
+
})
|
|
712
|
+
});
|
|
713
|
+
},
|
|
714
|
+
async ({ fetch }) => {
|
|
715
|
+
const invalid = await fetch("/users/abc?notify=maybe", postJson({}));
|
|
716
|
+
expect(invalid.status).toBe(400);
|
|
717
|
+
expect(await invalid.json()).toMatchObject({
|
|
718
|
+
status: 400,
|
|
719
|
+
error: "VALIDATION_FAILED",
|
|
720
|
+
data: {
|
|
721
|
+
issues: [
|
|
722
|
+
{ source: "params", path: ["id"] },
|
|
723
|
+
{ source: "query", path: ["notify"] },
|
|
724
|
+
{ source: "body", path: ["name"] }
|
|
725
|
+
]
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
const valid = await fetch(
|
|
729
|
+
"/users/42?notify=yes",
|
|
730
|
+
postJson({ name: " Ada " })
|
|
731
|
+
);
|
|
732
|
+
expect(await valid.json()).toEqual({
|
|
733
|
+
id: 42,
|
|
734
|
+
notify: "yes",
|
|
735
|
+
name: "Ada"
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
));
|
|
739
|
+
});
|
|
740
|
+
describe("errors", () => {
|
|
741
|
+
it("serializes returned and thrown HTTP errors", () => withApp(
|
|
742
|
+
(router) => {
|
|
743
|
+
router.defineRoute({
|
|
744
|
+
path: "/returned",
|
|
745
|
+
handler: () => notFoundError("User not found", { data: { id: "1" } })
|
|
746
|
+
});
|
|
747
|
+
router.defineRoute({
|
|
748
|
+
path: "/thrown",
|
|
749
|
+
handler: ({ res }) => {
|
|
750
|
+
res.setHeader("www-authenticate", "Bearer");
|
|
751
|
+
throw unauthorizedError();
|
|
752
|
+
}
|
|
753
|
+
});
|
|
754
|
+
},
|
|
755
|
+
async ({ fetch }) => {
|
|
756
|
+
const returned = await fetch("/returned");
|
|
757
|
+
expect(returned.status).toBe(404);
|
|
758
|
+
expect(await returned.json()).toEqual({
|
|
759
|
+
status: 404,
|
|
760
|
+
error: "NOT_FOUND",
|
|
761
|
+
message: "User not found",
|
|
762
|
+
data: { id: "1" }
|
|
763
|
+
});
|
|
764
|
+
const thrown = await fetch("/thrown");
|
|
765
|
+
expect(thrown.status).toBe(401);
|
|
766
|
+
expect(thrown.headers.get("www-authenticate")).toBe("Bearer");
|
|
767
|
+
expect(await thrown.json()).toEqual({
|
|
768
|
+
status: 401,
|
|
769
|
+
error: "UNAUTHORIZED",
|
|
770
|
+
message: "Unauthorized"
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
));
|
|
774
|
+
it("hides unexpected errors behind a 500 error and reports them", async () => {
|
|
775
|
+
const onError = vi.fn();
|
|
776
|
+
await withApp(
|
|
777
|
+
(router) => {
|
|
778
|
+
router.defineRoute({
|
|
779
|
+
path: "/crash",
|
|
780
|
+
handler: () => {
|
|
781
|
+
throw new Error("Database password is hunter2");
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
},
|
|
785
|
+
async ({ fetch }) => {
|
|
786
|
+
const res = await fetch("/crash");
|
|
787
|
+
expect(res.status).toBe(500);
|
|
788
|
+
expect(res.headers.get("x-request-id")).toBeTruthy();
|
|
789
|
+
expect(await res.json()).toEqual({
|
|
790
|
+
status: 500,
|
|
791
|
+
error: "INTERNAL_ERROR",
|
|
792
|
+
message: "Internal Server Error"
|
|
793
|
+
});
|
|
794
|
+
expect(onError).toHaveBeenCalledOnce();
|
|
795
|
+
},
|
|
796
|
+
{ onError }
|
|
797
|
+
);
|
|
798
|
+
});
|
|
799
|
+
it("exposes error details when exposeErrors is enabled", () => withApp(
|
|
800
|
+
(router) => {
|
|
801
|
+
router.defineRoute({
|
|
802
|
+
path: "/crash",
|
|
803
|
+
handler: () => {
|
|
804
|
+
throw new Error("Boom");
|
|
805
|
+
}
|
|
806
|
+
});
|
|
807
|
+
},
|
|
808
|
+
async ({ fetch }) => {
|
|
809
|
+
expect(await (await fetch("/crash")).json()).toMatchObject({
|
|
810
|
+
status: 500,
|
|
811
|
+
data: { name: "Error", message: "Boom" }
|
|
812
|
+
});
|
|
813
|
+
},
|
|
814
|
+
{ exposeErrors: true }
|
|
815
|
+
));
|
|
816
|
+
it("returns a 500 error when the response does not match its schema", () => withApp(
|
|
817
|
+
(router) => {
|
|
818
|
+
router.defineRoute({
|
|
819
|
+
path: "/invalid",
|
|
820
|
+
schema: (z) => ({ response: z.object({ id: z.string() }) }),
|
|
821
|
+
// @ts-expect-error the result does not match the schema
|
|
822
|
+
handler: () => ({ id: 1 })
|
|
823
|
+
});
|
|
824
|
+
},
|
|
825
|
+
async ({ fetch }) => {
|
|
826
|
+
expect((await fetch("/invalid")).status).toBe(500);
|
|
827
|
+
}
|
|
828
|
+
));
|
|
829
|
+
});
|
|
830
|
+
describe("middlewares and filters", () => {
|
|
831
|
+
it("stops the pipeline when a middleware returns a response", () => withApp(
|
|
832
|
+
(router) => {
|
|
833
|
+
router.defineRoute({
|
|
834
|
+
path: "/private",
|
|
835
|
+
middlewares: [
|
|
836
|
+
({ req }) => req.header("authorization") ? void 0 : unauthorizedError()
|
|
837
|
+
],
|
|
838
|
+
handler: () => ({ secret: true })
|
|
839
|
+
});
|
|
840
|
+
router.defineRoute({
|
|
841
|
+
path: "/legacy",
|
|
842
|
+
middlewares: [() => redirect("/modern", { status: 301 })],
|
|
843
|
+
handler: () => ({ unreachable: true })
|
|
844
|
+
});
|
|
845
|
+
},
|
|
846
|
+
async ({ fetch }) => {
|
|
847
|
+
expect((await fetch("/private")).status).toBe(401);
|
|
848
|
+
expect(
|
|
849
|
+
await (await fetch("/private", { headers: { authorization: "x" } })).json()
|
|
850
|
+
).toEqual({ secret: true });
|
|
851
|
+
const legacy = await fetch("/legacy", { redirect: "manual" });
|
|
852
|
+
expect(legacy.status).toBe(301);
|
|
853
|
+
expect(legacy.headers.get("location")).toBe("/modern");
|
|
854
|
+
}
|
|
855
|
+
));
|
|
856
|
+
it("applies app and router response filters", () => withApp(
|
|
857
|
+
(router) => {
|
|
858
|
+
router.addResponseFilter((_ctx, result) => ({
|
|
859
|
+
...result,
|
|
860
|
+
router: true
|
|
861
|
+
}));
|
|
862
|
+
router.defineRoute({ path: "/data", handler: () => ({ a: 1 }) });
|
|
863
|
+
},
|
|
864
|
+
async ({ fetch }) => {
|
|
865
|
+
expect(await (await fetch("/data")).json()).toEqual({
|
|
866
|
+
a: 1,
|
|
867
|
+
app: true,
|
|
868
|
+
router: true
|
|
869
|
+
});
|
|
870
|
+
},
|
|
871
|
+
{
|
|
872
|
+
responseFilters: [
|
|
873
|
+
(_ctx, result) => ({ ...result, app: true })
|
|
874
|
+
]
|
|
875
|
+
}
|
|
876
|
+
));
|
|
877
|
+
});
|
|
878
|
+
describe("responses", () => {
|
|
879
|
+
it("applies statuses, headers and cookies", () => withApp(
|
|
880
|
+
(router) => {
|
|
881
|
+
router.defineRoute({
|
|
882
|
+
method: "post",
|
|
883
|
+
path: "/created",
|
|
884
|
+
status: 201,
|
|
885
|
+
handler: ({ res }) => {
|
|
886
|
+
res.setHeader("location", "/items/1");
|
|
887
|
+
res.setCookie("session", "abc", { httpOnly: true });
|
|
888
|
+
res.setCookie("theme", "dark");
|
|
889
|
+
return { id: 1 };
|
|
890
|
+
}
|
|
891
|
+
});
|
|
892
|
+
router.defineRoute({
|
|
893
|
+
path: "/accepted",
|
|
894
|
+
handler: ({ res }) => {
|
|
895
|
+
res.setStatus(202);
|
|
896
|
+
return json({ queued: true }, { headers: { "x-queue": "a" } });
|
|
897
|
+
}
|
|
898
|
+
});
|
|
899
|
+
},
|
|
900
|
+
async ({ fetch }) => {
|
|
901
|
+
const created = await fetch("/created", { method: "POST" });
|
|
902
|
+
expect(created.status).toBe(201);
|
|
903
|
+
expect(created.headers.get("location")).toBe("/items/1");
|
|
904
|
+
expect(created.headers.getSetCookie()).toEqual([
|
|
905
|
+
"session=abc; Path=/; HttpOnly",
|
|
906
|
+
"theme=dark; Path=/"
|
|
907
|
+
]);
|
|
908
|
+
const accepted = await fetch("/accepted");
|
|
909
|
+
expect(accepted.status).toBe(202);
|
|
910
|
+
expect(accepted.headers.get("x-queue")).toBe("a");
|
|
911
|
+
expect(await accepted.json()).toEqual({ queued: true });
|
|
912
|
+
}
|
|
913
|
+
));
|
|
914
|
+
it("answers 204 without body when the handler returns nothing", () => withApp(
|
|
915
|
+
(router) => {
|
|
916
|
+
router.defineRoute({
|
|
917
|
+
method: "delete",
|
|
918
|
+
path: "/items/:id",
|
|
919
|
+
handler: () => {
|
|
920
|
+
}
|
|
921
|
+
});
|
|
922
|
+
},
|
|
923
|
+
async ({ fetch }) => {
|
|
924
|
+
const res = await fetch("/items/1", { method: "DELETE" });
|
|
925
|
+
expect(res.status).toBe(204);
|
|
926
|
+
expect(res.headers.get("content-type")).toBeNull();
|
|
927
|
+
expect(await res.text()).toBe("");
|
|
928
|
+
}
|
|
929
|
+
));
|
|
930
|
+
it("does not add framework headers", () => withApp(
|
|
931
|
+
(router) => {
|
|
932
|
+
router.defineRoute({ path: "/plain", handler: () => "ok" });
|
|
933
|
+
},
|
|
934
|
+
async ({ fetch }) => {
|
|
935
|
+
const res = await fetch("/plain");
|
|
936
|
+
expect(res.headers.get("x-powered-by")).toBeNull();
|
|
937
|
+
expect(res.headers.get("etag")).toBeNull();
|
|
938
|
+
}
|
|
939
|
+
));
|
|
940
|
+
it("sends redirects", () => withApp(
|
|
941
|
+
(router) => {
|
|
942
|
+
router.defineRoute({
|
|
943
|
+
path: "/old",
|
|
944
|
+
handler: () => redirect("/new")
|
|
945
|
+
});
|
|
946
|
+
},
|
|
947
|
+
async ({ fetch }) => {
|
|
948
|
+
const res = await fetch("/old", { redirect: "manual" });
|
|
949
|
+
expect(res.status).toBe(302);
|
|
950
|
+
expect(res.headers.get("location")).toBe("/new");
|
|
951
|
+
}
|
|
952
|
+
));
|
|
953
|
+
it("sends text responses", () => withApp(
|
|
954
|
+
(router) => {
|
|
955
|
+
router.defineRoute({
|
|
956
|
+
path: "/page",
|
|
957
|
+
handler: () => text("<h1>H\xE9llo</h1>", {
|
|
958
|
+
headers: { "content-type": "text/html; charset=utf-8" }
|
|
959
|
+
})
|
|
960
|
+
});
|
|
961
|
+
router.defineRoute({ path: "/health", handler: () => text("OK") });
|
|
962
|
+
},
|
|
963
|
+
async ({ fetch }) => {
|
|
964
|
+
const page = await fetch("/page");
|
|
965
|
+
expect(page.headers.get("content-type")).toBe(
|
|
966
|
+
"text/html; charset=utf-8"
|
|
967
|
+
);
|
|
968
|
+
expect(await page.text()).toBe("<h1>H\xE9llo</h1>");
|
|
969
|
+
const health = await fetch("/health");
|
|
970
|
+
expect(health.headers.get("content-type")).toBe(
|
|
971
|
+
"text/plain; charset=utf-8"
|
|
972
|
+
);
|
|
973
|
+
expect(await health.text()).toBe("OK");
|
|
974
|
+
}
|
|
975
|
+
));
|
|
976
|
+
it("sends files without altering their bytes", () => {
|
|
977
|
+
const bytes = Uint8Array.from({ length: 256 }, (_, index) => index);
|
|
978
|
+
return withApp(
|
|
979
|
+
(router) => {
|
|
980
|
+
router.defineRoute({
|
|
981
|
+
path: "/download",
|
|
982
|
+
handler: () => file(bytes, {
|
|
983
|
+
filename: "rapport \xE9t\xE9.bin",
|
|
984
|
+
contentType: "application/octet-stream"
|
|
985
|
+
})
|
|
986
|
+
});
|
|
987
|
+
router.defineRoute({
|
|
988
|
+
path: "/blob",
|
|
989
|
+
handler: () => file(new Blob([bytes], { type: "image/png" }), {
|
|
990
|
+
disposition: "inline"
|
|
991
|
+
})
|
|
992
|
+
});
|
|
993
|
+
},
|
|
994
|
+
async ({ fetch }) => {
|
|
995
|
+
const res = await fetch("/download");
|
|
996
|
+
expect(res.headers.get("content-length")).toBe("256");
|
|
997
|
+
expect(res.headers.get("content-disposition")).toBe(
|
|
998
|
+
`attachment; filename="rapport _t_.bin"; filename*=UTF-8''rapport%20%C3%A9t%C3%A9.bin`
|
|
999
|
+
);
|
|
1000
|
+
expect(new Uint8Array(await res.arrayBuffer())).toEqual(bytes);
|
|
1001
|
+
const blob = await fetch("/blob");
|
|
1002
|
+
expect(blob.headers.get("content-type")).toBe("image/png");
|
|
1003
|
+
expect(new Uint8Array(await blob.arrayBuffer())).toEqual(bytes);
|
|
1004
|
+
}
|
|
1005
|
+
);
|
|
1006
|
+
});
|
|
1007
|
+
});
|
|
1008
|
+
describe("cors", () => {
|
|
1009
|
+
const APP = "https://app.example.com";
|
|
1010
|
+
const EVIL = "https://evil.example.org";
|
|
1011
|
+
const corsRoutes = (router) => {
|
|
1012
|
+
router.defineRoute({ path: "/users", handler: () => [] });
|
|
1013
|
+
router.defineRoute({
|
|
1014
|
+
method: "post",
|
|
1015
|
+
path: "/users",
|
|
1016
|
+
handler: () => ({ created: true })
|
|
1017
|
+
});
|
|
1018
|
+
router.defineRoute({
|
|
1019
|
+
path: "/private",
|
|
1020
|
+
middlewares: [() => unauthorizedError()],
|
|
1021
|
+
handler: () => ({ secret: true })
|
|
1022
|
+
});
|
|
1023
|
+
router.defineRoute({
|
|
1024
|
+
method: "options",
|
|
1025
|
+
path: "/custom",
|
|
1026
|
+
handler: () => ({ custom: true })
|
|
1027
|
+
});
|
|
1028
|
+
router.defineRoute({
|
|
1029
|
+
path: "/export.csv",
|
|
1030
|
+
handler: () => stream(["a,b\n"], { contentType: "text/csv" })
|
|
1031
|
+
});
|
|
1032
|
+
};
|
|
1033
|
+
const preflight = (origin = APP) => ({
|
|
1034
|
+
method: "OPTIONS",
|
|
1035
|
+
headers: {
|
|
1036
|
+
origin,
|
|
1037
|
+
"access-control-request-method": "POST",
|
|
1038
|
+
"access-control-request-headers": "content-type, authorization"
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
it("answers preflight requests before the middlewares", () => withApp(
|
|
1042
|
+
corsRoutes,
|
|
1043
|
+
async ({ fetch }) => {
|
|
1044
|
+
const res = await fetch("/users", preflight());
|
|
1045
|
+
expect(res.status).toBe(204);
|
|
1046
|
+
expect(res.headers.get("access-control-allow-origin")).toBe(APP);
|
|
1047
|
+
expect(res.headers.get("access-control-allow-methods")).toBe(
|
|
1048
|
+
"GET, HEAD, POST"
|
|
1049
|
+
);
|
|
1050
|
+
expect(res.headers.get("access-control-allow-headers")).toBe(
|
|
1051
|
+
"content-type, authorization"
|
|
1052
|
+
);
|
|
1053
|
+
expect(res.headers.get("access-control-max-age")).toBe("600");
|
|
1054
|
+
expect(res.headers.get("vary")).toContain("Origin");
|
|
1055
|
+
expect(await res.text()).toBe("");
|
|
1056
|
+
const private_ = await fetch("/private", preflight());
|
|
1057
|
+
expect(private_.status).toBe(204);
|
|
1058
|
+
const custom = await fetch("/custom", preflight());
|
|
1059
|
+
expect(custom.status).toBe(204);
|
|
1060
|
+
expect(custom.headers.get("access-control-allow-methods")).toBe(
|
|
1061
|
+
"OPTIONS"
|
|
1062
|
+
);
|
|
1063
|
+
const refused = await fetch("/users", preflight(EVIL));
|
|
1064
|
+
expect(refused.status).toBe(405);
|
|
1065
|
+
expect(
|
|
1066
|
+
refused.headers.get("access-control-allow-origin")
|
|
1067
|
+
).toBeNull();
|
|
1068
|
+
expect((await fetch("/missing", preflight())).status).toBe(404);
|
|
1069
|
+
},
|
|
1070
|
+
{ cors: { origin: [APP], maxAge: 600 } }
|
|
1071
|
+
));
|
|
1072
|
+
it("adds the CORS headers to every response, errors included", () => withApp(
|
|
1073
|
+
corsRoutes,
|
|
1074
|
+
async ({ fetch }) => {
|
|
1075
|
+
const requests = [
|
|
1076
|
+
["/users", {}, 200],
|
|
1077
|
+
["/private", {}, 401],
|
|
1078
|
+
["/missing", {}, 404],
|
|
1079
|
+
["/users", { method: "DELETE" }, 405],
|
|
1080
|
+
[
|
|
1081
|
+
"/users",
|
|
1082
|
+
{
|
|
1083
|
+
method: "POST",
|
|
1084
|
+
headers: { "content-type": "application/json" },
|
|
1085
|
+
body: "{oops"
|
|
1086
|
+
},
|
|
1087
|
+
400
|
|
1088
|
+
],
|
|
1089
|
+
["/export.csv", {}, 200]
|
|
1090
|
+
];
|
|
1091
|
+
for (const [path, init, status] of requests) {
|
|
1092
|
+
const res = await fetch(path, {
|
|
1093
|
+
...init,
|
|
1094
|
+
headers: { ...init.headers, origin: APP }
|
|
1095
|
+
});
|
|
1096
|
+
expect(res.status).toBe(status);
|
|
1097
|
+
expect(res.headers.get("access-control-allow-origin")).toBe(APP);
|
|
1098
|
+
expect(res.headers.get("access-control-allow-credentials")).toBe(
|
|
1099
|
+
"true"
|
|
1100
|
+
);
|
|
1101
|
+
expect(res.headers.get("access-control-expose-headers")).toBe(
|
|
1102
|
+
"x-request-id"
|
|
1103
|
+
);
|
|
1104
|
+
expect(res.headers.get("vary")).toContain("Origin");
|
|
1105
|
+
await res.arrayBuffer();
|
|
1106
|
+
}
|
|
1107
|
+
const refused = await fetch("/users", { headers: { origin: EVIL } });
|
|
1108
|
+
expect(refused.status).toBe(200);
|
|
1109
|
+
expect(
|
|
1110
|
+
refused.headers.get("access-control-allow-origin")
|
|
1111
|
+
).toBeNull();
|
|
1112
|
+
expect(refused.headers.get("vary")).toContain("Origin");
|
|
1113
|
+
},
|
|
1114
|
+
{
|
|
1115
|
+
cors: {
|
|
1116
|
+
origin: APP,
|
|
1117
|
+
credentials: true,
|
|
1118
|
+
exposedHeaders: ["x-request-id"]
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
));
|
|
1122
|
+
});
|
|
1123
|
+
describe("streaming", () => {
|
|
1124
|
+
const incremental = options.bufferedResponses ? it.skip : it;
|
|
1125
|
+
it("streams chunks", () => withApp(
|
|
1126
|
+
(router) => {
|
|
1127
|
+
router.defineRoute({
|
|
1128
|
+
path: "/export.csv",
|
|
1129
|
+
handler: () => stream(
|
|
1130
|
+
async function* () {
|
|
1131
|
+
yield "id,name\n";
|
|
1132
|
+
yield new TextEncoder().encode("1,Ada\n");
|
|
1133
|
+
},
|
|
1134
|
+
{ contentType: "text/csv" }
|
|
1135
|
+
)
|
|
1136
|
+
});
|
|
1137
|
+
},
|
|
1138
|
+
async ({ fetch }) => {
|
|
1139
|
+
const res = await fetch("/export.csv");
|
|
1140
|
+
expect(res.status).toBe(200);
|
|
1141
|
+
expect(res.headers.get("content-type")).toBe("text/csv");
|
|
1142
|
+
expect(await res.text()).toBe("id,name\n1,Ada\n");
|
|
1143
|
+
}
|
|
1144
|
+
));
|
|
1145
|
+
incremental("flushes Server-Sent Events as they are produced", () => {
|
|
1146
|
+
const released = deferred();
|
|
1147
|
+
return withApp(
|
|
1148
|
+
(router) => {
|
|
1149
|
+
router.defineRoute({
|
|
1150
|
+
path: "/events",
|
|
1151
|
+
handler: () => sse(
|
|
1152
|
+
async function* ({ signal }) {
|
|
1153
|
+
try {
|
|
1154
|
+
yield { event: "ready", data: { ok: true } };
|
|
1155
|
+
await waitForAbort(signal);
|
|
1156
|
+
} finally {
|
|
1157
|
+
released.resolve();
|
|
1158
|
+
}
|
|
1159
|
+
},
|
|
1160
|
+
{ heartbeat: false }
|
|
1161
|
+
)
|
|
1162
|
+
});
|
|
1163
|
+
},
|
|
1164
|
+
async ({ fetch }) => {
|
|
1165
|
+
const res = await fetch("/events");
|
|
1166
|
+
expect(res.headers.get("content-type")).toBe(
|
|
1167
|
+
"text/event-stream; charset=utf-8"
|
|
1168
|
+
);
|
|
1169
|
+
expect(res.headers.get("cache-control")).toBe("no-cache");
|
|
1170
|
+
const reader = res.body.getReader();
|
|
1171
|
+
expect(await within(readChunk(reader), "first event")).toBe(
|
|
1172
|
+
'event: ready\ndata: {"ok":true}\n\n'
|
|
1173
|
+
);
|
|
1174
|
+
await reader.cancel();
|
|
1175
|
+
await within(released.promise, "source release on disconnect");
|
|
1176
|
+
}
|
|
1177
|
+
);
|
|
1178
|
+
});
|
|
1179
|
+
incremental(
|
|
1180
|
+
"cancels the response stream when the client disconnects",
|
|
1181
|
+
() => {
|
|
1182
|
+
const cancelled = deferred();
|
|
1183
|
+
return withApp(
|
|
1184
|
+
(router) => {
|
|
1185
|
+
router.defineRoute({
|
|
1186
|
+
path: "/download",
|
|
1187
|
+
handler: () => file(
|
|
1188
|
+
new ReadableStream({
|
|
1189
|
+
pull: (controller) => controller.enqueue(new Uint8Array(64 * 1024)),
|
|
1190
|
+
cancel: () => cancelled.resolve()
|
|
1191
|
+
})
|
|
1192
|
+
)
|
|
1193
|
+
});
|
|
1194
|
+
},
|
|
1195
|
+
async ({ fetch }) => {
|
|
1196
|
+
const res = await fetch("/download");
|
|
1197
|
+
const reader = res.body.getReader();
|
|
1198
|
+
await reader.read();
|
|
1199
|
+
await reader.cancel();
|
|
1200
|
+
await within(cancelled.promise, "response stream cancel");
|
|
1201
|
+
}
|
|
1202
|
+
);
|
|
1203
|
+
}
|
|
1204
|
+
);
|
|
1205
|
+
incremental("aborts req.signal when the client disconnects", () => {
|
|
1206
|
+
const started = deferred();
|
|
1207
|
+
const aborted = deferred();
|
|
1208
|
+
return withApp(
|
|
1209
|
+
(router) => {
|
|
1210
|
+
router.defineRoute({
|
|
1211
|
+
path: "/slow",
|
|
1212
|
+
handler: async ({ req }) => {
|
|
1213
|
+
started.resolve();
|
|
1214
|
+
await waitForAbort(req.signal);
|
|
1215
|
+
aborted.resolve();
|
|
1216
|
+
return { late: true };
|
|
1217
|
+
}
|
|
1218
|
+
});
|
|
1219
|
+
},
|
|
1220
|
+
async ({ fetch }) => {
|
|
1221
|
+
const controller = new AbortController();
|
|
1222
|
+
const pending = fetch("/slow", { signal: controller.signal }).catch(
|
|
1223
|
+
() => void 0
|
|
1224
|
+
);
|
|
1225
|
+
await within(started.promise, "handler start");
|
|
1226
|
+
controller.abort();
|
|
1227
|
+
await within(aborted.promise, "req.signal abort");
|
|
1228
|
+
await pending;
|
|
1229
|
+
}
|
|
1230
|
+
);
|
|
1231
|
+
});
|
|
1232
|
+
incremental(
|
|
1233
|
+
"aborts the connection when a stream fails",
|
|
1234
|
+
() => withApp(
|
|
1235
|
+
(router) => {
|
|
1236
|
+
router.defineRoute({
|
|
1237
|
+
path: "/broken",
|
|
1238
|
+
handler: () => stream(async function* () {
|
|
1239
|
+
yield "partial";
|
|
1240
|
+
throw new Error("Source failed");
|
|
1241
|
+
})
|
|
1242
|
+
});
|
|
1243
|
+
},
|
|
1244
|
+
async ({ fetch }) => {
|
|
1245
|
+
const res = await fetch("/broken");
|
|
1246
|
+
expect(res.status).toBe(200);
|
|
1247
|
+
const outcome = await within(
|
|
1248
|
+
res.text().then(
|
|
1249
|
+
() => "completed",
|
|
1250
|
+
() => "aborted"
|
|
1251
|
+
),
|
|
1252
|
+
"body read"
|
|
1253
|
+
);
|
|
1254
|
+
expect(outcome).toBe("aborted");
|
|
1255
|
+
}
|
|
1256
|
+
)
|
|
1257
|
+
);
|
|
1258
|
+
});
|
|
1259
|
+
});
|
|
1260
|
+
};
|
|
1261
|
+
export {
|
|
1262
|
+
createFetchTarget,
|
|
1263
|
+
createHttpTarget,
|
|
1264
|
+
runConformanceSuite
|
|
1265
|
+
};
|
|
1266
|
+
//# sourceMappingURL=conformance.js.map
|