@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.
- package/CLAUDE.md +4 -2
- package/dist/index.d.ts +51 -6
- package/dist/index.js +134 -34
- package/dist/index.js.map +1 -1
- package/docs/auth-tests.md +12 -12
- package/docs/extension-hooks.md +32 -7
- package/docs/fuzz-tests.md +5 -6
- package/docs/rate-limit-tests.md +3 -2
- package/package.json +3 -3
package/docs/auth-tests.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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 /
|
|
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
|
-
-
|
|
191
|
-
- **Endpoints absent from `apiMetaMap`.** The runner reads `apiMetaMap[page]?.[name]?.[version]?.auth
|
|
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`).
|
package/docs/extension-hooks.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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:**
|
|
169
|
-
- **
|
|
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
|
|
package/docs/fuzz-tests.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 /
|
|
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
|
|
package/docs/rate-limit-tests.md
CHANGED
|
@@ -25,14 +25,14 @@ interface RateLimitCheckInput {
|
|
|
25
25
|
|
|
26
26
|
Flow:
|
|
27
27
|
|
|
28
|
-
1. Fire `rateLimit` requests back-to-back.
|
|
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
|
|
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.
|
|
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.
|
|
57
|
+
"@luckystack/core": "^0.3.0"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
|
-
"@luckystack/login": "^0.
|
|
60
|
+
"@luckystack/login": "^0.3.0",
|
|
61
61
|
"socket.io-client": "^4.8.0",
|
|
62
62
|
"zod": "^4.0.0"
|
|
63
63
|
},
|