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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10215-test-body-limits-test-ts-runs-in-under-1 → main 1 commit fork 1 thread · 1 unresolved
1 file
changed +111
−66
test/body-limits.test.ts +111 −66
| @@ -16,6 +16,12 @@ import { seedAdmin } from "./helpers/admin.ts"; | ||
| 16 | 16 | // declared length and an undeclared (chunked) one are both stopped at the |
| 17 | 17 | // route's cap, and a refused body ends its connection instead of riding |
| 18 | 18 | // on into the next request. |
| 19 | interface RawOutcome { | |
| 20 | text: string; | |
| 21 | closedByServer: boolean; | |
| 22 | ms: number; | |
| 23 | } | |
| 24 | ||
| 19 | 25 | describe("request body limits", () => { |
| 20 | 26 | let dataDir: string; |
| 21 | 27 | let app: App; |
| @@ -23,6 +29,11 @@ describe("request body limits", () => { | ||
| 23 | 29 | let base: string; |
| 24 | 30 | let port: number; |
| 25 | 31 | 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 | >; | |
| 26 | 37 | |
| 27 | 38 | beforeAll(async () => { |
| 28 | 39 | dataDir = await mkdtemp(join(tmpdir(), "forge-body-limits-")); |
| @@ -42,6 +53,36 @@ describe("request body limits", () => { | ||
| 42 | 53 | visibility: "public", |
| 43 | 54 | }); |
| 44 | 55 | if (!repo.ok) throw new Error(repo.error); |
| 56 | // The listener ends a connection with \`server.timeout(request, 1)\`, | |
| 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 | }; | |
| 45 | 86 | }); |
| 46 | 87 | afterAll(async () => { |
| 47 | 88 | servers.stop(); |
| @@ -124,7 +165,7 @@ describe("request body limits", () => { | ||
| 124 | 165 | const raw = ( |
| 125 | 166 | write: (socket: ReturnType<typeof connect>) => void, |
| 126 | 167 | waitMs = 2000, |
| 127 | ): Promise<{ text: string; closedByServer: boolean; ms: number }> => | |
| 168 | ): Promise<RawOutcome> => | |
| 128 | 169 | new Promise((resolve) => { |
| 129 | 170 | const started = Date.now(); |
| 130 | 171 | const socket = connect(port, "127.0.0.1"); |
| @@ -146,13 +187,44 @@ describe("request body limits", () => { | ||
| 146 | 187 | }, waitMs); |
| 147 | 188 | }); |
| 148 | 189 | |
| 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 | ||
| 149 | 217 | 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. | |
| 150 | 222 | const body = "x".repeat(big); |
| 151 | 223 | const out = await raw((s) => |
| 152 | 224 | 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") + | |
| 154 | 226 | `${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`, | |
| 156 | 228 | ), |
| 157 | 229 | ); |
| 158 | 230 | // Not anchored to a line: a body with no newline runs into the next |
| @@ -183,35 +255,6 @@ describe("request body limits", () => { | ||
| 183 | 255 | expect(next.status).toBe(401); |
| 184 | 256 | }); |
| 185 | 257 | |
| 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 | ||
| 215 | 258 | test("an Idempotency-Key does not buy a read past the cap", async () => { |
| 216 | 259 | const res = await fetch(`${base}/api/v1/repos`, { |
| 217 | 260 | method: "POST", |
| @@ -225,40 +268,6 @@ describe("request body limits", () => { | ||
| 225 | 268 | expect(res.status).toBe(413); |
| 226 | 269 | }); |
| 227 | 270 | |
| 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 | ||
| 262 | 271 | test("a fetch's compressed negotiation that inflates past its cap stops there", async () => { |
| 263 | 272 | // A few dozen kilobytes that would inflate to 64 MiB. |
| 264 | 273 | const bomb = gzipSync(Buffer.alloc(64 * 1024 * 1024)); |
| @@ -307,4 +316,40 @@ describe("request body limits", () => { | ||
| 307 | 316 | await rm(work, { recursive: true, force: true }); |
| 308 | 317 | } |
| 309 | 318 | }); |
| 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); | |
| 310 | 355 | }); |
The comment has literal backslash-escaped backticks. Write plain backticks.