import { describe, expect, it, afterAll, beforeAll } from 'bun:test'; import { Hono } from 'hono'; import { bodyParser } from '../_middlewares/body-parser'; import { createRouter } from '../create-router'; import { handleSidecarMessage } from '../sidecar-registry'; import { createSidecarProxy } from './create-proxy'; // Regression cover for COMMS/MULTIPART_PROXY_BUG.md: every multipart upload through a sidecar proxy // arrived corrupted, because bodyParser parsed a body the proxy only meant to forward. Hono cached the // resulting FormData and re-serialised it with a NEW boundary when the proxy asked for the bytes, while // the proxy still forwarded the ORIGINAL content-type header. The far side then failed with // "Multipart: Unexpected end of form". // // The assertion that matters is the one the bug report names: the boundary in the content-type header and // the boundary in the body must be the same one the client sent. const BOUNDARY = '6600BE83-EFF4-498D-9739-B6E978654467'; // A JPEG-shaped payload with embedded NULs and high bytes — the things a naive text round-trip mangles. // Deliberately not tiny: Bun 1.3.10's multipart parser truncates an 8-byte part at its first NUL, which is // a fixture artefact (64 bytes and up are fine) but cost time to chase, so it is written down here. const FILE_BYTES = (() => { const bytes = new Uint8Array(512); bytes.set([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01]); for (let i = 12; i < bytes.length; i++) bytes[i] = (i * 7) % 256; bytes.set([0xff, 0xd9], bytes.length - 2); return bytes; })(); /** The phone's exact wire format: a UUID boundary, fields in a fixed order, a binary file part. */ function phoneMultipart(): { body: Uint8Array; contentType: string } { const fields = [ ['deviceAssetId', 'probe-1'], ['deviceId', 'probe-device'], ['fileCreatedAt', '2026-08-07T00:00:00.000Z'], ['fileModifiedAt', '2026-08-07T00:00:00.000Z'], ['isFavorite', 'false'], ]; const head = fields .map(([k, v]) => `--${BOUNDARY}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`) .join(''); const filePrologue = `--${BOUNDARY}\r\n` + `Content-Disposition: form-data; name="assetData"; filename="t.jpg"\r\n` + `Content-Type: image/jpeg\r\n\r\n`; const epilogue = `\r\n--${BOUNDARY}--\r\n`; const enc = new TextEncoder(); const parts = [enc.encode(head), enc.encode(filePrologue), FILE_BYTES, enc.encode(epilogue)]; const body = new Uint8Array(new ArrayBuffer(parts.reduce((n, p) => n + p.length, 0))); let at = 0; for (const p of parts) { body.set(p, at); at += p.length; } return { body, contentType: `multipart/form-data; boundary=${BOUNDARY}` }; } /** Stands in for a sidecar. Reports what actually arrived on the wire. */ type Seen = { contentType: string; bodyBoundary: string | null; fields: string[]; fileBytes: number[] | null; raw: Uint8Array; }; let sidecar: ReturnType; let app: Hono; let seen: Seen | null = null; beforeAll(async () => { sidecar = Bun.serve({ port: 0, async fetch(req) { // A rejection with no body at all — the shape from COMMS/UPLOAD_EMPTY_400.md that tells the client // "no" and gives it nothing to act on. if (new URL(req.url).pathname.endsWith('/empty400')) return new Response(null, { status: 400 }); const contentType = req.headers.get('content-type') ?? ''; const raw = new Uint8Array(await req.arrayBuffer()); const asText = new TextDecoder().decode(raw); const bodyBoundary = asText.match(/^--([^\r\n-][^\r\n]*?)\r\n/)?.[1] ?? null; // Re-parse from the bytes we actually received, exactly as Immich would. let fields: string[] = []; let fileBytes: number[] | null = null; try { const form = await new Request('http://x', { method: 'POST', headers: { 'content-type': contentType }, body: raw, }).formData(); fields = [...form.keys()]; const f = form.get('assetData'); if (f instanceof File) fileBytes = [...new Uint8Array(await f.arrayBuffer())]; } catch (err) { seen = { contentType, bodyBoundary, fields: [], fileBytes: null, raw }; return new Response(JSON.stringify({ message: String(err) }), { status: 400 }); } seen = { contentType, bodyBoundary, fields, fileBytes, raw }; return new Response(JSON.stringify({ ok: true }), { status: 201 }); }, }); const proxy = createSidecarProxy({ name: 'testcar', prefix: '/api/testcar' }); // What the registration socket would deliver when the sidecar reports its port. handleSidecarMessage('test', { type: 'testcar:server', port: sidecar.port } as never); const protectedRouter = createRouter(); protectedRouter.use(bodyParser()); protectedRouter.use(async (ctx, next) => { ctx.set('user', { id: 1 } as never); return next(); }); protectedRouter.route('/testcar', proxy.router); app = new Hono(); app.route('/api', protectedRouter); }); afterAll(() => sidecar?.stop(true)); describe('sidecar proxy multipart forwarding', () => { it('forwards the client boundary unchanged, in both the header and the body', async () => { const { body, contentType } = phoneMultipart(); const res = await app.request('http://x/api/testcar/_officer/assets', { method: 'POST', headers: { 'content-type': contentType }, body: new Blob([body]), }); expect(res.status).toBe(201); expect(seen).not.toBeNull(); // The bug: header said one boundary, body used another. expect(seen!.contentType).toContain(BOUNDARY); expect(seen!.bodyBoundary).toBe(BOUNDARY); // Bun's FormData re-serialiser announces itself; if this appears, the body was rebuilt. expect(seen!.bodyBoundary).not.toContain('WebkitFormBoundary'); }); it('delivers the body byte-for-byte, fields in the order sent', async () => { const { body, contentType } = phoneMultipart(); const res = await app.request('http://x/api/testcar/_officer/assets', { method: 'POST', headers: { 'content-type': contentType }, body: new Blob([body]), }); expect(res.status).toBe(201); // The strongest form of "forwarded, not rebuilt": the bytes on the far side are the bytes we sent. expect([...seen!.raw]).toEqual([...body]); expect(seen!.fields).toEqual([ 'deviceAssetId', 'deviceId', 'fileCreatedAt', 'fileModifiedAt', 'isFavorite', 'assetData', ]); expect(seen!.fileBytes).toEqual([...FILE_BYTES]); }); it('reports a rejection instead of passing it on in silence', async () => { // COMMS/UPLOAD_EMPTY_400.md: uploads failed with an empty-bodied 400 and nothing logged it anywhere, // so localising the fault took an evening of curl bisection. The platform is the only hop that sees // both sides. `requestBytes` is the field that would have cracked it — a size threshold is legible at // a glance instead of by bisection. const warnings: unknown[][] = []; const realWarn = console.warn; console.warn = (...args: unknown[]) => void warnings.push(args); try { const payload = JSON.stringify({ some: 'payload' }); const res = await app.request('http://x/api/testcar/_officer/empty400', { method: 'POST', // Set explicitly: `app.request` does not derive it, and this is the field the bug report needed. headers: { 'content-type': 'application/json', 'content-length': String(payload.length) }, body: payload, }); // Pass-through is unchanged: the proxy reports, it does not rewrite. expect(res.status).toBe(400); expect(await res.text()).toBe(''); expect(warnings.length).toBe(1); const [message, detail] = warnings[0] as [string, Record]; expect(message).toContain('[testcar]'); expect(message).toContain('POST /_officer/empty400'); expect(message).toContain('400'); // The empty body is named, not left to be inferred from a byte count of zero. expect(detail.emptyBody).toBe(true); expect(detail.requestBytes).toBe(String(payload.length)); expect(detail.responseBytes).toBe('0'); } finally { console.warn = realWarn; } }); it('says nothing about a request that succeeded', async () => { const { body, contentType } = phoneMultipart(); const warnings: unknown[][] = []; const realWarn = console.warn; console.warn = (...args: unknown[]) => void warnings.push(args); try { const res = await app.request('http://x/api/testcar/_officer/assets', { method: 'POST', headers: { 'content-type': contentType }, body: new Blob([body]), }); expect(res.status).toBe(201); expect(warnings).toEqual([]); } finally { console.warn = realWarn; } }); it('still forwards a JSON body untouched', async () => { const res = await app.request('http://x/api/testcar/_officer/echo', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ hello: 'world' }), }); expect(res.status).toBe(400); // not multipart — the stub fails to parse it, which is fine expect(seen!.contentType).toBe('application/json'); }); });