@luckystack/test-runner 0.1.8 → 0.2.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.
@@ -1,226 +1,226 @@
1
- # Auth Enforcement Tests
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.
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.
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) ?? {}`. `GET` drops the body.
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 `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.
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.
39
-
40
- ### `runAuthEnforcementTests(input)`
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.
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
- - **`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`).
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**: `{ status: 'pass', httpStatus, responseStatus: 'error', errorCode: 'auth.required', durationMs }`.
98
- - **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 / 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 }`.
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
- - **`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`.
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.
1
+ # Auth Enforcement Tests
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.
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.
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) ?? {}`. `GET` drops the body.
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 `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.
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.
39
+
40
+ ### `runAuthEnforcementTests(input)`
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.
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
+ - **`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`).
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**: `{ status: 'pass', httpStatus, responseStatus: 'error', errorCode: 'auth.required', durationMs }`.
98
+ - **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 / 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 }`.
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
+ - **`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`.
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.