diff --git a/packages/qa/http-conformance/README.md b/packages/qa/http-conformance/README.md index c6246632058..3d16671ada8 100644 --- a/packages/qa/http-conformance/README.md +++ b/packages/qa/http-conformance/README.md @@ -40,8 +40,8 @@ suite is what breaks. Deliberately absent (each a known escape hatch whose consumers feature-detect and degrade): `getRawApp()` (Hono-specific — metadata HMR, -cloud-connection routes, static/SPA + CORS + Server-Timing), `mount()`, -multipart parsing (binary bodies stay raw behind the lazy `req.rawBody()`). +cloud-connection routes, static/SPA + CORS + Server-Timing) and `mount()`. +Bodies: JSON and forms (multipart included) are parsed into `req.body`, and every other media type stays raw behind the lazy `req.rawBody()`. ## Not a product server diff --git a/packages/qa/http-conformance/src/adapter.ts b/packages/qa/http-conformance/src/adapter.ts index ee115c088c2..d3cdbc3cfd7 100644 --- a/packages/qa/http-conformance/src/adapter.ts +++ b/packages/qa/http-conformance/src/adapter.ts @@ -98,13 +98,162 @@ export function compileRoute(pattern: string): { regex: RegExp; keys: string[] } return { regex: new RegExp(`^${body}$`), keys }; } -/** Body-parsing content types we consume eagerly; everything else stays raw. */ -function isEagerlyParsed(contentType: string): boolean { - return ( - contentType.includes('application/json') || - contentType.includes('application/x-www-form-urlencoded') || - contentType.startsWith('text/') - ); +/* + * ── The request body: parsed by its declared content type, or REFUSED ──────── + * + * [#24083] `IHttpRequest.body` (`@objectstack/spec/contracts`) is the parsed + * JSON document for `application/json`, the parsed fields of a form + * (`multipart/form-data`, `application/x-www-form-urlencoded`), `{}` when the + * request carries no body, and otherwise a REFUSAL thrown from the read, never + * an invented value. This adapter used to read malformed declared JSON as `{}`, + * a multipart body as `{}`, and a `text/*` body as a string. + * + * What follows MIRRORS the standard transport's decision + * (`readRequestBody` in plugin-hono-server's `src/request-body.ts`, whose + * module note says why each arm answers what it does), and does not import + * it: this adapter is the instrument that measures that transport, and a + * reference that ran the subject's code could not see the subject drift. The + * two are held equal from outside instead, byte for byte, by + * `request-body.conformance.test.ts`. So a change to either side's wording or + * arms is a change to both. + * + * - Declared JSON (`application/json`, matched case-insensitively): read as + * text and parsed. Empty or whitespace-only is `{}`; anything else that does + * not parse is a refusal. + * - A form: parsed into fields by the Fetch `Response.formData()` that Node + * ships (the very parser the standard transport's framework calls), in its + * field shape: one value per key, the last one winning, except a key that + * ends in `[]`, which collects every value into an array; a file part is a + * `File`. A parse failure is a refusal. + * - Any other media type, or none: nothing is read, so the bytes stay on the + * socket for `rawBody()`. A request that carries a body by its framing + * headers (RFC 9112 §6.3) is a refusal on read; one that carries none is + * `{}`. + * + * GET and HEAD carry no body here, as on the standard transport, whose + * framework hands those methods a request with no body: nothing is read from + * the socket for them, and the decision above runs on empty bytes. + */ + +/** + * The refusal, as a DECLARED ADR-0112 throw: `status` and a registered `code` + * (`INVALID_REQUEST` is on the error-code ledger). Module-private. + */ +class RequestBodyRefusal extends Error { + readonly status = 400; + readonly code = 'INVALID_REQUEST'; + + constructor(message: string) { + super(message); + this.name = 'RequestBodyRefusal'; + } +} + +type RequestBodyReading = + | { readonly body: unknown; readonly refusal?: undefined } + | { readonly refusal: RequestBodyRefusal }; + +/** The refusal tail every message ends with: what the transport did NOT do. */ +const NOT_EMPTY = 'It was refused rather than read as an empty body'; + +const MALFORMED_JSON_BODY_MESSAGE = + `The request body could not be parsed as JSON (it was sent as application/json). ${NOT_EMPTY}: ` + + 'send a well-formed JSON document, or no body at all.'; + +const UNREADABLE_BODY_MESSAGE = `The request body could not be read. ${NOT_EMPTY}.`; + +/** A media type as the client spelled it, echoed only when it is a plain token pair. */ +function namedMediaType(mediaType: string): string { + if (mediaType === '') return 'with no Content-Type'; + return /^[a-z0-9][a-z0-9!#$&^_.+-]{0,63}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,63}$/.test(mediaType) + ? `as ${mediaType}` + : 'with an unrecognised Content-Type'; +} + +function malformedFormMessage(mediaType: string): string { + return `The request body could not be parsed as form data (it was sent ${namedMediaType(mediaType)}). ${NOT_EMPTY}.`; +} + +function unreadMediaTypeMessage(mediaType: string): string { + return `The request body was sent ${namedMediaType(mediaType)}, which this server does not read into a ` + + `request body. ${NOT_EMPTY}: send it as application/json.`; +} + +/** RFC 9112 §6.3: a `Content-Length` that is not `0`, or any `Transfer-Encoding`, is a body. */ +function declaresBody(headers: IncomingMessage['headers']): boolean { + const length = headers['content-length']; + if (length !== undefined && length.trim() !== '') return Number(length) !== 0; + return headers['transfer-encoding'] !== undefined; +} + +/** + * A form's fields in the standard transport's shape (see the block above). + * Built through `Object.fromEntries`, so a field named `__proto__` is an own + * key and never reaches the prototype chain, and then left with no prototype, + * as the standard transport's object has none. + */ +function formFields(form: FormData): Record { + const fields = new Map(); + form.forEach((value, key) => { + if (!key.endsWith('[]')) { + fields.set(key, value); + return; + } + const values = fields.get(key); + if (Array.isArray(values)) values.push(value); + else fields.set(key, [value]); + }); + return Object.setPrototypeOf(Object.fromEntries(fields), null) as Record; +} + +/** + * Decide the body (see the block above). `bytes` is the read of the request + * stream when this decision consumed it, so `rawBody()` hands back those same + * bytes, or that same failure, instead of reading a drained socket. + */ +async function readRequestBody( + nodeReq: IncomingMessage, + method: string, +): Promise<{ reading: RequestBodyReading; bytes?: Promise }> { + const contentType = String(nodeReq.headers['content-type'] ?? ''); + const lowered = contentType.toLowerCase(); + const readStream = (): Promise => + method === 'GET' || method === 'HEAD' ? Promise.resolve(Buffer.alloc(0)) : readBody(nodeReq); + + if (lowered.includes('application/json')) { + const bytes = readStream(); + let text: string; + try { + text = (await bytes).toString('utf8'); + } catch { + return { reading: { refusal: new RequestBodyRefusal(UNREADABLE_BODY_MESSAGE) }, bytes }; + } + if (text.trim() === '') return { reading: { body: {} }, bytes }; + try { + return { reading: { body: JSON.parse(text) }, bytes }; + } catch { + return { reading: { refusal: new RequestBodyRefusal(MALFORMED_JSON_BODY_MESSAGE) }, bytes }; + } + } + + const mediaType = lowered.split(';')[0]!.trim(); + if (mediaType === 'multipart/form-data' || mediaType === 'application/x-www-form-urlencoded') { + const bytes = readStream(); + try { + const form = await new Response(await bytes, { + headers: { 'Content-Type': contentType.replace(/^[^;]+/, (type) => type.toLowerCase()) }, + }).formData(); + return { reading: { body: formFields(form) }, bytes }; + } catch { + return { reading: { refusal: new RequestBodyRefusal(malformedFormMessage(mediaType)) }, bytes }; + } + } + + return { + reading: declaresBody(nodeReq.headers) + ? { refusal: new RequestBodyRefusal(unreadMediaTypeMessage(mediaType)) } + : { body: {} }, + }; } function readBody(req: IncomingMessage): Promise { @@ -211,7 +360,9 @@ export class NodeHttpServer implements IHttpServer { * 4. **A handler that writes nothing leaves the standard answer.** The * branch falls through to {@link writeUnmatchedResponse} — the 404, or * the 405 + `Allow`, unchanged. Writing nothing is the documented way - * to say "not mine". + * to say "not mine". The one exception is a fallback that read a body + * this adapter refused: as on the primary adapter, the refusal is then + * the answer, whether the fallback threw, answered or wrote nothing. * * Locked cross-adapter by this package's own * `fallback-seam.conformance.test.ts`, which runs the same cases against @@ -285,6 +436,31 @@ export class NodeHttpServer implements IHttpServer { })); } + /** + * [#24083] The answer to a request whose handler (a matched route's, or + * the fallback's) read a body this adapter refused: `400 INVALID_REQUEST` + * in the ADR-0112 envelope, the same two keys the primary adapter renders + * for this throw. The request's body was never readable, so no other + * answer describes it. + * + * One shape cannot be matched on `node:http`: a handler that WROTE before + * it read has put bytes on the wire, which the primary adapter (it holds + * the handler's answer until the handler returns) still replaces. Here + * what was sent stands, and the response is closed. + */ + private writeBodyRefusal(nodeRes: ServerResponse, refusal: RequestBodyRefusal) { + if (nodeRes.headersSent) { + if (!nodeRes.writableEnded) nodeRes.end(); + return; + } + nodeRes.statusCode = refusal.status; + nodeRes.setHeader('Content-Type', 'application/json; charset=utf-8'); + nodeRes.end(JSON.stringify({ + success: false, + error: { code: refusal.code, message: refusal.message }, + })); + } + private match(method: string, path: string): { route: CompiledRoute; params: Record } | undefined { const normalized = normalize(path); // HEAD is answered by GET handlers (body suppressed by node core for @@ -373,24 +549,15 @@ export class NodeHttpServer implements IHttpServer { const query = Object.fromEntries(queryEntries) as Record; // ── Body ──────────────────────────────────────────────────────────── - // JSON / urlencoded / text bodies are consumed eagerly (like the - // primary adapter); anything else (octet-stream, multipart) stays on - // the socket for the lazy `rawBody()` accessor so binary uploads pay - // no parsing cost. - const contentType = String(nodeReq.headers['content-type'] || ''); - let body: any = {}; - let bufferedBody: Buffer | undefined; - if (method !== 'GET' && method !== 'HEAD' && isEagerlyParsed(contentType)) { - bufferedBody = await readBody(nodeReq); - const text = bufferedBody.toString('utf8'); - if (contentType.includes('application/json')) { - try { body = text ? JSON.parse(text) : {}; } catch { body = {}; } - } else if (contentType.includes('application/x-www-form-urlencoded')) { - body = Object.fromEntries(new URLSearchParams(text)); - } else { - body = text; - } - } + // [#24083] Declared JSON and forms are read eagerly, as on the primary + // adapter; every other media type stays on the socket for the lazy + // `rawBody()` accessor, so a binary upload pays no parsing cost. What + // each media type answers is `readRequestBody`'s (above). + const { reading, bytes } = await readRequestBody(nodeReq, method); + let bodyReading: RequestBodyReading = reading; + let bufferedBody: Promise | undefined = bytes; + /** Set once a READ of `req.body` met the refusal; see the route branch below. */ + let refusedBodyRead: RequestBodyRefusal | undefined; // node:http exposes headers as a lowercased record already, Host // included — no backfill needed (the Fetch-API Host backfill in the @@ -400,13 +567,38 @@ export class NodeHttpServer implements IHttpServer { // primary adapter hands its fallback (its `readRouteParams` guard). params: matched?.params ?? {}, query, - body, + /** + * [#24083] An accessor rather than a value, as on the primary + * adapter: reading a body this adapter refused throws the refusal, + * which stops the handler at the read. A route that reads + * `rawBody()` instead never reaches it. Writable as before. + */ + get body(): any { + if (bodyReading.refusal) { + refusedBodyRead = bodyReading.refusal; + throw bodyReading.refusal; + } + return bodyReading.body; + }, + set body(value: any) { + bodyReading = { body: value }; + }, headers: nodeReq.headers as Record, method, path, - rawBody: async () => bufferedBody ?? (bufferedBody = await readBody(nodeReq)), + // The bytes of every body, a refused one included. + rawBody: () => (bufferedBody ??= readBody(nodeReq)), }; + /** + * [#24083] A read of a refused body makes the refusal THE answer, + * whatever the handler made of the throw, on a matched route and in + * the fallback alike, as on the primary adapter: anything the handler + * writes after the read is dropped, and {@link writeBodyRefusal} + * answers once it returns. + */ + const refusalStands = (): boolean => refusedBodyRead !== undefined; + // ── Response wrapper (contract + streaming extension) ─────────────── const isHead = method === 'HEAD'; let streaming = false; @@ -414,12 +606,12 @@ export class NodeHttpServer implements IHttpServer { status: (code: number) => { nodeRes.statusCode = code; return res; }, header: (name: string, value: string | string[]) => { nodeRes.setHeader(name, value); return res; }, json: (data: any) => { - if (nodeRes.writableEnded) return; + if (nodeRes.writableEnded || refusalStands()) return; if (!nodeRes.headersSent) nodeRes.setHeader('Content-Type', 'application/json; charset=utf-8'); nodeRes.end(isHead ? undefined : JSON.stringify(data)); }, send: (data: string | Uint8Array | ArrayBuffer) => { - if (nodeRes.writableEnded) return; + if (nodeRes.writableEnded || refusalStands()) return; if (typeof data === 'string') { if (!nodeRes.headersSent) nodeRes.setHeader('Content-Type', 'text/html; charset=utf-8'); nodeRes.end(isHead ? undefined : data); @@ -432,10 +624,12 @@ export class NodeHttpServer implements IHttpServer { }, // Streaming extension (SSE): map straight onto the native stream. write: (chunk: string | Uint8Array) => { + if (refusalStands()) return; streaming = true; if (!nodeRes.writableEnded) nodeRes.write(chunk); }, end: () => { + if (refusalStands()) return; if (!nodeRes.writableEnded) nodeRes.end(); }, }; @@ -447,9 +641,18 @@ export class NodeHttpServer implements IHttpServer { // an unmatched request on this adapter today either, and changing // that is a separate decision from installing this seam. const handler = fallback as RouteHandler; + let threw = false; try { await handler(req, res as IHttpResponse); } catch { + threw = true; + } + // [#24083] The fallback read a body this adapter refused: the + // refusal is the answer on every arm (thrown, caught and answered, + // caught and silent), as on the primary adapter, whose fallback + // runs through the same `runHandler` as its routes. + if (refusedBodyRead) return this.writeBodyRefusal(nodeRes, refusedBodyRead); + if (threw) { // Prefer failing to falling back: a fallback that THREW is a // broken consumer, and reporting its failure as this adapter's // ordinary 404 would hide it behind the most unremarkable @@ -481,6 +684,10 @@ export class NodeHttpServer implements IHttpServer { void advanced; // chain always continues — parity with the primary adapter } await matched.route.handler(req, res as IHttpResponse); + if (refusedBodyRead) { + this.writeBodyRefusal(nodeRes, refusedBodyRead); + return; + } // Handler resolved without producing a response and without // starting a stream (SSE handlers legitimately resolve while the // stream is still open — dispatcher drains it in the background). @@ -493,6 +700,10 @@ export class NodeHttpServer implements IHttpServer { })); } } catch (err: any) { + if (refusedBodyRead) { + this.writeBodyRefusal(nodeRes, refusedBodyRead); + return; + } if (!nodeRes.writableEnded) { if (!nodeRes.headersSent) { nodeRes.statusCode = typeof err?.statusCode === 'number' ? err.statusCode : 500; diff --git a/packages/qa/http-conformance/src/request-body.conformance.test.ts b/packages/qa/http-conformance/src/request-body.conformance.test.ts new file mode 100644 index 00000000000..679b9cdb0f8 --- /dev/null +++ b/packages/qa/http-conformance/src/request-body.conformance.test.ts @@ -0,0 +1,331 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24083] `IHttpRequest.body` — cross-adapter conformance. + * + * The contract (`packages/spec/src/contracts/http-server.ts`): the body is the + * parsed JSON document for `application/json`, the parsed fields of a form + * (`multipart/form-data`, `application/x-www-form-urlencoded`), `{}` when the + * request carries no body, and reading it may REFUSE — an implementation that + * cannot parse the body, or does not read its media type, throws from the read + * rather than hand over an invented value. The standard transport + * (`@objectstack/plugin-hono-server`) refuses with `400 INVALID_REQUEST` and + * answers the request itself, replacing whatever the handler wrote. + * + * Until this file nothing drove the reference adapter with a body it could + * not read, and it read malformed declared JSON as `{}`, a multipart body as + * `{}` and a `text/plain` body as a string, so the suite that exists to lock + * cross-adapter behaviour could not see the difference. The reference now + * MIRRORS the standard transport's decision without importing it (see the + * body block in `adapter.ts`); this file is what holds the two equal. + * + * ## What this pins, on BOTH adapters over a real socket + * + * 1. A malformed declared-JSON body is refused `400 INVALID_REQUEST` at the + * read, never read as `{}`, and the answer does not echo the body. + * 2. A non-empty body in a media type neither adapter reads (`text/plain`) + * is refused the same way, never handed over as a string. + * 3. A multipart body is parsed into its fields: one value per key with the + * last one winning, a `[]` key collecting every value, a file part as a + * file. + * 4. The refusal is the answer even when the handler catches it and writes + * its own. + * 5. `rawBody()` still reads the bytes of a refused body. + * 6. CONTROLS: an empty body is `{}`, and a valid JSON body is the document. + * 7. THE FALLBACK (`setFallbackHandler`), the door every declared `apis:` + * endpoint enters through: a fallback that reads a refused body is + * answered with the refusal whether it then throws, catches and answers, + * or catches and writes nothing, because the standard transport runs its + * fallback through the same `runHandler` as its routes. CONTROLS: a + * fallback that never reads the body and writes nothing leaves the 404, + * and the 405 + `Allow` (guarantee 4); one that reads a valid body gets + * the document (guarantee 2). + * + * Then PARITY: every row above, sent to both adapters, answers the same status + * and the same bytes (the refusal messages included, so the reference's + * mirrored wording cannot drift from the standard transport's unseen). + * + * `@objectstack/plugin-hono-server` resolves through its `dist/` (a ledgered + * pair in `scripts/check-test-source-alias.mjs`), as in the sibling + * `fallback-seam.conformance.test.ts`; the reference is this package's source. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import type { RouteHandler } from '@objectstack/core'; +import { HonoHttpServer } from '@objectstack/plugin-hono-server'; + +import { NodeHttpServer } from './adapter.js'; + +/** The `IHttpServer` members this suite drives. */ +interface BodyReadingServer { + post(path: string, handler: RouteHandler): void; + put(path: string, handler: RouteHandler): void; + setFallbackHandler(handler: RouteHandler): void; + listen(port: number): Promise; + close?(): Promise; + getPort(): number; +} + +type AdapterCase = { label: 'node' | 'hono'; make: () => BodyReadingServer }; + +const ADAPTERS: AdapterCase[] = [ + { label: 'node', make: () => new NodeHttpServer(0) }, + { label: 'hono', make: () => new HonoHttpServer(0) }, +]; + +/** A body value as JSON can carry it: a file part by its name, type and size. */ +function describeValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(describeValue); + if (value instanceof Blob) { + return { file: (value as Blob & { name?: string }).name, type: value.type, size: value.size }; + } + return value; +} + +function describeBody(body: unknown): unknown { + if (body === null || typeof body !== 'object' || Array.isArray(body)) return body; + return Object.fromEntries(Object.entries(body).map(([key, value]) => [key, describeValue(value)])); +} + +/** The request shapes, built fresh per send (a body is consumed by its send). */ +const BODIES = { + malformedJson: (): RequestInit => ({ headers: { 'content-type': 'application/json' }, body: '{ not json' }), + textPlain: (): RequestInit => ({ headers: { 'content-type': 'text/plain' }, body: 'plain words' }), + multipart: (): RequestInit => { + const form = new FormData(); + form.append('name', 'Ada'); + form.append('tag', 'first'); + form.append('tag', 'last'); + form.append('ids[]', '1'); + form.append('ids[]', '2'); + form.append('upload', new Blob(['three'], { type: 'text/plain' }), 'notes.txt'); + return { body: form }; + }, + noBody: (): RequestInit => ({}), + emptyJson: (): RequestInit => ({ headers: { 'content-type': 'application/json' }, body: '' }), + emptyText: (): RequestInit => ({ headers: { 'content-type': 'text/plain' }, body: '' }), + validJson: (): RequestInit => ({ + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ title: 'adapter parity', tags: ['a', 'b'], n: 3 }), + }), +} as const; +type BodyName = keyof typeof BODIES; + +/** The fields the multipart body parses into, on both adapters. */ +const MULTIPART_FIELDS = { + name: 'Ada', + tag: 'last', + 'ids[]': ['1', '2'], + upload: { file: 'notes.txt', type: 'text/plain', size: 5 }, +}; + +type Answer = { status: number; text: string; allow: string | null }; + +/** + * The fallback, by the path's second segment: what it does once it has read + * `req.body` (or not). Any other path, `/only-put` included, is `no-read`. + */ +const FALLBACK: RouteHandler = (req, res) => { + const mode = req.path.split('/')[2]; + if (mode === 'throw') { + const body = describeBody(req.body); + res.status(200); + res.json({ body }); + return; + } + if (mode === 'caught-answers') { + try { + void req.body; + } catch { + res.status(500); + res.json({ caught: true }); + return; + } + res.status(200); + res.json({ caught: false }); + return; + } + if (mode === 'caught-silent') { + try { + void req.body; + } catch { + return; + } + return; + } + if (mode === 'echo') { + const body = describeBody(req.body); + res.status(200); + res.json({ body }); + } + // `no-read`: never reads the body and writes nothing, the documented way + // to say "not mine". +}; + +/** Boot one adapter with the routes and the fallback this suite reads through. */ +async function boot(make: () => BodyReadingServer, opened: BodyReadingServer[]) { + const server = make(); + // Reads `req.body` and answers what it read. + server.post('/echo', (req, res) => { + const body = describeBody(req.body); + res.status(200); + res.json({ body }); + }); + // Catches the refusal and answers its own — what the contract says a + // handler must not do, and what the transport's answer overrides. + server.post('/caught', (req, res) => { + try { + void req.body; + } catch { + res.status(500); + res.json({ caught: true }); + return; + } + res.status(200); + res.json({ caught: false }); + }); + // Reads the raw bytes only, and never `req.body`. + server.post('/raw', async (req, res) => { + const bytes = await req.rawBody!(); + res.status(200); + res.json({ raw: bytes.toString('utf8') }); + }); + // A path that exists under another verb, for the fallback's 405 control. + server.put('/only-put', (_req, res) => { + res.status(200); + res.json({ route: 'put' }); + }); + server.setFallbackHandler(FALLBACK); + await server.listen(0); + opened.push(server); + const base = `http://127.0.0.1:${server.getPort()}`; + return async (path: string, body: BodyName): Promise => { + const res = await fetch(`${base}${path}`, { method: 'POST', ...BODIES[body]() }); + return { status: res.status, text: await res.text(), allow: res.headers.get('allow') }; + }; +} + +function expectRefused(answer: Answer, sent: string) { + expect(answer.status, answer.text).toBe(400); + expect(JSON.parse(answer.text)).toEqual({ + success: false, + error: { code: 'INVALID_REQUEST', message: expect.any(String) }, + }); + expect(answer.text).not.toContain(sent); +} + +describe.each(ADAPTERS)('IHttpRequest.body conformance on the $label adapter', ({ make }) => { + const opened: BodyReadingServer[] = []; + afterEach(async () => { + await Promise.all(opened.splice(0).map((server) => server.close?.())); + }); + + it('a malformed declared-JSON body is refused 400 INVALID_REQUEST at the read, never read as {}', async () => { + const send = await boot(make, opened); + expectRefused(await send('/echo', 'malformedJson'), '{ not json'); + }); + + it('a non-empty text/plain body is refused the same way, never handed over as a string', async () => { + const send = await boot(make, opened); + expectRefused(await send('/echo', 'textPlain'), 'plain words'); + }); + + it('a multipart body is parsed into its fields', async () => { + const send = await boot(make, opened); + const answer = await send('/echo', 'multipart'); + expect(answer.status, answer.text).toBe(200); + expect(JSON.parse(answer.text)).toEqual({ body: MULTIPART_FIELDS }); + }); + + it('the refusal is the answer even when the handler catches it and writes its own', async () => { + const send = await boot(make, opened); + expectRefused(await send('/caught', 'textPlain'), 'plain words'); + }); + + it('rawBody() still reads the bytes of a body that req.body refuses', async () => { + const send = await boot(make, opened); + expect(await send('/raw', 'malformedJson')).toEqual({ status: 200, text: JSON.stringify({ raw: '{ not json' }), allow: null }); + expect(await send('/raw', 'textPlain')).toEqual({ status: 200, text: JSON.stringify({ raw: 'plain words' }), allow: null }); + }); + + it('a fallback that reads a refused body and throws is answered with the refusal', async () => { + const send = await boot(make, opened); + expectRefused(await send('/fallback/throw', 'malformedJson'), '{ not json'); + }); + + it('a fallback that catches the refusal and answers its own is still answered with the refusal', async () => { + const send = await boot(make, opened); + expectRefused(await send('/fallback/caught-answers', 'textPlain'), 'plain words'); + }); + + it('a fallback that catches the refusal and writes nothing is answered with the refusal, not the 404', async () => { + const send = await boot(make, opened); + expectRefused(await send('/fallback/caught-silent', 'malformedJson'), '{ not json'); + }); + + it('CONTROL — a fallback that never reads the body and writes nothing leaves the 404, and the 405 + Allow', async () => { + const send = await boot(make, opened); + const missing = await send('/fallback/no-read', 'malformedJson'); + expect(missing.status, missing.text).toBe(404); + expect(JSON.parse(missing.text)).toEqual({ success: false, error: { code: 'ENDPOINT_NOT_FOUND', message: 'Not found' } }); + const otherVerb = await send('/only-put', 'malformedJson'); + expect(otherVerb.status, otherVerb.text).toBe(405); + expect(otherVerb.allow).toBe('PUT'); + expect(JSON.parse(otherVerb.text)).toMatchObject({ success: false, error: { code: 'METHOD_NOT_ALLOWED' } }); + }); + + it('CONTROL — a fallback that reads a valid JSON body gets the document', async () => { + const send = await boot(make, opened); + const answer = await send('/fallback/echo', 'validJson'); + expect(answer.status, answer.text).toBe(200); + expect(JSON.parse(answer.text)).toEqual({ body: { title: 'adapter parity', tags: ['a', 'b'], n: 3 } }); + }); + + it('CONTROL — an empty body is {}: none at all, an empty JSON body, an empty text/plain body', async () => { + const send = await boot(make, opened); + for (const body of ['noBody', 'emptyJson', 'emptyText'] as const) { + const answer = await send('/echo', body); + expect({ body, status: answer.status, read: JSON.parse(answer.text) }).toEqual({ body, status: 200, read: { body: {} } }); + } + }); + + it('CONTROL — a valid JSON body is the parsed document', async () => { + const send = await boot(make, opened); + const answer = await send('/echo', 'validJson'); + expect(answer.status, answer.text).toBe(200); + expect(JSON.parse(answer.text)).toEqual({ body: { title: 'adapter parity', tags: ['a', 'b'], n: 3 } }); + }); +}); + +describe('IHttpRequest.body parity: both adapters answer each body with the same status and the same bytes', () => { + const opened: BodyReadingServer[] = []; + afterEach(async () => { + await Promise.all(opened.splice(0).map((server) => server.close?.())); + }); + + const ROWS: Array<[path: string, body: BodyName]> = [ + ['/echo', 'malformedJson'], + ['/echo', 'textPlain'], + ['/echo', 'multipart'], + ['/echo', 'noBody'], + ['/echo', 'emptyJson'], + ['/echo', 'emptyText'], + ['/echo', 'validJson'], + ['/caught', 'textPlain'], + ['/raw', 'malformedJson'], + ['/raw', 'textPlain'], + ['/fallback/throw', 'malformedJson'], + ['/fallback/caught-answers', 'textPlain'], + ['/fallback/caught-silent', 'malformedJson'], + ['/fallback/no-read', 'malformedJson'], + ['/only-put', 'malformedJson'], + ['/fallback/echo', 'validJson'], + ]; + + it.each(ROWS)('%s · %s', async (path, body) => { + const [node, hono] = ADAPTERS; + const reference = await (await boot(node!.make, opened))(path, body); + const standard = await (await boot(hono!.make, opened))(path, body); + expect(reference, `the reference answered ${reference.text}; the standard transport ${standard.text}`).toEqual(standard); + }); +});