Public: anyone can read this; the crew can change it.

test/body-limits.test.ts runs in under 10 s, every test under 1 s, proving what it proves today #10225

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/10215-test-body-limits-test-ts-runs-in-under-1 → main 1 commit fork 1 thread · 1 unresolved

  • Merged. mason/10215-test-body-limits-test-ts-runs-in-under-1 was merged into main as 32938cb.

Review

Sign in to review this change.
1 file changed +111 −66
test/body-limits.test.ts +111 −66
@@ -16,6 +16,12 @@ import { seedAdmin } from "./helpers/admin.ts";
1616 // declared length and an undeclared (chunked) one are both stopped at the
1717 // route's cap, and a refused body ends its connection instead of riding
1818 // on into the next request.
19 interface RawOutcome {
20 text: string;
21 closedByServer: boolean;
22 ms: number;
23 }
24
1925 describe("request body limits", () => {
2026 let dataDir: string;
2127 let app: App;
@@ -23,6 +29,11 @@ describe("request body limits", () => {
2329 let base: string;
2430 let port: number;
2531 let ownerToken: string;
32 /** Connections the server must end, started together in beforeAll. */
33 let hangups: Record<
34 "stranger" | "endless" | "endlessUnread",
35 Promise<RawOutcome>
36 >;
2637
2738 beforeAll(async () => {
2839 dataDir = await mkdtemp(join(tmpdir(), "forge-body-limits-"));
@@ -42,6 +53,36 @@ describe("request body limits", () => {
4253 visibility: "public",
4354 });
4455 if (!repo.ok) throw new Error(repo.error);
56 // The listener ends a connection with \`server.timeout(request, 1)\`,

The comment has literal backslash-escaped backticks. Write plain backticks.

57 // and the runtime fires socket timeouts on a 4-second tick: each
58 // connection the server hangs up on costs up to 4 s of waiting,
59 // whatever the client sends. The three are started together here and
60 // awaited by their tests, so the file waits for that tick once.
61 hangups = {
62 stranger: raw(
63 pump(
64 chunkedPost("/api/v1/fleet/ops", "application/json"),
65 3 * 1024 * 1024,
66 1,
67 ),
68 10_000,
69 ),
70 endless: raw(
71 pump(chunkedPost("/oauth/register", "application/json"), Infinity, 5),
72 30_000,
73 ),
74 endlessUnread: raw(
75 pump(
76 chunkedPost(
77 "/ada/garden.git/git-receive-pack",
78 "application/x-git-receive-pack-request",
79 ),
80 Infinity,
81 5,
82 ),
83 30_000,
84 ),
85 };
4586 });
4687 afterAll(async () => {
4788 servers.stop();
@@ -124,7 +165,7 @@ describe("request body limits", () => {
124165 const raw = (
125166 write: (socket: ReturnType<typeof connect>) => void,
126167 waitMs = 2000,
127 ): Promise<{ text: string; closedByServer: boolean; ms: number }> =>
168 ): Promise<RawOutcome> =>
128169 new Promise((resolve) => {
129170 const started = Date.now();
130171 const socket = connect(port, "127.0.0.1");
@@ -146,13 +187,44 @@ describe("request body limits", () => {
146187 }, waitMs);
147188 });
148189
190 /**
191 * A chunked request: `head`, then a 64 KiB chunk every `everyMs` until
192 * `total` bytes are sent and the body ends — or, when `total` is
193 * Infinity, until the server hangs up. Streamed, not one write: a single
194 * large write races the answer with EPIPE on the still-flushing send
195 * buffer and the client sees nothing.
196 */
197 const pump =
198 (head: string, total: number, everyMs: number) =>
199 (socket: ReturnType<typeof connect>): void => {
200 const chunk = "x".repeat(64 * 1024);
201 let left = total;
202 socket.write(head);
203 const timer = setInterval(() => {
204 if (socket.destroyed) return clearInterval(timer);
205 if (left <= 0) {
206 socket.write(`0\r\n\r\n`);
207 return clearInterval(timer);
208 }
209 const n = Math.min(left, chunk.length);
210 left -= n;
211 socket.write(`${n.toString(16)}\r\n${chunk.slice(0, n)}\r\n`);
212 }, everyMs);
213 };
214 const chunkedPost = (path: string, type: string): string =>
215 `POST ${path} HTTP/1.1\r\nHost: x\r\nContent-Type: ${type}\r\nTransfer-Encoding: chunked\r\n\r\n`;
216
149217 test("a request after a refused chunked body on the same connection is never misanswered", async () => {
218 // Invariant: a refused chunked body is drained, so the request behind
219 // it on the same connection is answered as itself (413, then 200).
220 // The trailing request asks to close, so the test ends on its answer
221 // instead of waiting out a kept-alive connection.
150222 const body = "x".repeat(big);
151223 const out = await raw((s) =>
152224 s.write(
153 `POST /oauth/register HTTP/1.1\r\nHost: x\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n` +
225 chunkedPost("/oauth/register", "application/json") +
154226 `${body.length.toString(16)}\r\n${body}\r\n0\r\n\r\n` +
155 `GET /api/v1/health HTTP/1.1\r\nHost: x\r\n\r\n`,
227 `GET /api/v1/health HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n`,
156228 ),
157229 );
158230 // Not anchored to a line: a body with no newline runs into the next
@@ -183,35 +255,6 @@ describe("request body limits", () => {
183255 expect(next.status).toBe(401);
184256 });
185257
186 test("a stranger gets the stranger's cap on a route that reads its body before asking who is calling", async () => {
187 // POST /api/v1/fleet/ops reads its body, then refuses an anonymous
188 // caller. 3 MiB is under the signed-in cap (16 MiB) and well past the
189 // stranger's (64 KiB) plus the drain budget: the forge stops reading
190 // and ends the connection instead of reading it all to say 401.
191 // Streamed, not one write: a single 3 MiB write races the answer with
192 // EPIPE on the still-flushing send buffer and the client sees nothing.
193 const chunk = "x".repeat(64 * 1024);
194 let left = 3 * 1024 * 1024;
195 const out = await raw((s) => {
196 s.write(
197 `POST /api/v1/fleet/ops HTTP/1.1\r\nHost: x\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n`,
198 );
199 const timer = setInterval(() => {
200 if (s.destroyed) return clearInterval(timer);
201 if (left <= 0) {
202 s.write(`0\r\n\r\n`);
203 return clearInterval(timer);
204 }
205 const n = Math.min(left, chunk.length);
206 left -= n;
207 s.write(`${n.toString(16)}\r\n${chunk.slice(0, n)}\r\n`);
208 }, 1);
209 }, 10_000);
210 expect(out.text).toMatch(/^HTTP\/1\.1 (401|413)/);
211 expect(out.text.toLowerCase()).toContain("connection: close");
212 expect(out.closedByServer).toBe(true);
213 });
214
215258 test("an Idempotency-Key does not buy a read past the cap", async () => {
216259 const res = await fetch(`${base}/api/v1/repos`, {
217260 method: "POST",
@@ -225,40 +268,6 @@ describe("request body limits", () => {
225268 expect(res.status).toBe(413);
226269 });
227270
228 test("a chunked body that never ends is cut off, not read until the idle timeout", async () => {
229 const chunk = "x".repeat(4096);
230 const out = await raw((s) => {
231 s.write(
232 `POST /oauth/register HTTP/1.1\r\nHost: x\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n`,
233 );
234 const timer = setInterval(() => {
235 if (s.destroyed) return clearInterval(timer);
236 s.write(`${chunk.length.toString(16)}\r\n${chunk}\r\n`);
237 }, 5);
238 }, 30_000);
239 expect(out.text).toStartWith("HTTP/1.1 413");
240 expect(out.closedByServer).toBe(true);
241 // Long before the 120 s idle timeout, with room for a loaded machine.
242 expect(out.ms).toBeLessThan(20_000);
243 });
244
245 test("an endless body a route never reads is cut off too (an anonymous push)", async () => {
246 const chunk = "x".repeat(4096);
247 const out = await raw((s) => {
248 s.write(
249 `POST /ada/garden.git/git-receive-pack HTTP/1.1\r\nHost: x\r\nContent-Type: application/x-git-receive-pack-request\r\nTransfer-Encoding: chunked\r\n\r\n`,
250 );
251 const timer = setInterval(() => {
252 if (s.destroyed) return clearInterval(timer);
253 s.write(`${chunk.length.toString(16)}\r\n${chunk}\r\n`);
254 }, 5);
255 }, 30_000);
256 expect(out.text).toMatch(/^HTTP\/1\.1 40[14]/);
257 expect(out.closedByServer).toBe(true);
258 // Long before the 120 s idle timeout, with room for a loaded machine.
259 expect(out.ms).toBeLessThan(20_000);
260 });
261
262271 test("a fetch's compressed negotiation that inflates past its cap stops there", async () => {
263272 // A few dozen kilobytes that would inflate to 64 MiB.
264273 const bomb = gzipSync(Buffer.alloc(64 * 1024 * 1024));
@@ -307,4 +316,40 @@ describe("request body limits", () => {
307316 await rm(work, { recursive: true, force: true });
308317 }
309318 });
319
320 // These three await the connections started in beforeAll; last in the
321 // file, so every test above runs while the runtime's timer comes round.
322 test("a stranger gets the stranger's cap on a route that reads its body before asking who is calling", async () => {
323 // Invariant: an anonymous body past STRANGER_MAX + DRAIN_MAX is
324 // answered and its connection ended by the server, on a route that
325 // reads first and asks who is calling after.
326 // POST /api/v1/fleet/ops reads its body, then refuses an anonymous
327 // caller. 3 MiB is under the signed-in cap (16 MiB) and well past the
328 // stranger's (64 KiB) plus the drain budget: the forge stops reading
329 // and ends the connection instead of reading it all to say 401.
330 const out = await hangups.stranger;
331 expect(out.text).toMatch(/^HTTP\/1\.1 (401|413)/);
332 expect(out.text.toLowerCase()).toContain("connection: close");
333 expect(out.closedByServer).toBe(true);
334 }, 15_000);
335
336 test("a chunked body that never ends is cut off, not read until the idle timeout", async () => {
337 // Invariant: a body that never ends, on a route that reads it, is
338 // answered 413 and its connection ended long before the idle timeout.
339 const out = await hangups.endless;
340 expect(out.text).toStartWith("HTTP/1.1 413");
341 expect(out.closedByServer).toBe(true);
342 // Long before the 120 s idle timeout, with room for a loaded machine.
343 expect(out.ms).toBeLessThan(20_000);
344 }, 30_000);
345
346 test("an endless body a route never reads is cut off too (an anonymous push)", async () => {
347 // Invariant: a body that never ends, on a route that never reads it,
348 // is answered and its connection ended long before the idle timeout.
349 const out = await hangups.endlessUnread;
350 expect(out.text).toMatch(/^HTTP\/1\.1 40[14]/);
351 expect(out.closedByServer).toBe(true);
352 // Long before the 120 s idle timeout, with room for a loaded machine.
353 expect(out.ms).toBeLessThan(20_000);
354 }, 30_000);
310355 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs