From 63abd132a3024fb569b855453bb0bde77c748e09 Mon Sep 17 00:00:00 2001 From: Petra Jaros Date: Tue, 8 Oct 2024 16:22:22 -0400 Subject: [PATCH 01/10] Apply TypeScript config to `test` as well --- tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 89d3edc..e85fbd1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { - "include": ["src"], + "include": ["src", "test"], "compilerOptions": { "declaration": true, "declarationMap": true, From e08521e2f67814f93e27b644642cf3f1d4796ff2 Mon Sep 17 00:00:00 2001 From: Petra Jaros Date: Tue, 8 Oct 2024 16:22:33 -0400 Subject: [PATCH 02/10] Ignore `.wrangler` --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 145ef73..e55ad2f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ node_modules dist .mf .env -.dev.vars \ No newline at end of file +.dev.vars +.wrangler From b43c1ae82e7723e05e7fc89eb3a7678688948779 Mon Sep 17 00:00:00 2001 From: Petra Jaros Date: Tue, 8 Oct 2024 16:22:59 -0400 Subject: [PATCH 03/10] Refactor promises in Wrangler test harness --- test/fixtures/worker-fixture.js | 26 ++++---- test/helpers/run-wrangler.js | 112 ++++++++++++++------------------ 2 files changed, 64 insertions(+), 74 deletions(-) diff --git a/test/fixtures/worker-fixture.js b/test/fixtures/worker-fixture.js index 867a17c..e7c032a 100644 --- a/test/fixtures/worker-fixture.js +++ b/test/fixtures/worker-fixture.js @@ -6,6 +6,12 @@ import { runWranglerDev } from '../helpers/run-wrangler.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) +/** + * The wrangler environment to use for the test worker. + * @type {string} + */ +const wranglerEnv = process.env.WRANGLER_ENV || 'integration' + /** * Worker information object * @typedef {Object} WorkerInfo @@ -13,20 +19,13 @@ const __dirname = path.dirname(__filename) * @property {number | undefined} port - The port of the test worker. * @property {() => Promise | undefined} stop - Function to stop the test worker. * @property {() => string | undefined} getOutput - Function to get the output of the test worker. - * @property {string} wranglerEnv - The wrangler environment to use for the test worker. */ /** * Worker information object - * @type {WorkerInfo} + * @type {WorkerInfo | undefined} */ -const workerInfo = { - ip: undefined, - port: undefined, - stop: undefined, - getOutput: undefined, - wranglerEnv: process.env.WRANGLER_ENV || 'integration' -} +let workerInfo; /** * Sets up the test worker. @@ -34,13 +33,12 @@ const workerInfo = { */ export const mochaGlobalSetup = async () => { try { - const result = await runWranglerDev( + workerInfo = await runWranglerDev( resolve(__dirname, '../../'), // The directory of the worker with the wrangler.toml ['--local'], process.env, - workerInfo.wranglerEnv + wranglerEnv ) - Object.assign(workerInfo, result) console.log(`Output: ${await workerInfo.getOutput()}`) console.log('WorkerInfo:', workerInfo) console.log('Test worker started!') @@ -55,6 +53,9 @@ export const mochaGlobalSetup = async () => { * @returns {Promise} */ export const mochaGlobalTeardown = async () => { + // If the worker is not running, nothing to do. + if (!workerInfo) return; + try { const { stop } = workerInfo await stop?.() @@ -71,5 +72,6 @@ export const mochaGlobalTeardown = async () => { * @returns {WorkerInfo} */ export function getWorkerInfo () { + if (!workerInfo) throw new Error('Worker not running.'); return workerInfo } diff --git a/test/helpers/run-wrangler.js b/test/helpers/run-wrangler.js index 3bc3cc9..f9beea6 100644 --- a/test/helpers/run-wrangler.js +++ b/test/helpers/run-wrangler.js @@ -62,70 +62,58 @@ async function runLongLivedWrangler ( cwd, env ) { - let settledReadyPromise = false - /** @type {(value: { ip: string port: number }) => void} */ - let resolveReadyPromise - /** @type {(reason: unknown) => void} */ - let rejectReadyPromise - - const ready = new Promise((resolve, reject) => { - resolveReadyPromise = resolve - rejectReadyPromise = reject - }) - - const wranglerProcess = fork(wranglerEntryPath, command, { - stdio: ['ignore', /* stdout */ 'pipe', /* stderr */ 'pipe', 'ipc'], - cwd, - env: { ...process.env, ...env, PWD: cwd } - }).on('message', (message) => { - if (settledReadyPromise) return - settledReadyPromise = true - clearTimeout(timeoutHandle) - resolveReadyPromise(JSON.parse(message.toString())) - }) + return new Promise((resolve, reject) => { + const wranglerProcess = fork(wranglerEntryPath, command, { + stdio: ['ignore', /* stdout */ 'pipe', /* stderr */ 'pipe', 'ipc'], + cwd, + env: { ...process.env, ...env, PWD: cwd } + }).on('message', (messageJSON) => { + clearTimeout(timeoutHandle) + /** @type {{ ip: string, port: number }} */ + const message = JSON.parse(messageJSON.toString()) + resolve({...message, stop, getOutput, clearOutput}) + }) - const chunks = [] - wranglerProcess.stdout?.on('data', (chunk) => { - chunks.push(chunk) - }) - wranglerProcess.stderr?.on('data', (chunk) => { - chunks.push(chunk) - }) - const getOutput = () => Buffer.concat(chunks).toString() - const clearOutput = () => (chunks.length = 0) + /** @type {Buffer[]} */ + const chunks = [] + wranglerProcess.stdout?.on('data', (chunk) => { + chunks.push(chunk) + }) + wranglerProcess.stderr?.on('data', (chunk) => { + chunks.push(chunk) + }) + const getOutput = () => Buffer.concat(chunks).toString() + const clearOutput = () => (chunks.length = 0) - const timeoutHandle = setTimeout(() => { - if (settledReadyPromise) return - settledReadyPromise = true - const separator = '='.repeat(80) - const message = [ - 'Timed out starting long-lived Wrangler:', - separator, - getOutput(), - separator - ].join('\n') - rejectReadyPromise(new Error(message)) - }, 50_000) + const timeoutHandle = setTimeout(() => { + const separator = '='.repeat(80) + const message = [ + 'Timed out starting long-lived Wrangler:', + separator, + getOutput(), + separator + ].join('\n') + reject(new Error(message)) + }, 50_000) - async function stop () { - return new Promise((resolve) => { - assert( - wranglerProcess.pid, - `Command "${command.join(' ')}" had no process id` - ) - treeKill(wranglerProcess.pid, (e) => { - if (e) { - console.error( - 'Failed to kill command: ' + command.join(' '), - wranglerProcess.pid, - e - ) - } - resolve() + /** @type {WranglerProcessInfo['stop']} */ + async function stop () { + return new Promise((resolve) => { + assert( + wranglerProcess.pid, + `Command "${command.join(' ')}" had no process id` + ) + treeKill(wranglerProcess.pid, (e) => { + if (e) { + console.error( + 'Failed to kill command: ' + command.join(' '), + wranglerProcess.pid, + e + ) + } + resolve() + }) }) - }) - } - - const { ip, port } = await ready - return { ip, port, stop, getOutput, clearOutput } + } + }) } From bd5ad7d6823810bd0096a501cc2ce78b53894e9b Mon Sep 17 00:00:00 2001 From: Petra Jaros Date: Wed, 9 Oct 2024 10:50:20 -0400 Subject: [PATCH 04/10] Address linter Dang lack of Prettier... --- test/fixtures/worker-fixture.js | 8 ++++---- test/helpers/run-wrangler.js | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/test/fixtures/worker-fixture.js b/test/fixtures/worker-fixture.js index e7c032a..8c7eeaa 100644 --- a/test/fixtures/worker-fixture.js +++ b/test/fixtures/worker-fixture.js @@ -6,7 +6,7 @@ import { runWranglerDev } from '../helpers/run-wrangler.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -/** +/** * The wrangler environment to use for the test worker. * @type {string} */ @@ -25,7 +25,7 @@ const wranglerEnv = process.env.WRANGLER_ENV || 'integration' * Worker information object * @type {WorkerInfo | undefined} */ -let workerInfo; +let workerInfo /** * Sets up the test worker. @@ -54,7 +54,7 @@ export const mochaGlobalSetup = async () => { */ export const mochaGlobalTeardown = async () => { // If the worker is not running, nothing to do. - if (!workerInfo) return; + if (!workerInfo) return try { const { stop } = workerInfo @@ -72,6 +72,6 @@ export const mochaGlobalTeardown = async () => { * @returns {WorkerInfo} */ export function getWorkerInfo () { - if (!workerInfo) throw new Error('Worker not running.'); + if (!workerInfo) throw new Error('Worker not running.') return workerInfo } diff --git a/test/helpers/run-wrangler.js b/test/helpers/run-wrangler.js index f9beea6..0535f7c 100644 --- a/test/helpers/run-wrangler.js +++ b/test/helpers/run-wrangler.js @@ -71,7 +71,7 @@ async function runLongLivedWrangler ( clearTimeout(timeoutHandle) /** @type {{ ip: string, port: number }} */ const message = JSON.parse(messageJSON.toString()) - resolve({...message, stop, getOutput, clearOutput}) + resolve({ ...message, stop, getOutput, clearOutput }) }) /** @type {Buffer[]} */ From 95736e5a10b0e1b0c1e7aed9160144c9d6b6d93c Mon Sep 17 00:00:00 2001 From: Petra Jaros Date: Thu, 17 Oct 2024 10:38:53 -0400 Subject: [PATCH 05/10] Restructure rate-limiter tests * Make types work * Test through with stubs more and less with mocks * Use `@import` a bunch for readability * Avoid `let`s in favor of `const`s * Separate the rate-limiter middleware's expected environment interface from the general environment, and don't combine them until we actually combine the middlewares into the stack. --- package-lock.json | 30 ++ package.json | 3 + src/bindings.d.ts | 24 +- src/handlers/rate-limiter.js | 34 +- src/handlers/rate-limiter.types.ts | 22 ++ test/unit/middlewares/rate-limiter.spec.js | 416 +++++++++++++-------- 6 files changed, 333 insertions(+), 196 deletions(-) create mode 100644 src/handlers/rate-limiter.types.ts diff --git a/package-lock.json b/package-lock.json index fc9f02a..07c7fe5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,9 @@ }, "devDependencies": { "@cloudflare/workers-types": "^4.20231218.0", + "@types/chai": "^5.0.0", + "@types/mocha": "^10.0.9", + "@types/sinon": "^17.0.3", "@ucanto/principal": "^8.1.0", "@web3-storage/content-claims": "^5.0.0", "@web3-storage/public-bucket": "^1.1.0", @@ -3252,6 +3255,12 @@ "@stablelib/wipe": "^1.0.1" } }, + "node_modules/@types/chai": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.0.0.tgz", + "integrity": "sha512-+DwhEHAaFPPdJ2ral3kNHFQXnTfscEEFsUxzD+d7nlcLrFK23JtNjH71RGasTcHb88b4vVi4mTyfpf8u2L8bdA==", + "dev": true + }, "node_modules/@types/dns-packet": { "version": "5.6.5", "resolved": "https://registry.npmjs.org/@types/dns-packet/-/dns-packet-5.6.5.tgz", @@ -3278,6 +3287,12 @@ "integrity": "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==", "dev": true }, + "node_modules/@types/mocha": { + "version": "10.0.9", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.9.tgz", + "integrity": "sha512-sicdRoWtYevwxjOHNMPTl3vSfJM6oyW8o1wXeI7uww6b6xHg8eBznQDNSGBCDJmsE8UMxP05JgZRtsKbTqt//Q==", + "dev": true + }, "node_modules/@types/node": { "version": "20.14.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.0.tgz", @@ -3300,6 +3315,21 @@ "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.1.tgz", "integrity": "sha512-xoDlM2S4ortawSWORYqsdU+2rxdh4LRW9ytc3zmT37RIKQh6IHyKwwtKhKis9ah8ol07DCkZxPt8BBvPjC6v4g==" }, + "node_modules/@types/sinon": { + "version": "17.0.3", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.3.tgz", + "integrity": "sha512-j3uovdn8ewky9kRBG19bOwaZbexJu/XjtkHyjvUgt4xfPFz18dcORIMqnYh66Fx3Powhcr85NT5+er3+oViapw==", + "dev": true, + "dependencies": { + "@types/sinonjs__fake-timers": "*" + } + }, + "node_modules/@types/sinonjs__fake-timers": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz", + "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==", + "dev": true + }, "node_modules/@ucanto/client": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/@ucanto/client/-/client-9.0.1.tgz", diff --git a/package.json b/package.json index 08a2cc2..0cd60c3 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,9 @@ }, "devDependencies": { "@cloudflare/workers-types": "^4.20231218.0", + "@types/chai": "^5.0.0", + "@types/mocha": "^10.0.9", + "@types/sinon": "^17.0.3", "@ucanto/principal": "^8.1.0", "@web3-storage/content-claims": "^5.0.0", "@web3-storage/public-bucket": "^1.1.0", diff --git a/src/bindings.d.ts b/src/bindings.d.ts index cd36dde..68c1b71 100644 --- a/src/bindings.d.ts +++ b/src/bindings.d.ts @@ -1,29 +1,11 @@ -import type { R2Bucket, KVNamespace, RateLimit } from '@cloudflare/workers-types' +import type { R2Bucket } from '@cloudflare/workers-types' import { CID } from '@web3-storage/gateway-lib/handlers' -import { RATE_LIMIT_EXCEEDED } from './constants.js' +import { Environment as RateLimiterEnvironment } from './handlers/rate-limiter.types.ts' -export { } - -export interface Environment { +export interface Environment extends RateLimiterEnvironment { VERSION: string - DEBUG: string CARPARK: R2Bucket CONTENT_CLAIMS_SERVICE_URL?: string - ACCOUNTING_SERVICE_URL: string - RATE_LIMITER: RateLimit - AUTH_TOKEN_METADATA: KVNamespace - FF_RATE_LIMITER_ENABLED: string -} - -export type RateLimitExceeded = typeof RATE_LIMIT_EXCEEDED[keyof typeof RATE_LIMIT_EXCEEDED] - -export interface RateLimitService { - check: (cid: CID, req: Request) => Promise -} - -export interface TokenMetadata { - locationClaim?: unknown // TODO: figure out the right type to use for this - we probably need it for the private data case to verify auth - invalid?: boolean } export interface AccountingService { diff --git a/src/handlers/rate-limiter.js b/src/handlers/rate-limiter.js index 0b942d1..3c55190 100644 --- a/src/handlers/rate-limiter.js +++ b/src/handlers/rate-limiter.js @@ -3,10 +3,14 @@ import { RATE_LIMIT_EXCEEDED } from '../constants.js' import { Accounting } from '../services/accounting.js' /** - * @typedef {import('../bindings.js').Environment} Environment - * @typedef {import('@web3-storage/gateway-lib').IpfsUrlContext} IpfsUrlContext - * @typedef {import('../bindings.js').RateLimitService} RateLimitService - * @typedef {import('../bindings.js').RateLimitExceeded} RateLimitExceeded + * @import { Context, IpfsUrlContext, Middleware } from '@web3-storage/gateway-lib' + * @import { R2Bucket, KVNamespace, RateLimit } from '@cloudflare/workers-types' + * @import { + * Environment, + * TokenMetadata, + * RateLimitService, + * RateLimitExceeded + * } from './rate-limiter.types.js' */ /** @@ -15,7 +19,7 @@ import { Accounting } from '../services/accounting.js' * it can be enabled or disabled using the FF_RATE_LIMITER_ENABLED flag. * Every successful request is recorded in the accounting service. * - * @type {import('@web3-storage/gateway-lib').Middleware} + * @type {Middleware} */ export function withRateLimit (handler) { return async (req, env, ctx) => { @@ -29,7 +33,9 @@ export function withRateLimit (handler) { if (isRateLimitExceeded === RATE_LIMIT_EXCEEDED.YES) { throw new HttpError('Too Many Requests', { status: 429 }) } else { - const accounting = Accounting.create({ serviceURL: env.ACCOUNTING_SERVICE_URL }) + const accounting = Accounting.create({ + serviceURL: env.ACCOUNTING_SERVICE_URL + }) // NOTE: non-blocking call to the accounting service ctx.waitUntil(accounting.record(dataCid, req)) return handler(req, env, ctx) @@ -89,7 +95,7 @@ async function getAuthorizationTokenFromRequest (request) { } /** - * @param {import('@cloudflare/workers-types').RateLimit} rateLimitAPI + * @param {RateLimit} rateLimitAPI * @param {import('multiformats/cid').CID} cid * @returns {Promise} * @throws {Error} if no rate limit API is found @@ -108,10 +114,10 @@ async function isRateLimited (rateLimitAPI, cid) { } /** - * @param {import("../bindings.js").Environment} env + * @param {Environment} env * @param {string} authToken - * @param {import('@web3-storage/gateway-lib').Context} ctx - * @returns {Promise} + * @param {Context} ctx + * @returns {Promise} */ async function getTokenMetadata (env, authToken, ctx) { const cachedValue = await env.AUTH_TOKEN_METADATA.get(authToken) @@ -121,7 +127,9 @@ async function getTokenMetadata (env, authToken, ctx) { return decode(cachedValue) } - const accounting = Accounting.create({ serviceURL: env.ACCOUNTING_SERVICE_URL }) + const accounting = Accounting.create({ + serviceURL: env.ACCOUNTING_SERVICE_URL + }) const tokenMetadata = await accounting.getTokenMetadata(authToken) if (tokenMetadata) { // NOTE: non-blocking call to the auth token metadata cache @@ -134,7 +142,7 @@ async function getTokenMetadata (env, authToken, ctx) { /** * @param {string} s - * @returns {import('../bindings.js').TokenMetadata} + * @returns {TokenMetadata} */ function decode (s) { // TODO should this be dag-json? @@ -142,7 +150,7 @@ function decode (s) { } /** - * @param {import('../bindings.js').TokenMetadata} m + * @param {TokenMetadata} m * @returns {string} */ function encode (m) { diff --git a/src/handlers/rate-limiter.types.ts b/src/handlers/rate-limiter.types.ts new file mode 100644 index 0000000..35842a8 --- /dev/null +++ b/src/handlers/rate-limiter.types.ts @@ -0,0 +1,22 @@ +import { CID } from '@web3-storage/gateway-lib/handlers' +import { Environment as MiddlewareEnvironment } from '@web3-storage/gateway-lib' +import { KVNamespace, RateLimit } from '@cloudflare/workers-types' +import { RATE_LIMIT_EXCEEDED } from './../constants.js' + +export interface Environment extends MiddlewareEnvironment { + ACCOUNTING_SERVICE_URL: string + RATE_LIMITER: RateLimit + AUTH_TOKEN_METADATA: KVNamespace + FF_RATE_LIMITER_ENABLED: string +} + +export interface TokenMetadata { + locationClaim?: unknown // TODO: figure out the right type to use for this - we probably need it for the private data case to verify auth + invalid?: boolean +} + +export type RateLimitExceeded = typeof RATE_LIMIT_EXCEEDED[keyof typeof RATE_LIMIT_EXCEEDED] + +export interface RateLimitService { + check: (cid: CID, req: Request) => Promise +} diff --git a/test/unit/middlewares/rate-limiter.spec.js b/test/unit/middlewares/rate-limiter.spec.js index 4e6ca86..0d90346 100644 --- a/test/unit/middlewares/rate-limiter.spec.js +++ b/test/unit/middlewares/rate-limiter.spec.js @@ -1,204 +1,296 @@ -/* eslint-disable no-unused-expressions */ -import { describe, it, beforeEach, afterEach } from 'mocha' +/* eslint-disable no-unused-expressions + --- + `no-unused-expressions` doesn't understand that several of Chai's assertions + are implemented as getters rather than explicit function calls; it thinks + the assertions are unused expressions. */ +import { describe, it, afterEach } from 'mocha' import { expect } from 'chai' import sinon from 'sinon' import { withRateLimit } from '../../../src/handlers/rate-limiter.js' import { HttpError } from '@web3-storage/gateway-lib/util' +import { CID } from 'multiformats' +import { sha256 } from 'multiformats/hashes/sha2' +import * as raw from 'multiformats/codecs/raw' +import { inspect } from 'util' -describe('withRateLimits', () => { - let env - let rateLimiter - let handler - let ctx - let sandbox - - beforeEach(() => { - sandbox = sinon.createSandbox() - rateLimiter = { - limit: sandbox.stub() - } - env = { - RATE_LIMITER: rateLimiter, - FF_RATE_LIMITER_ENABLED: 'true', - ACCOUNTING_SERVICE_URL: 'http://example.com', - AUTH_TOKEN_METADATA: { - get: sandbox.stub(), - put: sandbox.stub() - } - } - handler = sandbox.stub() - ctx = { - dataCid: 'test-cid', - waitUntil: sandbox.stub() +/** + * @import { SinonStub } from 'sinon' + * @import { Environment } from '../../../src/handlers/rate-limiter.types.js' + * @import { + * Handler, + * IpfsUrlContext, + * Context as MiddlewareContext, + * Environment as MiddlewareEnvironment, + * } from '@web3-storage/gateway-lib' + */ + +/** + * Resolves to the reason for the rejection of a promise, or `undefined` if the + * promise resolves. + * @param {Promise} promise + * @returns {Promise} + */ +const rejection = (promise) => promise.then(() => {}).catch((err) => err) + +/** + * Asserts that a value is an instance of a class, in a way that TypeScript can + * understand too. Just a simple wrapper around Chai's `instanceOf`, typed as an + * assertion function. + * + * @template {Function} Class + * @param {unknown} value + * @param {Class} aClass + * @returns {asserts value is InstanceType} + */ +const expectToBeInstanceOf = (value, aClass) => { + expect(value).to.be.instanceOf(aClass) +} + +const sandbox = sinon.createSandbox() + +/** + * Creates a Sinon stub which has no default behavior and throws an error if + * called without a specific behavior being set. + * + * @example + * const toWord = stub('toWord') + * toWord.withArgs(1).returns('one') + * toWord.withArgs(2).returns('two') + * + * toWord(1) // => 'one' + * toWord(2) // => 'two' + * toWord(3) // => Error: Unexpected call to toWord with args: 3 + * + * @template {readonly any[]} TArgs + * @template {any} R + * @param {string} name + * @returns {sinon.SinonStub} + */ +const stub = (name) => + /** @type {sinon.SinonStub} */ ( + /** @type {unknown} */ + ( + sandbox.stub().callsFake((...args) => { + throw new Error( + `Unexpected call to ${name} with args: ${inspect(args)}` + ) + }) + ) + ) + +/** + * Same as {@link stub}, but with concessions for overloaded functions. + * TypeScript cannot properly infer from the type of overloaded functions, and + * instead infers from the last overload. This can cause surprising results. + * `stubOverloaded` returns a stub typed with `unknown` arg and return types, + * but also typed as the original function, with all its overloads intact. + * Sinon calls will lack type information, but regular use of the function + * will be properly typed. + * + * @template {Function} Fn + * @param {string} name + * @returns {Fn & sinon.SinonStub} + */ +const stubOverloaded = (name) => + /** @type {Fn & sinon.SinonStub} */ ( + /** @type {unknown} */ + ( + sandbox.stub().callsFake((...args) => { + throw new Error( + `Unexpected call to ${name} with args: ${inspect(args)}` + ) + }) + ) + ) + +/** @typedef {Handler} RequestHandler */ +/** @type {SinonStub, ReturnType>} */ +const innerHandler = stub('nextHandler') + +/** + * Creates a request with an optional authorization header. + * + * @param {Object} [options] + * @param {string} [options.authorization] The value for the `Authorization` + * header, if any. + */ +const createRequest = async ({ authorization } = {}) => + new Request('http://doesnt-matter.com/', { + headers: new Headers( + authorization ? { Authorization: authorization } : {} + ) + }) + +const env = + /** @satisfies {Environment} */ + ({ + DEBUG: 'false', + ACCOUNTING_SERVICE_URL: 'http://example.com', + RATE_LIMITER: { + limit: stub('limit') + }, + FF_RATE_LIMITER_ENABLED: 'true', + AUTH_TOKEN_METADATA: { + get: stubOverloaded('get'), + getWithMetadata: stubOverloaded('getWithMetadata'), + put: stub('put'), + list: stub('list'), + delete: stub('delete') } }) +const ctx = + /** @satisfies {IpfsUrlContext} */ + ({ + // Doesn't matter what the CID is, as long as it's consistent. + dataCid: CID.create( + 1, + raw.code, + await sha256.digest(new Uint8Array([1, 2, 3])) + ), + waitUntil: stub('waitUntil').returns(undefined), + path: '', + searchParams: new URLSearchParams() + }) + +describe('withRateLimits', async () => { afterEach(() => { - sandbox.restore() + sandbox.reset() }) it('should call next if no auth token and rate limit is not exceeded', async () => { - rateLimiter.limit.resolves({ success: true }) + const request = await createRequest() - const request = { - headers: { - get: sandbox.stub() - } - } - const wrappedHandler = withRateLimit(handler) + env.RATE_LIMITER.limit + .withArgs({ key: ctx.dataCid.toString() }) + .resolves({ success: true }) - await wrappedHandler(request, env, ctx) + const innerResponse = new Response() + innerHandler.withArgs(request, env, ctx).resolves(innerResponse) - expect(rateLimiter.limit.calledOnce).to.be.true - expect(rateLimiter.limit.calledWith({ key: ctx.dataCid.toString() })).to.be.true - expect(handler.calledOnce).to.be.true - expect(handler.calledWith(request, env, ctx)).to.be.true + const wrappedHandler = withRateLimit(innerHandler) + const response = await wrappedHandler(request, env, ctx) + + expect(innerHandler.calledOnce).to.be.true + expect(innerHandler.calledWith(request, env, ctx)).to.be.true + expect(response).to.equal(innerResponse) }) it('should throw an error if no auth token and rate limit is exceeded', async () => { - rateLimiter.limit.resolves({ success: false }) + const request = await createRequest() - const request = { - headers: { - get: sandbox.stub() - } - } - const wrappedHandler = withRateLimit(handler) - - try { - await wrappedHandler(request, env, ctx) - throw new Error('Expected error was not thrown') - } catch (err) { - expect(rateLimiter.limit.calledOnce).to.be.true - expect(rateLimiter.limit.calledWith({ key: ctx.dataCid.toString() })).to.be.true - expect(handler.notCalled).to.be.true - expect(err).to.be.instanceOf(HttpError) - expect(err.message).to.equal('Too Many Requests') - } + env.RATE_LIMITER.limit + .withArgs({ key: ctx.dataCid.toString() }) + .resolves({ success: false }) + + const wrappedHandler = withRateLimit(innerHandler) + const error = await rejection(wrappedHandler(request, env, ctx)) + + expect(innerHandler.notCalled).to.be.true + expectToBeInstanceOf(error, HttpError) + expect(error.status).to.equal(429) + expect(error.message).to.equal('Too Many Requests') }) it('should call next if auth token is present but no token metadata and rate limit is not exceeded', async () => { - rateLimiter.limit.resolves({ success: true }) - env.AUTH_TOKEN_METADATA.get.resolves(null) - - const request = { - headers: { - get: sandbox.stub().callsFake((header) => { - if (header === 'Authorization') { - return 'Bearer test-token' - } - return null - }) - } - } - const wrappedHandler = withRateLimit(handler) + const request = await createRequest({ + authorization: 'Bearer test-token' + }) + + const innerResponse = new Response() + innerHandler.withArgs(request, env, ctx).resolves(innerResponse) - await wrappedHandler(request, env, ctx) + env.RATE_LIMITER.limit + .withArgs({ key: ctx.dataCid.toString() }) + .resolves({ success: true }) + env.AUTH_TOKEN_METADATA.get.withArgs('test-token').resolves(null) - expect(rateLimiter.limit.calledOnce).to.be.true - expect(rateLimiter.limit.calledWith({ key: ctx.dataCid.toString() })).to.be.true - expect(handler.calledOnce).to.be.true - expect(handler.calledWith(request, env, ctx)).to.be.true + const wrappedHandler = withRateLimit(innerHandler) + const response = await wrappedHandler(request, env, ctx) + + expect(response).to.equal(innerResponse) }) it('should throw an error if auth token is present but no token metadata and rate limit is exceeded', async () => { - rateLimiter.limit.resolves({ success: false }) - env.AUTH_TOKEN_METADATA.get.resolves(null) - - const request = { - headers: { - get: sandbox.stub().callsFake((header) => { - if (header === 'Authorization') { - return 'Bearer test-token' - } - return null - }) - } - } - const wrappedHandler = withRateLimit(handler) - - try { - await wrappedHandler(request, env, ctx) - throw new Error('Expected error was not thrown') - } catch (err) { - expect(rateLimiter.limit.calledOnce).to.be.true - expect(rateLimiter.limit.calledWith({ key: ctx.dataCid.toString() })).to.be.true - expect(handler.notCalled).to.be.true - expect(err).to.be.instanceOf(HttpError) - expect(err.message).to.equal('Too Many Requests') - } + const request = await createRequest({ + authorization: 'Bearer test-token' + }) + + env.RATE_LIMITER.limit + .withArgs({ key: ctx.dataCid.toString() }) + .resolves({ success: false }) + env.AUTH_TOKEN_METADATA.get.withArgs('test-token').resolves(null) + + const wrappedHandler = withRateLimit(innerHandler) + + const error = await rejection(wrappedHandler(request, env, ctx)) + + expect(innerHandler.notCalled).to.be.true + expectToBeInstanceOf(error, HttpError) + expect(error.status).to.equal(429) + expect(error.message).to.equal('Too Many Requests') }) it('should call next if auth token is present and token metadata is invalid but rate limit is not exceeded', async () => { - rateLimiter.limit.resolves({ success: true }) - env.AUTH_TOKEN_METADATA.get.resolves(JSON.stringify({ invalid: true })) - - const request = { - headers: { - get: sandbox.stub().callsFake((header) => { - if (header === 'Authorization') { - return 'Bearer test-token' - } - return null - }) - } - } - const wrappedHandler = withRateLimit(handler) + const request = await createRequest({ + authorization: 'Bearer test-token' + }) + + const innerResponse = new Response() + innerHandler.withArgs(request, env, ctx).resolves(innerResponse) + + env.RATE_LIMITER.limit + .withArgs({ key: ctx.dataCid.toString() }) + .resolves({ success: true }) + env.AUTH_TOKEN_METADATA.get + .withArgs('test-token') + .resolves(JSON.stringify({ invalid: true })) + + const wrappedHandler = withRateLimit(innerHandler) - await wrappedHandler(request, env, ctx) + const response = await wrappedHandler(request, env, ctx) - expect(rateLimiter.limit.calledOnce).to.be.true - expect(rateLimiter.limit.calledWith({ key: ctx.dataCid.toString() })).to.be.true - expect(handler.calledOnce).to.be.true - expect(handler.calledWith(request, env, ctx)).to.be.true + expect(response).to.equal(innerResponse) }) it('should throw an error if auth token is present and token metadata is invalid and rate limit is exceeded', async () => { - rateLimiter.limit.resolves({ success: false }) - env.AUTH_TOKEN_METADATA.get.resolves(JSON.stringify({ invalid: true })) - - const request = { - headers: { - get: sandbox.stub().callsFake((header) => { - if (header === 'Authorization') { - return 'Bearer test-token' - } - return null - }) - } - } - const wrappedHandler = withRateLimit(handler) - - try { - await wrappedHandler(request, env, ctx) - throw new Error('Expected error was not thrown') - } catch (err) { - expect(rateLimiter.limit.calledOnce).to.be.true - expect(rateLimiter.limit.calledWith({ key: ctx.dataCid.toString() })).to.be.true - expect(handler.notCalled).to.be.true - expect(err).to.be.instanceOf(HttpError) - expect(err.message).to.equal('Too Many Requests') - } + const request = await createRequest({ + authorization: 'Bearer test-token' + }) + + env.RATE_LIMITER.limit + .withArgs({ key: ctx.dataCid.toString() }) + .resolves({ success: false }) + env.AUTH_TOKEN_METADATA.get + .withArgs('test-token') + .resolves(JSON.stringify({ invalid: true })) + + const wrappedHandler = withRateLimit(innerHandler) + + const error = await rejection(wrappedHandler(request, env, ctx)) + + expect(innerHandler.notCalled).to.be.true + expectToBeInstanceOf(error, HttpError) + expect(error.status).to.equal(429) + expect(error.message).to.equal('Too Many Requests') }) it('should call next if auth token is present and token metadata is valid', async () => { - env.AUTH_TOKEN_METADATA.get.resolves(JSON.stringify({ invalid: false })) - - const request = { - headers: { - get: sandbox.stub().callsFake((header) => { - if (header === 'Authorization') { - return 'Bearer test-token' - } - return null - }) - } - } - const wrappedHandler = withRateLimit(handler) + const request = await createRequest({ + authorization: 'Bearer test-token' + }) + + const innerResponse = new Response() + innerHandler.withArgs(request, env, ctx).resolves(innerResponse) + + env.AUTH_TOKEN_METADATA.get + .withArgs('test-token') + .resolves(JSON.stringify({ invalid: false })) + + const wrappedHandler = withRateLimit(innerHandler) - await wrappedHandler(request, env, ctx) + const response = await wrappedHandler(request, env, ctx) - expect(handler.calledOnce).to.be.true - expect(handler.calledWith(request, env, ctx)).to.be.true + expect(response).to.equal(innerResponse) }) }) From fc8297096d2e7e75f7bb6281ea736ac921fdc229 Mon Sep 17 00:00:00 2001 From: Petra Jaros Date: Thu, 17 Oct 2024 10:54:08 -0400 Subject: [PATCH 06/10] Move car-block's `Environment` out as well --- src/bindings.d.ts | 5 ++--- src/handlers/car-block.js | 9 ++++++--- src/handlers/car-block.types.ts | 6 ++++++ 3 files changed, 14 insertions(+), 6 deletions(-) create mode 100644 src/handlers/car-block.types.ts diff --git a/src/bindings.d.ts b/src/bindings.d.ts index 68c1b71..6f12378 100644 --- a/src/bindings.d.ts +++ b/src/bindings.d.ts @@ -1,10 +1,9 @@ -import type { R2Bucket } from '@cloudflare/workers-types' import { CID } from '@web3-storage/gateway-lib/handlers' import { Environment as RateLimiterEnvironment } from './handlers/rate-limiter.types.ts' +import { Environment as CarBlockEnvironment } from './handlers/car-block.types.ts' -export interface Environment extends RateLimiterEnvironment { +export interface Environment extends CarBlockEnvironment, RateLimiterEnvironment { VERSION: string - CARPARK: R2Bucket CONTENT_CLAIMS_SERVICE_URL?: string } diff --git a/src/handlers/car-block.js b/src/handlers/car-block.js index d8d027a..80636c7 100644 --- a/src/handlers/car-block.js +++ b/src/handlers/car-block.js @@ -7,14 +7,17 @@ import { base58btc } from 'multiformats/bases/base58' import { CAR_CODE } from '../constants.js' /** - * @typedef {import('@web3-storage/gateway-lib').IpfsUrlContext} CarBlockHandlerContext - * @typedef {{ offset: number, length?: number } | { offset?: number, length: number } | { suffix: number }} Range + * @import { Context, IpfsUrlContext as CarBlockHandlerContext, Handler } from '@web3-storage/gateway-lib' + * @import { R2Bucket, KVNamespace, RateLimit } from '@cloudflare/workers-types' + * @import { Environment } from './car-block.types.js' */ +/** @typedef {{ offset: number, length?: number } | { offset?: number, length: number } | { suffix: number }} Range */ + /** * Handler that serves CAR files directly from R2. * - * @type {import('@web3-storage/gateway-lib').Handler} + * @type {Handler} */ export async function handleCarBlock (request, env, ctx) { const { searchParams, dataCid } = ctx diff --git a/src/handlers/car-block.types.ts b/src/handlers/car-block.types.ts new file mode 100644 index 0000000..3316f70 --- /dev/null +++ b/src/handlers/car-block.types.ts @@ -0,0 +1,6 @@ +import { Environment as MiddlewareEnvironment } from '@web3-storage/gateway-lib' +import { R2Bucket } from '@cloudflare/workers-types' + +export interface Environment extends MiddlewareEnvironment { + CARPARK: R2Bucket +} \ No newline at end of file From f9fffdbfe3c2d31e0220aa6ece5a8a0acabc4731 Mon Sep 17 00:00:00 2001 From: Petra Jaros Date: Thu, 17 Oct 2024 11:04:45 -0400 Subject: [PATCH 07/10] Use version of TypeScript with `@import` --- package-lock.json | 9 +++++---- package.json | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 07c7fe5..46d3b4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,7 @@ "sinon": "^19.0.2", "standard": "^17.1.0", "tree-kill": "^1.2.2", - "typescript": "^5.3.3", + "typescript": "^5.6.3", "wrangler": "^3.78.8" } }, @@ -10089,10 +10089,11 @@ } }, "node_modules/typescript": { - "version": "5.4.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", - "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 0cd60c3..5fac3c0 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "sinon": "^19.0.2", "standard": "^17.1.0", "tree-kill": "^1.2.2", - "typescript": "^5.3.3", + "typescript": "^5.6.3", "wrangler": "^3.78.8" }, "standard": { From 0c5f794f6c1b8d672ff5962a68d429b050f4f6c6 Mon Sep 17 00:00:00 2001 From: Petra Jaros Date: Thu, 17 Oct 2024 11:23:40 -0400 Subject: [PATCH 08/10] Add missing `@types/node-fetch` --- package-lock.json | 80 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 81 insertions(+) diff --git a/package-lock.json b/package-lock.json index 46d3b4f..cbfc5ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@cloudflare/workers-types": "^4.20231218.0", "@types/chai": "^5.0.0", "@types/mocha": "^10.0.9", + "@types/node-fetch": "^2.6.11", "@types/sinon": "^17.0.3", "@ucanto/principal": "^8.1.0", "@web3-storage/content-claims": "^5.0.0", @@ -3301,6 +3302,17 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.11", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.11.tgz", + "integrity": "sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, "node_modules/@types/node-forge": { "version": "1.3.11", "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.11.tgz", @@ -4280,6 +4292,13 @@ "node": ">=12" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/atomically": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.0.3.tgz", @@ -4607,6 +4626,19 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -4984,6 +5016,16 @@ "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", "dev": true }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/diff": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", @@ -6048,6 +6090,21 @@ "is-callable": "^1.1.3" } }, + "node_modules/form-data": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/freeport-promise": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/freeport-promise/-/freeport-promise-2.0.0.tgz", @@ -8077,6 +8134,29 @@ "node": ">=10.0.0" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", diff --git a/package.json b/package.json index 5fac3c0..f051805 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@cloudflare/workers-types": "^4.20231218.0", "@types/chai": "^5.0.0", "@types/mocha": "^10.0.9", + "@types/node-fetch": "^2.6.11", "@types/sinon": "^17.0.3", "@ucanto/principal": "^8.1.0", "@web3-storage/content-claims": "^5.0.0", From c43609c0a93ddac2a6e5d07646b6009646a52fb2 Mon Sep 17 00:00:00 2001 From: Petra Jaros Date: Thu, 17 Oct 2024 11:23:52 -0400 Subject: [PATCH 09/10] Type empty array before pushing to it --- test/miniflare/freeway.spec.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/miniflare/freeway.spec.js b/test/miniflare/freeway.spec.js index c66a8b4..c04c53b 100644 --- a/test/miniflare/freeway.spec.js +++ b/test/miniflare/freeway.spec.js @@ -13,6 +13,8 @@ import { Builder, toBlobKey } from '../helpers/builder.js' import { generateBlockLocationClaims, mockClaimsService, generateLocationClaim } from '../helpers/content-claims.js' import { mockBucketService } from '../helpers/bucket.js' +/** @import { Block, Position } from 'carstream' */ + /** * @param {{ arrayBuffer: () => Promise }} a * @param {{ arrayBuffer: () => Promise }} b @@ -163,6 +165,7 @@ describe('freeway', () => { const source = /** @type {ReadableStream} */ (res.body) const carStream = new CARReaderStream() + /** @type {(Block & Position)[]} */ const blocks = [] await source.pipeThrough(carStream).pipeTo(new WritableStream({ write: (block) => { blocks.push(block) } @@ -221,6 +224,7 @@ describe('freeway', () => { const source = /** @type {ReadableStream} */ (obj.body) const carStream = new CARReaderStream() + /** @type {(Block & Position)[]} */ const blocks = [] await source.pipeThrough(carStream).pipeTo(new WritableStream({ write: (block) => { blocks.push(block) } From 020b869a5c4f41f20aa393dfb14df098b508694a Mon Sep 17 00:00:00 2001 From: Petra Jaros Date: Fri, 18 Oct 2024 16:44:28 -0400 Subject: [PATCH 10/10] Kill Wrangler with `SIGKILL` It's the only way to be sure. Co-authored-by: Felipe Forbeck --- test/helpers/run-wrangler.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/helpers/run-wrangler.js b/test/helpers/run-wrangler.js index 0535f7c..6fc3235 100644 --- a/test/helpers/run-wrangler.js +++ b/test/helpers/run-wrangler.js @@ -103,7 +103,7 @@ async function runLongLivedWrangler ( wranglerProcess.pid, `Command "${command.join(' ')}" had no process id` ) - treeKill(wranglerProcess.pid, (e) => { + treeKill(wranglerProcess.pid, 'SIGKILL', (e) => { if (e) { console.error( 'Failed to kill command: ' + command.join(' '),