diff --git a/AGENTS.md b/AGENTS.md index 51a9624..302d82a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ - Use functions and plain objects, not classes. - Start each source file with a comment that says what the file owns. Give each export a short JSDoc. - Update `README.md` in the same change when behavior, config, or the control API changes. -- Test through a real proxy and a local upstream server in `packages/service/tests/`. Do not mock modules. Do not reach the internet. +- Test through a real proxy and a local upstream server in `packages/service/tests/`. Start them with `startTestService()` and `startUpstream()` of `tests/harness.ts`. Wrap a tight timing limit in `budget()`, so that it allows for coverage, unless coverage barely slows that check. Do not mock modules. Do not reach the internet. - The proxy decrypts HTTPS. Never write a credential: every recording goes through `packages/service/src/secrets.ts`. - The upstream host always comes from `allow`, never from the client. - Write docs in ASD-STE100 English: common words, active voice, short sentences. diff --git a/packages/service/tests/harness.ts b/packages/service/tests/harness.ts new file mode 100644 index 0000000..29a3234 --- /dev/null +++ b/packages/service/tests/harness.ts @@ -0,0 +1,69 @@ +/** + * The shared setup of the service tests: a local upstream and a service in + * the test process. Each one closes when its test finishes. + */ +import { createHash } from "node:crypto"; +import { createServer, type RequestListener, type Server } from "node:http"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { onTestFinished } from "vitest"; +import { + startRoachService, + type RoachService, + type RoachServiceConfig, +} from "../src/service.ts"; + +/** + * The time limit of a timing check: `ms`, or 3 times `ms` with coverage. + * Coverage makes the service slower (`vitest.config.ts`). + */ +export const budget = (ms: number) => + ms * Number(process.env.ROACH_TEST_SLOWDOWN ?? "1"); + +/** The write token of a test service. */ +export const TOKEN = "write-token"; + +/** The SHA-256 of a token in hex, as `writeTokenHash` keeps it. */ +const hash = (token: string) => + createHash("sha256").update(token).digest("hex"); + +/** + * Start an HTTP upstream on 127.0.0.1 with `handler`. Returns the server + * and its origin. It closes when the test finishes. + */ +export async function startUpstream( + handler: RequestListener, +): Promise<{ server: Server; origin: string }> { + const server = createServer(handler); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + onTestFinished( + () => new Promise((resolve) => server.close(() => resolve())), + ); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("No port"); + return { server, origin: `http://127.0.0.1:${address.port}` }; +} + +/** + * Start a service in this process, with the recordings in a new directory. + * It can reach the upstream on 127.0.0.1 (`privateNetworks`), and its write + * token is `TOKEN`. `extra` adds to or replaces this config. The service + * closes, and its directory goes, when the test finishes. + */ +export async function startTestService( + extra: Partial = {}, +): Promise<{ service: RoachService; directory: string }> { + const directory = await mkdtemp(path.join(tmpdir(), "roach-")); + const service = await startRoachService({ + directory, + privateNetworks: true, + writeTokenHash: hash(TOKEN), + ...extra, + }); + onTestFinished(async () => { + await service.close(); + await rm(directory, { recursive: true, force: true }); + }); + return { service, directory }; +} diff --git a/packages/service/tests/recording.test.ts b/packages/service/tests/recording.test.ts index efdfe3f..dd9f26f 100644 --- a/packages/service/tests/recording.test.ts +++ b/packages/service/tests/recording.test.ts @@ -2,17 +2,7 @@ * Recording and replay: modes, sessions, changing values, and misses. Each * test starts runs on a service with a local upstream. */ -import { createHash } from "node:crypto"; -import { createServer, type Server } from "node:http"; -import { - mkdir, - mkdtemp, - readdir, - readFile, - rm, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { ProxyAgent, request } from "undici"; import { @@ -28,15 +18,14 @@ import { describeRecordingMisses, describeRecordingStats, } from "@sentry/roach/report"; -import { startRoachService, type RoachService } from "../src/service.ts"; +import type { RoachService } from "../src/service.ts"; +import { startTestService, startUpstream, TOKEN } from "./harness.ts"; import { keyRequest } from "../src/request-key.ts"; import type { RecordingMode, RecordingRule } from "@sentry/roach/types"; import { extractValues, VALUE_PATTERNS } from "@sentry/roach/values"; -const TOKEN = "write-token"; const TENANT = "acme/alpha"; -let upstream: Server; let origin: string; let liveRequests: number; /** The event stream that the upstream sends for a request body. */ @@ -184,7 +173,7 @@ beforeEach(async () => { liveRequests = 0; respond = () => `data: ${liveRequests}\n\n`; upstreamGate = Promise.resolve(); - upstream = createServer((incoming, outgoing) => { + ({ origin } = await startUpstream((incoming, outgoing) => { liveRequests += 1; const chunks: Buffer[] = []; incoming.on("data", (chunk: Buffer) => chunks.push(chunk)); @@ -193,28 +182,14 @@ beforeEach(async () => { outgoing.writeHead(200, { "content-type": "text/event-stream" }); outgoing.end(respond(Buffer.concat(chunks).toString("utf8"))); }); - }); - await new Promise((resolve) => - upstream.listen(0, "127.0.0.1", resolve), - ); - const address = upstream.address(); - if (!address || typeof address === "string") throw new Error("No port"); - origin = `http://127.0.0.1:${address.port}`; - directory = await mkdtemp(path.join(tmpdir(), "roach-")); - service = await startRoachService({ - directory, - privateNetworks: true, - writeTokenHash: createHash("sha256").update(TOKEN).digest("hex"), - }); + })); + ({ service, directory } = await startTestService()); }); afterEach(async () => { await agent?.close(); agent = undefined; await Promise.all(runs.splice(0).map((run) => run.close().catch(() => {}))); - await service.close(); - await new Promise((resolve) => upstream.close(() => resolve())); - await rm(directory, { recursive: true, force: true }); }); describe("recording", () => { diff --git a/packages/service/tests/redaction.test.ts b/packages/service/tests/redaction.test.ts index 6ed13eb..c941487 100644 --- a/packages/service/tests/redaction.test.ts +++ b/packages/service/tests/redaction.test.ts @@ -4,26 +4,23 @@ * and request samples. The keys of recordings must not change. Each test * starts runs on a service with a local upstream. */ -import { createHash } from "node:crypto"; -import { createServer, type Server } from "node:http"; import { brotliCompressSync, constants, gzipSync, zstdCompressSync, } from "node:zlib"; -import { mkdtemp, readdir, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { readdir, readFile } from "node:fs/promises"; import path from "node:path"; import { ProxyAgent, request } from "undici"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { startRemoteRun, type RemoteRoach } from "@sentry/roach/client"; -import { startRoachService, type RoachService } from "../src/service.ts"; +import type { RoachService } from "../src/service.ts"; +import { budget, startTestService, startUpstream, TOKEN } from "./harness.ts"; import { keyRequest } from "../src/request-key.ts"; import type { Recording, RequestSample } from "../src/store.ts"; import type { RecordingMode, RecordingRule } from "@sentry/roach/types"; -const TOKEN = "write-token"; const TENANT = "acme/alpha"; const API_KEY = "sk-live-request-key-456"; const MINTED = "minted-access-token-0123456789"; @@ -62,7 +59,6 @@ const BOMB = brotliCompressSync(Buffer.alloc(65 * 1024 * 1024), { const BINARY_HEAD = Buffer.from([0x00, 0xff, 0xfe, 0x80]); const BINARY_TAIL = Buffer.from([0xc3, 0x28, 0x00, 0x01]); -let upstream: Server; let origin: string; let directory: string; let service: RoachService; @@ -163,6 +159,27 @@ async function follow(run: RemoteRoach) { return [first.source, second.source]; } +/** + * Run `work`, and return the longest time in ms that a timer of 5 ms waited + * while it ran. The service runs in this process, so a long wait shows that + * the service blocked the event loop. + */ +async function longestStall(work: () => Promise): Promise { + let last = performance.now(); + let longest = 0; + const timer = setInterval(() => { + const now = performance.now(); + longest = Math.max(longest, now - last); + last = now; + }, 5); + try { + await work(); + } finally { + clearInterval(timer); + } + return longest; +} + /** The one recording of the rule. */ async function onlyRecording(): Promise<{ text: string; file: Recording }> { const files = [...(await stored("api")).values()]; @@ -171,7 +188,7 @@ async function onlyRecording(): Promise<{ text: string; file: Recording }> { } beforeEach(async () => { - upstream = createServer((incoming, outgoing) => { + ({ origin } = await startUpstream((incoming, outgoing) => { const chunks: Buffer[] = []; incoming.on("data", (chunk: Buffer) => chunks.push(chunk)); incoming.on("end", () => { @@ -330,27 +347,13 @@ beforeEach(async () => { outgoing.end(JSON.stringify({ ok: true })); } }); - }); - await new Promise((resolve) => - upstream.listen(0, "127.0.0.1", resolve), - ); - const address = upstream.address(); - if (!address || typeof address === "string") throw new Error("No port"); - origin = `http://127.0.0.1:${address.port}`; - directory = await mkdtemp(path.join(tmpdir(), "roach-redaction-")); - service = await startRoachService({ - directory, - privateNetworks: true, - writeTokenHash: createHash("sha256").update(TOKEN).digest("hex"), - }); + })); + ({ service, directory } = await startTestService()); }); afterEach(async () => { await Promise.all(agents.splice(0).map((agent) => agent.close())); await Promise.all(runs.splice(0).map((run) => run.close().catch(() => {}))); - await service.close(); - await new Promise((resolve) => upstream.close(() => resolve())); - await rm(directory, { recursive: true, force: true }); }); describe("redaction", () => { @@ -734,42 +737,6 @@ describe("redaction", () => { ); }); - it("redacts many credential fields of a request without blocking the event loop", async () => { - // 64 different passwords in about 8 MiB, the most that the proxy - // checks. Each is a field to redact in the request sample. - const fields = Object.fromEntries( - Array.from({ length: 64 }, (_, index) => [ - `f${index}`, - { password: `${index}-${"p".repeat(128 * 1024)}` }, - ]), - ); - const body = JSON.stringify(fields); - const run = await start("auto"); - let last = performance.now(); - let longest = 0; - const timer = setInterval(() => { - const now = performance.now(); - longest = Math.max(longest, now - last); - last = now; - }, 5); - const started = performance.now(); - try { - const live = await send(run, "/v1/fields", body); - expect(live.source).toBe("live"); - } finally { - clearInterval(timer); - } - // About 115 ms, and 700 ms with coverage. A pass for each field took - // 16 s. - expect(longest).toBeLessThan(2000); - expect(performance.now() - started).toBeLessThan(5000); - const [sample] = [...(await stored("_requests/api")).values()]; - const { text, withheld } = JSON.parse(sample!) as RequestSample; - expect(withheld).toBeUndefined(); - // `includes`, so a failure does not print 8 MiB. - expect(text.includes("p".repeat(4096))).toBe(false); - }, 120_000); - it("redacts a request sample with many different credential values", async () => { // The check reads the text once, for all values, so it has no limit // of values. @@ -789,14 +756,31 @@ describe("redaction", () => { expect(text).not.toMatch(/password-\d/); }); + // Each body is about 8 MiB, the most that the proxy checks. The longest + // stall is about 150 ms, and up to 1.8 s with coverage. `leaked` is in + // the text only when a password is. it.each([ - // A long value makes a search for it in the text slow. + // 64 different passwords. Each is a field to redact in the request + // sample. A pass for each field took 16 s. + [ + "many credential fields", + Object.fromEntries( + Array.from({ length: 64 }, (_, index) => [ + `f${index}`, + { password: `${index}-${"p".repeat(128 * 1024)}` }, + ]), + ), + "p".repeat(4096), + ], + // A long value makes a search for it in the text slow. A search for + // each value in the text took 11 s for one value, and minutes for 64. [ "a long value", { filler: "a".repeat(8 * 1024 * 1024), password: `${"a".repeat(1024)} b ${"a".repeat(1024)}`, }, + ` ${"a".repeat(1024)}`, ], [ "many long values", @@ -809,33 +793,26 @@ describe("redaction", () => { ]), ), }, + ` ${"a".repeat(1024)}`, ], ])( "checks a request sample with %s without blocking the event loop", - async (_, fields) => { + async (_, fields, leaked) => { const run = await start("auto"); - let last = performance.now(); - let longest = 0; - const timer = setInterval(() => { - const now = performance.now(); - longest = Math.max(longest, now - last); - last = now; - }, 5); - try { + const started = performance.now(); + const longest = await longestStall(async () => { const live = await send(run, "/v1/fields", JSON.stringify(fields)); expect(live.source).toBe("live"); - } finally { - clearInterval(timer); - } - // About 60 ms, and 300 ms with coverage. A search for each value in - // the text took 11 s for one value, and minutes for 64. - expect(longest).toBeLessThan(2000); + }); + expect(longest).toBeLessThan(budget(2000)); + expect(performance.now() - started).toBeLessThan(budget(5000)); const [sample] = [...(await stored("_requests/api")).values()]; const { text, withheld } = JSON.parse(sample!) as RequestSample; expect(withheld).toBeUndefined(); - expect(text.includes(` ${"a".repeat(1024)}`)).toBe(false); + // `includes`, so a failure does not print 8 MiB. + expect(text.includes(leaked)).toBe(false); }, - 60_000, + 120_000, ); const JSON_TYPE = "application/json"; @@ -955,24 +932,15 @@ describe("redaction", () => { parts.push(`password=p${String(i).padStart(9, "0")}`); } const run = await start("auto"); - let last = performance.now(); - let longest = 0; - const timer = setInterval(() => { - const now = performance.now(); - longest = Math.max(longest, now - last); - last = now; - }, 5); - try { + const longest = await longestStall(async () => { const live = await send(run, "/v1/fields", parts.join("&"), { "content-type": FORM_TYPE, }); expect(live.source).toBe("live"); - } finally { - clearInterval(timer); - } - // About 300 ms, and 900 ms with coverage. Three decodes of each part + }); + // About 550 ms, and 1.7 s with coverage. Three decodes of each part // took 1.9 s. - expect(longest).toBeLessThan(1500); + expect(longest).toBeLessThan(budget(1500)); const [sample] = [...(await stored("_requests/api")).values()]; const { text, withheld } = JSON.parse(sample!) as RequestSample; expect(withheld).toBeUndefined(); @@ -1084,22 +1052,13 @@ describe("redaction", () => { const run = await start("auto", [{ ...rule(), values }]); const strings = Array.from({ length: 900_000 }, () => '"\\u0001"'); const body = `{"a":[${strings.join(",")}],"password":"x"}`; - let last = performance.now(); - let longest = 0; - const timer = setInterval(() => { - const now = performance.now(); - longest = Math.max(longest, now - last); - last = now; - }, 5); - try { + const longest = await longestStall(async () => { const live = await send(run, "/v1/fields", body); expect(live.source).toBe("live"); - } finally { - clearInterval(timer); - } - // About 300 ms, and 1.2 s with coverage. A throw for each string took + }); + // About 1.2 s, and 2 s with coverage. A throw for each string took // 5.9 s. - expect(longest).toBeLessThan(3000); + expect(longest).toBeLessThan(budget(3000)); const [sample] = [...(await stored("_requests/api")).values()]; // The escapes that are not valid make the body of the text not JSON, // so the proxy cannot check it, and the sample has no text. @@ -1112,22 +1071,13 @@ describe("redaction", () => { const run = await start("auto"); const strings = Array.from({ length: 1_650_000 }, () => '"\\n"'); const body = `{"a":[${strings.join(",")}],"password":"pw"}`; - let last = performance.now(); - let longest = 0; - const timer = setInterval(() => { - const now = performance.now(); - longest = Math.max(longest, now - last); - last = now; - }, 5); - try { + const longest = await longestStall(async () => { const live = await send(run, "/v1/fields", body); expect(live.source).toBe("live"); - } finally { - clearInterval(timer); - } - // About 200 ms, and 1.4 s with coverage. Most of it is the parse of the + }); + // About 1 s, and 2.9 s with coverage. Most of it is the parse of the // body. A `JSON.parse` of each string made it about two times longer. - expect(longest).toBeLessThan(3000); + expect(longest).toBeLessThan(budget(3000)); const [sample] = [...(await stored("_requests/api")).values()]; expect(JSON.parse(sample!)).not.toHaveProperty("withheld"); }, 60_000); diff --git a/packages/service/tests/service.test.ts b/packages/service/tests/service.test.ts index c86f8dc..9af3366 100644 --- a/packages/service/tests/service.test.ts +++ b/packages/service/tests/service.test.ts @@ -1,17 +1,8 @@ import { execFileSync } from "node:child_process"; -import { createHash } from "node:crypto"; import { once } from "node:events"; -import { createServer, type Server } from "node:http"; +import { createServer } from "node:http"; import { connect } from "node:net"; -import { - mkdir, - mkdtemp, - readdir, - readFile, - rm, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { Readable } from "node:stream"; import { ProxyAgent, request } from "undici"; @@ -33,11 +24,11 @@ import { describeRecordingMisses } from "@sentry/roach/report"; import { keyInWorker } from "../src/key-worker.ts"; import { keyRequest } from "../src/request-key.ts"; import { clientGroup } from "../src/clients.ts"; -import { startRoachService, type RoachService } from "../src/service.ts"; +import type { RoachService } from "../src/service.ts"; +import { budget, startTestService, startUpstream } from "./harness.ts"; import type { RemoteRun, RunConfig } from "@sentry/roach/types"; import { VALUE_PATTERNS } from "@sentry/roach/values"; -let upstream: Server; let origin: string; let liveRequests: number; let directory: string; @@ -45,9 +36,6 @@ let service: RoachService; const agents: ProxyAgent[] = []; const runs: RemoteRoach[] = []; -const hash = (token: string) => - createHash("sha256").update(token).digest("hex"); - /** * Open a `CONNECT` tunnel to the upstream through the service at * `serviceUrl`, with the credentials of the proxy URL of a run. Returns @@ -228,7 +216,7 @@ async function recordings(tenant: string): Promise { beforeEach(async () => { liveRequests = 0; - upstream = createServer((incoming, outgoing) => { + ({ origin } = await startUpstream((incoming, outgoing) => { liveRequests += 1; if (incoming.headers["x-large"]) { outgoing.end("x".repeat(65 * 1024 * 1024)); @@ -245,29 +233,15 @@ beforeEach(async () => { serviceSecret: "service-secret-value-123", }), ); - }); - await new Promise((resolve) => - upstream.listen(0, "127.0.0.1", resolve), - ); - const address = upstream.address(); - if (!address || typeof address === "string") throw new Error("No port"); - origin = `http://127.0.0.1:${address.port}`; - directory = await mkdtemp(path.join(tmpdir(), "roach-service-")); - service = await startRoachService({ - directory, - // The upstream of the tests is on 127.0.0.1. - privateNetworks: true, - writeTokenHash: hash("write-token"), + })); + ({ service, directory } = await startTestService({ secrets: ["service-secret-value-123"], - }); + })); }); afterEach(async () => { await Promise.all(agents.splice(0).map((agent) => agent.close())); await Promise.all(runs.splice(0).map((run) => run.close().catch(() => {}))); - await service.close(); - await new Promise((resolve) => upstream.close(() => resolve())); - await rm(directory, { recursive: true, force: true }); }); describe("roach service", () => { @@ -534,46 +508,41 @@ describe("roach service", () => { // address with an IPv4 address in it is refused too, also with a // public one. const port = new URL(origin).port; - const guarded = await startRoachService({ - directory, - writeTokenHash: hash("write-token"), + const { service: guarded } = await startTestService({ + privateNetworks: false, }); - try { - for (const target of [ - origin, - `http://localhost:${port}`, - "http://[::ffff:808:808]", - "http://[::ffff:0:7f00:1]", - ]) { - const run = await startRemoteRun( - { url: guarded.url, token: "write-token" }, - { - tenant: "acme/alpha", - mode: "auto", - allow: [target], - rules: [{ name: "model", match: { url: `${target}/v1/` } }], - }, - ); - runs.push(run); - const agent = new ProxyAgent({ uri: run.url, proxyTunnel: true }); - agents.push(agent); - // One request that a rule records, and one that goes through. - for (const url of [`${target}/v1/messages`, `${target}/other`]) { - const response = await request(url, { dispatcher: agent }); - expect(response.statusCode).toBe(403); - expect(await response.body.text()).toMatch(/not a public address/); - } - // The stats tell this refusal apart from an origin not in `allow`, - // and count the requests only as refused. - const stats = await run.stats(); - expect(stats.counts).toEqual({ - model: { live: 0, missed: 0, replayed: 0 }, - }); - expect(stats.passthrough).toEqual({}); - expect(stats.refused).toEqual({ notPublic: { [target]: 2 } }); + for (const target of [ + origin, + `http://localhost:${port}`, + "http://[::ffff:808:808]", + "http://[::ffff:0:7f00:1]", + ]) { + const run = await startRemoteRun( + { url: guarded.url, token: "write-token" }, + { + tenant: "acme/alpha", + mode: "auto", + allow: [target], + rules: [{ name: "model", match: { url: `${target}/v1/` } }], + }, + ); + runs.push(run); + const agent = new ProxyAgent({ uri: run.url, proxyTunnel: true }); + agents.push(agent); + // One request that a rule records, and one that goes through. + for (const url of [`${target}/v1/messages`, `${target}/other`]) { + const response = await request(url, { dispatcher: agent }); + expect(response.statusCode).toBe(403); + expect(await response.body.text()).toMatch(/not a public address/); } - } finally { - await guarded.close(); + // The stats tell this refusal apart from an origin not in `allow`, + // and count the requests only as refused. + const stats = await run.stats(); + expect(stats.counts).toEqual({ + model: { live: 0, missed: 0, replayed: 0 }, + }); + expect(stats.passthrough).toEqual({}); + expect(stats.refused).toEqual({ notPublic: { [target]: 2 } }); } expect(liveRequests).toBe(1); }); @@ -680,17 +649,10 @@ describe("roach service", () => { it("names an old service when it returns no session URL", async () => { // A service before 0.2.0 answers `POST /session` with 204 and no body. - const old = createServer((incoming, outgoing) => { + const { origin: oldUrl } = await startUpstream((incoming, outgoing) => { incoming.resume(); outgoing.writeHead(204).end(); }); - await new Promise((resolve) => old.listen(0, "127.0.0.1", resolve)); - onTestFinished( - () => new Promise((resolve) => old.close(() => resolve())), - ); - const address = old.address(); - if (!address || typeof address === "string") throw new Error("No port"); - const oldUrl = `http://127.0.0.1:${address.port}`; await expect( connectRoach({ @@ -831,7 +793,7 @@ describe("limits of the service", () => { ); const waits = await caWaitsUntil(sent); // The keys are made in a worker, so the service answers at once. - expect(Math.max(...waits)).toBeLessThan(500); + expect(Math.max(...waits)).toBeLessThan(budget(500)); // The first key that goes past its time limit ends the run. The other // requests do not start their keys. const results = await sent; @@ -913,13 +875,7 @@ describe("limits of the service", () => { }); it("keeps the budget for other clients while one client stalls its bodies", async () => { - const local = await startRoachService({ - directory, - privateNetworks: true, - writeTokenHash: hash("write-token"), - proxyProtocol: true, - }); - onTestFinished(() => local.close()); + const { service: local } = await startTestService({ proxyProtocol: true }); const config = { tenant: "acme/alpha", mode: "replay", @@ -1182,7 +1138,11 @@ describe("limits of the service", () => { expect(failed.body).toMatch( /(key worker stopped|request took over \d+ ms).*This run has ended/s, ); - expect(Math.max(...waits)).toBeLessThan(500); + // The service sends the 16.5 MB body to the worker, and it got 511 ms + // once. A key in the service process takes seconds, or fills its heap. + // No `budget()`: coverage adds little here (about 280 ms against + // 220 ms), and 3 s would not catch a parse of the body in the service. + expect(Math.max(...waits)).toBeLessThan(1000); await expect(reader.stats()).rejects.toThrow(/404/); // The next key gets a new worker. const next = await startRun(undefined, "replay"); @@ -1301,7 +1261,7 @@ describe("limits of the service", () => { const waits = await caWaitsUntil(missed); expect((await missed).status).toBe(412); // The redaction is in the worker, and it stops after 5 s. - expect(Math.max(...waits)).toBeLessThan(500); + expect(Math.max(...waits)).toBeLessThan(budget(500)); expect(Date.now() - started).toBeLessThan(10_000); await opened.end(true); }); @@ -1375,15 +1335,11 @@ describe("limits of the service", () => { it("ends a run without a token after 60 minutes without traffic, or after 2 hours", async () => { // The sweep of the service runs each minute and reads the clock. vi.useFakeTimers({ toFake: ["setInterval", "clearInterval", "Date"] }); - const local = await startRoachService({ - directory, - privateNetworks: true, - writeTokenHash: hash("write-token"), - }); - onTestFinished(async () => { - await local.close(); + // The hooks run last first, so this runs after the service closes. + onTestFinished(() => { vi.useRealTimers(); }); + const { service: local } = await startTestService(); const start = (token: string | undefined) => startRemoteRun( { url: local.url, token }, @@ -1453,13 +1409,7 @@ describe("limits of the service", () => { it("times out slow headers, also with proxyProtocol and in a tunnel", async () => { // With `proxyProtocol`, the HTTP servers get their connections from a // TCP server, and Node did not check their time limits. - const local = await startRoachService({ - directory, - privateNetworks: true, - writeTokenHash: hash("write-token"), - proxyProtocol: true, - }); - onTestFinished(() => local.close()); + const { service: local } = await startTestService({ proxyProtocol: true }); const started = await rawRequest( local.url, "203.0.113.1", @@ -1531,13 +1481,7 @@ describe("limits of the service", () => { it("limits the runs without a token of each client", async () => { // Behind the load balancer, each connection starts with a PROXY // header. It gives the address of the client. - const local = await startRoachService({ - directory, - privateNetworks: true, - writeTokenHash: hash("write-token"), - proxyProtocol: true, - }); - onTestFinished(() => local.close()); + const { service: local } = await startTestService({ proxyProtocol: true }); const config = { tenant: "acme/alpha", mode: "replay", diff --git a/packages/service/tests/vitest.test.ts b/packages/service/tests/vitest.test.ts index 98eba1b..2f25afb 100644 --- a/packages/service/tests/vitest.test.ts +++ b/packages/service/tests/vitest.test.ts @@ -5,8 +5,6 @@ * `roach.json`, against a service and an upstream in this process. */ import { spawn } from "node:child_process"; -import { createHash } from "node:crypto"; -import { createServer, type Server } from "node:http"; import { mkdtemp, readdir, @@ -18,13 +16,12 @@ import { import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { startRoachService, type RoachService } from "../src/service.ts"; +import type { RoachService } from "../src/service.ts"; +import { startTestService, startUpstream, TOKEN } from "./harness.ts"; -const TOKEN = "write-token"; const TENANT = "acme/vitest"; const ROOT = path.resolve(import.meta.dirname, ".."); -let upstream: Server; let origin: string; let liveRequests: string[]; let directory: string; @@ -120,7 +117,7 @@ async function recordedSessions(): Promise { beforeEach(async () => { liveRequests = []; - upstream = createServer((incoming, outgoing) => { + ({ origin } = await startUpstream((incoming, outgoing) => { const chunks: Buffer[] = []; incoming.on("data", (chunk: Buffer) => chunks.push(chunk)); incoming.on("end", () => { @@ -128,19 +125,8 @@ beforeEach(async () => { outgoing.writeHead(200, { "content-type": "application/json" }); outgoing.end(`{"n":${liveRequests.length}}`); }); - }); - await new Promise((resolve) => - upstream.listen(0, "127.0.0.1", resolve), - ); - const address = upstream.address(); - if (!address || typeof address === "string") throw new Error("No port"); - origin = `http://127.0.0.1:${address.port}`; - directory = await mkdtemp(path.join(tmpdir(), "roach-")); - service = await startRoachService({ - directory, - privateNetworks: true, - writeTokenHash: createHash("sha256").update(TOKEN).digest("hex"), - }); + })); + ({ service, directory } = await startTestService()); project = await mkdtemp(path.join(tmpdir(), "roach-vitest-")); await symlink( @@ -163,9 +149,6 @@ beforeEach(async () => { }); afterEach(async () => { - await service.close(); - await new Promise((resolve) => upstream.close(() => resolve())); - await rm(directory, { recursive: true, force: true }); await rm(project, { recursive: true, force: true }); }); diff --git a/packages/service/vitest.config.ts b/packages/service/vitest.config.ts index ae8cebb..a833785 100644 --- a/packages/service/vitest.config.ts +++ b/packages/service/vitest.config.ts @@ -1,8 +1,16 @@ import { defineProject } from "vitest/config"; +/** Tells whether vitest runs with `--coverage`, which makes the service slower. */ +const coverage = process.argv.some((arg) => + /^--coverage(\.enabled(=true)?)?$/.test(arg), +); + export default defineProject({ test: { include: ["tests/**/*.test.ts"], testTimeout: 30_000, + // The timing checks allow 3 times more time with coverage + // (`budget()` in `tests/harness.ts`). + env: { ROACH_TEST_SLOWDOWN: coverage ? "3" : "1" }, }, });