@specific.dev/spectest 0.107.1 → 0.107.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -113,9 +113,15 @@ export async function startIngressServer(options) {
|
|
|
113
113
|
headers.append(incoming.rawHeaders[i], incoming.rawHeaders[i + 1]);
|
|
114
114
|
}
|
|
115
115
|
const method = incoming.method ?? "GET";
|
|
116
|
+
// An HTTP/1 request without framing has no body. Giving it even an
|
|
117
|
+
// empty stream makes fetch invent chunked framing upstream (e.g. a
|
|
118
|
+
// bodyless DELETE then fails Fastify's content-type validation).
|
|
119
|
+
// Keep explicitly chunked bodies streaming, including empty ones.
|
|
120
|
+
const hasBody = method !== "GET" && method !== "HEAD"
|
|
121
|
+
&& (headers.has("transfer-encoding") || Number(headers.get("content-length")) > 0);
|
|
116
122
|
const req = new Request(new URL(incoming.url ?? "/", `http://${headers.get("host") ?? "localhost"}`), {
|
|
117
123
|
method, headers, signal: abort.signal,
|
|
118
|
-
body:
|
|
124
|
+
body: hasBody ? Readable.toWeb(incoming) : undefined,
|
|
119
125
|
duplex: "half",
|
|
120
126
|
});
|
|
121
127
|
peers.set(req, incoming.socket);
|
package/package.json
CHANGED
|
@@ -8,7 +8,7 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { execFileSync } from "node:child_process";
|
|
11
|
-
import { forwardableHeaders, withIngressCors } from "./http-proxy";
|
|
11
|
+
import { forwardableHeaders, upstreamRequestHeaders, withIngressCors } from "./http-proxy";
|
|
12
12
|
import { startIngressServer, type IngressServer } from "./ingress-server";
|
|
13
13
|
import { startTlsTerminator, type TlsTerminator } from "./tls-terminator";
|
|
14
14
|
|
|
@@ -24,8 +24,14 @@ let release: (() => void) | undefined;
|
|
|
24
24
|
let upstreamClosed: Promise<unknown>;
|
|
25
25
|
|
|
26
26
|
beforeAll(async () => {
|
|
27
|
-
upstream = http.createServer((req, res) => {
|
|
27
|
+
upstream = http.createServer(async (req, res) => {
|
|
28
28
|
const path = req.url!;
|
|
29
|
+
if (path === "/request") {
|
|
30
|
+
const body = await bytes(req);
|
|
31
|
+
res.setHeader("content-type", "application/json");
|
|
32
|
+
res.end(JSON.stringify({ method: req.method, headers: req.headers, body: body.toString() }));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
29
35
|
let body = path === "/8960" ? object.subarray(0, 8960) : path === "/gzip" ? compressed : object;
|
|
30
36
|
res.setHeader("content-type", "application/octet-stream");
|
|
31
37
|
res.setHeader("etag", '"original"');
|
|
@@ -59,7 +65,8 @@ beforeAll(async () => {
|
|
|
59
65
|
port: 0, hostname: "127.0.0.1",
|
|
60
66
|
fetch: req => withIngressCors(req, "permissive", async current => {
|
|
61
67
|
const res = await fetch(`http://127.0.0.1:${upstreamPort}${new URL(current.url).pathname}`, {
|
|
62
|
-
method: current.method, headers: current.headers,
|
|
68
|
+
method: current.method, headers: upstreamRequestHeaders(current.headers, { proto: "http" }),
|
|
69
|
+
body: current.body, signal: current.signal,
|
|
63
70
|
decompress: false, keepalive: false,
|
|
64
71
|
});
|
|
65
72
|
return new Response(res.body, {
|
|
@@ -83,7 +90,7 @@ afterAll(() => {
|
|
|
83
90
|
if (dir) rmSync(dir, { recursive: true, force: true });
|
|
84
91
|
});
|
|
85
92
|
|
|
86
|
-
function request(path: string, options: { boundary?: "storage" | "http" | "https"; method?: string; headers?: Record<string, string
|
|
93
|
+
function request(path: string, options: { boundary?: "storage" | "http" | "https"; method?: string; headers?: Record<string, string>; body?: string } = {}) {
|
|
87
94
|
const boundary = options.boundary ?? "https";
|
|
88
95
|
return new Promise<http.IncomingMessage>((resolve, reject) => {
|
|
89
96
|
const req = (boundary === "https" ? https : http).request({
|
|
@@ -93,7 +100,7 @@ function request(path: string, options: { boundary?: "storage" | "http" | "https
|
|
|
93
100
|
}, resolve);
|
|
94
101
|
req.once("error", reject);
|
|
95
102
|
req.setTimeout(2000, () => req.destroy(new Error("ingress request timed out")));
|
|
96
|
-
req.end();
|
|
103
|
+
req.end(options.body);
|
|
97
104
|
});
|
|
98
105
|
}
|
|
99
106
|
|
|
@@ -180,6 +187,51 @@ describe("ingress response framing", () => {
|
|
|
180
187
|
}
|
|
181
188
|
});
|
|
182
189
|
|
|
190
|
+
describe("ingress request framing", () => {
|
|
191
|
+
for (const boundary of ["storage", "http", "https"] as const) {
|
|
192
|
+
test(`${boundary}: unframed requests do not acquire a chunked body`, async () => {
|
|
193
|
+
// fetch may send Content-Length: 0 and conceal this regression. Use
|
|
194
|
+
// node:http methods that send neither length nor transfer encoding.
|
|
195
|
+
for (const method of ["DELETE", "OPTIONS"]) {
|
|
196
|
+
const res = await request("/request", { boundary, method });
|
|
197
|
+
const received = JSON.parse((await bytes(res)).toString());
|
|
198
|
+
expect(res.statusCode).toBe(200);
|
|
199
|
+
expect(received.method).toBe(method);
|
|
200
|
+
// Bun fetch can normalize an absent body to Content-Length: 0.
|
|
201
|
+
// The direct boundary proves the client itself sent no framing.
|
|
202
|
+
if (boundary === "storage") expect(received.headers["content-length"]).toBeUndefined();
|
|
203
|
+
else expect(received.headers["content-length"] ?? "0").toBe("0");
|
|
204
|
+
expect(received.headers["transfer-encoding"]).toBeUndefined();
|
|
205
|
+
expect(received.body).toBe("");
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test(`${boundary}: explicit zero length stays empty`, async () => {
|
|
210
|
+
for (const method of ["DELETE", "OPTIONS", "POST", "PUT", "PATCH"]) {
|
|
211
|
+
const res = await request("/request", { boundary, method, headers: { "content-length": "0" } });
|
|
212
|
+
const received = JSON.parse((await bytes(res)).toString());
|
|
213
|
+
expect(received.headers["content-length"]).toBe("0");
|
|
214
|
+
expect(received.headers["transfer-encoding"]).toBeUndefined();
|
|
215
|
+
expect(received.body).toBe("");
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test(`${boundary}: framed bodies survive, including empty chunked requests`, async () => {
|
|
220
|
+
for (const body of ["", "request payload"]) {
|
|
221
|
+
for (const chunked of [false, true]) {
|
|
222
|
+
const res = await request("/request", { boundary, method: "DELETE", body,
|
|
223
|
+
headers: chunked ? { "transfer-encoding": "chunked" } : { "content-length": String(Buffer.byteLength(body)) },
|
|
224
|
+
});
|
|
225
|
+
const received = JSON.parse((await bytes(res)).toString());
|
|
226
|
+
expect(received.headers["content-length"]).toBe(chunked ? undefined : String(Buffer.byteLength(body)));
|
|
227
|
+
expect(received.headers["transfer-encoding"]).toBe(chunked ? "chunked" : undefined);
|
|
228
|
+
expect(received.body).toBe(body);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
183
235
|
describe("ingress request lifecycle", () => {
|
|
184
236
|
test("streams uploads and preserves the request URL, headers and peer", async () => {
|
|
185
237
|
const server = await startIngressServer({
|
|
@@ -131,9 +131,15 @@ export async function startIngressServer(options: {
|
|
|
131
131
|
headers.append(incoming.rawHeaders[i], incoming.rawHeaders[i + 1]);
|
|
132
132
|
}
|
|
133
133
|
const method = incoming.method ?? "GET";
|
|
134
|
+
// An HTTP/1 request without framing has no body. Giving it even an
|
|
135
|
+
// empty stream makes fetch invent chunked framing upstream (e.g. a
|
|
136
|
+
// bodyless DELETE then fails Fastify's content-type validation).
|
|
137
|
+
// Keep explicitly chunked bodies streaming, including empty ones.
|
|
138
|
+
const hasBody = method !== "GET" && method !== "HEAD"
|
|
139
|
+
&& (headers.has("transfer-encoding") || Number(headers.get("content-length")) > 0);
|
|
134
140
|
const req = new Request(new URL(incoming.url ?? "/", `http://${headers.get("host") ?? "localhost"}`), {
|
|
135
141
|
method, headers, signal: abort.signal,
|
|
136
|
-
body:
|
|
142
|
+
body: hasBody ? Readable.toWeb(incoming) as unknown as ReadableStream<Uint8Array> : undefined,
|
|
137
143
|
duplex: "half",
|
|
138
144
|
} as RequestInit);
|
|
139
145
|
peers.set(req, incoming.socket);
|