@luckystack/test-runner 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -14
- package/dist/index.js.map +1 -1
- package/docs/auth-tests.md +226 -226
- package/docs/contract-tests.md +259 -259
- package/docs/extension-hooks.md +438 -438
- package/docs/fuzz-tests.md +249 -249
- package/docs/rate-limit-tests.md +256 -256
- package/package.json +68 -68
package/docs/rate-limit-tests.md
CHANGED
|
@@ -1,256 +1,256 @@
|
|
|
1
|
-
# Rate-Limit Tests
|
|
2
|
-
|
|
3
|
-
The rate-limit layer proves one thing per endpoint: an endpoint declared `rateLimit: N` rejects the `(N+1)`th request inside its window with `errorCode: 'api.rateLimitExceeded'`. It is the layer that mutates the most server-side state — every probe drains a real bucket. Run it after the contract and auth layers, against a dedicated test instance, and ideally with `resetBetweenEndpoints: true`.
|
|
4
|
-
|
|
5
|
-
## Functions
|
|
6
|
-
|
|
7
|
-
### `runRateLimitCheck(input)`
|
|
8
|
-
|
|
9
|
-
Single-endpoint variant.
|
|
10
|
-
|
|
11
|
-
Signature:
|
|
12
|
-
|
|
13
|
-
```ts
|
|
14
|
-
runRateLimitCheck(input: RateLimitCheckInput): Promise<ContractCheckResult>
|
|
15
|
-
|
|
16
|
-
interface RateLimitCheckInput {
|
|
17
|
-
endpoint: EndpointDescriptor;
|
|
18
|
-
baseUrl: string;
|
|
19
|
-
rateLimit: number;
|
|
20
|
-
inputFor?: (endpoint: EndpointDescriptor) => unknown;
|
|
21
|
-
headers?: Record<string, string>;
|
|
22
|
-
requestTimeoutMs?: number; // default 10000
|
|
23
|
-
}
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
Flow:
|
|
27
|
-
|
|
28
|
-
1. Fire `rateLimit` requests back-to-back. Their responses are ignored — they exist only to drain the bucket. The framework records each one against the limiter, regardless of whether the request itself succeeded or failed.
|
|
29
|
-
2. Fire one final request. This is the probe.
|
|
30
|
-
3. Parse the probe response. If `status === 'error'` AND `errorCode === 'api.rateLimitExceeded'`, pass. Anything else — including a `success` response, a different error code, or a network failure on the final probe — is a fail.
|
|
31
|
-
|
|
32
|
-
Important details:
|
|
33
|
-
|
|
34
|
-
- **Serial, not parallel.** Concurrent requests would race and the limiter could see them in any order. Serial keeps the math predictable: request N+1 is the one that crosses the threshold.
|
|
35
|
-
- **The first `rateLimit` requests are not asserted.** They can be `success`, validation-errors, anything. The whole point is to count, not to validate intermediate behavior. The contract layer already covers correctness of those.
|
|
36
|
-
- **Default timeout is 10 seconds**, double the contract layer. Rate limiters at the upper bound of `maxRateLimitToTest` (default 50) generate ~50 requests per endpoint, which adds latency.
|
|
37
|
-
- **Headers and `inputFor` are forwarded to every request**, including the drain requests. Use them when the validator rejects empty input and you want every probe to hit the limiter rather than fail at validation. (Validation failures still consume rate-limit budget in the framework.)
|
|
38
|
-
|
|
39
|
-
### `runRateLimitTests(input)`
|
|
40
|
-
|
|
41
|
-
Full sweep. Reads `rateLimit` from `apiMetaMap[page][name][version].rateLimit`, skips endpoints where it is `false`, `undefined`, or above `maxRateLimitToTest`, then runs `runRateLimitCheck` against the rest.
|
|
42
|
-
|
|
43
|
-
Signature:
|
|
44
|
-
|
|
45
|
-
```ts
|
|
46
|
-
runRateLimitTests(input: RunRateLimitTestsInput): Promise<RunContractSummary>
|
|
47
|
-
|
|
48
|
-
interface RunRateLimitTestsInput {
|
|
49
|
-
apiMethodMap: ApiMethodMap;
|
|
50
|
-
apiMetaMap: ApiMetaMap;
|
|
51
|
-
baseUrl: string;
|
|
52
|
-
skip?: string[];
|
|
53
|
-
inputFor?: (endpoint: EndpointDescriptor) => unknown;
|
|
54
|
-
headers?: Record<string, string>;
|
|
55
|
-
maxRateLimitToTest?: number; // default 50
|
|
56
|
-
resetBetweenEndpoints?: boolean; // default false
|
|
57
|
-
resetToken?: string;
|
|
58
|
-
onResult?: (result: ContractCheckResult) => void;
|
|
59
|
-
}
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
Behavior notes:
|
|
63
|
-
|
|
64
|
-
- **Endpoints with `rateLimit: false` are silently skipped.** No `skipped` result, no `onResult` call. The summary's `total` reflects only endpoints with a numeric rate limit.
|
|
65
|
-
- **Endpoints with `rateLimit > maxRateLimitToTest` are skipped loudly.** They appear with `status: 'skipped'` and `reason: 'rateLimit ${N} exceeds maxRateLimitToTest=${max}'`. Bump `maxRateLimitToTest` to cover them, but be aware each endpoint then fires `N+1` requests serially.
|
|
66
|
-
- **`resetBetweenEndpoints: true` calls `resetServerState({ baseUrl, token: resetToken })` before every endpoint** that survives the skip filter. Without this, the limiter bucket on a shared IP (the test runner's host) is not flushed between endpoints — endpoint A's drain requests bleed into endpoint B's window and the N+1 assertion fires too early or too late depending on key collisions.
|
|
67
|
-
- **`resetToken` is required when `TEST_RESET_TOKEN` is set on the server.** Always set this in any non-prod environment that is reachable over the network (staging, preview). The server is fail-closed: missing token = 403, unset env var server-side = 403 (never "open").
|
|
68
|
-
- **`onResult` fires for tested AND `maxRateLimitToTest`-skipped endpoints**, but not for `rateLimit: false` endpoints (those don't show up at all).
|
|
69
|
-
|
|
70
|
-
### `resetServerState(input)`
|
|
71
|
-
|
|
72
|
-
POST to the server's `/_test/reset` endpoint. Used between rate-limit probes and at the start of a CI run to ensure a clean slate.
|
|
73
|
-
|
|
74
|
-
Signature:
|
|
75
|
-
|
|
76
|
-
```ts
|
|
77
|
-
resetServerState(input: ResetServerStateInput): Promise<boolean>
|
|
78
|
-
|
|
79
|
-
interface ResetServerStateInput {
|
|
80
|
-
baseUrl: string;
|
|
81
|
-
token?: string;
|
|
82
|
-
}
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
Returns `true` when the server responds 2xx, `false` on network error, parse error, or non-2xx. The error is swallowed deliberately — the most common reason for `false` is "the route is disabled in this environment", and the caller usually wants to proceed anyway.
|
|
86
|
-
|
|
87
|
-
URL: `${baseUrl}/_test/reset` (POST). When `token` is set, the request includes `X-Test-Reset-Token: ${token}`. The header name is part of the server contract.
|
|
88
|
-
|
|
89
|
-
## `/_test/reset` server contract
|
|
90
|
-
|
|
91
|
-
The endpoint is served by `@luckystack/server` (see `packages/server/src/httpRoutes/testResetRoute.ts`). It is mounted at `projectConfig.http.testResetEndpoint` (default `/_test/reset`).
|
|
92
|
-
|
|
93
|
-
Gating, in order:
|
|
94
|
-
|
|
95
|
-
1. `process.env.NODE_ENV` must be exactly `'development'` or `'test'`. Anything else — including missing — returns 404. "Anything but production" was rejected as too loose: a misconfigured env var must not expose this route.
|
|
96
|
-
2. `process.env.TEST_RESET_TOKEN` must be set on the server AND `req.headers['x-test-reset-token']` must match exactly. Empty server-side token = 403 unconditionally. The header check happens after the NODE_ENV check, so leaking the token name from a 403 only happens in dev/test environments.
|
|
97
|
-
|
|
98
|
-
What it clears (when both gates pass):
|
|
99
|
-
|
|
100
|
-
- **All rate-limit buckets** via `clearAllRateLimits()` from `@luckystack/core`. Every key under the rate-limit Redis namespace is dropped. Always cleared.
|
|
101
|
-
- **All sessions** under `${projectName}-session:*` and active-user records under `${projectName}-activeUsers:*`. Scanned in batches of 200 keys via `redis.scan + redis.del`. Project name comes from `getProjectName()` for consistency across `session.ts`, `rateLimiter.ts`, and this endpoint.
|
|
102
|
-
- **Registered runtime hooks** when `?include=hooks` is in the query string. NOT included by default because framework-internal handlers (e.g. presence postLogout) register at boot — clearing them would require a server restart to recover.
|
|
103
|
-
|
|
104
|
-
Response:
|
|
105
|
-
|
|
106
|
-
```json
|
|
107
|
-
{ "status": "success", "cleared": ["rateLimits", "sessions", "activeUsers"] }
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
The `cleared` array reflects what actually had keys to drop. It is informational; `runRateLimitTests` only checks `response.ok`.
|
|
111
|
-
|
|
112
|
-
## Types
|
|
113
|
-
|
|
114
|
-
```ts
|
|
115
|
-
interface RateLimitCheckInput {
|
|
116
|
-
endpoint: EndpointDescriptor;
|
|
117
|
-
baseUrl: string;
|
|
118
|
-
rateLimit: number;
|
|
119
|
-
inputFor?: (endpoint: EndpointDescriptor) => unknown;
|
|
120
|
-
headers?: Record<string, string>;
|
|
121
|
-
requestTimeoutMs?: number;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
interface RunRateLimitTestsInput {
|
|
125
|
-
apiMethodMap: ApiMethodMap;
|
|
126
|
-
apiMetaMap: ApiMetaMap;
|
|
127
|
-
baseUrl: string;
|
|
128
|
-
skip?: string[];
|
|
129
|
-
inputFor?: (endpoint: EndpointDescriptor) => unknown;
|
|
130
|
-
headers?: Record<string, string>;
|
|
131
|
-
maxRateLimitToTest?: number;
|
|
132
|
-
resetBetweenEndpoints?: boolean;
|
|
133
|
-
resetToken?: string;
|
|
134
|
-
onResult?: (result: ContractCheckResult) => void;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
interface ResetServerStateInput {
|
|
138
|
-
baseUrl: string;
|
|
139
|
-
token?: string;
|
|
140
|
-
}
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
## Result shapes by branch
|
|
144
|
-
|
|
145
|
-
- **Pass**: `{ status: 'pass', httpStatus, responseStatus: 'error', errorCode: 'api.rateLimitExceeded', durationMs }`.
|
|
146
|
-
- **Fail / final probe never returned**: `{ status: 'fail', reason: 'Final rate-limit probe request failed to return a response', durationMs }`.
|
|
147
|
-
- **Fail / wrong status or code**: `{ status: 'fail', httpStatus, responseStatus, errorCode, reason: "expected 'api.rateLimitExceeded' on request ${N+1} but got <status>/<code>", durationMs }`.
|
|
148
|
-
- **Skip / over max**: `{ status: 'skipped', durationMs: 0, reason: 'rateLimit ${N} exceeds maxRateLimitToTest=${max}' }`.
|
|
149
|
-
- **Skip / explicit**: `{ status: 'skipped', durationMs: 0, reason: 'Explicitly skipped' }`.
|
|
150
|
-
|
|
151
|
-
`durationMs` covers the entire `N+1` request sequence, not just the final probe.
|
|
152
|
-
|
|
153
|
-
## Examples
|
|
154
|
-
|
|
155
|
-
### Full sweep with state reset between endpoints
|
|
156
|
-
|
|
157
|
-
```ts
|
|
158
|
-
import { apiMethodMap } from './generated/apiMethodMap.generated';
|
|
159
|
-
import { apiMetaMap } from './generated/apiMetaMap.generated';
|
|
160
|
-
import {
|
|
161
|
-
resetServerState,
|
|
162
|
-
runRateLimitTests,
|
|
163
|
-
logContractResult,
|
|
164
|
-
logContractSummary,
|
|
165
|
-
} from '@luckystack/test-runner';
|
|
166
|
-
|
|
167
|
-
const baseUrl = process.env.TEST_BASE_URL ?? 'http://127.0.0.1:80';
|
|
168
|
-
const resetToken = process.env.TEST_RESET_TOKEN;
|
|
169
|
-
|
|
170
|
-
await resetServerState({ baseUrl, token: resetToken });
|
|
171
|
-
|
|
172
|
-
const summary = await runRateLimitTests({
|
|
173
|
-
apiMethodMap,
|
|
174
|
-
apiMetaMap,
|
|
175
|
-
baseUrl,
|
|
176
|
-
resetBetweenEndpoints: true,
|
|
177
|
-
resetToken,
|
|
178
|
-
onResult: logContractResult,
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
logContractSummary(summary);
|
|
182
|
-
```
|
|
183
|
-
|
|
184
|
-
### Local dev: skip the reset (faster, less hygienic)
|
|
185
|
-
|
|
186
|
-
```ts
|
|
187
|
-
await runRateLimitTests({
|
|
188
|
-
apiMethodMap,
|
|
189
|
-
apiMetaMap,
|
|
190
|
-
baseUrl: process.env.TEST_BASE_URL ?? 'http://127.0.0.1:80',
|
|
191
|
-
// No resetBetweenEndpoints — fine if all endpoints have separate limiter keys.
|
|
192
|
-
});
|
|
193
|
-
```
|
|
194
|
-
|
|
195
|
-
This works when every endpoint has its own limiter key (the framework default is `<ip>:<endpoint>`). It fails when limiter keys are coarsened to IP-only.
|
|
196
|
-
|
|
197
|
-
### Single-endpoint debug
|
|
198
|
-
|
|
199
|
-
```ts
|
|
200
|
-
import { runRateLimitCheck } from '@luckystack/test-runner';
|
|
201
|
-
|
|
202
|
-
const result = await runRateLimitCheck({
|
|
203
|
-
endpoint: {
|
|
204
|
-
page: 'auth',
|
|
205
|
-
name: 'login',
|
|
206
|
-
version: 'v1',
|
|
207
|
-
method: 'POST',
|
|
208
|
-
fullPath: 'api/auth/login/v1',
|
|
209
|
-
},
|
|
210
|
-
baseUrl: process.env.TEST_BASE_URL ?? 'http://127.0.0.1:80',
|
|
211
|
-
rateLimit: 5, // matches the value in src/auth/_api/login_v1.ts
|
|
212
|
-
inputFor: () => ({ email: 'test@example.com', password: 'wrong' }),
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
console.log(result);
|
|
216
|
-
```
|
|
217
|
-
|
|
218
|
-
### Staging deploy with a token
|
|
219
|
-
|
|
220
|
-
```ts
|
|
221
|
-
await runRateLimitTests({
|
|
222
|
-
apiMethodMap,
|
|
223
|
-
apiMetaMap,
|
|
224
|
-
baseUrl: 'https://staging.example.com',
|
|
225
|
-
resetBetweenEndpoints: true,
|
|
226
|
-
resetToken: process.env.STAGING_TEST_RESET_TOKEN,
|
|
227
|
-
maxRateLimitToTest: 100, // wider net for nightly sweep
|
|
228
|
-
});
|
|
229
|
-
```
|
|
230
|
-
|
|
231
|
-
Set `TEST_RESET_TOKEN` on the staging server to the same value. Without both halves of the token, the reset returns 403 and the rate-limit windows leak across endpoints.
|
|
232
|
-
|
|
233
|
-
### Tighten the budget
|
|
234
|
-
|
|
235
|
-
```ts
|
|
236
|
-
await runRateLimitTests({
|
|
237
|
-
apiMethodMap,
|
|
238
|
-
apiMetaMap,
|
|
239
|
-
baseUrl,
|
|
240
|
-
maxRateLimitToTest: 20, // skip everything noisier than 20/window
|
|
241
|
-
});
|
|
242
|
-
```
|
|
243
|
-
|
|
244
|
-
Endpoints with `rateLimit: 100` will appear as skipped with the reason inline.
|
|
245
|
-
|
|
246
|
-
## Edge cases and gotchas
|
|
247
|
-
|
|
248
|
-
- **Time-window granularity.** The framework rate-limiter uses Redis-backed sliding windows. Firing `N+1` serial requests typically completes in well under the window, so the assertion is reliable. If your endpoint sets `rateLimit: 1` and the window is sub-second, latency between the drain and the probe can let the bucket reset — bump `requestTimeoutMs` only buys per-call slack; the window itself is server-controlled.
|
|
249
|
-
- **Distributed rate-limit storage.** The runner assumes one limiter authority (one Redis instance, or a single cluster). Multi-region setups with eventual consistency can produce false negatives: the N+1 probe lands on a different shard than the drain. Run this layer against a single-region staging.
|
|
250
|
-
- **Multi-instance servers.** Without sticky sessions, the drain requests fan out to several pods and each pod sees fewer than N. The probe lands on a pod that has not yet seen the bucket fill. Either pin the runner to one upstream, or set the limiter keying to include the user-ip header consistently.
|
|
251
|
-
- **Limiter keys.** The framework keys on a combination of route + IP (or session, when present). Two endpoints can share a coarser key (e.g. `ip:<all>`). When they do, `resetBetweenEndpoints` is mandatory. When they do not, the option is optional and the test runs faster without it.
|
|
252
|
-
- **The `validation.failed` trap.** If the validator rejects every drain request before the limiter sees them, the framework still counts the request — but verifying this assumption is the user's responsibility. Use `inputFor` to provide payloads that survive validation when in doubt.
|
|
253
|
-
- **`maxRateLimitToTest` accidentally too high.** With `maxRateLimitToTest: 1000` and 30 endpoints, the sweep fires `~30000` requests serially. This will time out CI long before it completes. Default 50 is deliberate.
|
|
254
|
-
- **`/_test/reset` returning false from `resetServerState`.** Most common cause: `NODE_ENV !== 'development' | 'test'` on the server. Second most common: missing token. Third: route disabled via custom routing. The runner does not retry — it returns `false` and proceeds. The next endpoint's window will be polluted; consider failing the sweep early.
|
|
255
|
-
- **Sessions cleared as collateral damage.** `resetServerState` always drops sessions and active users in addition to limiter buckets. If you have a long-running logged-in test alongside this layer, run the rate-limit sweep first, then re-establish the session.
|
|
256
|
-
- **`?include=hooks` is opt-in for a reason.** Do not call `resetServerState` with this flag in a long-running test process — framework-internal hooks register only at boot, and dropping them mid-run will break presence, post-logout cleanup, and similar side-effects until restart.
|
|
1
|
+
# Rate-Limit Tests
|
|
2
|
+
|
|
3
|
+
The rate-limit layer proves one thing per endpoint: an endpoint declared `rateLimit: N` rejects the `(N+1)`th request inside its window with `errorCode: 'api.rateLimitExceeded'`. It is the layer that mutates the most server-side state — every probe drains a real bucket. Run it after the contract and auth layers, against a dedicated test instance, and ideally with `resetBetweenEndpoints: true`.
|
|
4
|
+
|
|
5
|
+
## Functions
|
|
6
|
+
|
|
7
|
+
### `runRateLimitCheck(input)`
|
|
8
|
+
|
|
9
|
+
Single-endpoint variant.
|
|
10
|
+
|
|
11
|
+
Signature:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
runRateLimitCheck(input: RateLimitCheckInput): Promise<ContractCheckResult>
|
|
15
|
+
|
|
16
|
+
interface RateLimitCheckInput {
|
|
17
|
+
endpoint: EndpointDescriptor;
|
|
18
|
+
baseUrl: string;
|
|
19
|
+
rateLimit: number;
|
|
20
|
+
inputFor?: (endpoint: EndpointDescriptor) => unknown;
|
|
21
|
+
headers?: Record<string, string>;
|
|
22
|
+
requestTimeoutMs?: number; // default 10000
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Flow:
|
|
27
|
+
|
|
28
|
+
1. Fire `rateLimit` requests back-to-back. Their responses are ignored — they exist only to drain the bucket. The framework records each one against the limiter, regardless of whether the request itself succeeded or failed.
|
|
29
|
+
2. Fire one final request. This is the probe.
|
|
30
|
+
3. Parse the probe response. If `status === 'error'` AND `errorCode === 'api.rateLimitExceeded'`, pass. Anything else — including a `success` response, a different error code, or a network failure on the final probe — is a fail.
|
|
31
|
+
|
|
32
|
+
Important details:
|
|
33
|
+
|
|
34
|
+
- **Serial, not parallel.** Concurrent requests would race and the limiter could see them in any order. Serial keeps the math predictable: request N+1 is the one that crosses the threshold.
|
|
35
|
+
- **The first `rateLimit` requests are not asserted.** They can be `success`, validation-errors, anything. The whole point is to count, not to validate intermediate behavior. The contract layer already covers correctness of those.
|
|
36
|
+
- **Default timeout is 10 seconds**, double the contract layer. Rate limiters at the upper bound of `maxRateLimitToTest` (default 50) generate ~50 requests per endpoint, which adds latency.
|
|
37
|
+
- **Headers and `inputFor` are forwarded to every request**, including the drain requests. Use them when the validator rejects empty input and you want every probe to hit the limiter rather than fail at validation. (Validation failures still consume rate-limit budget in the framework.)
|
|
38
|
+
|
|
39
|
+
### `runRateLimitTests(input)`
|
|
40
|
+
|
|
41
|
+
Full sweep. Reads `rateLimit` from `apiMetaMap[page][name][version].rateLimit`, skips endpoints where it is `false`, `undefined`, or above `maxRateLimitToTest`, then runs `runRateLimitCheck` against the rest.
|
|
42
|
+
|
|
43
|
+
Signature:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
runRateLimitTests(input: RunRateLimitTestsInput): Promise<RunContractSummary>
|
|
47
|
+
|
|
48
|
+
interface RunRateLimitTestsInput {
|
|
49
|
+
apiMethodMap: ApiMethodMap;
|
|
50
|
+
apiMetaMap: ApiMetaMap;
|
|
51
|
+
baseUrl: string;
|
|
52
|
+
skip?: string[];
|
|
53
|
+
inputFor?: (endpoint: EndpointDescriptor) => unknown;
|
|
54
|
+
headers?: Record<string, string>;
|
|
55
|
+
maxRateLimitToTest?: number; // default 50
|
|
56
|
+
resetBetweenEndpoints?: boolean; // default false
|
|
57
|
+
resetToken?: string;
|
|
58
|
+
onResult?: (result: ContractCheckResult) => void;
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Behavior notes:
|
|
63
|
+
|
|
64
|
+
- **Endpoints with `rateLimit: false` are silently skipped.** No `skipped` result, no `onResult` call. The summary's `total` reflects only endpoints with a numeric rate limit.
|
|
65
|
+
- **Endpoints with `rateLimit > maxRateLimitToTest` are skipped loudly.** They appear with `status: 'skipped'` and `reason: 'rateLimit ${N} exceeds maxRateLimitToTest=${max}'`. Bump `maxRateLimitToTest` to cover them, but be aware each endpoint then fires `N+1` requests serially.
|
|
66
|
+
- **`resetBetweenEndpoints: true` calls `resetServerState({ baseUrl, token: resetToken })` before every endpoint** that survives the skip filter. Without this, the limiter bucket on a shared IP (the test runner's host) is not flushed between endpoints — endpoint A's drain requests bleed into endpoint B's window and the N+1 assertion fires too early or too late depending on key collisions.
|
|
67
|
+
- **`resetToken` is required when `TEST_RESET_TOKEN` is set on the server.** Always set this in any non-prod environment that is reachable over the network (staging, preview). The server is fail-closed: missing token = 403, unset env var server-side = 403 (never "open").
|
|
68
|
+
- **`onResult` fires for tested AND `maxRateLimitToTest`-skipped endpoints**, but not for `rateLimit: false` endpoints (those don't show up at all).
|
|
69
|
+
|
|
70
|
+
### `resetServerState(input)`
|
|
71
|
+
|
|
72
|
+
POST to the server's `/_test/reset` endpoint. Used between rate-limit probes and at the start of a CI run to ensure a clean slate.
|
|
73
|
+
|
|
74
|
+
Signature:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
resetServerState(input: ResetServerStateInput): Promise<boolean>
|
|
78
|
+
|
|
79
|
+
interface ResetServerStateInput {
|
|
80
|
+
baseUrl: string;
|
|
81
|
+
token?: string;
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Returns `true` when the server responds 2xx, `false` on network error, parse error, or non-2xx. The error is swallowed deliberately — the most common reason for `false` is "the route is disabled in this environment", and the caller usually wants to proceed anyway.
|
|
86
|
+
|
|
87
|
+
URL: `${baseUrl}/_test/reset` (POST). When `token` is set, the request includes `X-Test-Reset-Token: ${token}`. The header name is part of the server contract.
|
|
88
|
+
|
|
89
|
+
## `/_test/reset` server contract
|
|
90
|
+
|
|
91
|
+
The endpoint is served by `@luckystack/server` (see `packages/server/src/httpRoutes/testResetRoute.ts`). It is mounted at `projectConfig.http.testResetEndpoint` (default `/_test/reset`).
|
|
92
|
+
|
|
93
|
+
Gating, in order:
|
|
94
|
+
|
|
95
|
+
1. `process.env.NODE_ENV` must be exactly `'development'` or `'test'`. Anything else — including missing — returns 404. "Anything but production" was rejected as too loose: a misconfigured env var must not expose this route.
|
|
96
|
+
2. `process.env.TEST_RESET_TOKEN` must be set on the server AND `req.headers['x-test-reset-token']` must match exactly. Empty server-side token = 403 unconditionally. The header check happens after the NODE_ENV check, so leaking the token name from a 403 only happens in dev/test environments.
|
|
97
|
+
|
|
98
|
+
What it clears (when both gates pass):
|
|
99
|
+
|
|
100
|
+
- **All rate-limit buckets** via `clearAllRateLimits()` from `@luckystack/core`. Every key under the rate-limit Redis namespace is dropped. Always cleared.
|
|
101
|
+
- **All sessions** under `${projectName}-session:*` and active-user records under `${projectName}-activeUsers:*`. Scanned in batches of 200 keys via `redis.scan + redis.del`. Project name comes from `getProjectName()` for consistency across `session.ts`, `rateLimiter.ts`, and this endpoint.
|
|
102
|
+
- **Registered runtime hooks** when `?include=hooks` is in the query string. NOT included by default because framework-internal handlers (e.g. presence postLogout) register at boot — clearing them would require a server restart to recover.
|
|
103
|
+
|
|
104
|
+
Response:
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{ "status": "success", "cleared": ["rateLimits", "sessions", "activeUsers"] }
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The `cleared` array reflects what actually had keys to drop. It is informational; `runRateLimitTests` only checks `response.ok`.
|
|
111
|
+
|
|
112
|
+
## Types
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
interface RateLimitCheckInput {
|
|
116
|
+
endpoint: EndpointDescriptor;
|
|
117
|
+
baseUrl: string;
|
|
118
|
+
rateLimit: number;
|
|
119
|
+
inputFor?: (endpoint: EndpointDescriptor) => unknown;
|
|
120
|
+
headers?: Record<string, string>;
|
|
121
|
+
requestTimeoutMs?: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface RunRateLimitTestsInput {
|
|
125
|
+
apiMethodMap: ApiMethodMap;
|
|
126
|
+
apiMetaMap: ApiMetaMap;
|
|
127
|
+
baseUrl: string;
|
|
128
|
+
skip?: string[];
|
|
129
|
+
inputFor?: (endpoint: EndpointDescriptor) => unknown;
|
|
130
|
+
headers?: Record<string, string>;
|
|
131
|
+
maxRateLimitToTest?: number;
|
|
132
|
+
resetBetweenEndpoints?: boolean;
|
|
133
|
+
resetToken?: string;
|
|
134
|
+
onResult?: (result: ContractCheckResult) => void;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface ResetServerStateInput {
|
|
138
|
+
baseUrl: string;
|
|
139
|
+
token?: string;
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Result shapes by branch
|
|
144
|
+
|
|
145
|
+
- **Pass**: `{ status: 'pass', httpStatus, responseStatus: 'error', errorCode: 'api.rateLimitExceeded', durationMs }`.
|
|
146
|
+
- **Fail / final probe never returned**: `{ status: 'fail', reason: 'Final rate-limit probe request failed to return a response', durationMs }`.
|
|
147
|
+
- **Fail / wrong status or code**: `{ status: 'fail', httpStatus, responseStatus, errorCode, reason: "expected 'api.rateLimitExceeded' on request ${N+1} but got <status>/<code>", durationMs }`.
|
|
148
|
+
- **Skip / over max**: `{ status: 'skipped', durationMs: 0, reason: 'rateLimit ${N} exceeds maxRateLimitToTest=${max}' }`.
|
|
149
|
+
- **Skip / explicit**: `{ status: 'skipped', durationMs: 0, reason: 'Explicitly skipped' }`.
|
|
150
|
+
|
|
151
|
+
`durationMs` covers the entire `N+1` request sequence, not just the final probe.
|
|
152
|
+
|
|
153
|
+
## Examples
|
|
154
|
+
|
|
155
|
+
### Full sweep with state reset between endpoints
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
import { apiMethodMap } from './generated/apiMethodMap.generated';
|
|
159
|
+
import { apiMetaMap } from './generated/apiMetaMap.generated';
|
|
160
|
+
import {
|
|
161
|
+
resetServerState,
|
|
162
|
+
runRateLimitTests,
|
|
163
|
+
logContractResult,
|
|
164
|
+
logContractSummary,
|
|
165
|
+
} from '@luckystack/test-runner';
|
|
166
|
+
|
|
167
|
+
const baseUrl = process.env.TEST_BASE_URL ?? 'http://127.0.0.1:80';
|
|
168
|
+
const resetToken = process.env.TEST_RESET_TOKEN;
|
|
169
|
+
|
|
170
|
+
await resetServerState({ baseUrl, token: resetToken });
|
|
171
|
+
|
|
172
|
+
const summary = await runRateLimitTests({
|
|
173
|
+
apiMethodMap,
|
|
174
|
+
apiMetaMap,
|
|
175
|
+
baseUrl,
|
|
176
|
+
resetBetweenEndpoints: true,
|
|
177
|
+
resetToken,
|
|
178
|
+
onResult: logContractResult,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
logContractSummary(summary);
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Local dev: skip the reset (faster, less hygienic)
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
await runRateLimitTests({
|
|
188
|
+
apiMethodMap,
|
|
189
|
+
apiMetaMap,
|
|
190
|
+
baseUrl: process.env.TEST_BASE_URL ?? 'http://127.0.0.1:80',
|
|
191
|
+
// No resetBetweenEndpoints — fine if all endpoints have separate limiter keys.
|
|
192
|
+
});
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
This works when every endpoint has its own limiter key (the framework default is `<ip>:<endpoint>`). It fails when limiter keys are coarsened to IP-only.
|
|
196
|
+
|
|
197
|
+
### Single-endpoint debug
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
import { runRateLimitCheck } from '@luckystack/test-runner';
|
|
201
|
+
|
|
202
|
+
const result = await runRateLimitCheck({
|
|
203
|
+
endpoint: {
|
|
204
|
+
page: 'auth',
|
|
205
|
+
name: 'login',
|
|
206
|
+
version: 'v1',
|
|
207
|
+
method: 'POST',
|
|
208
|
+
fullPath: 'api/auth/login/v1',
|
|
209
|
+
},
|
|
210
|
+
baseUrl: process.env.TEST_BASE_URL ?? 'http://127.0.0.1:80',
|
|
211
|
+
rateLimit: 5, // matches the value in src/auth/_api/login_v1.ts
|
|
212
|
+
inputFor: () => ({ email: 'test@example.com', password: 'wrong' }),
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
console.log(result);
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Staging deploy with a token
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
await runRateLimitTests({
|
|
222
|
+
apiMethodMap,
|
|
223
|
+
apiMetaMap,
|
|
224
|
+
baseUrl: 'https://staging.example.com',
|
|
225
|
+
resetBetweenEndpoints: true,
|
|
226
|
+
resetToken: process.env.STAGING_TEST_RESET_TOKEN,
|
|
227
|
+
maxRateLimitToTest: 100, // wider net for nightly sweep
|
|
228
|
+
});
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Set `TEST_RESET_TOKEN` on the staging server to the same value. Without both halves of the token, the reset returns 403 and the rate-limit windows leak across endpoints.
|
|
232
|
+
|
|
233
|
+
### Tighten the budget
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
await runRateLimitTests({
|
|
237
|
+
apiMethodMap,
|
|
238
|
+
apiMetaMap,
|
|
239
|
+
baseUrl,
|
|
240
|
+
maxRateLimitToTest: 20, // skip everything noisier than 20/window
|
|
241
|
+
});
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Endpoints with `rateLimit: 100` will appear as skipped with the reason inline.
|
|
245
|
+
|
|
246
|
+
## Edge cases and gotchas
|
|
247
|
+
|
|
248
|
+
- **Time-window granularity.** The framework rate-limiter uses Redis-backed sliding windows. Firing `N+1` serial requests typically completes in well under the window, so the assertion is reliable. If your endpoint sets `rateLimit: 1` and the window is sub-second, latency between the drain and the probe can let the bucket reset — bump `requestTimeoutMs` only buys per-call slack; the window itself is server-controlled.
|
|
249
|
+
- **Distributed rate-limit storage.** The runner assumes one limiter authority (one Redis instance, or a single cluster). Multi-region setups with eventual consistency can produce false negatives: the N+1 probe lands on a different shard than the drain. Run this layer against a single-region staging.
|
|
250
|
+
- **Multi-instance servers.** Without sticky sessions, the drain requests fan out to several pods and each pod sees fewer than N. The probe lands on a pod that has not yet seen the bucket fill. Either pin the runner to one upstream, or set the limiter keying to include the user-ip header consistently.
|
|
251
|
+
- **Limiter keys.** The framework keys on a combination of route + IP (or session, when present). Two endpoints can share a coarser key (e.g. `ip:<all>`). When they do, `resetBetweenEndpoints` is mandatory. When they do not, the option is optional and the test runs faster without it.
|
|
252
|
+
- **The `validation.failed` trap.** If the validator rejects every drain request before the limiter sees them, the framework still counts the request — but verifying this assumption is the user's responsibility. Use `inputFor` to provide payloads that survive validation when in doubt.
|
|
253
|
+
- **`maxRateLimitToTest` accidentally too high.** With `maxRateLimitToTest: 1000` and 30 endpoints, the sweep fires `~30000` requests serially. This will time out CI long before it completes. Default 50 is deliberate.
|
|
254
|
+
- **`/_test/reset` returning false from `resetServerState`.** Most common cause: `NODE_ENV !== 'development' | 'test'` on the server. Second most common: missing token. Third: route disabled via custom routing. The runner does not retry — it returns `false` and proceeds. The next endpoint's window will be polluted; consider failing the sweep early.
|
|
255
|
+
- **Sessions cleared as collateral damage.** `resetServerState` always drops sessions and active users in addition to limiter buckets. If you have a long-running logged-in test alongside this layer, run the rate-limit sweep first, then re-establish the session.
|
|
256
|
+
- **`?include=hooks` is opt-in for a reason.** Do not call `resetServerState` with this flag in a long-running test process — framework-internal hooks register only at boot, and dropping them mid-run will break presence, post-logout cleanup, and similar side-effects until restart.
|