File size: 3,237 Bytes
6c3af4e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
import { Agent, createServer, request, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("hash-wasm", () => ({ argon2Verify: async () => false }));
vi.mock("./verifyTokenAndRateLimit.ts", () => ({
  consumeRateLimitPoint: async () => true,
}));

/**
 * The body cap answers mid-upload and drops the socket to stop reading. Only a
 * real connection shows what that costs the caller's next request: a mocked
 * req/res has no pooled connection to be dropped underneath it.
 *
 * Runs under the default jsdom environment on purpose. `node:http` works there,
 * and a per-file environment pragma cannot be used to switch it: the global
 * setup file `client/setupTests.ts` reaches for `window`, so the node
 * environment fails at import. The two `*.integration.test.ts` files escape
 * that through `vitest.integration.config.ts`, which declares no setup file,
 * but `npm test` does not run that config.
 */
describe("validateAccessKeyServerHook over a real socket", () => {
  let server: Server;
  let port: number;
  let originalAccessKeys: string | undefined;

  beforeEach(async () => {
    originalAccessKeys = process.env.ACCESS_KEYS;
    process.env.ACCESS_KEYS = "test-key";
    const { validateAccessKeyServerHook } = await import(
      "./validateAccessKeyServerHook.ts"
    );
    server = createServer((req, res) => {
      const middlewares = {
        use: (
          handler: (req: unknown, res: unknown, next: () => void) => void,
        ) =>
          handler(req, res, () => {
            res.statusCode = 404;
            res.end();
          }),
      };
      validateAccessKeyServerHook({ middlewares } as never);
    });
    await new Promise<void>((resolve) => server.listen(0, resolve));
    port = (server.address() as AddressInfo).port;
  });

  afterEach(async () => {
    await new Promise<void>((resolve) => server.close(() => resolve()));
    if (originalAccessKeys === undefined) delete process.env.ACCESS_KEYS;
    else process.env.ACCESS_KEYS = originalAccessKeys;
  });

  function post(agent: Agent, payload: string | Buffer) {
    return new Promise<string>((resolve) => {
      const req = request(
        { port, method: "POST", path: "/api/validate-access-key", agent },
        (res) => {
          res.resume();
          res.on("end", () => resolve(`status=${res.statusCode}`));
        },
      );
      req.on("error", (error) =>
        resolve(`error=${(error as NodeJS.ErrnoException).code}`),
      );
      req.end(payload);
    });
  }

  it("refuses an oversized body with 413 without breaking the next request", async () => {
    const agent = new Agent({ keepAlive: true, maxSockets: 1 });

    try {
      expect(await post(agent, Buffer.alloc(64 * 1024, "x"))).toBe(
        "status=413",
      );
      // A fresh connection, because the 413 told the agent not to pool the one
      // the server then dropped. Without that header the agent reuses the
      // dropped socket and this is an ECONNRESET.
      expect(await post(agent, JSON.stringify({ accessKeyHash: "x" }))).toBe(
        "status=200",
      );
    } finally {
      agent.destroy();
    }
  });
});