Skip to content
File

Blob: src/workerd/api/tests/http-test.js

javascript436 lines
1// Copyright (c) 2023 Cloudflare, Inc.
2// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3// https://opensource.org/licenses/Apache-2.0
4 
5import assert from 'node:assert';
6import util from 'node:util';
7 
8let scheduledLastCtrl;
9 
10export default {
11 async fetch(request, env, ctx) {
12 const { pathname } = new URL(request.url);
13 if (pathname === '/body-length') {
14 return Response.json(Object.fromEntries(request.headers));
15 }
16 if (pathname === '/ray-id') {
17 return new Response(null, {
18 status: 200,
19 headers: { 'CF-Ray': 'test-ray-id-123' },
20 });
21 }
22 if (pathname === '/web-socket') {
23 const pair = new WebSocketPair();
24 pair[0].addEventListener('message', (event) => {
25 pair[0].send(util.inspect(event));
26 });
27 pair[0].accept();
28 return new Response(null, {
29 status: 101,
30 webSocket: pair[1],
31 });
32 }
33 return new Response(null, { status: 404 });
34 },
35 
36 async scheduled(ctrl, env, ctx) {
37 scheduledLastCtrl = ctrl;
38 if (ctrl.cron === '* * * * 30') ctrl.noRetry();
39 },
40 
41 async test(ctrl, env, ctx) {
42 // Call `fetch()` with known body length
43 {
44 const body = new FixedLengthStream(3);
45 const writer = body.writable.getWriter();
46 void writer.write(new Uint8Array([1, 2, 3]));
47 void writer.close();
48 const response = await env.SERVICE.fetch(
49 'http://placeholder/body-length',
50 {
51 method: 'POST',
52 body: body.readable,
53 }
54 );
55 const headers = new Headers(await response.json());
56 assert.strictEqual(headers.get('Content-Length'), '3');
57 assert.strictEqual(headers.get('Transfer-Encoding'), null);
58 }
59 
60 // Check `fetch()` with unknown body length
61 {
62 const body = new IdentityTransformStream();
63 const writer = body.writable.getWriter();
64 void writer.write(new Uint8Array([1, 2, 3]));
65 void writer.close();
66 const response = await env.SERVICE.fetch(
67 'http://placeholder/body-length',
68 {
69 method: 'POST',
70 body: body.readable,
71 }
72 );
73 const headers = new Headers(await response.json());
74 assert.strictEqual(headers.get('Content-Length'), null);
75 assert.strictEqual(headers.get('Transfer-Encoding'), 'chunked');
76 }
77 
78 // Call `fetch()` with GET to endpoint that returns CF-Ray header, verifying
79 // cloudflare.ray_id is set on the trace span from the response headers.
80 {
81 const response = await env.SERVICE.fetch('http://placeholder/ray-id');
82 assert.strictEqual(response.status, 200);
83 assert.strictEqual(response.headers.get('CF-Ray'), 'test-ray-id-123');
84 }
85 
86 // Call `scheduled()` with no options
87 {
88 const result = await env.SERVICE.scheduled();
89 assert.strictEqual(result.outcome, 'ok');
90 assert(!result.noRetry);
91 assert(Math.abs(Date.now() - scheduledLastCtrl.scheduledTime) < 3_000);
92 assert.strictEqual(scheduledLastCtrl.cron, '');
93 }
94 
95 // Call `scheduled()` with options, and noRetry()
96 {
97 const result = await env.SERVICE.scheduled({
98 scheduledTime: 1000,
99 cron: '* * * * 30',
100 });
101 assert.strictEqual(result.outcome, 'ok');
102 assert(result.noRetry);
103 assert.strictEqual(scheduledLastCtrl.scheduledTime, 1000);
104 assert.strictEqual(scheduledLastCtrl.cron, '* * * * 30');
105 }
106 },
107};
108 
109// inspect tests
110export const test = {
111 async test(ctrl, env, ctx) {
112 // Check URL with duplicate search param keys
113 const url = new URL('http://user:pass@placeholder:8787/path?a=1&a=2&b=3');
114 assert.strictEqual(
115 util.inspect(url),
116 `URL {
117 origin: 'http://placeholder:8787',
118 href: 'http://user:pass@placeholder:8787/path?a=1&a=2&b=3',
119 protocol: 'http:',
120 username: 'user',
121 password: 'pass',
122 host: 'placeholder:8787',
123 hostname: 'placeholder',
124 port: '8787',
125 pathname: '/path',
126 search: '?a=1&a=2&b=3',
127 hash: '',
128 searchParams: URLSearchParams(3) { 'a' => '1', 'a' => '2', 'b' => '3' }
129}`
130 );
131 
132 // Check FormData with lower depth
133 const formData = new FormData();
134 formData.set('string', 'hello');
135 formData.set(
136 'blob',
137 new Blob(['<h1>BLOB</h1>'], {
138 type: 'text/html',
139 })
140 );
141 formData.set(
142 'file',
143 new File(['password123'], 'passwords.txt', {
144 type: 'text/plain',
145 lastModified: 1000,
146 })
147 );
148 assert.strictEqual(
149 util.inspect(formData, { depth: 0 }),
150 `FormData(3) { 'string' => 'hello', 'blob' => [File], 'file' => [File] }`
151 );
152 
153 // Check request with mutable headers
154 const request = new Request('http://placeholder', {
155 method: 'POST',
156 body: 'message',
157 headers: { 'Content-Type': 'text/plain' },
158 });
159 if (env.CACHE_ENABLED) {
160 assert.strictEqual(
161 util.inspect(request),
162 `Request {
163 method: 'POST',
164 url: 'http://placeholder',
165 headers: Headers(1) { 'content-type' => 'text/plain', [immutable]: false },
166 redirect: 'follow',
167 fetcher: null,
168 signal: AbortSignal { aborted: false, reason: undefined, onabort: null },
169 cf: undefined,
170 integrity: '',
171 keepalive: false,
172 cache: undefined,
173 body: ReadableStream {
174 locked: false,
175 [state]: 'readable',
176 [supportsBYOB]: true,
177 [length]: 7n
178 },
179 bodyUsed: false
180}`
181 );
182 } else {
183 assert.strictEqual(
184 util.inspect(request),
185 `Request {
186 method: 'POST',
187 url: 'http://placeholder',
188 headers: Headers(1) { 'content-type' => 'text/plain', [immutable]: false },
189 redirect: 'follow',
190 fetcher: null,
191 signal: AbortSignal { aborted: false, reason: undefined, onabort: null },
192 cf: undefined,
193 integrity: '',
194 keepalive: false,
195 body: ReadableStream {
196 locked: false,
197 [state]: 'readable',
198 [supportsBYOB]: true,
199 [length]: 7n
200 },
201 bodyUsed: false
202}`
203 );
204 }
205 
206 // Check response with immutable headers
207 const response = await env.SERVICE.fetch('http://placeholder/not-found');
208 assert.strictEqual(
209 util.inspect(response),
210 `Response {
211 status: 404,
212 statusText: 'Not Found',
213 headers: Headers(0) { [immutable]: true },
214 ok: false,
215 redirected: false,
216 url: 'http://placeholder/not-found',
217 webSocket: null,
218 cf: undefined,
219 type: 'default',
220 body: ReadableStream {
221 locked: false,
222 [state]: 'readable',
223 [supportsBYOB]: true,
224 [length]: 0n
225 },
226 bodyUsed: false
227}`
228 );
229 
230 // Check `MessageEvent` with unimplemented properties
231 const webSocketResponse = await env.SERVICE.fetch(
232 'http://placeholder/web-socket',
233 {
234 headers: { Upgrade: 'websocket' },
235 }
236 );
237 const webSocket = webSocketResponse.webSocket;
238 assert.notStrictEqual(webSocket, null);
239 // The server-side WebSocketPair socket's binaryType depends on the compat flag.
240 const bt = new WebSocketPair()[0].binaryType;
241 const wsStr = `WebSocket {\n readyState: 1,\n url: null,\n protocol: '',\n extensions: '',\n binaryType: '${bt}'\n }`;
242 const messagePromise = new Promise((resolve) => {
243 webSocket.addEventListener('message', (event) => {
244 assert.strictEqual(
245 event.data,
246 `MessageEvent {
247 ports: [ [length]: 0 ],
248 source: null,
249 lastEventId: '',
250 origin: null,
251 data: 'data',
252 type: 'message',
253 eventPhase: 2,
254 composed: false,
255 bubbles: false,
256 cancelable: false,
257 defaultPrevented: false,
258 returnValue: true,
259 currentTarget: ${wsStr},
260 target: ${wsStr},
261 srcElement: ${wsStr},
262 timeStamp: 0,
263 isTrusted: true,
264 cancelBubble: false,
265 NONE: 0,
266 CAPTURING_PHASE: 1,
267 AT_TARGET: 2,
268 BUBBLING_PHASE: 3
269}`
270 );
271 resolve();
272 });
273 });
274 webSocket.accept();
275 webSocket.send('data');
276 webSocket.close();
277 await messagePromise;
278 
279 // Test sending to oversized URL (bigger than MAX_TRACE_BYTES), relevant primarily for tail worker test.
280 await env.SERVICE.fetch('http://placeholder/' + '0'.repeat(2 ** 18));
281 },
282};
283 
284async function assertRequestCacheThrowsError(
285 cacheHeader,
286 errorName = 'Error',
287 errorMessage = "The 'cache' field on 'RequestInitializerDict' is not implemented."
288) {
289 assert.throws(
290 () => {
291 new Request('https://example.org', { cache: cacheHeader });
292 },
293 {
294 name: errorName,
295 message: errorMessage,
296 }
297 );
298}
299 
300async function assertFetchCacheRejectsError(
301 cacheHeader,
302 errorName = 'Error',
303 errorMessage = "The 'cache' field on 'RequestInitializerDict' is not implemented."
304) {
305 await assert.rejects(
306 (async () => {
307 await fetch('https://example.org', { cache: cacheHeader });
308 })(),
309 {
310 name: errorName,
311 message: errorMessage,
312 }
313 );
314}
315 
316export const cacheMode = {
317 async test(ctrl, env, ctx) {
318 var failureCases = [
319 'default',
320 'force-cache',
321 'no-cache',
322 'only-if-cached',
323 'reload',
324 'unsupported',
325 ];
326 assert.strictEqual('cache' in Request.prototype, env.CACHE_ENABLED);
327 {
328 const req = new Request('https://example.org', {});
329 assert.strictEqual(req.cache, undefined);
330 }
331 if (!env.CACHE_ENABLED) {
332 failureCases.push('no-store');
333 for (const cacheMode in failureCases) {
334 await assertRequestCacheThrowsError(cacheMode);
335 await assertFetchCacheRejectsError(cacheMode);
336 }
337 } else {
338 {
339 const req = new Request('https://example.org', { cache: 'no-store' });
340 assert.strictEqual(req.cache, 'no-store');
341 }
342 {
343 const response = await env.SERVICE.fetch(
344 'http://placeholder/not-found',
345 { cache: 'no-store' }
346 );
347 assert.strictEqual(
348 util.inspect(response),
349 `Response {
350 status: 404,
351 statusText: 'Not Found',
352 headers: Headers(0) { [immutable]: true },
353 ok: false,
354 redirected: false,
355 url: 'http://placeholder/not-found',
356 webSocket: null,
357 cf: undefined,
358 type: 'default',
359 body: ReadableStream {
360 locked: false,
361 [state]: 'readable',
362 [supportsBYOB]: true,
363 [length]: 0n
364 },
365 bodyUsed: false
366}`
367 );
368 }
369 for (const cacheMode in failureCases) {
370 await assertRequestCacheThrowsError(
371 cacheMode,
372 'TypeError',
373 'Unsupported cache mode: ' + cacheMode
374 );
375 await assertFetchCacheRejectsError(
376 cacheMode,
377 'TypeError',
378 'Unsupported cache mode: ' + cacheMode
379 );
380 }
381 
382 // Test cache mode compatibility with cf.cacheTtl
383 // cache: 'no-store' with cf: { cacheTtl: -1 } should succeed because
384 // -1 is the NOCACHE_TTL value that no-store sets automatically.
385 {
386 const req = new Request('https://example.org', {
387 cache: 'no-store',
388 cf: { cacheTtl: -1 },
389 });
390 assert.strictEqual(req.cache, 'no-store');
391 // Fetch should succeed with compatible cacheTtl
392 await env.SERVICE.fetch('http://placeholder/not-found', {
393 cache: 'no-store',
394 cf: { cacheTtl: -1 },
395 });
396 }
397 
398 // cache: 'no-store' with cf: { cacheTtl: 300 } should throw TypeError
399 // because 300 is incompatible with no-store (validation happens at fetch time)
400 {
401 const req = new Request('https://example.org', {
402 cache: 'no-store',
403 cf: { cacheTtl: 300 },
404 });
405 // Request construction succeeds
406 assert.strictEqual(req.cache, 'no-store');
407 // But fetch should fail with incompatible cacheTtl
408 await assert.rejects(
409 env.SERVICE.fetch('http://placeholder/not-found', {
410 cache: 'no-store',
411 cf: { cacheTtl: 300 },
412 }),
413 {
414 name: 'TypeError',
415 message: /is not compatible with cache/,
416 }
417 );
418 }
419 
420 // cache: 'no-store' with cf: { cacheTtl: 0 } should also throw TypeError
421 {
422 await assert.rejects(
423 env.SERVICE.fetch('http://placeholder/not-found', {
424 cache: 'no-store',
425 cf: { cacheTtl: 0 },
426 }),
427 {
428 name: 'TypeError',
429 message: /is not compatible with cache/,
430 }
431 );
432 }
433 }
434 },
435};