@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.
Files changed (40) hide show
  1. package/AGENTS.md +7 -0
  2. package/CHANGELOG.md +7 -0
  3. package/README.md +13 -68
  4. package/dist/authEnforcementCheck.d.ts +12 -0
  5. package/dist/contractCheck.d.ts +24 -0
  6. package/dist/csrfEnforcementCheck.d.ts +25 -0
  7. package/dist/customTests.d.ts +145 -0
  8. package/dist/extensionRegistry.d.ts +83 -0
  9. package/dist/fuzzCheck.d.ts +12 -0
  10. package/dist/index.d.ts +49 -745
  11. package/dist/index.js.map +1 -1
  12. package/dist/probeRequest.d.ts +48 -0
  13. package/dist/rateLimitCheck.d.ts +14 -0
  14. package/dist/reporter.d.ts +3 -0
  15. package/dist/resetServerState.d.ts +13 -0
  16. package/dist/resolveTestBaseUrl.d.ts +7 -0
  17. package/dist/resolveTestEnvironment.d.ts +21 -0
  18. package/dist/runAllTests.d.ts +88 -0
  19. package/dist/runAuthEnforcementTests.d.ts +17 -0
  20. package/dist/runContractTests.d.ts +22 -0
  21. package/dist/runCsrfEnforcementTests.d.ts +29 -0
  22. package/dist/runFuzzTests.d.ts +16 -0
  23. package/dist/runRateLimitTests.d.ts +38 -0
  24. package/dist/runRegisteredLayers.d.ts +24 -0
  25. package/dist/sampleInputFor.d.ts +13 -0
  26. package/dist/schemaSampleInput.d.ts +12 -0
  27. package/dist/socketProbeTransport.d.ts +9 -0
  28. package/dist/streamWatcher.d.ts +37 -0
  29. package/dist/testDataMarker.d.ts +2 -0
  30. package/dist/testLayerHelpers.d.ts +55 -0
  31. package/dist/testSocket.d.ts +58 -0
  32. package/dist/types.d.ts +46 -0
  33. package/dist/walkEndpoints.d.ts +6 -0
  34. package/docs/auth-tests.md +4 -223
  35. package/docs/contract-tests.md +11 -307
  36. package/docs/extension-hooks.md +4 -467
  37. package/docs/fuzz-tests.md +4 -245
  38. package/docs/rate-limit-tests.md +9 -261
  39. package/package.json +5 -5
  40. package/CLAUDE.md +0 -117
@@ -1,226 +1,7 @@
1
- # Auth Enforcement Tests
1
+ # Auth enforcement tests
2
2
 
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.
3
+ This layer deliberately calls protected endpoints **without credentials** to verify that the guard rejects an anonymous request. A rejection from validation or an unrelated handler is not evidence that authorization ran. Routes with additional role or scope predicates still need project-specific checks using authenticated lower-privilege users; anonymous tests alone cannot prove tenant isolation.
4
4
 
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.
5
+ Sync routes are covered only when the sweep receives the generated sync metadata, which is also where their auth requirement is read from. Their probes travel over the project's sync transport, so the check exercises the same guard path a real client hits (ADR 0064). GET probes carry their sample payload in the query string like the real client (ADR 0062), so a GET result is not distorted by a payload that never arrived.
6
6
 
7
- ## Functions
8
-
9
- ### `runAuthEnforcementCheck(input)`
10
-
11
- Single-endpoint variant.
12
-
13
- Signature:
14
-
15
- ```ts
16
- runAuthEnforcementCheck(input: AuthEnforcementCheckInput): Promise<ContractCheckResult>
17
-
18
- interface AuthEnforcementCheckInput {
19
- endpoint: EndpointDescriptor;
20
- baseUrl: string;
21
- inputFor?: (endpoint: EndpointDescriptor) => unknown;
22
- requestTimeoutMs?: number; // default 5000
23
- }
24
- ```
25
-
26
- Flow:
27
-
28
- 1. Build URL the same way the contract layer does.
29
- 2. Build body via `inputFor?.(endpoint) ?? {}`. On `GET` it travels in the query string, as in the contract layer.
30
- 3. **Send the request with no session cookie, no `Authorization` header, no `X-CSRF-Token`.** The runner deliberately omits every form of credential — that is the whole point of this layer. `headers` is not a supported field for this reason; passing one would defeat the test.
31
- 4. Parse the JSON. Parse failure is a fail.
32
- 5. Branch on `parsed.status`:
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
- - Not `'error'` (missing, weird) -> fail with `'Response missing standard `status` envelope'`.
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
-
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
-
40
- ### `runAuthEnforcementTests(input)`
41
-
42
- Full sweep. Iterates every API route (plus every sync route when `syncMetaMap` is given — its requirement is read from `syncMetaMap`), narrowed by `filter` before any request, 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
-
44
- Signature:
45
-
46
- ```ts
47
- runAuthEnforcementTests(input: RunAuthEnforcementTestsInput): Promise<RunContractSummary>
48
-
49
- interface RunAuthEnforcementTestsInput {
50
- apiMethodMap: ApiMethodMap;
51
- apiMetaMap: ApiMetaMap;
52
- baseUrl: string;
53
- skip?: string[];
54
- inputFor?: (endpoint: EndpointDescriptor) => unknown;
55
- onResult?: (result: ContractCheckResult) => void;
56
- }
57
-
58
- type ApiMetaMap = Record<string, Record<string, Record<string, {
59
- method: string;
60
- auth: { login: boolean; additional?: Record<string, unknown>[] };
61
- rateLimit?: number | false;
62
- }>>>;
63
- ```
64
-
65
- Behavior notes:
66
-
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
- - **`onResult` fires only for endpoints that pass `requiresLogin`.** Public endpoints don't trigger it; they don't exist for this layer.
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
-
71
- Returns the same `RunContractSummary` shape as the contract layer; `total` is the number of protected endpoints actually probed (after public-endpoint filtering).
72
-
73
- ## Types
74
-
75
- ```ts
76
- interface AuthEnforcementCheckInput {
77
- endpoint: EndpointDescriptor;
78
- baseUrl: string;
79
- inputFor?: (endpoint: EndpointDescriptor) => unknown;
80
- requestTimeoutMs?: number;
81
- }
82
-
83
- interface RunAuthEnforcementTestsInput {
84
- apiMethodMap: ApiMethodMap;
85
- apiMetaMap: ApiMetaMap;
86
- baseUrl: string;
87
- skip?: string[];
88
- inputFor?: (endpoint: EndpointDescriptor) => unknown;
89
- onResult?: (result: ContractCheckResult) => void;
90
- }
91
- ```
92
-
93
- `ContractCheckResult` and `RunContractSummary` are shared with the contract layer — see `contract-tests.md`.
94
-
95
- ## Result shapes by branch
96
-
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 }`.
99
- - **Fail / open endpoint**: `{ status: 'fail', httpStatus, responseStatus: 'success', reason: 'auth.login endpoint returned success without a session', 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 }`.
101
- - **Fail / non-envelope**: `{ status: 'fail', httpStatus, responseStatus: 'unknown', reason: 'Response missing standard `status` envelope', durationMs }`.
102
- - **Fail / fetch no response**: `{ status: 'fail', reason: 'fetch returned no response', durationMs }`.
103
- - **Skip**: `{ status: 'skipped', durationMs: 0, reason: 'Explicitly skipped' }`.
104
-
105
- ## Examples
106
-
107
- ### Full sweep in CI
108
-
109
- ```ts
110
- import { apiMethodMap } from './generated/apiMethodMap.generated';
111
- import { apiMetaMap } from './generated/apiMetaMap.generated';
112
- import {
113
- runAuthEnforcementTests,
114
- logContractResult,
115
- logContractSummary,
116
- } from '@luckystack/test-runner';
117
-
118
- const summary = await runAuthEnforcementTests({
119
- apiMethodMap,
120
- apiMetaMap,
121
- baseUrl: process.env.TEST_BASE_URL ?? 'http://127.0.0.1:80',
122
- onResult: logContractResult,
123
- });
124
-
125
- logContractSummary(summary);
126
-
127
- if (summary.failed > 0) {
128
- // A failure here is a security finding, not a flaky test.
129
- process.exit(2);
130
- }
131
- ```
132
-
133
- ### Single endpoint debug
134
-
135
- ```ts
136
- import { runAuthEnforcementCheck, logContractResult } from '@luckystack/test-runner';
137
-
138
- const result = await runAuthEnforcementCheck({
139
- endpoint: {
140
- page: 'billing',
141
- name: 'cancelSubscription',
142
- version: 'v1',
143
- method: 'POST',
144
- fullPath: 'api/billing/cancelSubscription/v1',
145
- },
146
- baseUrl: process.env.TEST_BASE_URL ?? 'http://127.0.0.1:80',
147
- });
148
-
149
- logContractResult(result);
150
- ```
151
-
152
- ### Bypass an over-eager validator
153
-
154
- Some validators reject `{}` before the auth guard runs. The framework runs auth before validation, but a custom middleware (or a custom error formatter) can flip that order. If you see `errorCode: 'validation.failed'` instead of `auth.required`, supply a payload that the validator accepts so the request reaches the auth check:
155
-
156
- ```ts
157
- await runAuthEnforcementTests({
158
- apiMethodMap,
159
- apiMetaMap,
160
- baseUrl,
161
- inputFor: (endpoint) => {
162
- if (endpoint.fullPath === 'api/billing/cancelSubscription/v1') {
163
- return { subscriptionId: 'sub_test' };
164
- }
165
- return {};
166
- },
167
- });
168
- ```
169
-
170
- When you see this happen for many endpoints, the right fix is usually in the middleware order in `@luckystack/api`, not in the runner.
171
-
172
- ### Skip endpoints with custom auth schemes
173
-
174
- Endpoints that authenticate via a non-session mechanism (e.g. a webhook with HMAC signature, an internal cron token) declare `auth.login: false` and live outside this layer. If for some reason a webhook endpoint is registered as `auth.login: true` (and you cannot fix the source), skip it:
175
-
176
- ```ts
177
- await runAuthEnforcementTests({
178
- apiMethodMap,
179
- apiMetaMap,
180
- baseUrl,
181
- skip: ['integrations/stripeWebhook'],
182
- });
183
- ```
184
-
185
- Prefer fixing the meta over skipping — a wrong `auth.login` flag breaks runtime behavior, not just this test.
186
-
187
- ## Edge cases and gotchas
188
-
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
- - **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
- - **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
- - **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
- - **`@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`).
195
- - **`onResult` async handlers.** The runner does `input.onResult?.(result)` without awaiting. Sync side-effects fire in order; async work is fire-and-forget. Push results into an array and process after the sweep if you need ordering guarantees.
196
- - **Empty `summary.total`.** When the protected-endpoint filter excludes every endpoint, the sweep returns `{ total: 0, passed: 0, failed: 0, skipped: 0, results: [] }`. This is the expected "no protected endpoints" outcome, not a failure mode. Distinguish from network failure by inspecting `apiMetaMap` directly before running the sweep when you want to assert there *should* be protected endpoints.
197
-
198
- ## Pairing with the contract layer
199
-
200
- The auth-enforcement layer covers the unauthenticated path. It does NOT cover the authenticated path — an endpoint that's protected, accepts a valid session, but crashes on real input would pass this layer cleanly. Run the contract layer with real session headers as the second half of the matrix:
201
-
202
- ```ts
203
- await runContractTests({
204
- apiMethodMap,
205
- baseUrl,
206
- headers: { Cookie: 'session=<test-session-cookie>' },
207
- });
208
-
209
- await runAuthEnforcementTests({
210
- apiMethodMap,
211
- apiMetaMap,
212
- baseUrl,
213
- // No headers — the layer deliberately omits credentials.
214
- });
215
- ```
216
-
217
- The first sweep proves "logged-in callers get sane responses"; the second proves "logged-out callers get blocked". A custom role-enforcement layer (registered via `registerTestLayer`) is the natural third leg.
218
-
219
- ## Interaction with `@luckystack/login`
220
-
221
- Sessions written by `@luckystack/login` live in Redis under `${projectName}-session:*`. The auth layer does not read them and never sets a `Cookie` header. When wiring an end-to-end matrix:
222
-
223
- 1. Use `resetServerState()` from `rate-limit-tests.md` to flush sessions before kicking off any logged-in sweep.
224
- 2. Establish one session through the project's `/auth/login` flow (or call `setSession` directly in your harness).
225
- 3. Pass that session as `Cookie: session=<token>` to the contract or fuzz layer.
226
- 4. Run the auth-enforcement layer last (or before step 2) — it does its own thing with no credentials, so order vs the logged-in sweep does not matter for correctness, only for test runtime.
7
+ Do not add shared auth headers or a cookie to this sweep: that would defeat its purpose. Treat an unexpected rejection code as a security finding to investigate, not a reason to skip or loosen the assertion. Source and generated metadata define which endpoints participate and the expected response envelope; avoid copying a route-by-route inventory here.
@@ -1,314 +1,18 @@
1
- # Contract Tests
1
+ # Endpoint contract tests
2
2
 
3
- The contract layer is the entry point of `@luckystack/test-runner`. It walks every endpoint that the devkit emits into `apiMethodMap.generated.ts` (and every sync route in `syncMetaMap` when one is passed), fires a single well-formed request, and asserts the response conforms to the framework envelope. It is the cheapest and broadest of the four layers — it does NOT prove business logic, it proves the route exists, decodes input without crashing, and replies with `{ status: 'success' | 'error', errorCode? }`.
3
+ Generated route maps drive a smoke check over the API surface so tests catch mismatches between generated input schemas and live handlers. Endpoint-specific side effects and business rules still require application tests. Serial execution reduces interference from shared rate-limit state and makes failures easier to attribute; do not assume a parallel sweep is interchangeable.
4
4
 
5
- ## When to use it
5
+ ## Probes follow the project transport
6
6
 
7
- - CI on every PR: catches `_api/<page>/<name>_v<n>.ts` files that have a typo in `main`, a broken import, or a renamed Zod field.
8
- - After regenerating the type map (`npx @luckystack/devkit gen`): confirms the routing table the runtime resolves still matches the file system.
9
- - During refactors of `@luckystack/api` request handling: contract failures here are a fast signal that the envelope shape regressed.
7
+ A route is only meaningfully tested over the path real users hit (ADR 0064). API routes go over HTTP. Sync routes go over the project's own `transport.invocation`: Socket.io in a socket-mode project (the default), the routed `/sync/*` HTTP path in a routed-HTTP project; the caller may override this. The CSRF layer always probes sync over HTTP, because CSRF is an HTTP-only guard. Layer-5 `ctx.callSync` follows the same rule, so a socket-mode project's custom sync tests run through the socket pipeline and differences between the two pipelines surface as failures instead of being masked.
10
8
 
11
- ## Functions
9
+ - Sync routes are swept only when the sweep receives the generated sync metadata (and sync input schemas for valid samples). Without it the sweep warns and covers API routes only.
10
+ - All sync probes in one run target a single throwaway room that the sweep's own session joins, so room-membership enforcement sees a member rather than rejecting every probe.
11
+ - Sockets the runner opens on the caller's session close as an intentional disconnect, the signal a real client sends; a plain close would let presence log that session out shortly after the sweep.
12
+ - GET probes carry their payload in the query string under the reserved key the real client uses (ADR 0062); `fetch` cannot send a GET body, and dropping the payload made every GET route with a required field fail validation.
12
13
 
13
- ### `walkEndpoints(apiMethodMap)`
14
+ ## Sweep boundaries
14
15
 
15
- Flatten the three-level generated map (`page -> name -> version -> method`) into a flat list of endpoint descriptors. Used internally by every `run*Tests` sweep and exported so consumers can build custom layers on top of the same iteration order.
16
+ A route filter narrows the selection before any request is sent; filtered-out routes are never called. An authenticated sweep calls every selected route with its own session, logout-type routes included: a route that destroys the current session (logout, account deletion, session revoke) leaves every later layer running anonymously, so skip such routes explicitly.
16
17
 
17
- Signature:
18
-
19
- ```ts
20
- walkEndpoints(apiMethodMap: ApiMethodMap): EndpointDescriptor[]
21
-
22
- type ApiMethodMap = Record<string, Record<string, Record<string, string>>>;
23
- ```
24
-
25
- The order is deterministic: it follows `Object.entries` over the generated map, which the devkit emits sorted by page name, then api name, then version. Empty maps return `[]` (not an error).
26
-
27
- `walkSyncEndpoints(syncMap)` is the sync counterpart: it takes the generated `syncMetaMap` (or a `syncMethodMap`-shaped map), yields `fullPath: sync/<page>/<name>/<version>`, and marks each descriptor `kind: 'sync'`. A descriptor without `kind` is an API route.
28
-
29
- ### Sync routes and probe transports
30
-
31
- The checks only know "this endpoint, this payload"; a `ProbeTransport` owns the wire. API routes always go over HTTP. Sync routes go over the transport the sweep passes as `syncTransport`: `runAllTests` picks Socket.io (`createSocketProbeTransport` — the same `sync` emit a real client makes) unless the project's `transport.invocation` is `routed-http` or the caller sets `syncTransport: 'http'`, in which case `createHttpProbeTransport` posts the `{ data, receiver }` envelope to `/sync/*`. The CSRF layer always probes sync over HTTP, because CSRF is an HTTP-only guard. Every sync probe in a run targets one throwaway room; with an `authToken` the sweep session joins it first, so room-membership enforcement sees a member rather than rejecting every probe. Without `syncMetaMap`, `runAllTests` warns that sync routes are not swept.
32
-
33
- Layer-5 `ctx.callSync` follows the same rule (project `transport.invocation`, per-call `opts.transport` override) — see `docs/ARCHITECTURE_TESTING.md`.
34
-
35
- ### `runContractCheck(input)`
36
-
37
- Run the contract assertion against one endpoint.
38
-
39
- Signature:
40
-
41
- ```ts
42
- runContractCheck(input: ContractCheckInput): Promise<ContractCheckResult>
43
-
44
- interface ContractCheckInput {
45
- endpoint: EndpointDescriptor;
46
- baseUrl: string;
47
- inputFor?: (endpoint: EndpointDescriptor) => unknown;
48
- headers?: Record<string, string>;
49
- requestTimeoutMs?: number; // default 5000
50
- transport?: ProbeTransport; // default: HTTP
51
- }
52
- ```
53
-
54
- Flow:
55
-
56
- 1. Build URL: `${baseUrl.replace(/\/$/, '')}/${endpoint.fullPath}` so trailing slashes on `baseUrl` are tolerated. (HTTP transport; a socket transport emits instead — see above.)
57
- 2. Build body: `inputFor?.(endpoint) ?? {}`. For `GET` it rides the query string under the reserved key the real client uses (core `buildRoutedGetUrl`); otherwise it is `JSON.stringify`d.
58
- 3. Send the request with `AbortController` wired to `requestTimeoutMs`. A timeout surfaces as a `fetch` error rather than hanging the sweep.
59
- 4. Parse the JSON body. Any parse error is a contract failure (the framework always returns JSON).
60
- 5. Assert `parsed.status` is exactly `'success'` or `'error'`. Anything else (`null`, `undefined`, missing key, a custom shape) fails with `reason: 'Response missing standard `status` envelope'`.
61
- 6. If `parsed.status === 'error'`, assert `parsed.errorCode` is truthy. Missing `errorCode` on an error response is a contract violation — every framework error path is required to set one.
62
-
63
- Pass criterion: a response that round-trips through the framework envelope, regardless of whether it's success or a typed error.
64
-
65
- ### `runContractTests(input)`
66
-
67
- Full sweep version of `runContractCheck`. Iterates every API route (plus every sync route when `syncMetaMap` is given), narrowed by `filter` before any request is sent, serially, and aggregates results.
68
-
69
- Signature:
70
-
71
- ```ts
72
- runContractTests(input: RunContractTestsInput): Promise<RunContractSummary>
73
-
74
- interface RunContractTestsInput {
75
- apiMethodMap: ApiMethodMap;
76
- syncMetaMap?: SyncMetaMap; // sweep sync routes too
77
- filter?: string; // substring on `<page>/<name>/<version>`
78
- syncTransport?: ProbeTransport; // HTTP when omitted
79
- baseUrl: string;
80
- skip?: string[];
81
- inputFor?: (endpoint: EndpointDescriptor) => unknown;
82
- headers?: Record<string, string>;
83
- onResult?: (result: ContractCheckResult) => void;
84
- }
85
- ```
86
-
87
- Behavior:
88
-
89
- - **Every route is called with the sweep's session — logout-type routes included.** An authenticated sweep that hits a route which destroys the current session (a logout, account deletion, session revoke) loses its session for every layer after it. Put such routes in `skip` (`TEST_SKIP` in the scaffolded script).
90
-
91
- - **Serial, not parallel.** The runner walks endpoints one by one. This avoids tripping shared rate-limit buckets, keeps log output linear, and stops endpoint-ordering bugs from being masked by races. A pool option may land later; do not assume concurrency.
92
- - **`skip` matching is two-tier.** `skip: ['billing/getInvoice']` skips every version of that endpoint. `skip: ['billing/getInvoice/v2']` skips only v2. Both forms compose: pass both to skip every version AND keep the specific one explicit.
93
- - **`onResult` is fired after every endpoint** including skipped ones, so progress reporters update in real time. Throwing inside `onResult` propagates out of the sweep — wrap your own handler in `tryCatch` if you want it isolated.
94
- - **Skipped results carry `status: 'skipped'` with `reason: 'Explicitly skipped'`** and `durationMs: 0`. They count toward `summary.skipped`, not `passed` or `failed`.
95
-
96
- Returned summary:
97
-
98
- ```ts
99
- interface RunContractSummary {
100
- total: number; // results.length, including skipped
101
- passed: number;
102
- failed: number;
103
- skipped: number;
104
- results: ContractCheckResult[];
105
- }
106
- ```
107
-
108
- ### `sampleSchemaInput(schema)`
109
-
110
- Walk a Zod schema and produce a deterministic minimal-valid value. The contract layer does not call this automatically — consumers use it inside a custom `inputFor` when an endpoint's Zod validator rejects an empty object.
111
-
112
- Signature:
113
-
114
- ```ts
115
- sampleSchemaInput(schema: z.ZodTypeAny): unknown
116
- ```
117
-
118
- Type-to-sample table:
119
-
120
- | Zod node | Sample |
121
- | --- | --- |
122
- | `ZodString` | `'test'` |
123
- | `ZodNumber` | `0` |
124
- | `ZodBoolean` | `false` |
125
- | `ZodNull` | `null` |
126
- | `ZodUndefined` | `undefined` |
127
- | `ZodAny` / `ZodUnknown` | `null` |
128
- | `ZodLiteral` | the literal value |
129
- | `ZodOptional` / `ZodNullable` | `undefined` (field omitted in objects) |
130
- | `ZodUnion` | first option, recursively sampled |
131
- | `ZodArray` | `[]` |
132
- | `ZodObject` | object with every non-`undefined` child sampled |
133
- | `ZodRecord` | `{}` |
134
- | `ZodDate` | `new Date().toISOString()` |
135
- | anything else | `null` |
136
-
137
- `ZodOptional`/`ZodNullable` returning `undefined` is deliberate — the object branch drops keys whose sample is `undefined`, so optional fields stay absent rather than being sent as `null`. The result is the smallest payload that the validator will accept.
138
-
139
- This is NOT a property-based generator. It always returns the same value for the same schema. Swap in `fast-check` if you need randomized fuzz that respects the schema.
140
-
141
- ### `resolveTestBaseUrl(options?)`
142
-
143
- Resolve the live server target for test entrypoints. Priority is: non-empty `TEST_BASE_URL`, a valid integer port from `<cwd>/node_modules/.luckystack/dev-server.json` whose owner PID is still alive, then `fallbackUrl` (default `http://localhost:80`). Generated projects pass `http://localhost:${ports.backend}` as the fallback, so config changes, dev auto-increment hops, and stale crash leftovers remain truthful.
144
-
145
- ```ts
146
- resolveTestBaseUrl(options?: {
147
- cwd?: string;
148
- fallbackUrl?: string;
149
- }): string
150
- ```
151
-
152
- This helper does not start or probe a server; connection failures remain visible to the actual test layer.
153
-
154
- ### `resolveTestEnvironment(input?)`
155
-
156
- Prepare the **test process** before DB/Redis-backed integration tests run. It first calls core's `loadEnvFiles()`. When `loadProjectConfig` returns a config with a non-empty `secretManager.url`, it dynamically loads the optional `@luckystack/secret-manager` peer and runs `initSecretManager({ ...config.secretManager, source: 'remote' })`.
157
-
158
- ```ts
159
- await resolveTestEnvironment({
160
- loadProjectConfig: async () => (await import('./config')).default,
161
- });
162
- ```
163
-
164
- `runAllTests` calls this automatically through its required `loadProjectConfig` callback. The official scaffolded `scripts/testAll.ts` supplies it. The callback is lazy deliberately: `.env` and `.env.local` must load before `config.ts` reads `LUCKYSTACK_SECRET_MANAGER_URL`.
165
-
166
- This is a separate process from the live server. Resolving `DATABASE_URL` during server boot does **not** resolve it for Layer-5 `ctx.prisma` calls. If a secret-manager URL is configured but the package cannot load, resolution fails loudly before any layer runs instead of handing a raw `DATABASE_URL_V<n>` pointer to Prisma.
167
-
168
- For a custom Vitest integration setup, call `resolveTestEnvironment` from a setup file before importing modules that construct env-backed clients. Direct Layer-5 callers must pass the same lazy loader to `runCustomTests({ ..., loadProjectConfig })`; it resolves before discovery/import. Both orchestrator APIs reject a missing loader at runtime, including from untyped JavaScript. `runAllTests` uses an internal already-prepared Layer-5 entrypoint, so the custom layer does not reload `.env.local` pointers over freshly resolved values.
169
-
170
- ## Types
171
-
172
- ```ts
173
- type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
174
-
175
- interface EndpointDescriptor {
176
- page: string;
177
- name: string;
178
- version: string;
179
- method: HttpMethod;
180
- fullPath: string; // `api/<page>/<name>/<version>` or `sync/<page>/<name>/<version>`
181
- kind?: 'api' | 'sync'; // absent = 'api'
182
- }
183
-
184
- interface ContractCheckResult {
185
- endpoint: EndpointDescriptor;
186
- status: 'pass' | 'fail' | 'skipped';
187
- httpStatus?: number;
188
- responseStatus?: 'success' | 'error' | 'unknown';
189
- errorCode?: string;
190
- reason?: string;
191
- durationMs: number;
192
- }
193
-
194
- interface RunContractSummary {
195
- total: number;
196
- passed: number;
197
- failed: number;
198
- skipped: number;
199
- results: ContractCheckResult[];
200
- }
201
-
202
- interface ResolveTestEnvironmentInput {
203
- loadProjectConfig?: () => unknown | Promise<unknown>;
204
- }
205
-
206
- interface RunCustomTestsInput {
207
- // other Layer-5 options omitted
208
- loadProjectConfig: NonNullable<ResolveTestEnvironmentInput['loadProjectConfig']>;
209
- }
210
- ```
211
-
212
- `responseStatus: 'unknown'` only appears on failed results when the JSON either failed to parse or did not carry a `status` field. `errorCode` is set on pass results when `responseStatus === 'error'` and on fail results when the auth/rate-limit layers' `errorCode` mismatch path runs (contract layer never sets it on fail).
213
-
214
- ## Result shapes by branch
215
-
216
- - **Pass / success**: `{ status: 'pass', httpStatus, responseStatus: 'success', errorCode: undefined, durationMs }`.
217
- - **Pass / typed error**: `{ status: 'pass', httpStatus, responseStatus: 'error', errorCode: '<code>', durationMs }`. A typed framework error still satisfies the contract.
218
- - **Fail / fetch error**: `{ status: 'fail', reason: <Error.message>, durationMs }`. No `httpStatus`. Network refused, DNS failure, or `AbortController` firing all land here.
219
- - **Fail / JSON parse**: `{ status: 'fail', httpStatus, responseStatus: 'unknown', reason: 'JSON parse failed: ...', durationMs }`.
220
- - **Fail / missing envelope**: `{ status: 'fail', httpStatus, responseStatus: 'unknown', reason: 'Response missing standard `status` envelope', durationMs }`.
221
- - **Fail / error without code**: `{ status: 'fail', httpStatus, responseStatus: 'error', reason: 'Error response missing `errorCode`', durationMs }`.
222
- - **Skip**: `{ status: 'skipped', durationMs: 0, reason: 'Explicitly skipped' }`.
223
-
224
- ## Examples
225
-
226
- ### CI sweep with default inputs
227
-
228
- ```ts
229
- import { apiMethodMap } from './generated/apiMethodMap.generated';
230
- import {
231
- runContractTests,
232
- logContractResult,
233
- logContractSummary,
234
- resolveTestBaseUrl,
235
- } from '@luckystack/test-runner';
236
- import { ports } from './config.ports';
237
-
238
- const summary = await runContractTests({
239
- apiMethodMap,
240
- baseUrl: resolveTestBaseUrl({ fallbackUrl: `http://localhost:${ports.backend}` }),
241
- onResult: logContractResult,
242
- });
243
-
244
- logContractSummary(summary);
245
-
246
- if (summary.failed > 0) {
247
- process.exit(1);
248
- }
249
- ```
250
-
251
- ### Endpoint-specific input via `inputFor`
252
-
253
- Most validators reject `{}`. Use `inputFor` to return a shape the validator will accept for the specific endpoints that need it; fall through to `{}` for everything else.
254
-
255
- ```ts
256
- import { sampleSchemaInput } from '@luckystack/test-runner';
257
- import { sendInviteSchema } from 'src/organization-settings/_api/sendInvite_v1';
258
-
259
- await runContractTests({
260
- apiMethodMap,
261
- baseUrl,
262
- inputFor: (endpoint) => {
263
- if (endpoint.fullPath === 'api/organization-settings/sendInvite/v1') {
264
- return sampleSchemaInput(sendInviteSchema);
265
- }
266
- return {}; // default
267
- },
268
- });
269
- ```
270
-
271
- ### Single-endpoint debug loop
272
-
273
- ```ts
274
- import { runContractCheck, logContractResult } from '@luckystack/test-runner';
275
-
276
- const result = await runContractCheck({
277
- endpoint: {
278
- page: 'billing',
279
- name: 'getInvoice',
280
- version: 'v1',
281
- method: 'POST',
282
- fullPath: 'api/billing/getInvoice/v1',
283
- },
284
- baseUrl: process.env.TEST_BASE_URL ?? 'http://127.0.0.1:80',
285
- inputFor: () => ({ invoiceId: 'inv_1' }),
286
- headers: { Cookie: 'session=abc' },
287
- requestTimeoutMs: 15000, // bump for slow endpoints
288
- });
289
-
290
- logContractResult(result);
291
- ```
292
-
293
- ### Skip noisy endpoints
294
-
295
- ```ts
296
- await runContractTests({
297
- apiMethodMap,
298
- baseUrl,
299
- skip: [
300
- 'uploads/fileUpload', // needs multipart, not JSON
301
- 'billing/getInvoice/v1', // deprecated, kept for backwards compat
302
- ],
303
- });
304
- ```
305
-
306
- ## Edge cases and gotchas
307
-
308
- - **`GET` endpoints receive `inputFor` through the query string.** `fetch` cannot send a GET body, so the payload is JSON-encoded under the same reserved query key the real client sends and the server reads. Before this, the payload was dropped and every GET route with a required field failed validation. Layer 5's `ctx.callApi` uses the same encoding.
309
- - **`headers` are merged after `Content-Type: application/json`.** If you pass `Content-Type` yourself you will override the default; the framework parses based on the header so this is intentional.
310
- - **`baseUrl` trailing slash is normalized once.** `http://localhost/` and `http://localhost` produce identical requests. Embedded `//` further down the path is not collapsed.
311
- - **`requestTimeoutMs` is per-request, not per-sweep.** A 30-endpoint sweep with the default 5000ms timeout can run for over two minutes if every endpoint stalls. The sweep itself has no overall timeout.
312
- - **An endpoint that returns `204 No Content`** fails the contract because there is no JSON to parse. The framework never emits 204 from `_api/*`; this only happens if you've added a custom route handler that intercepts the path.
313
- - **A response with `status: 'success'` and an extra `errorCode`** still passes. The contract layer only validates the envelope keys it cares about; unknown extras are ignored.
314
- - **Endpoints absent from `apiMetaMap` are still walked.** The contract layer never reads `apiMetaMap`. Auth and rate-limit layers do — see those docs.
18
+ The test process must load environment files and resolve configured secret pointers before importing code that creates database clients. The live server's resolved environment is isolated from the runner process. The loader callback is intentionally deferred until after local env layers are available. Consult the runner source for current return shapes and schema sampling; a sampled valid payload is not exhaustive input coverage.