@luckystack/test-runner 0.2.7 → 0.3.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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  The auth-enforcement layer answers one question per endpoint: "If a logged-out caller hits this route, does the framework reject them?" It is the cheapest insurance policy against a `_api/*` file that forgets to set `auth.login: true`, an `auth.login` guard that was silently swapped to `false` during a refactor, or a middleware change in `@luckystack/api` that stops short-circuiting unauthenticated requests.
4
4
 
5
- Pass criterion: the endpoint returns `{ status: 'error', errorCode: 'auth.required' }`. Anything else — `success`, a different `errorCode`, a missing envelope, a network error — is a failure.
5
+ Pass criterion: the endpoint returns `{ status: 'error', errorCode }` where `errorCode` is one of the auth-rejection codes — `auth.required` (an `auth.login: true` route) or `auth.forbidden` (a route guarded only by `auth.additional[]` predicates). Anything else — `success`, a different `errorCode`, a missing envelope, a network error — is a failure.
6
6
 
7
7
  ## Functions
8
8
 
@@ -32,14 +32,14 @@ Flow:
32
32
  5. Branch on `parsed.status`:
33
33
  - `'success'` -> fail with `reason: 'auth.login endpoint returned success without a session'`. This is the headline case: the endpoint is open when it should be closed.
34
34
  - Not `'error'` (missing, weird) -> fail with `'Response missing standard `status` envelope'`.
35
- - `'error'` with `errorCode !== 'auth.required'` -> fail with the expected/actual codes inline in the `reason`. A different error code (e.g. validation rejecting `{}`) means the auth guard never ran — the request was killed earlier in the pipeline.
36
- - `'error'` with `errorCode === 'auth.required'` -> pass.
35
+ - `'error'` with an `errorCode` outside the auth-rejection set (`auth.required` | `auth.forbidden`) -> fail with the expected/actual codes inline in the `reason` (`expected an auth-rejection errorCode (auth.required | auth.forbidden) but got '<actual>'`). A different error code (e.g. validation rejecting `{}`) means the auth guard never ran — the request was killed earlier in the pipeline.
36
+ - `'error'` with `errorCode === 'auth.required'` OR `errorCode === 'auth.forbidden'` -> pass.
37
37
 
38
- The canonical error code is imported from the framework constant `auth.required` (see `packages/api/src/handleHttpApiRequest.ts`). It is intentionally hard-coded in the runner rather than fed from config because the whole point is to detect drift: if `@luckystack/api` ever renames the code, this layer should fail every protected endpoint until consumers acknowledge the change.
38
+ The accepted error codes are the framework's auth-rejection set, hard-coded in the runner as `EXPECTED_ERROR_CODES = new Set(['auth.required', 'auth.forbidden'])` (see `packages/api/src/handleHttpApiRequest.ts` + `packages/core/src/validateRequest.ts`). `auth.required` is what an `auth.login: true` route emits for an anonymous caller; `auth.forbidden` is what an `auth.additional[]`-only route (`login: false`) emits when the predicate rejects the anonymous caller. Either one is a PASS — both mean the guard fired. They are intentionally hard-coded rather than fed from config because the whole point is to detect drift: if `@luckystack/api` ever renames a code, this layer should fail every protected endpoint until consumers acknowledge the change.
39
39
 
40
40
  ### `runAuthEnforcementTests(input)`
41
41
 
42
- Full sweep. Iterates `walkEndpoints(input.apiMethodMap)` and only checks endpoints where `apiMetaMap[page][name][version].auth.login === true`. Public endpoints (`auth.login: false` or missing meta) are skipped **silently** — they do not appear in the summary at all. This keeps the report focused on what this layer actually covers; public endpoints are already exercised by the contract layer.
42
+ Full sweep. Iterates `walkEndpoints(input.apiMethodMap)` and checks every endpoint that declares an authorization requirement — `apiMetaMap[page][name][version].auth.login === true` OR a non-empty `auth.additional[]` predicate list (`hasAdditional`). Truly-public endpoints (`auth.login: false` AND no `additional[]`, or missing meta) are skipped **silently** — they do not appear in the summary at all. This keeps the report focused on what this layer actually covers; public endpoints are already exercised by the contract layer.
43
43
 
44
44
  Signature:
45
45
 
@@ -66,7 +66,7 @@ Behavior notes:
66
66
 
67
67
  - **`skip` uses the same two-tier matching as `runContractTests`**: pass `'<page>/<name>'` for version-agnostic skip, or `'<page>/<name>/<version>'` for a specific version.
68
68
  - **`onResult` fires only for endpoints that pass `requiresLogin`.** Public endpoints don't trigger it; they don't exist for this layer.
69
- - **`additional` auth checks (e.g. role-based gates) are NOT covered.** Only the `login: true` flag is read. An endpoint with `auth.login: true, additional: [{ role: 'admin' }]` is asserted by this layer to reject unauthenticated requests with `auth.required`; the role check is out of scope and lives in a custom layer (see `extension-hooks.md`).
69
+ - **Unauthenticated enforcement covers `additional[]`-only routes too.** An endpoint guarded only by `auth.additional[]` predicates (`login: false`) is probed and must reject an anonymous caller with `auth.forbidden`. The DEEPER role/scope *value* assertion (e.g. "this admin-only route rejects a logged-in non-admin") is out of scope and lives in a custom layer (see `extension-hooks.md`).
70
70
 
71
71
  Returns the same `RunContractSummary` shape as the contract layer; `total` is the number of protected endpoints actually probed (after public-endpoint filtering).
72
72
 
@@ -94,12 +94,12 @@ interface RunAuthEnforcementTestsInput {
94
94
 
95
95
  ## Result shapes by branch
96
96
 
97
- - **Pass**: `{ status: 'pass', httpStatus, responseStatus: 'error', errorCode: 'auth.required', durationMs }`.
97
+ - **Pass (`auth.login` route)**: `{ status: 'pass', httpStatus, responseStatus: 'error', errorCode: 'auth.required', durationMs }`.
98
+ - **Pass (`additional[]`-only route)**: `{ status: 'pass', httpStatus, responseStatus: 'error', errorCode: 'auth.forbidden', durationMs }`.
98
99
  - **Fail / open endpoint**: `{ status: 'fail', httpStatus, responseStatus: 'success', reason: 'auth.login endpoint returned success without a session', durationMs }`.
99
- - **Fail / wrong errorCode**: `{ status: 'fail', httpStatus, responseStatus: 'error', errorCode: '<actual>', reason: "expected errorCode 'auth.required' but got '<actual>'", durationMs }`.
100
+ - **Fail / wrong errorCode**: `{ status: 'fail', httpStatus, responseStatus: 'error', errorCode: '<actual>', reason: "expected an auth-rejection errorCode (auth.required | auth.forbidden) but got '<actual>'", durationMs }`.
100
101
  - **Fail / non-envelope**: `{ status: 'fail', httpStatus, responseStatus: 'unknown', reason: 'Response missing standard `status` envelope', durationMs }`.
101
- - **Fail / JSON parse**: `{ status: 'fail', httpStatus, responseStatus: 'unknown', reason: 'JSON parse failed: ...', durationMs }`.
102
- - **Fail / fetch error**: `{ status: 'fail', reason: <Error.message>, durationMs }`.
102
+ - **Fail / fetch no response**: `{ status: 'fail', reason: 'fetch returned no response', durationMs }`.
103
103
  - **Skip**: `{ status: 'skipped', durationMs: 0, reason: 'Explicitly skipped' }`.
104
104
 
105
105
  ## Examples
@@ -187,8 +187,8 @@ Prefer fixing the meta over skipping — a wrong `auth.login` flag breaks runtim
187
187
  ## Edge cases and gotchas
188
188
 
189
189
  - **CSRF.** The framework enforces CSRF on state-changing requests. The runner sends no `X-CSRF-Token`, so for a brief window the request can be rejected on CSRF grounds before reaching auth. The current framework returns `auth.required` ahead of CSRF for unauthenticated sessions, so this layer passes; if you reorder middleware to CSRF-first, this layer will need updating.
190
- - **`additional` auth checks** are not covered here. Put role/scope checks in a `registerTestLayer({ name: 'role-enforcement', run })` plug-in (see `extension-hooks.md`).
191
- - **Endpoints absent from `apiMetaMap`.** The runner reads `apiMetaMap[page]?.[name]?.[version]?.auth.login`. If any link in that chain is missing, `requiresLogin` returns `false` and the endpoint is silently skipped. Regenerate the meta map if your sweep reports `total: 0`.
190
+ - **Deeper `additional` role/scope *value* checks** are not covered here (the layer only asserts an anonymous caller is rejected with `auth.forbidden`). Put authenticated-but-wrong-role checks in a `registerTestLayer({ name: 'role-enforcement', run })` plug-in (see `extension-hooks.md`).
191
+ - **Endpoints absent from `apiMetaMap`.** The runner reads `apiMetaMap[page]?.[name]?.[version]?.auth`. If any link in that chain is missing, `hasAuthRequirement` returns `false` and the endpoint is silently skipped. Regenerate the meta map if your sweep reports `total: 0`.
192
192
  - **Session-cookie names are project-specific.** This layer never sets one, so the name doesn't matter to the runner. It does matter for the auth layer's mirror image: a custom "logged-in sweep" you might write to confirm protected endpoints work *with* a valid session.
193
193
  - **Wrong errorCode is a real signal.** When you see `expected 'auth.required' but got 'auth.sessionExpired'`, the framework is detecting and reporting the missing session — just with a stricter code. Either align the runner expectation (fork or PR) or align the framework code. Do not skip.
194
194
  - **`@luckystack/login` session-token interaction.** Sessions live in Redis under `${projectName}-session:*` keys. The runner does not touch them. If a logged-in regression suite must run before this layer, use `resetServerState()` to clear sessions and rate limits in one shot (see `rate-limit-tests.md`).
@@ -29,7 +29,7 @@ Output format:
29
29
  - Pass success: `[PASS] POST /api/billing/getInvoice/v1 12ms http=200`
30
30
  - Pass typed error: `[PASS] POST /api/auth/login/v1 8ms http=200 (error: auth.required)`
31
31
  - Skip: `[SKIP] POST /api/uploads/fileUpload/v1 0ms Explicitly skipped`
32
- - Fail: `[FAIL] POST /api/billing/cancelSubscription/v1 14ms http=500 reason=fuzz payload produced 5xx: ...`
32
+ - Fail: `[FAIL] POST /api/billing/cancelSubscription/v1 14ms http=500 reason=fuzz payload produced non-envelope response: ...`
33
33
 
34
34
  Status tag is one of `[PASS]`, `[FAIL]`, `[SKIP]`. `http=-` appears when no HTTP status is captured (network error, abort). `durationMs` is always present.
35
35
 
@@ -49,7 +49,7 @@ Output:
49
49
  Contract tests: 47/50 passed, 2 failed, 1 skipped
50
50
 
51
51
  Failures:
52
- - /api/billing/cancelSubscription/v1: fuzz payload produced 5xx: { nested: ...
52
+ - /api/billing/cancelSubscription/v1: fuzz payload produced non-envelope response: { nested: ...
53
53
  - /api/integrations/stripeWebhook/v1: Response missing standard `status` envelope
54
54
  ```
55
55
 
@@ -84,7 +84,7 @@ interface TestLayerResult {
84
84
 
85
85
  Replace-by-name: registering a layer with a name that already exists overwrites the previous entry. Use this to update a layer mid-run, e.g. after fetching a fresh auth token.
86
86
 
87
- The runner does **not** currently wire registered layers into the built-in sweeps automatically. Today they live in the registry as a coordination surface for custom test harnesses — read them with `listTestLayers()`, iterate `walkEndpoints`, and call `layer.run(input)` per endpoint. The roadmap is for a future `runRegisteredLayers({ apiMethodMap, baseUrl })` entry point to walk the registry as a fifth (or N-th) built-in layer.
87
+ The built-in sweeps (contract/auth/rate-limit/fuzz) do **not** run registered layers — they only cover the framework envelope, and `runAllTests` does not call into the registry. To execute the registered layers, call **`runRegisteredLayers({ apiMethodMap })`** (documented below): it walks every registered layer against every endpoint, fans each result through the registered reporter, and POSTs the webhook. You can still drive the registry by hand — read it with `listTestLayers()`, iterate `walkEndpoints`, and call `layer.run(input)` per endpoint — but `runRegisteredLayers` is the supported entry point and adds error-isolation the manual loop does not.
88
88
 
89
89
  ### `listTestLayers()`
90
90
 
@@ -98,6 +98,30 @@ listTestLayers(): TestLayer[]
98
98
 
99
99
  Order is registration order (Map iteration order). The returned array is a fresh copy — mutating it does not affect the registry.
100
100
 
101
+ ### `runRegisteredLayers(input)`
102
+
103
+ Run every registered layer against every endpoint in `apiMethodMap` and emit the aggregate through the registered reporter. This is the shipped entry point for the layer registry — the built-in sweeps never touch it, and it never runs the built-in sweeps.
104
+
105
+ Signature:
106
+
107
+ ```ts
108
+ runRegisteredLayers(input: RunRegisteredLayersInput): Promise<TestSummary>
109
+
110
+ interface RunRegisteredLayersInput {
111
+ apiMethodMap: ApiMethodMap;
112
+ authToken?: string; // forwarded to each layer's run({ authToken })
113
+ skip?: string[]; // '<page>/<name>' or '<page>/<name>/<version>'
114
+ }
115
+ ```
116
+
117
+ Behavior (read from `src/runRegisteredLayers.ts`):
118
+
119
+ - **Cartesian walk.** For each layer from `listTestLayers()` (registration order) × each endpoint from `walkEndpoints(apiMethodMap)`, it calls `layer.run({ endpoint: endpoint.fullPath, method: endpoint.method, authToken })`. `skip` uses the same two-tier matching as the built-in sweeps; skipped endpoints produce no result.
120
+ - **Error isolation.** Each `layer.run` is wrapped in `tryCatch`. A throwing layer (or one returning a falsy result) does not abort the walk — it is recorded as a failed `TestResult` whose `message` is the thrown error's message, or `'layer returned no result'`. Each result's `durationMs` is measured around the call. This is the behavior the manual `listTestLayers()` loop lacks, where a throw propagates and skips the rest.
121
+ - **Reporter fan-out.** After each result it calls `reporter.onResult?.(result)`; after the walk it calls `reporter.onSummary?.(summary)`. Both are wrapped in `tryCatch`, so a throwing reporter callback never fails the run — unlike the built-in sweeps, whose `onResult` is called un-wrapped.
122
+ - **Webhook.** If the reporter declares a `webhookUrl`, the summary is POSTed to it: `Content-Type: application/json`, plus `Authorization: Bearer <token>` when `webhookAuth?.type === 'bearer'`, body `JSON.stringify(summary)`, with a 10s `AbortController` timeout. The POST is best-effort (`tryCatch`-wrapped) — a failure never fails the run. A plaintext-`http:` URL pointed at a non-loopback host logs a `console.warn` (the summary carries endpoint paths, error codes, and any bearer token in clear) but is never blocked, since the URL is consumer-self-registered.
123
+ - **Result.** Returns the `TestSummary` (`totalLayers`, `totalEndpoints`, `passed`, `failed`, `results`). Unlike the built-in sweeps, the per-result shape here is `TestResult` (the registry shape), not `ContractCheckResult`.
124
+
101
125
  ### `registerTestReporter(reporter)`
102
126
 
103
127
  Set the reporter slot, or clear it by passing `null`.
@@ -134,7 +158,7 @@ interface TestSummary {
134
158
 
135
159
  Replace-by-overwrite: only one reporter is active. To compose multiple sinks, write a single reporter that fans out internally.
136
160
 
137
- Like layers, the reporter is a coordination surface today: the built-in sweeps emit `ContractCheckResult` shapes, not `TestResult`. The registered reporter is intended for the cross-layer aggregator that lives in your test harness — collect results from each built-in sweep, map them into `TestResult`, and emit through this reporter so all sinks see one stream.
161
+ `runRegisteredLayers` drives this reporter directly: it emits a `TestResult` per layer×endpoint through `onResult`, calls `onSummary` once at the end, and POSTs `webhookUrl`. The built-in sweeps do NOT — they emit `ContractCheckResult` shapes, not `TestResult`, so for them the reporter is a coordination surface your cross-layer aggregator consumes: collect each built-in sweep's results, map them into `TestResult`, and emit through this reporter so all sinks see one stream.
138
162
 
139
163
  ### `getTestReporter()`
140
164
 
@@ -160,13 +184,14 @@ The name advertises the intent: do not import this from production paths. There
160
184
 
161
185
  ## Webhook contract
162
186
 
163
- When `reporter.webhookUrl` is set, the harness that drives the reporter is expected to POST the JSON-serialised `TestSummary` to that URL at the end of the sweep. The runner's own built-in sweeps do not call the webhook themselves — that's the job of the consumer's aggregator. Recommended client behavior:
187
+ When `reporter.webhookUrl` is set, **`runRegisteredLayers` POSTs the JSON-serialised `TestSummary` to that URL automatically** at the end of its run — you do not wire it yourself. The built-in sweeps (contract/auth/rate-limit/fuzz) do NOT: if you drive only those, the POST is your aggregator's job. The behavior `runRegisteredLayers` implements (and a hand-rolled aggregator should mirror):
164
188
 
165
189
  - **Method:** `POST`.
166
190
  - **Headers:** `Content-Type: application/json`, plus `Authorization: Bearer <token>` when `webhookAuth?.type === 'bearer'`.
167
191
  - **Body:** `JSON.stringify(summary)`. `TestSummary.results` can be large — clip or compress on the producer side if the receiver has body limits.
168
- - **Timeout:** wrap the POST in `tryCatch` with a sensible per-call timeout (e.g. 5s). A webhook failure should not fail the sweep.
169
- - **Retries:** none by default. Add idempotency at the receiver if you want retries; the runner is a one-shot test driver, not a queue.
192
+ - **Timeout:** a 10s `AbortController` timeout, with the POST wrapped in `tryCatch`. A webhook failure does not fail the run.
193
+ - **Plaintext warning:** a `webhookUrl` whose protocol is `http:` and whose host is not loopback logs a `console.warn` before POSTing — the summary (endpoint paths, error codes, metadata) and any bearer token go over the wire unencrypted. The warning never blocks the POST.
194
+ - **Retries:** none. Add idempotency at the receiver if you want retries; the runner is a one-shot test driver, not a queue.
170
195
 
171
196
  Concrete example of a webhook-emitting reporter:
172
197
 
@@ -1,8 +1,8 @@
1
1
  # Fuzz Tests
2
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.
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 *crashes* (returns no response at all) and never returns a body that escapes the framework envelope. A 5xx status is **tolerated as long as it still carries a valid `{ status: 'error', errorCode }` envelope** — what the layer pins is that the framework caught and shaped the error, not the numeric status code. It is intentionally exhaustive and slow; wire it into nightly CI, not into every PR.
4
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.
5
+ Pass criterion: across every probe, the server returns *some* response (no fetch crash) whose body is a valid framework envelope `{ status: 'success' | 'error', errorCode? }` (with `errorCode` a string whenever `status === 'error'`). The HTTP status code itself is NOT asserted — a 5xx that still returns a valid envelope passes. A crashed connection (no response at all), a non-JSON / non-envelope body, or an `error` envelope missing a string `errorCode` is a fail, and the probe loop short-circuits to report the offending payload.
6
6
 
7
7
  ## Functions
8
8
 
@@ -29,8 +29,8 @@ Flow:
29
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
30
  3. After each request:
31
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: ...'`.
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
34
  - **`status === 'error'` and `errorCode` is not a string:** fail with `reason: 'fuzz error response missing errorCode for payload: ...'`.
35
35
  4. If every payload passes, return pass.
36
36
 
@@ -144,8 +144,7 @@ interface TestFixture<TPayload = unknown> {
144
144
 
145
145
  - **Pass**: `{ status: 'pass', durationMs }`. No `httpStatus` — the layer does not report which payloads passed, only that none failed.
146
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 }`.
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.
149
148
  - **Fail / error missing code**: `{ status: 'fail', httpStatus, reason: 'fuzz error response missing errorCode for payload: <preview>', durationMs }`.
150
149
  - **Skip**: `{ status: 'skipped', durationMs: 0, reason: 'Explicitly skipped' }`.
151
150
 
@@ -25,14 +25,14 @@ interface RateLimitCheckInput {
25
25
 
26
26
  Flow:
27
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.
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
29
  2. Fire one final request. This is the probe.
30
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
31
 
32
32
  Important details:
33
33
 
34
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.
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
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
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
38
 
@@ -143,6 +143,7 @@ interface ResetServerStateInput {
143
143
  ## Result shapes by branch
144
144
 
145
145
  - **Pass**: `{ status: 'pass', httpStatus, responseStatus: 'error', errorCode: 'api.rateLimitExceeded', durationMs }`.
146
+ - **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.
146
147
  - **Fail / final probe never returned**: `{ status: 'fail', reason: 'Final rate-limit probe request failed to return a response', durationMs }`.
147
148
  - **Fail / wrong status or code**: `{ status: 'fail', httpStatus, responseStatus, errorCode, reason: "expected 'api.rateLimitExceeded' on request ${N+1} but got <status>/<code>", durationMs }`.
148
149
  - **Skip / over max**: `{ status: 'skipped', durationMs: 0, reason: 'rateLimit ${N} exceeds maxRateLimitToTest=${max}' }`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luckystack/test-runner",
3
- "version": "0.2.7",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -54,10 +54,10 @@
54
54
  "test": "vitest run"
55
55
  },
56
56
  "dependencies": {
57
- "@luckystack/core": "^0.2.7"
57
+ "@luckystack/core": "^0.3.0"
58
58
  },
59
59
  "peerDependencies": {
60
- "@luckystack/login": "^0.2.7",
60
+ "@luckystack/login": "^0.3.0",
61
61
  "socket.io-client": "^4.8.0",
62
62
  "zod": "^4.0.0"
63
63
  },