@luckystack/test-runner 0.1.4 → 0.1.6

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.
@@ -1,249 +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.
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.