Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
37 changes: 37 additions & 0 deletions src/mock-http.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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.
*/
Expand All @@ -113,6 +121,8 @@ export class MockHttp extends Hookified {
images: true,
};

private _rateLimit?: RateLimitPluginOptions;

private _server: FastifyInstance = Fastify();
private _taps: TapManager = new TapManager();

Expand All @@ -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;
}
}

/**
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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();
}
Expand Down
142 changes: 142 additions & 0 deletions test/mock-http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
Loading