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