From cf635d2285d5a3c4c9ae2e55ba4a20cd8b4bfde0 Mon Sep 17 00:00:00 2001 From: Jared Wray Date: Tue, 11 Nov 2025 15:09:14 -0800 Subject: [PATCH] feat: adding in rate limit options --- README.md | 134 ++++++++++++++++++++++++++++++++++++++ package.json | 1 + src/mock-http.ts | 37 +++++++++++ test/mock-http.test.ts | 142 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 314 insertions(+) diff --git a/README.md b/README.md index e5e6775..ad8254e 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,138 @@ const tap = mock.taps.inject( ); ``` +# Rate Limiting + +MockHttp supports rate limiting using [@fastify/rate-limit](https://github.com/fastify/fastify-rate-limit). Rate limiting is **disabled by default** and can be enabled by providing configuration options. + +## Enabling Rate Limiting + +To enable rate limiting, pass a `rateLimit` configuration object when creating your MockHttp instance: + +```javascript +import { MockHttp } from '@jaredwray/mockhttp'; + +const mock = new MockHttp({ + rateLimit: { + max: 100, // Maximum 100 requests + timeWindow: '1 minute' // Per 1 minute window + } +}); + +await mock.start(); +``` + +## Common Configuration Options + +The `rateLimit` option accepts all [@fastify/rate-limit options](https://github.com/fastify/fastify-rate-limit#options): + +### Basic Rate Limiting + +```javascript +// Limit to 50 requests per minute +const mock = new MockHttp({ + rateLimit: { + max: 50, + timeWindow: '1 minute' + } +}); +``` + +### Stricter Limits with Custom Error Response + +```javascript +const mock = new MockHttp({ + rateLimit: { + max: 30, + timeWindow: 60000, // 1 minute in milliseconds + errorResponseBuilder: (req, context) => ({ + statusCode: 429, + error: 'Too Many Requests', + message: `Rate limit exceeded. Try again in ${context.after}` + }) + } +}); +``` + +### Allow List (Exclude Specific IPs) + +```javascript +const mock = new MockHttp({ + rateLimit: { + max: 100, + timeWindow: '1 minute', + allowList: ['127.0.0.1', '192.168.1.100'] // These IPs bypass rate limiting + } +}); +``` + +### Custom Key Generator (Rate Limit by Header) + +```javascript +const mock = new MockHttp({ + rateLimit: { + max: 100, + timeWindow: '1 minute', + keyGenerator: (request) => { + // Rate limit by API key instead of IP + return request.headers['x-api-key'] || request.ip; + } + } +}); +``` + +### Advanced Configuration + +```javascript +const mock = new MockHttp({ + rateLimit: { + global: true, // Apply to all routes + max: 100, // Max requests + timeWindow: '1 minute', // Time window + cache: 10000, // Cache size for tracking clients + skipOnError: false, // Don't skip on storage errors + ban: 10, // Ban after 10 rate limit violations + continueExceeding: false, // Don't reset window on each request + enableDraftSpec: true, // Use IETF draft spec headers + addHeaders: { // Customize rate limit headers + 'x-ratelimit-limit': true, + 'x-ratelimit-remaining': true, + 'x-ratelimit-reset': true + } + } +}); +``` + +## Disabling Rate Limiting + +Rate limiting is disabled by default. To explicitly disable it (or disable it after it was enabled): + +```javascript +const mock = new MockHttp(); // No rateLimit option = disabled + +// Or explicitly set to undefined +const mock2 = new MockHttp({ + rateLimit: undefined +}); +``` + +## Available Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `max` | number \| function | `1000` | Maximum requests per time window | +| `timeWindow` | number \| string | `60000` | Duration of rate limit window (milliseconds or string like '1 minute') | +| `cache` | number | `5000` | LRU cache size for tracking clients | +| `allowList` | array \| function | `[]` | IPs or function to exclude from rate limiting | +| `keyGenerator` | function | IP-based | Function to generate unique client identifier | +| `errorResponseBuilder` | function | Default 429 | Custom error response function | +| `skipOnError` | boolean | `false` | Skip rate limiting if storage errors occur | +| `ban` | number | `-1` | Ban client after N violations (disabled by default) | +| `continueExceeding` | boolean | `false` | Renew time window on each request while limited | +| `enableDraftSpec` | boolean | `false` | Use IETF draft specification headers | + +For the complete list of options, see the [@fastify/rate-limit documentation](https://github.com/fastify/fastify-rate-limit#options). + # API Reference ## MockHttp Class @@ -264,6 +396,7 @@ new MockHttp(options?) - `autoDetectPort?`: boolean - Auto-detect next available port if in use (default: true) - `helmet?`: boolean - Use Helmet for security headers (default: true) - `apiDocs?`: boolean - Enable Swagger API documentation (default: true) + - `rateLimit?`: RateLimitPluginOptions - Enable and configure rate limiting (default: undefined/disabled) - `httpBin?`: HttpBinOptions - Configure which httpbin routes to enable - `httpMethods?`: boolean - Enable HTTP method routes (default: true) - `redirects?`: boolean - Enable redirect routes (default: true) @@ -284,6 +417,7 @@ new MockHttp(options?) - `autoDetectPort`: boolean - Get/set auto-detect port behavior - `helmet`: boolean - Get/set Helmet security headers - `apiDocs`: boolean - Get/set API documentation +- `rateLimit`: RateLimitPluginOptions | undefined - Get/set rate limiting options - `httpBin`: HttpBinOptions - Get/set httpbin route options - `server`: FastifyInstance - Get/set the Fastify server instance - `taps`: TapManager - Get/set the TapManager instance diff --git a/package.json b/package.json index 7f6730e..36a3b22 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "dependencies": { "@fastify/cookie": "^11.0.2", "@fastify/helmet": "^13.0.2", + "@fastify/rate-limit": "^10.3.0", "@fastify/static": "^8.3.0", "@fastify/swagger": "^9.6.0", "@fastify/swagger-ui": "^5.2.3", diff --git a/src/mock-http.ts b/src/mock-http.ts index fa08fbc..d0f5c0c 100644 --- a/src/mock-http.ts +++ b/src/mock-http.ts @@ -1,6 +1,9 @@ import path from "node:path"; import fastifyCookie from "@fastify/cookie"; import fastifyHelmet from "@fastify/helmet"; +import fastifyRateLimit, { + type RateLimitPluginOptions, +} from "@fastify/rate-limit"; import fastifyStatic from "@fastify/static"; import { fastifySwagger } from "@fastify/swagger"; import { detect } from "detect-port"; @@ -88,6 +91,11 @@ export type MockHttpOptions = { * Whether to use Swagger UI. Defaults to true. */ httpBin?: HttpBinOptions; + /** + * Rate limiting options. When undefined, rate limiting is disabled. + * When set with options, rate limiting is enabled with those options. + */ + rateLimit?: RateLimitPluginOptions; /** * Hookified options. */ @@ -113,6 +121,8 @@ export class MockHttp extends Hookified { images: true, }; + private _rateLimit?: RateLimitPluginOptions; + private _server: FastifyInstance = Fastify(); private _taps: TapManager = new TapManager(); @@ -138,6 +148,10 @@ export class MockHttp extends Hookified { if (options?.httpBin !== undefined) { this._httpBin = options.httpBin; } + + if (options?.rateLimit !== undefined) { + this._rateLimit = options.rateLimit; + } } /** @@ -236,6 +250,24 @@ export class MockHttp extends Hookified { this._httpBin = httpBinary; } + /** + * Rate limiting options. When undefined, rate limiting is disabled. + * When set with options, rate limiting is enabled with those options. + * @default undefined + */ + public get rateLimit(): RateLimitPluginOptions | undefined { + return this._rateLimit; + } + + /** + * Rate limiting options. When undefined, rate limiting is disabled. + * When set with options, rate limiting is enabled with those options. + * @default undefined + */ + public set rateLimit(rateLimit: RateLimitPluginOptions | undefined) { + this._rateLimit = rateLimit; + } + /** * The Fastify server instance. */ @@ -337,6 +369,11 @@ export class MockHttp extends Hookified { }); } + // Register the rate limit plugin if configured + if (this._rateLimit) { + await this._server.register(fastifyRateLimit, this._rateLimit); + } + if (this._apiDocs) { await this.registerApiDocs(); } diff --git a/test/mock-http.test.ts b/test/mock-http.test.ts index 1561c01..92ba2a1 100644 --- a/test/mock-http.test.ts +++ b/test/mock-http.test.ts @@ -433,4 +433,146 @@ describe("MockHttp", () => { await mock.close(); }); }); + + describe("rate limiting", () => { + test("should be disabled by default", async () => { + const mock = new MockHttp(); + expect(mock.rateLimit).toBeUndefined(); + + await mock.start(); + + // Make multiple requests quickly - should all succeed + const responses = await Promise.all([ + mock.server.inject({ method: "GET", url: "/get" }), + mock.server.inject({ method: "GET", url: "/get" }), + mock.server.inject({ method: "GET", url: "/get" }), + mock.server.inject({ method: "GET", url: "/get" }), + mock.server.inject({ method: "GET", url: "/get" }), + ]); + + // All should return 200, not 429 (rate limit) + for (const response of responses) { + expect(response.statusCode).toBe(200); + } + + await mock.close(); + }); + + test("should be able to set rate limit options", () => { + const rateLimitOptions = { + max: 10, + timeWindow: "1 minute", + }; + + const mock = new MockHttp({ + rateLimit: rateLimitOptions, + }); + + expect(mock.rateLimit).toEqual(rateLimitOptions); + }); + + test("should be able to modify rate limit options via setter", () => { + const mock = new MockHttp(); + expect(mock.rateLimit).toBeUndefined(); + + const rateLimitOptions = { + max: 50, + timeWindow: 60000, + }; + + mock.rateLimit = rateLimitOptions; + expect(mock.rateLimit).toEqual(rateLimitOptions); + }); + + test("should enforce rate limits when enabled", async () => { + const mock = new MockHttp({ + rateLimit: { + max: 3, // Only allow 3 requests + timeWindow: 60000, // Per minute + }, + }); + + await mock.start(); + + // Make 3 requests - should all succeed + const response1 = await mock.server.inject({ + method: "GET", + url: "/get", + }); + const response2 = await mock.server.inject({ + method: "GET", + url: "/get", + }); + const response3 = await mock.server.inject({ + method: "GET", + url: "/get", + }); + + expect(response1.statusCode).toBe(200); + expect(response2.statusCode).toBe(200); + expect(response3.statusCode).toBe(200); + + // 4th request should be rate limited + const response4 = await mock.server.inject({ + method: "GET", + url: "/get", + }); + expect(response4.statusCode).toBe(429); // Too Many Requests + + await mock.close(); + }); + + test("should include rate limit headers when enabled", async () => { + const mock = new MockHttp({ + rateLimit: { + max: 100, + timeWindow: "1 minute", + }, + }); + + await mock.start(); + + const response = await mock.server.inject({ method: "GET", url: "/get" }); + + expect(response.statusCode).toBe(200); + expect(response.headers["x-ratelimit-limit"]).toBeDefined(); + expect(response.headers["x-ratelimit-remaining"]).toBeDefined(); + expect(response.headers["x-ratelimit-reset"]).toBeDefined(); + + await mock.close(); + }); + + test("should support custom error response builder", async () => { + const customErrorMessage = "Custom rate limit message"; + + const mock = new MockHttp({ + rateLimit: { + max: 2, + timeWindow: 60000, + errorResponseBuilder: () => ({ + statusCode: 429, + error: "Rate Limit Exceeded", + message: customErrorMessage, + }), + }, + }); + + await mock.start(); + + // Make requests to exceed limit + await mock.server.inject({ method: "GET", url: "/get" }); + await mock.server.inject({ method: "GET", url: "/get" }); + + const rateLimitedResponse = await mock.server.inject({ + method: "GET", + url: "/get", + }); + + expect(rateLimitedResponse.statusCode).toBe(429); + const body = rateLimitedResponse.json(); + expect(body.message).toBe(customErrorMessage); + + await mock.close(); + }); + }); });