Skip to content
Source
Sign inSign up

http.test.ts

856 lines36.4 KB

1import { expect, it } from "@effect/vitest";
2import { describe, vi } from "vite-plus/test";
3import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
4import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
5import * as NodeServices from "@effect/platform-node/NodeServices";
6import * as Context from "effect/Context";
7import * as DateTime from "effect/DateTime";
8import * as Deferred from "effect/Deferred";
9import * as Effect from "effect/Effect";
10import * as Fiber from "effect/Fiber";
11import * as FileSystem from "effect/FileSystem";
12import * as Layer from "effect/Layer";
13import * as Path from "effect/Path";
14import * as PlatformError from "effect/PlatformError";
15import * as Queue from "effect/Queue";
16import * as Tracer from "effect/Tracer";
17import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/http";
18import { ASSET_ROUTE_PREFIX } from "./assets/AssetAccess.ts";
19import { ATTACHMENT_UPLOAD_ROUTE_PREFIX } from "./assets/AttachmentUpload.ts";
20import { openMediaFile } from "./assets/MediaFile.ts";
21​
22import {
23 injectMcpAppCsp,
24 injectMcpAppCookieBootstrap,
25 injectMcpAppThemeBootstrap,
26 MCP_APP_MAX_HTML_BYTES,
27} from "@t3tools/shared/mcpApp";
28import { ORCHESTRATION_PROTOCOL_HEADER } from "@t3tools/contracts";
29​
30import * as ServerConfig from "./config.ts";
31​
32import { assetResponseHeaders, assetFileResponse, downloadContentDisposition } from "./http.ts";
33import * as ServerHttp from "./http.ts";
34​
35describe("MCP app asset responses", () => {
36 it.effect(
37 "transforms existing snapshots only with explicit render intent and fresh metadata",
38 () =>
39 Effect.gen(function* () {
40 const fs = yield* FileSystem.FileSystem;
41 const path = yield* Path.Path;
42 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-mcp-theme-" });
43 const file = path.join(directory, "app.html");
44 const html = injectMcpAppCsp(
45 "<html><head><script>vendor()</script></head><body>☃</body></html>",
46 undefined,
47 );
48 yield* fs.writeFileString(file, html);
49 for (const method of ["GET", "HEAD"] as const) {
50 const response = HttpServerResponse.toWeb(
51 yield* assetFileResponse(
52 { path: file, mimeType: "text/html", renderIntent: "mcp-app" },
53 "bytes=0-1",
54 undefined,
55 method,
56 ),
57 );
58 const expected = injectMcpAppCookieBootstrap(injectMcpAppThemeBootstrap(html));
59 expect(response.status).toBe(200);
60 expect(response.headers.get("content-length")).toBe(
61 String(new TextEncoder().encode(expected).byteLength),
62 );
63 expect(response.headers.get("etag")).toBeNull();
64 expect(response.headers.get("last-modified")).toBeNull();
65 expect(response.headers.get("content-security-policy")).toBe(
66 "sandbox allow-scripts allow-forms allow-popups allow-downloads",
67 );
68 expect(yield* Effect.promise(() => response.text())).toBe(
69 method === "HEAD" ? "" : expected,
70 );
71 }
72 for (const download of [false, true]) {
73 const response = HttpServerResponse.toWeb(
74 yield* assetFileResponse({
75 path: file,
76 mimeType: "text/html",
77 download,
78 ...(download ? { renderIntent: "mcp-app" as const } : {}),
79 }),
80 );
81 expect(yield* Effect.promise(() => response.text())).toBe(html);
82 }
83 }).pipe(Effect.provide(layerFileResponse)),
84 );
85​
86 it.effect("rejects oversized captures before transforming them", () =>
87 Effect.gen(function* () {
88 const fs = yield* FileSystem.FileSystem;
89 const path = yield* Path.Path;
90 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-mcp-theme-size-" });
91 const file = path.join(directory, "app.html");
92 yield* fs.writeFileString(file, "x".repeat(MCP_APP_MAX_HTML_BYTES + 128 * 1024 + 1));
93 const response = yield* assetFileResponse({ path: file, renderIntent: "mcp-app" });
94 expect(response.status).toBe(413);
95 }).pipe(Effect.provide(layerFileResponse)),
96 );
97});
98​
99describe("browser API CORS", () => {
100 it("accepts protocol negotiation with authenticated browser headers", async () => {
101 const layerRoute = Layer.effectDiscard(
102 Effect.gen(function* () {
103 const router = yield* HttpRouter.HttpRouter;
104 yield* router.add("GET", "/api/environment", HttpServerResponse.empty());
105 }),
106 );
107 const layerApp = Layer.merge(layerRoute, ServerHttp.layerBrowserApiCors).pipe(
108 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "http-cors-test-" })),
109 Layer.provide(NodeServices.layer),
110 );
111 const { handler, dispose } = HttpRouter.toWebHandler(layerApp, { disableLogger: true });
112​
113 try {
114 const response = await handler(
115 new Request("https://backend.example/api/environment", {
116 method: "OPTIONS",
117 headers: {
118 origin: "https://app.t3.codes",
119 "access-control-request-method": "GET",
120 "access-control-request-headers": [
121 ORCHESTRATION_PROTOCOL_HEADER,
122 "authorization",
123 "dpop",
124 ].join(", "),
125 },
126 }),
127 );
128​
129 expect(response.status).toBe(204);
130 expect(response.headers.get("access-control-allow-origin")).toBe("*");
131 const allowedHeaders = new Set(
132 (response.headers.get("access-control-allow-headers") ?? "")
133 .split(",")
134 .map((header) => header.trim().toLowerCase()),
135 );
136 expect(allowedHeaders.has(ORCHESTRATION_PROTOCOL_HEADER)).toBe(true);
137 expect(allowedHeaders.has("authorization")).toBe(true);
138 expect(allowedHeaders.has("dpop")).toBe(true);
139 } finally {
140 await dispose();
141 }
142 });
143});
144​
145describe("untraced requests", () => {
146 it.effect("keeps signed asset and upload URLs out of HTTP server spans", () =>
147 Effect.gen(function* () {
148 const serverSpans: Array<string> = [];
149 const layerTracer = Layer.succeed(
150 Tracer.Tracer,
151 Tracer.make({
152 span: (options) => {
153 if (options.kind === "server") serverSpans.push(options.name);
154 return new Tracer.NativeSpan(options);
155 },
156 }),
157 );
158 const layerRoutes = Layer.effectDiscard(
159 Effect.gen(function* () {
160 const router = yield* HttpRouter.HttpRouter;
161 yield* router.add("GET", `${ASSET_ROUTE_PREFIX}/*`, HttpServerResponse.empty());
162 yield* router.add(
163 "POST",
164 `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/*`,
165 HttpServerResponse.empty(),
166 );
167 yield* router.add("GET", "/api/environment", HttpServerResponse.empty());
168 }),
169 );
170 const services = yield* Layer.build(
171 HttpRouter.serve(layerRoutes, { disableListenLog: true, disableLogger: true }).pipe(
172 ServerHttp.withUntracedRequests,
173 Layer.provideMerge(NodeHttpServer.layerTest),
174 Layer.provide(layerTracer),
175 ),
176 );
177 const client = Context.get(services, HttpClient.HttpClient);
178​
179 // Each URL is a bearer credential: url.full and url.path would carry it.
180 for (const request of [
181 HttpClientRequest.get(`${ASSET_ROUTE_PREFIX}/payload.signature/image.png`),
182 HttpClientRequest.get(`${ASSET_ROUTE_PREFIX}/payload.signature/image.png?v=1`),
183 HttpClientRequest.post(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/payload.signature`),
184 HttpClientRequest.post(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/payload.signature?v=1`),
185 HttpClientRequest.head(`${ASSET_ROUTE_PREFIX}/payload.signature/image.png`),
186 ]) {
187 expect((yield* client.execute(request)).status).toBe(204);
188 }
189 expect(serverSpans).toEqual([]);
190​
191 expect((yield* client.execute(HttpClientRequest.get("/api/environment"))).status).toBe(204);
192 expect(serverSpans).toEqual(["GET"]);
193​
194 for (const path of [
195 `${ASSET_ROUTE_PREFIX}-other/file`,
196 `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}-other/token`,
197 ]) {
198 expect((yield* client.execute(HttpClientRequest.get(path))).status).toBe(404);
199 }
200 expect(serverSpans).toEqual(["GET", "GET", "GET"]);
201 }).pipe(Effect.provide(NodeServices.layer)),
202 );
203});
204​
205const layerFileResponse = Layer.mergeAll(NodeHttpPlatform.layer, NodeServices.layer);
206​
207const makeStaticRequest = Effect.fn("HttpTest.makeStaticRequest")(function* (staticDir: string) {
208 const config = yield* ServerConfig.ServerConfig;
209 const fileSystem = yield* FileSystem.FileSystem;
210 const layerApp = Layer.merge(
211 ServerHttp.layerStaticAndDevRoute,
212 ServerHttp.layerHttpCompression,
213 ).pipe(Layer.provideMerge(NodeHttpPlatform.layer));
214 // HttpRouter.serve prefers outer services; inject the test FileSystem between it and NodeHttpServer.layerTest.
215 const services = yield* Layer.build(
216 HttpRouter.serve(layerApp, { disableListenLog: true }).pipe(
217 Layer.provideMerge(ServerConfig.layer({ ...config, staticDir })),
218 Layer.provideMerge(Layer.succeed(FileSystem.FileSystem, fileSystem)),
219 Layer.provideMerge(NodeHttpServer.layerTest),
220 ),
221 );
222 const client = Context.get(services, HttpClient.HttpClient);
223 return (resource: string, options?: HttpClientRequest.Options) =>
224 client.execute(HttpClientRequest.make(options?.method ?? "GET")(resource, options));
225});
226​
227it.layer(
228 ServerConfig.layerTest(process.cwd(), { prefix: "t3-static-http-test-" }).pipe(
229 Layer.provideMerge(NodeServices.layer),
230 ),
231)("static HTTP responses", (it) => {
232 it.effect("revalidates non-HTML files and returns changed contents", () =>
233 Effect.gen(function* () {
234 const fs = yield* FileSystem.FileSystem;
235 const path = yield* Path.Path;
236 const staticDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-static-cache-" });
237 const assetPath = path.join(staticDir, "app.js");
238 yield* fs.writeFileString(assetPath, 'export const build = "first";');
239 const request = yield* makeStaticRequest(staticDir);
240​
241 const initial = yield* request("/app.js");
242 expect(initial.status).toBe(200);
243 expect(yield* initial.text).toContain("first");
244 const etag = initial.headers["etag"]!;
245 const lastModified = initial.headers["last-modified"]!;
246 expect(etag).toBeTruthy();
247 expect(lastModified).toBeTruthy();
248 for (const headers of [
249 { "if-none-match": etag },
250 { "if-none-match": etag.replace(/^W\//, "") },
251 { "if-none-match": `"other", ${etag}` },
252 { "if-none-match": "*" },
253 { "if-modified-since": lastModified },
254 ]) {
255 const response = yield* request("/app.js", { headers });
256 expect(response.status).toBe(304);
257 expect(response.headers["etag"]).toBe(etag);
258 expect(response.headers["cache-control"]).toBe("no-cache");
259 expect(yield* response.text).toBe("");
260 }
261 const mismatched = yield* request("/app.js", {
262 headers: { "if-none-match": '"another-build"', "if-modified-since": lastModified },
263 });
264 expect(mismatched.status).toBe(200);
265 expect(yield* mismatched.text).toContain("first");
266​
267 yield* fs.writeFileString(assetPath, 'export const build = "the next build";');
268 const changed = yield* request("/app.js", { headers: { "if-none-match": etag } });
269 expect(changed.status).toBe(200);
270 expect(changed.headers["etag"]).not.toBe(etag);
271 expect(yield* changed.text).toContain("next build");
272 }),
273 );
274​
275 it.effect("serves static files without handles and skips fallback reads for HEAD and 304", () =>
276 Effect.gen(function* () {
277 const fs = yield* FileSystem.FileSystem;
278 const path = yield* Path.Path;
279 const staticDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-static-no-open-" });
280 const filePath = path.join(staticDir, "app.txt");
281 const body = "packaged asset";
282 yield* fs.writeFileString(filePath, body);
283 let reads = 0;
284 // Electron asar files can be statted and read even when opening a handle fails.
285 const fileSystem = FileSystem.FileSystem.of({
286 ...fs,
287 open: (candidate, options) =>
288 candidate === filePath
289 ? Effect.fail(
290 PlatformError.systemError({
291 _tag: "NotFound",
292 module: "FileSystem",
293 method: "open",
294 pathOrDescriptor: candidate,
295 }),
296 )
297 : fs.open(candidate, options),
298 readFile: (candidate) =>
299 Effect.gen(function* () {
300 if (candidate === filePath) reads += 1;
301 return yield* fs.readFile(candidate);
302 }),
303 });
304 const request = yield* makeStaticRequest(staticDir).pipe(
305 Effect.provideService(FileSystem.FileSystem, fileSystem),
306 );
307​
308 const get = yield* request("/app.txt");
309 expect(get.status).toBe(200);
310 expect(yield* get.text).toBe(body);
311 expect(reads).toBeGreaterThan(0);
312 const readsAfterGet = reads;
313 const head = yield* request("/app.txt", { method: "HEAD" });
314 expect(head.status).toBe(200);
315 expect(yield* head.text).toBe("");
316 expect(reads).toBe(readsAfterGet);
317 const unchanged = yield* request("/app.txt", {
318 headers: { "if-none-match": get.headers["etag"]! },
319 });
320 expect(unchanged.status).toBe(304);
321 expect(yield* unchanged.text).toBe("");
322 expect(reads).toBe(readsAfterGet);
323 }),
324 );
325​
326 it.effect("serves replacement bytes with matching static fallback length and validators", () =>
327 Effect.gen(function* () {
328 const fs = yield* FileSystem.FileSystem;
329 const path = yield* Path.Path;
330 const staticDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-static-replaced-" });
331 const filePath = path.join(staticDir, "app.txt");
332 const before = DateTime.toDateUtc(DateTime.makeUnsafe("1985-10-26T08:15:00.000Z"));
333 const after = DateTime.toDateUtc(DateTime.makeUnsafe("2015-10-21T16:29:00.000Z"));
334 const replacement = "the replacement asset";
335 const replacementPath = path.join(staticDir, "replacement.txt");
336 yield* fs.writeFileString(filePath, "old");
337 yield* fs.utimes(filePath, before, before);
338 yield* fs.writeFileString(replacementPath, replacement);
339 yield* fs.utimes(replacementPath, after, after);
340 let replaceOnOpen = false;
341 const fileSystem = FileSystem.FileSystem.of({
342 ...fs,
343 open: (candidate, options) =>
344 Effect.gen(function* () {
345 if (candidate !== filePath) return yield* fs.open(candidate, options);
346 if (replaceOnOpen) {
347 yield* fs.rename(replacementPath, filePath);
348 replaceOnOpen = false;
349 }
350 return yield* PlatformError.systemError({
351 _tag: "NotFound",
352 module: "FileSystem",
353 method: "open",
354 pathOrDescriptor: candidate,
355 });
356 }),
357 });
358 const request = yield* makeStaticRequest(staticDir).pipe(
359 Effect.provideService(FileSystem.FileSystem, fileSystem),
360 );
361 const initial = yield* request("/app.txt", { headers: { "accept-encoding": "identity" } });
362 expect(yield* initial.text).toBe("old");
363​
364 replaceOnOpen = true;
365 const response = yield* request("/app.txt", { headers: { "accept-encoding": "identity" } });
366 expect(response.status).toBe(200);
367 expect(response.headers).toMatchObject({
368 "content-length": String(Buffer.byteLength(replacement)),
369 "last-modified": after.toUTCString(),
370 });
371 expect(response.headers["etag"]).not.toBe(initial.headers["etag"]);
372 expect(yield* response.text).toBe(replacement);
373 }),
374 );
375​
376 it.effect("serves changed HTML when deployments preserve its size and timestamp", () =>
377 Effect.gen(function* () {
378 const fs = yield* FileSystem.FileSystem;
379 const path = yield* Path.Path;
380 const staticDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-static-html-" });
381 const indexPath = path.join(staticDir, "index.html");
382 const modifiedAt = DateTime.toDateUtc(DateTime.makeUnsafe("1985-10-26T08:15:00.000Z"));
383 yield* fs.writeFileString(indexPath, "<html>old build</html>");
384 yield* fs.utimes(indexPath, modifiedAt, modifiedAt);
385 const request = yield* makeStaticRequest(staticDir);
386 const initial = yield* request("/");
387 expect(yield* initial.text).toBe("<html>old build</html>");
388 const previousEtag = initial.headers["etag"] ?? '"previous-html"';
389 const nextHtml = "<html>new build</html>";
390 yield* fs.writeFileString(indexPath, nextHtml);
391 yield* fs.utimes(indexPath, modifiedAt, modifiedAt);
392​
393 for (const [resource, headers] of [
394 ["/", { "if-none-match": previousEtag }],
395 ["/threads/example", { "if-modified-since": modifiedAt.toUTCString() }],
396 ["/", { "if-none-match": "*" }],
397 ] as const) {
398 const response = yield* request(resource, { headers });
399 expect(response.status).toBe(200);
400 expect(yield* response.text).toBe(nextHtml);
401 expect(response.headers["cache-control"]).toBe("no-cache");
402 expect(response.headers["etag"]).toBeUndefined();
403 expect(response.headers["last-modified"]).toBeUndefined();
404 }
405 const head = yield* request("/", {
406 method: "HEAD",
407 headers: { "if-none-match": previousEtag, "accept-encoding": "identity" },
408 });
409 expect(head.status).toBe(200);
410 expect(head.headers["content-length"]).toBe(String(Buffer.byteLength(nextHtml)));
411 expect(yield* head.text).toBe("");
412 }),
413 );
414​
415 it.effect("closes static descriptors after GET, HEAD, 304, and request cancellation", () =>
416 Effect.gen(function* () {
417 const fs = yield* FileSystem.FileSystem;
418 const path = yield* Path.Path;
419 const staticDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-static-close-" });
420 const filePath = path.join(staticDir, "app.txt");
421 const body = "file content\n".repeat(1024);
422 yield* fs.writeFileString(filePath, body);
423 const closed = yield* Queue.unbounded<FileSystem.File>();
424 const blocked = yield* Deferred.make<void>();
425 const active = new Set<FileSystem.File>();
426 let blockAfterOpen = false;
427 let bodyReads = 0;
428 const trackedFileSystem = FileSystem.FileSystem.of({
429 ...fs,
430 open: (candidate, options) =>
431 Effect.gen(function* () {
432 if (candidate !== filePath) return yield* fs.open(candidate, options);
433 let opened: FileSystem.File | undefined;
434 yield* Effect.addFinalizer(() =>
435 Effect.gen(function* () {
436 if (opened === undefined) return;
437 active.delete(opened);
438 yield* Queue.offer(closed, opened);
439 }),
440 );
441 const file = yield* fs.open(candidate, options);
442 opened = file;
443 active.add(file);
444 if (blockAfterOpen) {
445 yield* Deferred.succeed(blocked, undefined);
446 return yield* Effect.never;
447 }
448 return new Proxy(file, {
449 get(target, key) {
450 if (key === "readAlloc") {
451 return (size: number) => {
452 bodyReads += 1;
453 return target.readAlloc(size);
454 };
455 }
456 return Reflect.get(target, key, target);
457 },
458 });
459 }),
460 });
461 const request = yield* makeStaticRequest(staticDir).pipe(
462 Effect.provideService(FileSystem.FileSystem, trackedFileSystem),
463 );
464​
465 const get = yield* request("/app.txt");
466 expect(yield* get.text).toBe(body);
467 yield* Queue.take(closed);
468 expect(active.size).toBe(0);
469 expect(bodyReads).toBeGreaterThan(0);
470 const readsAfterGet = bodyReads;
471 const head = yield* request("/app.txt", {
472 method: "HEAD",
473 headers: { "accept-encoding": "gzip" },
474 });
475 expect(head.status).toBe(200);
476 expect(head.headers["content-encoding"]).toBe("gzip");
477 expect(yield* head.text).toBe("");
478 yield* Queue.take(closed);
479 expect(active.size).toBe(0);
480 expect(bodyReads).toBe(readsAfterGet);
481 const unchanged = yield* request("/app.txt", {
482 headers: { "if-none-match": get.headers["etag"]! },
483 });
484 expect(unchanged.status).toBe(304);
485 yield* Queue.take(closed);
486 expect(active.size).toBe(0);
487 expect(bodyReads).toBe(readsAfterGet);
488​
489 blockAfterOpen = true;
490 const cancelled = yield* request("/app.txt").pipe(Effect.forkChild);
491 yield* Deferred.await(blocked);
492 expect(active.size).toBe(1);
493 yield* Fiber.interrupt(cancelled);
494 yield* Queue.take(closed);
495 expect(active.size).toBe(0);
496 }),
497 );
498});
499​
500describe("video asset byte ranges", () => {
501 it.effect("uses current descriptor metadata after an in-place truncate or extension", () =>
502 Effect.gen(function* () {
503 const fs = yield* FileSystem.FileSystem;
504 const path = yield* Path.Path;
505 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-current-stat-" });
506 const filePath = path.join(directory, "clip.mp4");
507 for (const [contents, range, method, expected, status, contentRange] of [
508 ["1234", undefined, "GET", "1234", 200, null],
509 ["0123456789abcdef", undefined, "GET", "0123456789abcdef", 200, null],
510 ["1234", "bytes=4-", "GET", "", 416, "bytes */4"],
511 ["1234", "bytes=1-20", "GET", "234", 206, "bytes 1-3/4"],
512 ["0123456789abcdef", "bytes=10-", "GET", "abcdef", 206, "bytes 10-15/16"],
513 ["0123456789abcdef", undefined, "HEAD", "", 200, null],
514 ["", undefined, "GET", "", 200, null],
515 ["", "bytes=0-1", "GET", "", 416, "bytes */0"],
516 ] as const) {
517 yield* fs.writeFileString(filePath, "0123456789");
518 const canonicalPath = yield* fs.realPath(filePath);
519 const file = yield* openMediaFile(canonicalPath);
520 if (!file) throw new Error("Expected an opened media file");
521 yield* fs.writeFileString(filePath, contents);
522 const response = HttpServerResponse.toWeb(
523 yield* assetFileResponse(
524 { path: canonicalPath, file, mimeType: "video/mp4" },
525 range,
526 undefined,
527 method,
528 ),
529 );
530 expect(response.status).toBe(status);
531 expect(response.headers.get("content-range")).toBe(contentRange);
532 if (status !== 416) {
533 expect(response.headers.get("content-length")).toBe(
534 String(method === "HEAD" ? contents.length : expected.length),
535 );
536 }
537 expect(yield* Effect.promise(() => response.text())).toBe(expected);
538 }
539 }).pipe(Effect.provide(layerFileResponse)),
540 );
541​
542 it.effect(
543 "rejects unaddressable ranges before streaming and preserves small ranges on large files",
544 () =>
545 Effect.gen(function* () {
546 const fs = yield* FileSystem.FileSystem;
547 const path = yield* Path.Path;
548 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-offset-limit-" });
549 const filePath = path.join(directory, "clip.mp4");
550 yield* fs.writeFileString(filePath, "0123456789");
551 const canonicalPath = yield* fs.realPath(filePath);
552 const unsafeOffset = BigInt(Number.MAX_SAFE_INTEGER) + 1n;
553 const size = unsafeOffset + 32n;
554 for (const [range, status] of [
555 [`bytes=${unsafeOffset}-${unsafeOffset}`, 416],
556 [`bytes=0-${unsafeOffset}`, 416],
557 ["bytes=-1", 416],
558 [undefined, 413],
559 ["bytes=0-1", 206],
560 ] as const) {
561 const file = yield* openMediaFile(canonicalPath);
562 if (!file) throw new Error("Expected an opened media file");
563 const info = yield* Effect.promise(() => file.handle.stat({ bigint: true }));
564 info.size = size;
565 const statSpy = vi.spyOn(file.handle, "stat").mockResolvedValue(info);
566 yield* Effect.addFinalizer(() => Effect.sync(() => statSpy.mockRestore()));
567 const response = HttpServerResponse.toWeb(
568 yield* assetFileResponse({ path: canonicalPath, file, mimeType: "video/mp4" }, range),
569 );
570 expect(response.status).toBe(status);
571 if (status === 416) {
572 expect(response.headers.get("content-range")).toBe(`bytes */${size}`);
573 expect(yield* Effect.promise(() => response.text())).toBe("");
574 } else if (status === 206) {
575 expect(response.headers.get("content-range")).toBe(`bytes 0-1/${size}`);
576 expect(yield* Effect.promise(() => response.text())).toBe("01");
577 } else {
578 expect(yield* Effect.promise(() => response.text())).toBe(
579 "File is too large to preview.",
580 );
581 }
582 }
583 }).pipe(Effect.provide(layerFileResponse)),
584 );
585​
586 it.effect("keeps attachment media out of the cache once its signed URL expires", () =>
587 Effect.gen(function* () {
588 const fs = yield* FileSystem.FileSystem;
589 const path = yield* Path.Path;
590 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-attachment-media-" });
591 const filePath = path.join(directory, "audio.wav");
592 yield* fs.writeFileString(filePath, "RIFF");
593 const canonicalPath = yield* fs.realPath(filePath);
594 const response = HttpServerResponse.toWeb(
595 yield* assetFileResponse({ path: canonicalPath, mimeType: "audio/wav" }),
596 );
597 expect(response.headers.get("cache-control")).toBe("private, no-store");
598 expect(response.headers.get("accept-ranges")).toBe("bytes");
599 }).pipe(Effect.provide(layerFileResponse)),
600 );
601​
602 it.effect("closes guarded descriptors after full, HEAD, rejected, and cancelled responses", () =>
603 Effect.gen(function* () {
604 const fs = yield* FileSystem.FileSystem;
605 const path = yield* Path.Path;
606 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-cleanup-" });
607 const filePath = path.join(directory, "clip.mp4");
608 const bytes = new Uint8Array(1024 * 1024).fill(42);
609 yield* fs.writeFile(filePath, bytes);
610 const canonicalPath = yield* fs.realPath(filePath);
611 for (const mode of ["full", "HEAD", "rejected", "cancelled"] as const) {
612 const file = yield* Effect.scoped(
613 Effect.gen(function* () {
614 const file = yield* openMediaFile(canonicalPath);
615 if (!file) throw new Error("Expected an opened media file");
616 const response = HttpServerResponse.toWeb(
617 yield* assetFileResponse(
618 { path: canonicalPath, file, mimeType: "video/mp4" },
619 mode === "rejected" ? `bytes=${bytes.length}-` : "bytes=0-",
620 undefined,
621 mode === "HEAD" ? "HEAD" : "GET",
622 ),
623 );
624 if (mode === "HEAD") {
625 expect(response.status).toBe(200);
626 expect(response.headers.get("content-length")).toBe(String(bytes.length));
627 expect(response.headers.get("content-range")).toBeNull();
628 expect(yield* Effect.promise(() => response.text())).toBe("");
629 } else if (mode === "rejected") {
630 expect(response.status).toBe(416);
631 expect(yield* Effect.promise(() => response.text())).toBe("");
632 } else if (mode === "cancelled") {
633 const reader = response.body!.getReader();
634 const first = yield* Effect.promise(() => reader.read());
635 expect(first.done).toBe(false);
636 expect(first.value!.byteLength).toBeLessThan(bytes.length);
637 yield* Effect.promise(() => reader.cancel());
638 } else {
639 expect(yield* Effect.promise(() => response.arrayBuffer())).toEqual(bytes.buffer);
640 }
641 return file;
642 }),
643 );
644 expect(file.handle.fd).toBe(-1);
645 }
646 }).pipe(Effect.provide(layerFileResponse)),
647 );
648​
649 it.effect("streams exactly the requested bytes and leaves full downloads intact", () =>
650 Effect.gen(function* () {
651 const fs = yield* FileSystem.FileSystem;
652 const path = yield* Path.Path;
653 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-video-range-" });
654 const file = path.join(directory, "clip.mp4");
655 yield* fs.writeFileString(file, "0123456789");
656 const asset = { path: file, mimeType: "video/mp4" };
657 for (const [header, expected, contentRange] of [
658 ["bytes=0-1", "01", "bytes 0-1/10"],
659 ["bytes=4-", "456789", "bytes 4-9/10"],
660 ["bytes=-3", "789", "bytes 7-9/10"],
661 ["bytes=-999999999999999999999999", "0123456789", "bytes 0-9/10"],
662 ["bytes=8-999999999999999999999999", "89", "bytes 8-9/10"],
663 ] as const) {
664 const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset, header));
665 expect(response.status).toBe(206);
666 expect(response.headers.get("accept-ranges")).toBe("bytes");
667 expect(response.headers.get("content-range")).toBe(contentRange);
668 expect(response.headers.get("content-length")).toBe(String(expected.length));
669 expect(yield* Effect.promise(() => response.text())).toBe(expected);
670 }
671 for (const header of [
672 undefined,
673 "items=0-1",
674 "bytes=0-1,4-5",
675 "bytes=8-2",
676 "bytes=-",
677 "bytes=bad",
678 ]) {
679 const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset, header));
680 expect(response.status).toBe(200);
681 expect(yield* Effect.promise(() => response.text())).toBe("0123456789");
682 }
683 const conditional = HttpServerResponse.toWeb(
684 yield* assetFileResponse(asset, "bytes=0-1", '"old-etag"'),
685 );
686 expect(conditional.status).toBe(200);
687 expect(yield* Effect.promise(() => conditional.text())).toBe("0123456789");
688 const uppercase = HttpServerResponse.toWeb(
689 yield* assetFileResponse({ ...asset, mimeType: "Video/MP4" }, "bytes=0-1"),
690 );
691 expect(uppercase.status).toBe(206);
692 expect(yield* Effect.promise(() => uppercase.text())).toBe("01");
693 const image = HttpServerResponse.toWeb(
694 yield* assetFileResponse({ path: file, mimeType: "image/png" }, "bytes=0-1"),
695 );
696 expect(image.status).toBe(206);
697 expect(image.headers.get("accept-ranges")).toBe("bytes");
698 expect(yield* Effect.promise(() => image.text())).toBe("01");
699 for (const mimeType of ["text/plain", "text/csv", "application/octet-stream"]) {
700 for (const [range, expected, status] of [
701 ["bytes=2-5", "2345", 206],
702 ["bytes=-3", "789", 206],
703 ["bytes=10-", "", 416],
704 ] as const) {
705 const result = HttpServerResponse.toWeb(
706 yield* assetFileResponse({ path: file, mimeType }, range),
707 );
708 expect(result.status).toBe(status);
709 expect(yield* Effect.promise(() => result.text())).toBe(expected);
710 }
711 }
712 }).pipe(Effect.provide(layerFileResponse)),
713 );
714​
715 it.effect(
716 "supports native audio header probes and seeking without changing explicit downloads",
717 () =>
718 Effect.gen(function* () {
719 const fs = yield* FileSystem.FileSystem;
720 const path = yield* Path.Path;
721 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-audio-range-" });
722 const file = path.join(directory, "recording.wav");
723 yield* fs.writeFileString(file, "0123456789");
724 const asset = { path: file, mimeType: "audio/wav" };
725 for (const [header, expected] of [
726 ["bytes=0-1", "01"],
727 ["bytes=5-", "56789"],
728 ] as const) {
729 const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset, header));
730 expect(response.status).toBe(206);
731 expect(response.headers.get("content-type")).toBe("audio/wav");
732 expect(response.headers.get("accept-ranges")).toBe("bytes");
733 expect(response.headers.get("content-length")).toBe(String(expected.length));
734 expect(yield* Effect.promise(() => response.text())).toBe(expected);
735 }
736 const download = HttpServerResponse.toWeb(
737 yield* assetFileResponse({ ...asset, download: true, fileName: "recording.wav" }),
738 );
739 expect(download.status).toBe(200);
740 expect(download.headers.get("content-disposition")).toContain("attachment;");
741 expect(yield* Effect.promise(() => download.text())).toBe("0123456789");
742 }).pipe(Effect.provide(layerFileResponse)),
743 );
744​
745 it.effect("rejects ranges outside the file, including empty files", () =>
746 Effect.gen(function* () {
747 const fs = yield* FileSystem.FileSystem;
748 const path = yield* Path.Path;
749 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-video-range-" });
750 const file = path.join(directory, "clip.mp4");
751 yield* fs.writeFileString(file, "0123456789");
752 for (const header of ["bytes=10-", "bytes=-0", "bytes=999999999999999999999999-"]) {
753 const response = HttpServerResponse.toWeb(
754 yield* assetFileResponse({ path: file, mimeType: "video/mp4" }, header),
755 );
756 expect(response.status).toBe(416);
757 expect(response.headers.get("content-range")).toBe("bytes */10");
758 expect(yield* Effect.promise(() => response.text())).toBe("");
759 }
760 yield* fs.writeFileString(file, "");
761 const empty = HttpServerResponse.toWeb(
762 yield* assetFileResponse({ path: file, mimeType: "video/mp4" }, "bytes=0-1"),
763 );
764 expect(empty.status).toBe(416);
765 expect(empty.headers.get("content-range")).toBe("bytes */0");
766 }).pipe(Effect.provide(layerFileResponse)),
767 );
768});
769​
770describe("assetResponseHeaders", () => {
771 it("sandboxes SVG assets", () => {
772 expect(assetResponseHeaders("/attachments/user-image.svg")).toMatchObject({
773 "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
774 "X-Content-Type-Options": "nosniff",
775 });
776 expect(assetResponseHeaders("/attachments/user-image.SVG")).toHaveProperty(
777 "Content-Security-Policy",
778 );
779 });
780 it("serves inline attachment documents with their declared mime type", () => {
781 expect(
782 assetResponseHeaders("/attachments/upload.bin", { mimeType: "application/pdf" }),
783 ).toMatchObject({
784 "Content-Type": "application/pdf",
785 });
786 expect(
787 assetResponseHeaders("/attachments/upload.bin", { mimeType: "text/html" }),
788 ).toMatchObject({
789 "Content-Type": "text/html; charset=utf-8",
790 "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups allow-downloads",
791 });
792 });
793​
794 it("downloads uploaded documents without executing their content", () => {
795 expect(assetResponseHeaders("/attachments/upload.html", { download: true })).toMatchObject({
796 "Content-Disposition": "attachment",
797 "Content-Security-Policy": "default-src 'none'; sandbox",
798 "Content-Type": "application/octet-stream",
799 });
800 });
801​
802 it("serves the real filename and mime type when the claims carry them", () => {
803 expect(
804 assetResponseHeaders("/attachments/thread-1-abc-pdf.pdf", {
805 download: true,
806 fileName: "Q3 report.pdf",
807 mimeType: "application/pdf",
808 }),
809 ).toMatchObject({
810 "Content-Disposition": 'attachment; filename="Q3 report.pdf"',
811 "Content-Security-Policy": "default-src 'none'; sandbox",
812 "Content-Type": "application/pdf",
813 });
814 });
815​
816 it("keeps renderable mime types as octet-stream downloads", () => {
817 for (const mimeType of [
818 "text/html",
819 "text/xml",
820 "image/svg+xml",
821 "application/xhtml+xml",
822 "application/rss+xml",
823 "APPLICATION/XML",
824 "IMAGE/SVG+XML",
825 "application/xml-dtd",
826 "application/xml-external-parsed-entity",
827 "not a mime",
828 ]) {
829 expect(
830 assetResponseHeaders("/attachments/upload.bin", { download: true, mimeType }),
831 ).toHaveProperty("Content-Type", "application/octet-stream");
832 }
833 });
834});
835​
836describe("downloadContentDisposition", () => {
837 it("quotes plain names and strips quotes and control characters", () => {
838 expect(downloadContentDisposition("report.pdf")).toBe('attachment; filename="report.pdf"');
839 expect(downloadContentDisposition('we"ird\n.pdf')).toBe('attachment; filename="we_ird_.pdf"');
840 });
841​
842 it("adds an RFC 5987 encoded name for non-ASCII filenames", () => {
843 expect(downloadContentDisposition("répört.pdf")).toBe(
844 `attachment; filename="r_p_rt.pdf"; filename*=UTF-8''r%C3%A9p%C3%B6rt.pdf`,
845 );
846 expect(downloadContentDisposition("résumé'(*).pdf")).toBe(
847 `attachment; filename="r_sum_'(*).pdf"; filename*=UTF-8''r%C3%A9sum%C3%A9%27%28%2A%29.pdf`,
848 );
849 });
850​
851 it("does not throw on unpaired surrogates in the filename", () => {
852 expect(downloadContentDisposition("bad\ud800name.pdf")).toBe(
853 `attachment; filename="bad_name.pdf"; filename*=UTF-8''bad%EF%BF%BDname.pdf`,
854 );
855 });
856});