/** * Unit tests for lib/multipart-parts.ts, the keep-alive-safe replacement for * @fastify/multipart's request.parts(). * * The scenario that broke the plugin: the whole request body is already * buffered (request "end"/"close" fire immediately once piped) while the * consumer awaits slow storage writes between parts. The plugin's iterator * queued its end marker on request "close" and dropped every part emitted * after it; the busboy-driven iterator must deliver all parts regardless of * consumer pacing. */ import { PassThrough } from "node:stream"; import type { FastifyRequest } from "fastify"; import { describe, expect, it } from "vitest"; import { multipartParts } from "../../../apps/api/src/lib/multipart-parts.js"; const BOUNDARY = "----UnitBoundary1234"; function multipartBody( parts: Array<{ name: string; filename?: string; content: string | Buffer }>, ): Buffer { const chunks: Buffer[] = []; for (const part of parts) { let header = `--${BOUNDARY}\r\n`; if (part.filename) { header += `Content-Disposition: form-data; name="${part.name}"; filename="${part.filename}"\r\n`; header += "Content-Type: application/octet-stream\r\n\r\n"; } else { header += `Content-Disposition: form-data; name="${part.name}"\r\n\r\n`; } chunks.push(Buffer.from(header), Buffer.from(part.content), Buffer.from("\r\n")); } chunks.push(Buffer.from(`--${BOUNDARY}--\r\n`)); return Buffer.concat(chunks); } /** Fake request whose raw stream has the whole body buffered up front. */ function fakeRequest(body: Buffer): FastifyRequest { const raw = new PassThrough(); Object.assign(raw, { headers: { "content-type": `multipart/form-data; boundary=${BOUNDARY}` }, }); // Whole body available immediately: "end"/"close" fire as soon as the // pipe drains the stream, exactly like a warm keep-alive socket. raw.end(body); return { raw } as unknown as FastifyRequest; } const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); async function drain(stream: NodeJS.ReadableStream): Promise { const chunks: Buffer[] = []; for await (const chunk of stream) chunks.push(chunk as Buffer); return Buffer.concat(chunks); } describe("multipartParts", () => { it("yields every part when the consumer is slower than the body arrival", async () => { const body = multipartBody([ { name: "file", filename: "photo.jpg", content: Buffer.alloc(256 * 1024, 7) }, { name: "mask", filename: "mask.png", content: Buffer.alloc(8 * 1024, 9) }, { name: "clientJobId", content: "abc-123" }, { name: "format", content: "png" }, { name: "quality", content: "95" }, ]); const seen: string[] = []; const sizes: Record = {}; for await (const part of multipartParts(fakeRequest(body))) { if (part.type === "file") { const buf = await drain(part.file); sizes[part.fieldname] = buf.length; seen.push(`file:${part.fieldname}`); // Slow consumer: the raw stream has long since closed by now. await sleep(25); } else { seen.push(`field:${part.fieldname}=${part.value}`); } } expect(seen).toEqual([ "file:file", "file:mask", "field:clientJobId=abc-123", "field:format=png", "field:quality=95", ]); expect(sizes.file).toBe(256 * 1024); expect(sizes.mask).toBe(8 * 1024); }); it("propagates malformed multipart as an error", async () => { const raw = new PassThrough(); Object.assign(raw, { headers: { "content-type": `multipart/form-data; boundary=${BOUNDARY}` }, }); raw.end(Buffer.from("this is not multipart at all")); const request = { raw } as unknown as FastifyRequest; await expect(async () => { for await (const part of multipartParts(request)) { if (part.type === "file") await drain(part.file); } }).rejects.toThrow(); }); it("never leaves an over-limit file stream with zero error listeners", async () => { // env.MAX_UPLOAD_SIZE_MB is "10" for all vitest runs (vitest.config.ts), so // busboy's fileSize limit is 10MB here — same as production with that setting. // A caller that inspects a part but doesn't immediately drain it (e.g. a // route that rejects on some other check first, such as wrong file type) // must not leave the file stream's eventual "limit" -> destroy(error) as an // unhandled "error" event, which crashes the process (Node's default // behavior for an EventEmitter error with no listener attached). const oversized = Buffer.alloc(11 * 1024 * 1024, 1); // 11MB > the 10MB limit const body = multipartBody([{ name: "file", filename: "big.bin", content: oversized }]); const generator = multipartParts(fakeRequest(body)); const { value: part } = await generator.next(); if (part?.type !== "file") throw new Error("expected a file part"); // Deliberately do NOT drain part.file here — this is the abandoned-stream // scenario. The stream must already be safe against an unhandled "limit" // error the moment it's handed to the caller, not only once something // starts reading it. expect(part.file.listenerCount("error")).toBeGreaterThan(0); await generator.return(undefined); }); it("honors a route-specific two-file limit", async () => { const body = multipartBody([ { name: "index", filename: "ocr-runtime-index.json", content: "index" }, { name: "archive", filename: "ocr-runtime.tar.gz", content: "archive" }, { name: "extra", filename: "extra.bin", content: "unexpected" }, ]); await expect(async () => { for await (const part of multipartParts(fakeRequest(body), { files: 2 })) { if (part.type === "file") await drain(part.file); } }).rejects.toThrow("files limit"); }); });