@luckystack/test-runner 0.1.0
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 -0
- package/CLAUDE.md +98 -0
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/dist/index.d.ts +433 -0
- package/dist/index.js +1270 -0
- package/dist/index.js.map +1 -0
- package/docs/auth-tests.md +226 -0
- package/docs/contract-tests.md +259 -0
- package/docs/extension-hooks.md +438 -0
- package/docs/fuzz-tests.md +249 -0
- package/docs/rate-limit-tests.md +256 -0
- package/package.json +66 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# Fuzz Tests
|
|
2
|
+
|
|
3
|
+
The fuzz layer is the crash-resistance net. It feeds every endpoint a fixed catalogue of malformed payloads — wrong types, oversized strings, deeply nested objects, prototype-pollution attempts — and asserts the server never returns 5xx with a stack and never returns a body that escapes the framework envelope. It is intentionally exhaustive and slow; wire it into nightly CI, not into every PR.
|
|
4
|
+
|
|
5
|
+
Pass criterion: across every probe, the server replies with a 1xx/2xx/3xx/4xx status code AND a JSON body of `{ status: 'success' | 'error', errorCode? }`. Any 5xx, any non-JSON body, any non-envelope JSON, or any `error` without an `errorCode` is a fail and the probe loop short-circuits to report the offending payload.
|
|
6
|
+
|
|
7
|
+
## Functions
|
|
8
|
+
|
|
9
|
+
### `runFuzzCheck(input)`
|
|
10
|
+
|
|
11
|
+
Single-endpoint variant.
|
|
12
|
+
|
|
13
|
+
Signature:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
runFuzzCheck(input: FuzzCheckInput): Promise<ContractCheckResult>
|
|
17
|
+
|
|
18
|
+
interface FuzzCheckInput {
|
|
19
|
+
endpoint: EndpointDescriptor;
|
|
20
|
+
baseUrl: string;
|
|
21
|
+
headers?: Record<string, string>;
|
|
22
|
+
requestTimeoutMs?: number; // default 5000
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Flow:
|
|
27
|
+
|
|
28
|
+
1. Iterate the built-in `JUNK_PAYLOADS` list serially.
|
|
29
|
+
2. For each payload, POST/PUT/DELETE it to `${baseUrl}/${endpoint.fullPath}` (or send no body on GET). The body is `JSON.stringify(payload)` so values that lose information under stringify (e.g. `undefined` keys, `NaN`) round-trip through the wire encoding the same as in production.
|
|
30
|
+
3. After each request:
|
|
31
|
+
- **No response (fetch error, abort, DNS):** fail with `reason: 'fuzz probe crashed (no response) with payload: ...'`. The reason includes the first 80 chars of the payload JSON.
|
|
32
|
+
- **`httpStatus >= 500`:** fail with `reason: 'fuzz payload produced 5xx: ...'`. Concrete: the server threw an unhandled error and the HTTP layer surfaced it as 500.
|
|
33
|
+
- **Body fails JSON parse or `status` is not `'success' | 'error'`:** fail with `reason: 'fuzz payload produced non-envelope response: ...'`.
|
|
34
|
+
- **`status === 'error'` and `errorCode` is not a string:** fail with `reason: 'fuzz error response missing errorCode for payload: ...'`.
|
|
35
|
+
4. If every payload passes, return pass.
|
|
36
|
+
|
|
37
|
+
The loop stops at the first failing probe. The result captures only that probe's response details — earlier passes are not aggregated, this layer is binary per endpoint.
|
|
38
|
+
|
|
39
|
+
### `runFuzzTests(input)`
|
|
40
|
+
|
|
41
|
+
Full sweep. Iterates `walkEndpoints(input.apiMethodMap)` and runs `runFuzzCheck` against each.
|
|
42
|
+
|
|
43
|
+
Signature:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
runFuzzTests(input: RunFuzzTestsInput): Promise<RunContractSummary>
|
|
47
|
+
|
|
48
|
+
interface RunFuzzTestsInput {
|
|
49
|
+
apiMethodMap: ApiMethodMap;
|
|
50
|
+
baseUrl: string;
|
|
51
|
+
skip?: string[];
|
|
52
|
+
headers?: Record<string, string>;
|
|
53
|
+
onResult?: (result: ContractCheckResult) => void;
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
There is no per-endpoint metadata filtering here — every endpoint is fuzzed, regardless of `auth.login` or `rateLimit`. `skip` matching follows the same two-tier convention as the other sweeps: `'<page>/<name>'` for version-agnostic, `'<page>/<name>/<version>'` for specific.
|
|
58
|
+
|
|
59
|
+
`onResult` fires once per endpoint (after all junk payloads for that endpoint have run, or at the first failing probe). It is NOT called per junk payload.
|
|
60
|
+
|
|
61
|
+
## Built-in junk payloads
|
|
62
|
+
|
|
63
|
+
The current catalogue lives in `src/fuzzCheck.ts:JUNK_PAYLOADS`. It is intentionally small and stable so a flake here is meaningful:
|
|
64
|
+
|
|
65
|
+
| Payload | What it stresses |
|
|
66
|
+
| --- | --- |
|
|
67
|
+
| `null` | Validator that assumes the body is an object. |
|
|
68
|
+
| `[]` | Empty-array confusion (validators that destructure as object). |
|
|
69
|
+
| `[1, 2, 3]` | Array where an object is expected. |
|
|
70
|
+
| `'string-instead-of-object'` | String-as-body. |
|
|
71
|
+
| `1234567890` | Number-as-body. |
|
|
72
|
+
| `true` | Boolean-as-body. |
|
|
73
|
+
| `{ nested: { deeply: { nested: { value: 'x'.repeat(10000) } } } }` | Oversized nested strings. Catches handlers that log the entire body without truncation. |
|
|
74
|
+
| `{ __proto__: { polluted: true } }` | Prototype-pollution attempt. Detects parsers that merge into a shared object. |
|
|
75
|
+
| `{ key: null, other: undefined, third: Number.NaN }` | Edge values that survive JSON encoding in non-obvious ways (`undefined` keys drop, `NaN` becomes `null`). |
|
|
76
|
+
|
|
77
|
+
This is a lightweight fuzzer, not a property-based one. Real schema-driven fuzzing (TS type -> random valid input via something like `fast-check`) is deferred until the devkit emits Zod schemas alongside the runtime types. When that lands, the fuzz layer will preferentially use registered fixtures (see below), fall back to schema-random, and finally fall back to this fixed catalogue.
|
|
78
|
+
|
|
79
|
+
## Fixture registry
|
|
80
|
+
|
|
81
|
+
Realistic-payload fixtures plug into the fuzz layer's roadmap. Today they are registered but the current implementation of `runFuzzCheck` does not consume them automatically — they are designed to be read by custom test layers and by the upcoming schema-aware fuzz mode. Registering them now keeps fixtures co-located with the endpoint code that owns the type.
|
|
82
|
+
|
|
83
|
+
### `registerTestFixture(typeKey, fixture)`
|
|
84
|
+
|
|
85
|
+
Store a fixture for a given type key. Re-registering replaces the previous entry. Typically called at module load from a `_api/*` file or a central registration module.
|
|
86
|
+
|
|
87
|
+
Signature:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
registerTestFixture<TPayload = unknown>(typeKey: string, fixture: TestFixture<TPayload>): void
|
|
91
|
+
|
|
92
|
+
interface TestFixture<TPayload = unknown> {
|
|
93
|
+
valid: TPayload[];
|
|
94
|
+
invalid: TPayload[];
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Conventions:
|
|
99
|
+
|
|
100
|
+
- **`typeKey` is a project-defined string** that uniquely identifies the input type. Common patterns: the route literal (`api/billing/getInvoice/v1`), or the named TypeScript interface (`GetInvoiceInput`). Pick one convention per project.
|
|
101
|
+
- **`valid` payloads** are shapes the validator accepts. Used to drive happy-path behavior (e.g. by a custom layer running each valid fixture and asserting `status === 'success'`).
|
|
102
|
+
- **`invalid` payloads** are shapes the validator should reject. Used to probe the error envelope: each invalid fixture should return `{ status: 'error', errorCode: '<validation code>' }`.
|
|
103
|
+
- **Re-registration replaces.** No merging — pass the full list each time.
|
|
104
|
+
|
|
105
|
+
### `getTestFixture(typeKey)`
|
|
106
|
+
|
|
107
|
+
Read back the fixture. Returns `undefined` when no fixture is registered for that key.
|
|
108
|
+
|
|
109
|
+
Signature:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
getTestFixture(typeKey: string): TestFixture | undefined
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Use this from a custom layer (see `extension-hooks.md`) to iterate per-endpoint fixtures.
|
|
116
|
+
|
|
117
|
+
## Types
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
interface FuzzCheckInput {
|
|
121
|
+
endpoint: EndpointDescriptor;
|
|
122
|
+
baseUrl: string;
|
|
123
|
+
headers?: Record<string, string>;
|
|
124
|
+
requestTimeoutMs?: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface RunFuzzTestsInput {
|
|
128
|
+
apiMethodMap: ApiMethodMap;
|
|
129
|
+
baseUrl: string;
|
|
130
|
+
skip?: string[];
|
|
131
|
+
headers?: Record<string, string>;
|
|
132
|
+
onResult?: (result: ContractCheckResult) => void;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface TestFixture<TPayload = unknown> {
|
|
136
|
+
valid: TPayload[];
|
|
137
|
+
invalid: TPayload[];
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`ContractCheckResult` and `RunContractSummary` are shared across layers — see `contract-tests.md`.
|
|
142
|
+
|
|
143
|
+
## Result shapes by branch
|
|
144
|
+
|
|
145
|
+
- **Pass**: `{ status: 'pass', durationMs }`. No `httpStatus` — the layer does not report which payloads passed, only that none failed.
|
|
146
|
+
- **Fail / crash**: `{ status: 'fail', reason: 'fuzz probe crashed (no response) with payload: <preview>', durationMs }`.
|
|
147
|
+
- **Fail / 5xx**: `{ status: 'fail', httpStatus, reason: 'fuzz payload produced 5xx: <preview>', durationMs }`.
|
|
148
|
+
- **Fail / non-envelope**: `{ status: 'fail', httpStatus, reason: 'fuzz payload produced non-envelope response: <preview>', durationMs }`.
|
|
149
|
+
- **Fail / error missing code**: `{ status: 'fail', httpStatus, reason: 'fuzz error response missing errorCode for payload: <preview>', durationMs }`.
|
|
150
|
+
- **Skip**: `{ status: 'skipped', durationMs: 0, reason: 'Explicitly skipped' }`.
|
|
151
|
+
|
|
152
|
+
`durationMs` covers the time to the first failing probe (fail branches) or the time to walk the whole catalogue (pass branch).
|
|
153
|
+
|
|
154
|
+
## Examples
|
|
155
|
+
|
|
156
|
+
### Nightly CI sweep
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
import { apiMethodMap } from './generated/apiMethodMap.generated';
|
|
160
|
+
import {
|
|
161
|
+
runFuzzTests,
|
|
162
|
+
logContractResult,
|
|
163
|
+
logContractSummary,
|
|
164
|
+
} from '@luckystack/test-runner';
|
|
165
|
+
|
|
166
|
+
const summary = await runFuzzTests({
|
|
167
|
+
apiMethodMap,
|
|
168
|
+
baseUrl: process.env.TEST_BASE_URL ?? 'http://127.0.0.1:80',
|
|
169
|
+
onResult: logContractResult,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
logContractSummary(summary);
|
|
173
|
+
|
|
174
|
+
if (summary.failed > 0) {
|
|
175
|
+
process.exit(3);
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Authenticated fuzz (cookie-protected endpoints)
|
|
180
|
+
|
|
181
|
+
The fuzz layer does not strip credentials the way the auth layer does. Pass a session header so probes reach the handler instead of being killed at the auth guard:
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
await runFuzzTests({
|
|
185
|
+
apiMethodMap,
|
|
186
|
+
baseUrl: process.env.TEST_BASE_URL ?? 'http://127.0.0.1:80',
|
|
187
|
+
headers: {
|
|
188
|
+
Cookie: 'session=<test-session-cookie>',
|
|
189
|
+
},
|
|
190
|
+
skip: ['integrations/stripeWebhook'], // signed payloads, would always fail
|
|
191
|
+
});
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Single-endpoint debug
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
import { runFuzzCheck } from '@luckystack/test-runner';
|
|
198
|
+
|
|
199
|
+
const result = await runFuzzCheck({
|
|
200
|
+
endpoint: {
|
|
201
|
+
page: 'organization-settings',
|
|
202
|
+
name: 'sendInvite',
|
|
203
|
+
version: 'v1',
|
|
204
|
+
method: 'POST',
|
|
205
|
+
fullPath: 'api/organization-settings/sendInvite/v1',
|
|
206
|
+
},
|
|
207
|
+
baseUrl: process.env.TEST_BASE_URL ?? 'http://127.0.0.1:80',
|
|
208
|
+
headers: { Cookie: 'session=<test-session>' },
|
|
209
|
+
requestTimeoutMs: 8000,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
console.log(result);
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Register a fixture for a critical endpoint
|
|
216
|
+
|
|
217
|
+
Co-locate the fixture next to the endpoint so it travels with refactors:
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
// src/billing/_api/getInvoice_v1.ts
|
|
221
|
+
import { registerTestFixture } from '@luckystack/test-runner';
|
|
222
|
+
|
|
223
|
+
registerTestFixture<{ invoiceId: string; expand?: string[] }>('api/billing/getInvoice/v1', {
|
|
224
|
+
valid: [
|
|
225
|
+
{ invoiceId: 'inv_1' },
|
|
226
|
+
{ invoiceId: 'inv_1', expand: ['line_items'] },
|
|
227
|
+
],
|
|
228
|
+
invalid: [
|
|
229
|
+
{ invoiceId: '' },
|
|
230
|
+
{ invoiceId: 'inv_1', expand: ['malicious; DROP TABLE'] },
|
|
231
|
+
],
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
export const main = async ({ data }: ApiParams) => { /* ... */ };
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
A nightly custom layer can then walk every endpoint, look up the fixture by route, and run the valid/invalid lists against the live endpoint to verify expected behavior beyond the framework envelope. The fuzz layer itself does not yet auto-pick these up — that wiring lands with the schema-aware fuzz mode.
|
|
238
|
+
|
|
239
|
+
## Edge cases and gotchas
|
|
240
|
+
|
|
241
|
+
- **Binary uploads.** Endpoints expecting `multipart/form-data` will fail every JUNK payload because the Content-Type header is `application/json`. Skip these endpoints explicitly; they need a separate, format-aware fuzzer.
|
|
242
|
+
- **Streaming endpoints.** Endpoints that hold the connection open (SSE, long-poll) will hit `requestTimeoutMs` and report as crashed. Skip them or add support for streaming responses in a custom layer.
|
|
243
|
+
- **Endpoints with filesystem side-effects.** A fuzz probe that triggers a real file write happens on every endpoint where the validator is permissive. Add cleanup in a custom layer or run the fuzz sweep against a disposable test instance.
|
|
244
|
+
- **Prototype pollution.** The runner sends `{ __proto__: { polluted: true } }`. Node's `JSON.parse` already strips `__proto__` to a regular own-property in modern runtimes, so this probe is mostly defensive. A failure here means a handler is using a custom merge function that does not honor that hardening.
|
|
245
|
+
- **`NaN` and `undefined` after stringify.** `Number.NaN` JSON-stringifies as `null`; `undefined`-valued keys are dropped. The payload that hits the server is `{ key: null, third: null }`. This is intentional — it mirrors what a malicious client can actually transmit.
|
|
246
|
+
- **Auth-protected endpoints with no session header.** They will all fail with `errorCode: 'auth.required'` (which passes the fuzz envelope check, since the response is a typed error). The fuzz sweep will report them as `pass`. Whether that is what you want depends on whether you trust the auth layer's earlier verdict — most projects pass headers to fuzz so the handler logic is actually reached.
|
|
247
|
+
- **The `<preview>` in the `reason`** is `JSON.stringify(payload).slice(0, 80)`. Long payloads (oversized nested) are truncated. Look at the source `JUNK_PAYLOADS` to identify which probe caused the failure when the preview is cut.
|
|
248
|
+
- **Adding payloads.** Today the catalogue is hard-coded. To extend it, fork the package or wrap `runFuzzCheck` in a custom layer (`registerTestLayer({ name: 'extra-fuzz', run })`) that issues additional probes. The roadmap is to make this list registry-driven once the schema-aware mode lands.
|
|
249
|
+
- **`onResult` is per-endpoint.** If you need per-probe visibility for a single endpoint, instrument `runFuzzCheck` directly or call it once per payload yourself. The exported function is the smallest stable unit.
|
|
@@ -0,0 +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.
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@luckystack/test-runner",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"description": "Generated-type-driven test layers for LuckyStack: contract smoke (Zod-valid input), auth enforcement, rate-limit, and crash-resistance fuzz. Walks every endpoint via the generated apiMethodMap.",
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"author": "Mathijs van Melick <mathijsvanmelick3@gmail.com>",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/ItsLucky23/LuckyStack-v2.git",
|
|
15
|
+
"directory": "packages/test-runner"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/ItsLucky23/LuckyStack-v2/tree/master/packages/test-runner#readme",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/ItsLucky23/LuckyStack-v2/issues"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"luckystack",
|
|
23
|
+
"testing",
|
|
24
|
+
"contract-testing",
|
|
25
|
+
"fuzz",
|
|
26
|
+
"rate-limit",
|
|
27
|
+
"api-testing",
|
|
28
|
+
"auth-testing",
|
|
29
|
+
"zod"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=20.0.0"
|
|
33
|
+
},
|
|
34
|
+
"main": "./dist/index.js",
|
|
35
|
+
"types": "./dist/index.d.ts",
|
|
36
|
+
"exports": {
|
|
37
|
+
".": {
|
|
38
|
+
"types": "./dist/index.d.ts",
|
|
39
|
+
"import": "./dist/index.js"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"files": [
|
|
43
|
+
"dist",
|
|
44
|
+
"README.md",
|
|
45
|
+
"CLAUDE.md",
|
|
46
|
+
"docs",
|
|
47
|
+
"LICENSE",
|
|
48
|
+
"CHANGELOG.md"
|
|
49
|
+
],
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "tsup",
|
|
52
|
+
"build:watch": "tsup --watch",
|
|
53
|
+
"test": "vitest run"
|
|
54
|
+
},
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"@luckystack/core": "^0.1.0"
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"@luckystack/login": "^0.1.0",
|
|
60
|
+
"socket.io-client": "^4.8.0",
|
|
61
|
+
"zod": "^4.0.0"
|
|
62
|
+
},
|
|
63
|
+
"peerDependenciesMeta": {
|
|
64
|
+
"@luckystack/login": { "optional": true }
|
|
65
|
+
}
|
|
66
|
+
}
|