@luckystack/test-runner 0.10.2 → 0.11.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/AGENTS.md +7 -0
- package/CHANGELOG.md +7 -0
- package/README.md +13 -68
- package/dist/authEnforcementCheck.d.ts +12 -0
- package/dist/contractCheck.d.ts +24 -0
- package/dist/csrfEnforcementCheck.d.ts +25 -0
- package/dist/customTests.d.ts +145 -0
- package/dist/extensionRegistry.d.ts +83 -0
- package/dist/fuzzCheck.d.ts +12 -0
- package/dist/index.d.ts +49 -745
- package/dist/index.js.map +1 -1
- package/dist/probeRequest.d.ts +48 -0
- package/dist/rateLimitCheck.d.ts +14 -0
- package/dist/reporter.d.ts +3 -0
- package/dist/resetServerState.d.ts +13 -0
- package/dist/resolveTestBaseUrl.d.ts +7 -0
- package/dist/resolveTestEnvironment.d.ts +21 -0
- package/dist/runAllTests.d.ts +88 -0
- package/dist/runAuthEnforcementTests.d.ts +17 -0
- package/dist/runContractTests.d.ts +22 -0
- package/dist/runCsrfEnforcementTests.d.ts +29 -0
- package/dist/runFuzzTests.d.ts +16 -0
- package/dist/runRateLimitTests.d.ts +38 -0
- package/dist/runRegisteredLayers.d.ts +24 -0
- package/dist/sampleInputFor.d.ts +13 -0
- package/dist/schemaSampleInput.d.ts +12 -0
- package/dist/socketProbeTransport.d.ts +9 -0
- package/dist/streamWatcher.d.ts +37 -0
- package/dist/testDataMarker.d.ts +2 -0
- package/dist/testLayerHelpers.d.ts +55 -0
- package/dist/testSocket.d.ts +58 -0
- package/dist/types.d.ts +46 -0
- package/dist/walkEndpoints.d.ts +6 -0
- package/docs/auth-tests.md +4 -223
- package/docs/contract-tests.md +11 -307
- package/docs/extension-hooks.md +4 -467
- package/docs/fuzz-tests.md +4 -245
- package/docs/rate-limit-tests.md +9 -261
- package/package.json +5 -5
- package/CLAUDE.md +0 -117
package/docs/fuzz-tests.md
CHANGED
|
@@ -1,248 +1,7 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Crash-resistance fuzz tests
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
This slow layer deliberately sends malformed inputs to exercise error shaping and server survival; run it in a scheduled/nightly job rather than making every pull request wait for it. Its success criterion is a reachable server with a valid framework response envelope. **A shaped HTTP 5xx can pass**: this layer does not assert the absence of internal errors. Investigate 5xx rates separately.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
In an authenticated sweep, state-changing routes are reported as skipped so junk bodies cannot mutate the test database. Every sync route counts as state-changing, so an authenticated sweep does not fuzz sync routes at all; an anonymous sweep does. GET probes carry the junk payload in the query string like the real client (ADR 0062).
|
|
6
6
|
|
|
7
|
-
|
|
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}` (on GET the payload rides the query string, under the same reserved key the real client uses). 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` WITH a valid envelope:** **pass**. A 5xx is deliberately tolerated as long as the body is still a `{ status: 'error', errorCode }` envelope — that means the framework caught the error and shaped it, which is exactly the crash-resistance property this layer checks. Only the envelope checks below can fail a probe; the status code alone never does.
|
|
33
|
-
- **Body fails JSON parse or `status` is not `'success' | 'error'`:** fail with `reason: 'fuzz payload produced non-envelope response: ...'`. This is the check that actually catches a raw 5xx stack — an unhandled error that escaped the envelope arrives as a non-JSON / non-envelope body.
|
|
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 every API route (plus every sync route when `syncMetaMap` is given), narrowed by `filter` before any request, and runs `runFuzzCheck` against each. In an authenticated (cookie) sweep, state-changing routes are reported as skipped to avoid junk-body mutations on the test database — every sync route counts as state-changing, so an authenticated sweep does not fuzz sync routes.
|
|
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 (bar the authenticated-sweep skip above) 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
|
-
| `{ constructor: { polluted: true } }` | Prototype-pollution attempt via a named own-key. `{ __proto__: ... }` serializes to `{}` via JSON.stringify (prototype keys are not own-enumerable), so the probe uses `constructor` instead — it survives the JSON round-trip while still exercising parsers that misinterpret the key. |
|
|
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 / non-envelope**: `{ status: 'fail', httpStatus, reason: 'fuzz payload produced non-envelope response: <preview>', durationMs }`. A bare 5xx (raw stack, HTML error page, truncated body) lands here — a 5xx is NOT a fail on its own; only the missing/invalid envelope is.
|
|
148
|
-
- **Fail / error missing code**: `{ status: 'fail', httpStatus, reason: 'fuzz error response missing errorCode for payload: <preview>', durationMs }`.
|
|
149
|
-
- **Skip**: `{ status: 'skipped', durationMs: 0, reason: 'Explicitly skipped' }`.
|
|
150
|
-
|
|
151
|
-
`durationMs` covers the time to the first failing probe (fail branches) or the time to walk the whole catalogue (pass branch).
|
|
152
|
-
|
|
153
|
-
## Examples
|
|
154
|
-
|
|
155
|
-
### Nightly CI sweep
|
|
156
|
-
|
|
157
|
-
```ts
|
|
158
|
-
import { apiMethodMap } from './generated/apiMethodMap.generated';
|
|
159
|
-
import {
|
|
160
|
-
runFuzzTests,
|
|
161
|
-
logContractResult,
|
|
162
|
-
logContractSummary,
|
|
163
|
-
} from '@luckystack/test-runner';
|
|
164
|
-
|
|
165
|
-
const summary = await runFuzzTests({
|
|
166
|
-
apiMethodMap,
|
|
167
|
-
baseUrl: process.env.TEST_BASE_URL ?? 'http://127.0.0.1:80',
|
|
168
|
-
onResult: logContractResult,
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
logContractSummary(summary);
|
|
172
|
-
|
|
173
|
-
if (summary.failed > 0) {
|
|
174
|
-
process.exit(3);
|
|
175
|
-
}
|
|
176
|
-
```
|
|
177
|
-
|
|
178
|
-
### Authenticated fuzz (cookie-protected endpoints)
|
|
179
|
-
|
|
180
|
-
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:
|
|
181
|
-
|
|
182
|
-
```ts
|
|
183
|
-
await runFuzzTests({
|
|
184
|
-
apiMethodMap,
|
|
185
|
-
baseUrl: process.env.TEST_BASE_URL ?? 'http://127.0.0.1:80',
|
|
186
|
-
headers: {
|
|
187
|
-
Cookie: 'session=<test-session-cookie>',
|
|
188
|
-
},
|
|
189
|
-
skip: ['integrations/stripeWebhook'], // signed payloads, would always fail
|
|
190
|
-
});
|
|
191
|
-
```
|
|
192
|
-
|
|
193
|
-
### Single-endpoint debug
|
|
194
|
-
|
|
195
|
-
```ts
|
|
196
|
-
import { runFuzzCheck } from '@luckystack/test-runner';
|
|
197
|
-
|
|
198
|
-
const result = await runFuzzCheck({
|
|
199
|
-
endpoint: {
|
|
200
|
-
page: 'organization-settings',
|
|
201
|
-
name: 'sendInvite',
|
|
202
|
-
version: 'v1',
|
|
203
|
-
method: 'POST',
|
|
204
|
-
fullPath: 'api/organization-settings/sendInvite/v1',
|
|
205
|
-
},
|
|
206
|
-
baseUrl: process.env.TEST_BASE_URL ?? 'http://127.0.0.1:80',
|
|
207
|
-
headers: { Cookie: 'session=<test-session>' },
|
|
208
|
-
requestTimeoutMs: 8000,
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
console.log(result);
|
|
212
|
-
```
|
|
213
|
-
|
|
214
|
-
### Register a fixture for a critical endpoint
|
|
215
|
-
|
|
216
|
-
Co-locate the fixture next to the endpoint so it travels with refactors:
|
|
217
|
-
|
|
218
|
-
```ts
|
|
219
|
-
// src/billing/_api/getInvoice_v1.ts
|
|
220
|
-
import { registerTestFixture } from '@luckystack/test-runner';
|
|
221
|
-
|
|
222
|
-
registerTestFixture<{ invoiceId: string; expand?: string[] }>('api/billing/getInvoice/v1', {
|
|
223
|
-
valid: [
|
|
224
|
-
{ invoiceId: 'inv_1' },
|
|
225
|
-
{ invoiceId: 'inv_1', expand: ['line_items'] },
|
|
226
|
-
],
|
|
227
|
-
invalid: [
|
|
228
|
-
{ invoiceId: '' },
|
|
229
|
-
{ invoiceId: 'inv_1', expand: ['malicious; DROP TABLE'] },
|
|
230
|
-
],
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
export const main = async ({ data }: ApiParams) => { /* ... */ };
|
|
234
|
-
```
|
|
235
|
-
|
|
236
|
-
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.
|
|
237
|
-
|
|
238
|
-
## Edge cases and gotchas
|
|
239
|
-
|
|
240
|
-
- **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.
|
|
241
|
-
- **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.
|
|
242
|
-
- **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.
|
|
243
|
-
- **Prototype pollution.** The runner sends `{ constructor: { polluted: true } }`. `{ __proto__: ... }` serializes to `{}` via JSON.stringify (prototype keys are not own-enumerable), so the probe uses `constructor` instead — it survives the JSON round-trip while still exercising parsers that misinterpret the key. A failure here means a handler is using a custom merge function that conflates `constructor` with `Object.prototype.constructor`.
|
|
244
|
-
- **`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.
|
|
245
|
-
- **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.
|
|
246
|
-
- **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.
|
|
247
|
-
- **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.
|
|
248
|
-
- **`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.
|
|
7
|
+
A rejected unauthenticated request does not prove protected handler input was fuzzed; create authenticated, project-specific tests where necessary. Keep test traffic away from production and avoid payloads that trigger irreversible side effects. The fuzz source defines the actual probe catalogue; do not duplicate it as a static list here.
|
package/docs/rate-limit-tests.md
CHANGED
|
@@ -1,267 +1,15 @@
|
|
|
1
|
-
# Rate-
|
|
1
|
+
# Rate-limit checks and reset safety
|
|
2
2
|
|
|
3
|
-
The rate-limit layer
|
|
3
|
+
The rate-limit layer checks that eligible requests consume a bucket and that a later request is rejected. A request rejected earlier by auth or CSRF does not fill the limiter, so reporting a failed limiter after such a probe would be misleading. Establish authentication and CSRF conditions before interpreting rate-limit results; the runner source defines current response matching. Sync routes are checked too when the sweep receives the generated sync metadata, which carries their limits.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
The test-reset route exists only for deliberate development/test environments and requires a non-empty matching token. Never expose it in production or assume an unset environment means safe access. Reset clears framework rate-limit and default session-related state, **not** application database rows or custom session stores; rows a sweep creates persist after its bookend resets.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## Scoped reset
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
An unscoped reset also deletes every default session, including the one an authenticated sweep runs on. The reset therefore accepts a target selector, and the runner's own resets (bookends and between rate-limit endpoints) always clear rate limits only (ADR 0064).
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
- An unknown or empty target is rejected rather than ignored: a typo must not silently reset nothing, or everything.
|
|
12
|
+
- The unscoped reset keeps its old meaning on purpose; CI setups that call it directly to log users out between suites keep working. A direct call without a selector still logs out every logged-in test.
|
|
13
|
+
- The valid targets are one shared list in `@luckystack/core`, read by both server and runner so they cannot drift; a new target is added there.
|
|
12
14
|
|
|
13
|
-
|
|
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 to drain the bucket. Each drain response IS inspected — not for success/failure, but for a *pre-limiter* rejection. If any drain response carries an `errorCode` of `auth.required`, `auth.forbidden`, or `auth.csrfMismatch` (codes the server emits *before* the rate-limit pipeline runs: the auth guard and the CSRF middleware), the limiter bucket was never incremented for that request, so the check short-circuits to `status: 'skipped'` with `reason: 'drain request ${i+1} rejected pre-limiter (${errorCode}); bucket was never incremented — check CSRF/auth headers'`. Otherwise the drain response value is not asserted — the framework records every non-pre-limiter request against the limiter regardless of whether it succeeded or failed (e.g. validation errors, which run AFTER the limiter, still consume budget).
|
|
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 for success** — but each is checked for a *pre-limiter* rejection (`auth.required` / `auth.forbidden` / `auth.csrfMismatch`). Such a rejection means the limiter bucket never incremented, so the check returns `skipped` rather than a misleading fail on the final probe. Any other response (`success`, a validation error, etc.) is fine — the point is to count, not to validate intermediate behavior; the contract layer already covers correctness of those. (Validation, `api.invalidInputType`, runs AFTER the limiter and is deliberately NOT treated as pre-limiter.)
|
|
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` (from `syncMetaMap` for sync routes, which are swept when `syncMetaMap` is given; `filter` narrows the set before any request), 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, only: ['rateLimits'] })` before every endpoint** that survives the skip filter. Only limiter buckets are cleared, so an authenticated sweep keeps its session. 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 `runAllTests` bookends to clear framework-owned limiter (and, unscoped, default-session) state; it is not an application-database reset.
|
|
73
|
-
|
|
74
|
-
Signature:
|
|
75
|
-
|
|
76
|
-
```ts
|
|
77
|
-
resetServerState(input: ResetServerStateInput): Promise<boolean>
|
|
78
|
-
|
|
79
|
-
interface ResetServerStateInput {
|
|
80
|
-
baseUrl: string;
|
|
81
|
-
token?: string;
|
|
82
|
-
requestTimeoutMs?: number;
|
|
83
|
-
only?: TestResetTarget[]; // sent as `?only=`; omit for the server's default reset
|
|
84
|
-
}
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
Pass `only` to clear exactly those targets. Omitting it gives the server's default reset, which also deletes every Redis session — including an authenticated sweep's own. The runner itself therefore always passes `only: ['rateLimits']`.
|
|
88
|
-
|
|
89
|
-
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.
|
|
90
|
-
|
|
91
|
-
URL: `${baseUrl}/_test/reset` (POST). Supply `token` (normally `TEST_RESET_TOKEN`); the request sends it as `X-Test-Reset-Token`. The server always requires a non-empty matching value.
|
|
92
|
-
|
|
93
|
-
## `/_test/reset` server contract
|
|
94
|
-
|
|
95
|
-
The endpoint is served by `@luckystack/server` (see `packages/server/src/httpRoutes/testResetRoute.ts`). It is mounted at `projectConfig.http.testResetEndpoint` (default `/_test/reset`).
|
|
96
|
-
|
|
97
|
-
Gating, in order:
|
|
98
|
-
|
|
99
|
-
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.
|
|
100
|
-
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.
|
|
101
|
-
|
|
102
|
-
What it clears (when both gates pass):
|
|
103
|
-
|
|
104
|
-
**`?only=<target>[,<target>]`** clears exactly the listed targets and nothing else. The valid targets are the shared list in core `TEST_RESET_TARGETS` (rate limits, sessions, active users, hooks), so client and server cannot drift. An unknown or empty target is a `400` with `errorCode: 'test.invalidResetTarget'` — a typo must not silently reset nothing, or everything. Without `only`, the default below applies unchanged:
|
|
105
|
-
|
|
106
|
-
- **All rate-limit buckets** via `clearAllRateLimits()` from `@luckystack/core`. Every key under the rate-limit Redis namespace is dropped.
|
|
107
|
-
- **Default Redis session namespaces** matching `formatKey('-session', '') + ':*'` and `formatKey('-activeUsers', '') + ':*'`. They are scanned in batches of 200 and follow the registered Redis key formatter.
|
|
108
|
-
- **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.
|
|
109
|
-
|
|
110
|
-
Response:
|
|
111
|
-
|
|
112
|
-
```json
|
|
113
|
-
{ "status": "success", "cleared": ["rateLimits", "sessions", "activeUsers"] }
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
The `cleared` array reflects what actually had keys to drop. It is informational; `runRateLimitTests` only checks `response.ok`.
|
|
117
|
-
|
|
118
|
-
> **Storage boundary:** the endpoint does not call `SessionAdapter`. A custom DynamoDB/Postgres/in-memory session store survives this reset, as do application database rows. Tests using custom storage must clear it explicitly in their own setup/teardown.
|
|
119
|
-
|
|
120
|
-
## Types
|
|
121
|
-
|
|
122
|
-
```ts
|
|
123
|
-
interface RateLimitCheckInput {
|
|
124
|
-
endpoint: EndpointDescriptor;
|
|
125
|
-
baseUrl: string;
|
|
126
|
-
rateLimit: number;
|
|
127
|
-
inputFor?: (endpoint: EndpointDescriptor) => unknown;
|
|
128
|
-
headers?: Record<string, string>;
|
|
129
|
-
requestTimeoutMs?: number;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
interface RunRateLimitTestsInput {
|
|
133
|
-
apiMethodMap: ApiMethodMap;
|
|
134
|
-
apiMetaMap: ApiMetaMap;
|
|
135
|
-
baseUrl: string;
|
|
136
|
-
skip?: string[];
|
|
137
|
-
inputFor?: (endpoint: EndpointDescriptor) => unknown;
|
|
138
|
-
headers?: Record<string, string>;
|
|
139
|
-
maxRateLimitToTest?: number;
|
|
140
|
-
resetBetweenEndpoints?: boolean;
|
|
141
|
-
resetToken?: string;
|
|
142
|
-
onResult?: (result: ContractCheckResult) => void;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
interface ResetServerStateInput {
|
|
146
|
-
baseUrl: string;
|
|
147
|
-
token?: string;
|
|
148
|
-
requestTimeoutMs?: number;
|
|
149
|
-
only?: TestResetTarget[];
|
|
150
|
-
}
|
|
151
|
-
```
|
|
152
|
-
|
|
153
|
-
## Result shapes by branch
|
|
154
|
-
|
|
155
|
-
- **Pass**: `{ status: 'pass', httpStatus, responseStatus: 'error', errorCode: 'api.rateLimitExceeded', durationMs }`.
|
|
156
|
-
- **Skip / pre-limiter rejection**: `{ status: 'skipped', durationMs, reason: 'drain request ${i+1} rejected pre-limiter (${errorCode}); bucket was never incremented — check CSRF/auth headers' }`. Emitted when a drain request is rejected by the auth guard or CSRF middleware (`auth.required` / `auth.forbidden` / `auth.csrfMismatch`) before the limiter runs — the bucket never filled, so the `N+1` assertion would be meaningless.
|
|
157
|
-
- **Fail / final probe never returned**: `{ status: 'fail', reason: 'Final rate-limit probe request failed to return a response', durationMs }`.
|
|
158
|
-
- **Fail / wrong status or code**: `{ status: 'fail', httpStatus, responseStatus, errorCode, reason: "expected 'api.rateLimitExceeded' on request ${N+1} but got <status>/<code>", durationMs }`.
|
|
159
|
-
- **Skip / over max**: `{ status: 'skipped', durationMs: 0, reason: 'rateLimit ${N} exceeds maxRateLimitToTest=${max}' }`.
|
|
160
|
-
- **Skip / explicit**: `{ status: 'skipped', durationMs: 0, reason: 'Explicitly skipped' }`.
|
|
161
|
-
|
|
162
|
-
`durationMs` covers the entire `N+1` request sequence, not just the final probe.
|
|
163
|
-
|
|
164
|
-
## Examples
|
|
165
|
-
|
|
166
|
-
### Full sweep with state reset between endpoints
|
|
167
|
-
|
|
168
|
-
```ts
|
|
169
|
-
import { apiMethodMap } from './generated/apiMethodMap.generated';
|
|
170
|
-
import { apiMetaMap } from './generated/apiMetaMap.generated';
|
|
171
|
-
import {
|
|
172
|
-
resetServerState,
|
|
173
|
-
runRateLimitTests,
|
|
174
|
-
logContractResult,
|
|
175
|
-
logContractSummary,
|
|
176
|
-
} from '@luckystack/test-runner';
|
|
177
|
-
|
|
178
|
-
const baseUrl = process.env.TEST_BASE_URL ?? 'http://127.0.0.1:80';
|
|
179
|
-
const resetToken = process.env.TEST_RESET_TOKEN;
|
|
180
|
-
|
|
181
|
-
await resetServerState({ baseUrl, token: resetToken });
|
|
182
|
-
|
|
183
|
-
const summary = await runRateLimitTests({
|
|
184
|
-
apiMethodMap,
|
|
185
|
-
apiMetaMap,
|
|
186
|
-
baseUrl,
|
|
187
|
-
resetBetweenEndpoints: true,
|
|
188
|
-
resetToken,
|
|
189
|
-
onResult: logContractResult,
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
logContractSummary(summary);
|
|
193
|
-
```
|
|
194
|
-
|
|
195
|
-
### Local dev: skip the reset (faster, less hygienic)
|
|
196
|
-
|
|
197
|
-
```ts
|
|
198
|
-
await runRateLimitTests({
|
|
199
|
-
apiMethodMap,
|
|
200
|
-
apiMetaMap,
|
|
201
|
-
baseUrl: process.env.TEST_BASE_URL ?? 'http://127.0.0.1:80',
|
|
202
|
-
// No resetBetweenEndpoints — fine if all endpoints have separate limiter keys.
|
|
203
|
-
});
|
|
204
|
-
```
|
|
205
|
-
|
|
206
|
-
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.
|
|
207
|
-
|
|
208
|
-
### Single-endpoint debug
|
|
209
|
-
|
|
210
|
-
```ts
|
|
211
|
-
import { runRateLimitCheck } from '@luckystack/test-runner';
|
|
212
|
-
|
|
213
|
-
const result = await runRateLimitCheck({
|
|
214
|
-
endpoint: {
|
|
215
|
-
page: 'auth',
|
|
216
|
-
name: 'login',
|
|
217
|
-
version: 'v1',
|
|
218
|
-
method: 'POST',
|
|
219
|
-
fullPath: 'api/auth/login/v1',
|
|
220
|
-
},
|
|
221
|
-
baseUrl: process.env.TEST_BASE_URL ?? 'http://127.0.0.1:80',
|
|
222
|
-
rateLimit: 5, // matches the value in src/auth/_api/login_v1.ts
|
|
223
|
-
inputFor: () => ({ email: 'test@example.com', password: 'wrong' }),
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
console.log(result);
|
|
227
|
-
```
|
|
228
|
-
|
|
229
|
-
### Staging deploy with a token
|
|
230
|
-
|
|
231
|
-
```ts
|
|
232
|
-
await runRateLimitTests({
|
|
233
|
-
apiMethodMap,
|
|
234
|
-
apiMetaMap,
|
|
235
|
-
baseUrl: 'https://staging.example.com',
|
|
236
|
-
resetBetweenEndpoints: true,
|
|
237
|
-
resetToken: process.env.STAGING_TEST_RESET_TOKEN,
|
|
238
|
-
maxRateLimitToTest: 100, // wider net for nightly sweep
|
|
239
|
-
});
|
|
240
|
-
```
|
|
241
|
-
|
|
242
|
-
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.
|
|
243
|
-
|
|
244
|
-
### Tighten the budget
|
|
245
|
-
|
|
246
|
-
```ts
|
|
247
|
-
await runRateLimitTests({
|
|
248
|
-
apiMethodMap,
|
|
249
|
-
apiMetaMap,
|
|
250
|
-
baseUrl,
|
|
251
|
-
maxRateLimitToTest: 20, // skip everything noisier than 20/window
|
|
252
|
-
});
|
|
253
|
-
```
|
|
254
|
-
|
|
255
|
-
Endpoints with `rateLimit: 100` will appear as skipped with the reason inline.
|
|
256
|
-
|
|
257
|
-
## Edge cases and gotchas
|
|
258
|
-
|
|
259
|
-
- **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.
|
|
260
|
-
- **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.
|
|
261
|
-
- **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.
|
|
262
|
-
- **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.
|
|
263
|
-
- **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.
|
|
264
|
-
- **`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.
|
|
265
|
-
- **`/_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.
|
|
266
|
-
- **Sessions cleared as collateral damage — only on an unscoped reset.** `resetServerState` without `only` drops sessions and active users in addition to limiter buckets. The runner's own resets pass `only: ['rateLimits']` and leave sessions alone; a direct call without `only` still logs out every logged-in test.
|
|
267
|
-
- **`?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.
|
|
15
|
+
Isolate tests that reset state from concurrent authenticated sessions, and do not clear framework hooks in a long-running server or test process: boot-time registrations may not return until restart. See the server implementation for the current reset scope.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luckystack/test-runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"files": [
|
|
44
44
|
"dist",
|
|
45
45
|
"README.md",
|
|
46
|
-
"
|
|
46
|
+
"AGENTS.md",
|
|
47
47
|
"docs",
|
|
48
48
|
"LICENSE",
|
|
49
49
|
"CHANGELOG.md"
|
|
@@ -54,11 +54,11 @@
|
|
|
54
54
|
"test": "vitest run"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@luckystack/core": "^0.
|
|
57
|
+
"@luckystack/core": "^0.11.0"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
|
-
"@luckystack/login": "^0.
|
|
61
|
-
"@luckystack/secret-manager": "^0.
|
|
60
|
+
"@luckystack/login": "^0.11.0",
|
|
61
|
+
"@luckystack/secret-manager": "^0.11.0",
|
|
62
62
|
"socket.io-client": "^4.8.0",
|
|
63
63
|
"zod": "^4.0.0"
|
|
64
64
|
},
|