|
| 1 | +import { createServer, type Server } from "node:http"; |
1 | 2 | import { describe, expect, it, vi } from "vitest"; |
2 | 3 | import { createClickClackClient } from "./http-client.js"; |
3 | 4 |
|
| 5 | +const LOOPBACK_RESPONSE_BYTES = 18 * 1024 * 1024; |
| 6 | + |
| 7 | +async function listenLoopbackServer(server: Server): Promise<number> { |
| 8 | + return await new Promise((resolve, reject) => { |
| 9 | + server.once("error", reject); |
| 10 | + server.listen(0, "127.0.0.1", () => { |
| 11 | + server.off("error", reject); |
| 12 | + const address = server.address(); |
| 13 | + if (!address || typeof address === "string") { |
| 14 | + reject(new Error("expected loopback TCP address")); |
| 15 | + return; |
| 16 | + } |
| 17 | + resolve(address.port); |
| 18 | + }); |
| 19 | + }); |
| 20 | +} |
| 21 | + |
| 22 | +function createOversizedJsonServer(): { server: Server; closed: Promise<number> } { |
| 23 | + let resolveClosed: (sentBytes: number) => void = () => {}; |
| 24 | + const closed = new Promise<number>((resolve) => { |
| 25 | + resolveClosed = resolve; |
| 26 | + }); |
| 27 | + const server = createServer((req, res) => { |
| 28 | + let sentBytes = 0; |
| 29 | + let stopped = false; |
| 30 | + let prefixSent = false; |
| 31 | + const prefixChunk = Buffer.from('{"user":{"id":"'); |
| 32 | + const bodyChunk = Buffer.alloc(64 * 1024, 0x61); |
| 33 | + const suffixChunk = Buffer.from('"}}'); |
| 34 | + const writeBuffer = (buffer: Buffer) => { |
| 35 | + sentBytes += buffer.length; |
| 36 | + if (!res.write(buffer)) { |
| 37 | + res.once("drain", writeChunks); |
| 38 | + return false; |
| 39 | + } |
| 40 | + return true; |
| 41 | + }; |
| 42 | + const writeChunks = () => { |
| 43 | + if (!prefixSent) { |
| 44 | + prefixSent = true; |
| 45 | + if (!writeBuffer(prefixChunk)) { |
| 46 | + return; |
| 47 | + } |
| 48 | + } |
| 49 | + while (true) { |
| 50 | + if (stopped) { |
| 51 | + return; |
| 52 | + } |
| 53 | + if (sentBytes + bodyChunk.length + suffixChunk.length >= LOOPBACK_RESPONSE_BYTES) { |
| 54 | + break; |
| 55 | + } |
| 56 | + if (!writeBuffer(bodyChunk)) { |
| 57 | + return; |
| 58 | + } |
| 59 | + } |
| 60 | + if (!stopped) { |
| 61 | + sentBytes += suffixChunk.length; |
| 62 | + res.end(suffixChunk); |
| 63 | + } |
| 64 | + }; |
| 65 | + res.writeHead(200, { connection: "close", "content-type": "application/json" }); |
| 66 | + res.on("close", () => { |
| 67 | + stopped = true; |
| 68 | + resolveClosed(sentBytes); |
| 69 | + }); |
| 70 | + req.on("aborted", () => { |
| 71 | + stopped = true; |
| 72 | + res.destroy(); |
| 73 | + }); |
| 74 | + writeChunks(); |
| 75 | + }); |
| 76 | + return { server, closed }; |
| 77 | +} |
| 78 | + |
4 | 79 | function streamedErrorResponse(body: string, limit: number) { |
5 | 80 | const encoded = new TextEncoder().encode(body); |
6 | 81 | let readCount = 0; |
@@ -39,6 +114,25 @@ function streamedErrorResponse(body: string, limit: number) { |
39 | 114 | } |
40 | 115 |
|
41 | 116 | describe("ClickClack HTTP client", () => { |
| 117 | + it("bounds oversized success JSON responses and closes the stream early", async () => { |
| 118 | + const { server, closed } = createOversizedJsonServer(); |
| 119 | + const port = await listenLoopbackServer(server); |
| 120 | + const client = createClickClackClient({ |
| 121 | + baseUrl: `http://127.0.0.1:${port}`, |
| 122 | + token: "test-token", |
| 123 | + }); |
| 124 | + |
| 125 | + try { |
| 126 | + await expect(client.me()).rejects.toThrow( |
| 127 | + "ClickClack response: JSON response exceeds 16777216 bytes", |
| 128 | + ); |
| 129 | + const sentBytes = await closed; |
| 130 | + expect(sentBytes).toBeLessThan(LOOPBACK_RESPONSE_BYTES); |
| 131 | + } finally { |
| 132 | + server.close(); |
| 133 | + } |
| 134 | + }); |
| 135 | + |
42 | 136 | it("bounds error response bodies without using raw response.text()", async () => { |
43 | 137 | const streamed = streamedErrorResponse("x".repeat(9000), 8 * 1024); |
44 | 138 | const fetchMock = vi.fn(async () => streamed.response); |
|
0 commit comments