@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.
package/dist/index.js CHANGED
@@ -1,12 +1,25 @@
1
1
  // src/walkEndpoints.ts
2
+ var VALID_HTTP_METHODS = /* @__PURE__ */ new Set(["GET", "POST", "PUT", "DELETE"]);
3
+ var toHttpMethod = (raw) => {
4
+ const upper = raw.toUpperCase();
5
+ for (const m of VALID_HTTP_METHODS) {
6
+ if (m === upper) return m;
7
+ }
8
+ return null;
9
+ };
2
10
  var walkEndpoints = (apiMethodMap) => {
3
11
  const endpoints = [];
4
12
  for (const [page, nameMap] of Object.entries(apiMethodMap)) {
5
13
  if (!nameMap) continue;
6
14
  for (const [name, versionMap] of Object.entries(nameMap)) {
7
15
  if (!versionMap) continue;
8
- for (const [version, method] of Object.entries(versionMap)) {
9
- if (method === void 0) continue;
16
+ for (const [version, rawMethod] of Object.entries(versionMap)) {
17
+ if (rawMethod === void 0) continue;
18
+ const method = toHttpMethod(rawMethod);
19
+ if (!method) {
20
+ console.warn(`[test-runner] walkEndpoints: skipping api/${page}/${name}/${version} \u2014 unrecognised method '${rawMethod}' in apiMethodMap`);
21
+ continue;
22
+ }
10
23
  endpoints.push({
11
24
  page,
12
25
  name,
@@ -19,61 +32,94 @@ var walkEndpoints = (apiMethodMap) => {
19
32
  }
20
33
  return endpoints;
21
34
  };
35
+ var walkSyncEndpoints = (syncMethodMap) => {
36
+ const endpoints = [];
37
+ for (const [page, nameMap] of Object.entries(syncMethodMap)) {
38
+ if (!nameMap) continue;
39
+ for (const [name, versionMap] of Object.entries(nameMap)) {
40
+ if (!versionMap) continue;
41
+ for (const [version, method] of Object.entries(versionMap)) {
42
+ if (method === void 0) continue;
43
+ endpoints.push({
44
+ page,
45
+ name,
46
+ version,
47
+ method: "POST",
48
+ fullPath: `sync/${page}/${name}/${version}`
49
+ });
50
+ }
51
+ }
52
+ }
53
+ return endpoints;
54
+ };
22
55
 
23
- // src/contractCheck.ts
56
+ // src/probeRequest.ts
24
57
  import { tryCatch } from "@luckystack/core";
25
- var DEFAULT_REQUEST_TIMEOUT_MS = 5e3;
26
- var runContractCheck = async (input) => {
27
- const { endpoint, baseUrl } = input;
28
- const url = `${baseUrl.replace(/\/$/, "")}/${endpoint.fullPath}`;
29
- const body = input.inputFor ? input.inputFor(endpoint) : {};
30
- const started = Date.now();
31
- const requestTimeoutMs = input.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
58
+ var sendProbe = async (input) => {
32
59
  const controller = new AbortController();
33
60
  const timeoutHandle = setTimeout(() => {
34
61
  controller.abort();
35
- }, requestTimeoutMs);
36
- const [fetchError, response] = await tryCatch(() => fetch(url, {
37
- method: endpoint.method,
62
+ }, input.requestTimeoutMs);
63
+ const [fetchError, response] = await tryCatch(() => fetch(input.url, {
64
+ method: input.method,
38
65
  headers: {
39
66
  "Content-Type": "application/json",
40
67
  //? Browsers attach Origin on state-changing requests; the server's origin
41
- //? policy fail-closes POST/PUT/DELETE without one. Send the same-origin
42
- //? value so the request reaches the API pipeline (consumers can override).
43
- "Origin": new URL(baseUrl).origin,
68
+ //? policy fail-closes POST/PUT/DELETE without one.
69
+ "Origin": new URL(input.baseUrl).origin,
44
70
  ...input.headers
45
71
  },
46
- body: endpoint.method === "GET" ? void 0 : JSON.stringify(body),
72
+ body: input.method === "GET" ? void 0 : JSON.stringify(input.body),
47
73
  signal: controller.signal
48
74
  }));
49
75
  clearTimeout(timeoutHandle);
50
- if (fetchError || !response) {
76
+ if (fetchError || !response) return null;
77
+ const [, parsed] = await tryCatch(
78
+ async () => await response.json()
79
+ );
80
+ return { httpStatus: response.status, parsed: parsed ?? null };
81
+ };
82
+
83
+ // src/contractCheck.ts
84
+ var DEFAULT_REQUEST_TIMEOUT_MS = 5e3;
85
+ var runContractCheck = async (input) => {
86
+ const { endpoint, baseUrl } = input;
87
+ const url = `${baseUrl.replace(/\/$/, "")}/${endpoint.fullPath}`;
88
+ const body = input.inputFor ? input.inputFor(endpoint) : {};
89
+ const started = Date.now();
90
+ const result = await sendProbe({
91
+ url,
92
+ method: endpoint.method,
93
+ baseUrl,
94
+ body,
95
+ headers: input.headers,
96
+ requestTimeoutMs: input.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS
97
+ });
98
+ if (!result) {
51
99
  return {
52
100
  endpoint,
53
101
  status: "fail",
54
- reason: fetchError?.message ?? "fetch returned no response",
102
+ reason: "fetch returned no response",
55
103
  durationMs: Date.now() - started
56
104
  };
57
105
  }
58
- const [parseError, parsed] = await tryCatch(
59
- async () => await response.json()
60
- );
106
+ const { httpStatus, parsed } = result;
61
107
  const durationMs = Date.now() - started;
62
- if (parseError) {
108
+ if (!parsed) {
63
109
  return {
64
110
  endpoint,
65
111
  status: "fail",
66
- httpStatus: response.status,
112
+ httpStatus,
67
113
  responseStatus: "unknown",
68
- reason: `JSON parse failed: ${parseError.message}`,
114
+ reason: "JSON parse failed",
69
115
  durationMs
70
116
  };
71
117
  }
72
- if (!parsed || parsed.status !== "success" && parsed.status !== "error") {
118
+ if (parsed.status !== "success" && parsed.status !== "error") {
73
119
  return {
74
120
  endpoint,
75
121
  status: "fail",
76
- httpStatus: response.status,
122
+ httpStatus,
77
123
  responseStatus: "unknown",
78
124
  reason: "Response missing standard `status` envelope",
79
125
  durationMs
@@ -83,29 +129,69 @@ var runContractCheck = async (input) => {
83
129
  return {
84
130
  endpoint,
85
131
  status: "fail",
86
- httpStatus: response.status,
132
+ httpStatus,
87
133
  responseStatus: "error",
88
134
  reason: "Error response missing `errorCode`",
89
135
  durationMs
90
136
  };
91
137
  }
138
+ const bodyIsNonEmpty = body !== null && typeof body === "object" && !Array.isArray(body) && Object.keys(body).length > 0;
139
+ if (input.inputFor && bodyIsNonEmpty && parsed.status === "error") {
140
+ return {
141
+ endpoint,
142
+ status: "skipped",
143
+ httpStatus,
144
+ responseStatus: "error",
145
+ errorCode: parsed.errorCode,
146
+ reason: `inputFor produced a non-empty sample but route returned error (${parsed.errorCode ?? "unknown"}); provide a valid sample via inputFor to test the happy path`,
147
+ durationMs
148
+ };
149
+ }
92
150
  return {
93
151
  endpoint,
94
152
  status: "pass",
95
- httpStatus: response.status,
153
+ httpStatus,
96
154
  responseStatus: parsed.status,
97
155
  errorCode: parsed.errorCode,
98
156
  durationMs
99
157
  };
100
158
  };
101
159
 
102
- // src/runContractTests.ts
160
+ // src/testLayerHelpers.ts
161
+ var LAYER_KEYS = {
162
+ contract: "contract",
163
+ auth: "auth-enforcement",
164
+ rateLimit: "rate-limit",
165
+ csrf: "csrf-enforcement",
166
+ fuzz: "fuzz",
167
+ custom: "custom"
168
+ };
169
+ var STATE_CHANGING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "DELETE"]);
103
170
  var shouldSkip = (endpoint, skip) => {
104
171
  if (skip.length === 0) return false;
105
172
  const versioned = `${endpoint.page}/${endpoint.name}/${endpoint.version}`;
106
173
  const versionless = `${endpoint.page}/${endpoint.name}`;
107
174
  return skip.includes(versioned) || skip.includes(versionless);
108
175
  };
176
+ var requiresLogin = (apiMetaMap, endpoint) => {
177
+ const meta = apiMetaMap[endpoint.page]?.[endpoint.name]?.[endpoint.version];
178
+ return meta?.auth?.login ?? false;
179
+ };
180
+ var hasMetaEntry = (apiMetaMap, endpoint) => apiMetaMap[endpoint.page]?.[endpoint.name]?.[endpoint.version] !== void 0;
181
+ var getRateLimit = (apiMetaMap, endpoint) => {
182
+ const meta = apiMetaMap[endpoint.page]?.[endpoint.name]?.[endpoint.version];
183
+ if (!meta || meta.rateLimit === false || meta.rateLimit === void 0) return null;
184
+ return meta.rateLimit;
185
+ };
186
+ var calculateSummary = (results) => ({
187
+ total: results.length,
188
+ passed: results.filter((r) => r.status === "pass").length,
189
+ failed: results.filter((r) => r.status === "fail").length,
190
+ skipped: results.filter((r) => r.status === "skipped").length,
191
+ results
192
+ });
193
+
194
+ // src/runContractTests.ts
109
195
  var runContractTests = async (input) => {
110
196
  const endpoints = walkEndpoints(input.apiMethodMap);
111
197
  const skip = input.skip ?? [];
@@ -131,17 +217,10 @@ var runContractTests = async (input) => {
131
217
  results.push(result);
132
218
  input.onResult?.(result);
133
219
  }
134
- return {
135
- total: results.length,
136
- passed: results.filter((r) => r.status === "pass").length,
137
- failed: results.filter((r) => r.status === "fail").length,
138
- skipped: results.filter((r) => r.status === "skipped").length,
139
- results
140
- };
220
+ return calculateSummary(results);
141
221
  };
142
222
 
143
223
  // src/authEnforcementCheck.ts
144
- import { tryCatch as tryCatch2 } from "@luckystack/core";
145
224
  var DEFAULT_REQUEST_TIMEOUT_MS2 = 5e3;
146
225
  var EXPECTED_ERROR_CODE = "auth.required";
147
226
  var runAuthEnforcementCheck = async (input) => {
@@ -149,49 +228,30 @@ var runAuthEnforcementCheck = async (input) => {
149
228
  const url = `${baseUrl.replace(/\/$/, "")}/${endpoint.fullPath}`;
150
229
  const body = input.inputFor ? input.inputFor(endpoint) : {};
151
230
  const started = Date.now();
152
- const requestTimeoutMs = input.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS2;
153
- const controller = new AbortController();
154
- const timeoutHandle = setTimeout(() => {
155
- controller.abort();
156
- }, requestTimeoutMs);
157
- const [fetchError, response] = await tryCatch2(() => fetch(url, {
231
+ const result = await sendProbe({
232
+ url,
158
233
  method: endpoint.method,
159
- //? No session cookie, no auth header — deliberately. Origin IS sent so the
160
- //? server's origin policy doesn't 403 before the auth check can run.
161
- headers: { "Content-Type": "application/json", "Origin": new URL(baseUrl).origin },
162
- body: endpoint.method === "GET" ? void 0 : JSON.stringify(body),
163
- signal: controller.signal
164
- }));
165
- clearTimeout(timeoutHandle);
166
- if (fetchError || !response) {
234
+ baseUrl,
235
+ body,
236
+ requestTimeoutMs: input.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS2
237
+ });
238
+ if (!result) {
167
239
  return {
168
240
  endpoint,
169
241
  status: "fail",
170
- reason: fetchError?.message ?? "fetch returned no response",
242
+ reason: "fetch returned no response",
171
243
  durationMs: Date.now() - started
172
244
  };
173
245
  }
174
- const [parseError, parsed] = await tryCatch2(
175
- async () => await response.json()
176
- );
246
+ const { httpStatus, parsed } = result;
177
247
  const durationMs = Date.now() - started;
178
- if (parseError) {
179
- return {
180
- endpoint,
181
- status: "fail",
182
- httpStatus: response.status,
183
- responseStatus: "unknown",
184
- reason: `JSON parse failed: ${parseError.message}`,
185
- durationMs
186
- };
187
- }
188
248
  if (parsed?.status === "success") {
189
249
  return {
190
250
  endpoint,
191
251
  status: "fail",
192
- httpStatus: response.status,
252
+ httpStatus,
193
253
  responseStatus: "success",
194
- reason: `auth.login endpoint returned success without a session`,
254
+ reason: "auth.login endpoint returned success without a session",
195
255
  durationMs
196
256
  };
197
257
  }
@@ -199,7 +259,7 @@ var runAuthEnforcementCheck = async (input) => {
199
259
  return {
200
260
  endpoint,
201
261
  status: "fail",
202
- httpStatus: response.status,
262
+ httpStatus,
203
263
  responseStatus: "unknown",
204
264
  reason: "Response missing standard `status` envelope",
205
265
  durationMs
@@ -209,7 +269,7 @@ var runAuthEnforcementCheck = async (input) => {
209
269
  return {
210
270
  endpoint,
211
271
  status: "fail",
212
- httpStatus: response.status,
272
+ httpStatus,
213
273
  responseStatus: "error",
214
274
  errorCode: parsed.errorCode,
215
275
  reason: `expected errorCode '${EXPECTED_ERROR_CODE}' but got '${parsed.errorCode ?? "(missing)"}'`,
@@ -219,7 +279,7 @@ var runAuthEnforcementCheck = async (input) => {
219
279
  return {
220
280
  endpoint,
221
281
  status: "pass",
222
- httpStatus: response.status,
282
+ httpStatus,
223
283
  responseStatus: "error",
224
284
  errorCode: parsed.errorCode,
225
285
  durationMs
@@ -227,16 +287,6 @@ var runAuthEnforcementCheck = async (input) => {
227
287
  };
228
288
 
229
289
  // src/runAuthEnforcementTests.ts
230
- var shouldSkip2 = (endpoint, skip) => {
231
- if (skip.length === 0) return false;
232
- const versioned = `${endpoint.page}/${endpoint.name}/${endpoint.version}`;
233
- const versionless = `${endpoint.page}/${endpoint.name}`;
234
- return skip.includes(versioned) || skip.includes(versionless);
235
- };
236
- var requiresLogin = (apiMetaMap, endpoint) => {
237
- const meta = apiMetaMap[endpoint.page]?.[endpoint.name]?.[endpoint.version];
238
- return meta?.auth.login ?? false;
239
- };
240
290
  var runAuthEnforcementTests = async (input) => {
241
291
  const endpoints = walkEndpoints(input.apiMethodMap);
242
292
  const skip = input.skip ?? [];
@@ -245,7 +295,7 @@ var runAuthEnforcementTests = async (input) => {
245
295
  if (!requiresLogin(input.apiMetaMap, endpoint)) {
246
296
  continue;
247
297
  }
248
- if (shouldSkip2(endpoint, skip)) {
298
+ if (shouldSkip(endpoint, skip)) {
249
299
  const skipped = {
250
300
  endpoint,
251
301
  status: "skipped",
@@ -264,46 +314,50 @@ var runAuthEnforcementTests = async (input) => {
264
314
  results.push(result);
265
315
  input.onResult?.(result);
266
316
  }
267
- return {
268
- total: results.length,
269
- passed: results.filter((r) => r.status === "pass").length,
270
- failed: results.filter((r) => r.status === "fail").length,
271
- skipped: results.filter((r) => r.status === "skipped").length,
272
- results
273
- };
317
+ return calculateSummary(results);
274
318
  };
275
319
 
276
320
  // src/rateLimitCheck.ts
277
- import { tryCatch as tryCatch3 } from "@luckystack/core";
278
321
  var DEFAULT_REQUEST_TIMEOUT_MS3 = 1e4;
279
322
  var EXPECTED_ERROR_CODE2 = "api.rateLimitExceeded";
323
+ var PRE_LIMITER_ERROR_CODES = /* @__PURE__ */ new Set([
324
+ "auth.required",
325
+ "auth.csrfTokenMissing",
326
+ "auth.csrfTokenInvalid",
327
+ "auth.unauthorized",
328
+ "api.csrfTokenMissing",
329
+ "api.csrfTokenInvalid",
330
+ "api.unauthorized",
331
+ "api.validationError"
332
+ ]);
280
333
  var runRateLimitCheck = async (input) => {
281
334
  const { endpoint, baseUrl, rateLimit } = input;
282
335
  const url = `${baseUrl.replace(/\/$/, "")}/${endpoint.fullPath}`;
283
336
  const body = input.inputFor ? input.inputFor(endpoint) : {};
284
337
  const started = Date.now();
285
338
  const requestTimeoutMs = input.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS3;
286
- const send = async () => {
287
- const controller = new AbortController();
288
- const timeoutHandle = setTimeout(() => {
289
- controller.abort();
290
- }, requestTimeoutMs);
291
- const [error, response] = await tryCatch3(() => fetch(url, {
292
- method: endpoint.method,
293
- headers: {
294
- "Content-Type": "application/json",
295
- "Origin": new URL(baseUrl).origin,
296
- ...input.headers
297
- },
298
- body: endpoint.method === "GET" ? void 0 : JSON.stringify(body),
299
- signal: controller.signal
300
- }));
301
- clearTimeout(timeoutHandle);
302
- if (error || !response) return null;
303
- return response;
304
- };
339
+ const send = () => sendProbe({
340
+ url,
341
+ method: endpoint.method,
342
+ baseUrl,
343
+ body,
344
+ headers: input.headers,
345
+ requestTimeoutMs
346
+ });
305
347
  for (let i = 0; i < rateLimit; i += 1) {
306
- await send();
348
+ const drained = await send();
349
+ if (!drained) continue;
350
+ if (i === 0) {
351
+ const errorCode = drained.parsed?.errorCode;
352
+ if (errorCode && PRE_LIMITER_ERROR_CODES.has(errorCode)) {
353
+ return {
354
+ endpoint,
355
+ status: "skipped",
356
+ durationMs: Date.now() - started,
357
+ reason: `drain response rejected pre-limiter (${errorCode}); bucket was never incremented \u2014 check CSRF/auth headers`
358
+ };
359
+ }
360
+ }
307
361
  }
308
362
  const final = await send();
309
363
  const durationMs = Date.now() - started;
@@ -315,24 +369,23 @@ var runRateLimitCheck = async (input) => {
315
369
  durationMs
316
370
  };
317
371
  }
318
- const [, parsed] = await tryCatch3(
319
- async () => await final.json()
320
- );
372
+ const { httpStatus, parsed } = final;
321
373
  if (parsed?.status === "error" && parsed.errorCode === EXPECTED_ERROR_CODE2) {
322
374
  return {
323
375
  endpoint,
324
376
  status: "pass",
325
- httpStatus: final.status,
377
+ httpStatus,
326
378
  responseStatus: "error",
327
379
  errorCode: parsed.errorCode,
328
380
  durationMs
329
381
  };
330
382
  }
383
+ const responseStatus = parsed?.status === "success" || parsed?.status === "error" ? parsed.status : "unknown";
331
384
  return {
332
385
  endpoint,
333
386
  status: "fail",
334
- httpStatus: final.status,
335
- responseStatus: parsed?.status ?? "unknown",
387
+ httpStatus,
388
+ responseStatus,
336
389
  errorCode: parsed?.errorCode,
337
390
  reason: `expected '${EXPECTED_ERROR_CODE2}' on request ${rateLimit + 1} but got ${parsed?.status ?? "none"}/${parsed?.errorCode ?? "(none)"}`,
338
391
  durationMs
@@ -340,32 +393,23 @@ var runRateLimitCheck = async (input) => {
340
393
  };
341
394
 
342
395
  // src/resetServerState.ts
343
- import { tryCatch as tryCatch4 } from "@luckystack/core";
344
- var resetServerState = async ({ baseUrl, token }) => {
396
+ import { tryCatch as tryCatch2 } from "@luckystack/core";
397
+ var DEFAULT_REQUEST_TIMEOUT_MS4 = 1e4;
398
+ var resetServerState = async ({ baseUrl, token, requestTimeoutMs }) => {
345
399
  const url = `${baseUrl.replace(/\/$/, "")}/_test/reset`;
346
400
  const headers = { "Content-Type": "application/json" };
347
401
  if (token) headers["X-Test-Reset-Token"] = token;
348
- const [error, response] = await tryCatch4(() => fetch(url, { method: "POST", headers }));
402
+ const controller = new AbortController();
403
+ const timeoutHandle = setTimeout(() => {
404
+ controller.abort();
405
+ }, requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS4);
406
+ const [error, response] = await tryCatch2(() => fetch(url, { method: "POST", headers, signal: controller.signal }));
407
+ clearTimeout(timeoutHandle);
349
408
  if (error || !response) return false;
350
409
  return response.ok;
351
410
  };
352
411
 
353
412
  // src/runRateLimitTests.ts
354
- var getRateLimit = (apiMetaMap, endpoint) => {
355
- const meta = apiMetaMap[endpoint.page]?.[endpoint.name]?.[endpoint.version];
356
- if (!meta || meta.rateLimit === false || meta.rateLimit === void 0) return null;
357
- return meta.rateLimit;
358
- };
359
- var requiresLogin2 = (apiMetaMap, endpoint) => {
360
- const meta = apiMetaMap[endpoint.page]?.[endpoint.name]?.[endpoint.version];
361
- return meta?.auth.login ?? false;
362
- };
363
- var shouldSkip3 = (endpoint, skip) => {
364
- if (skip.length === 0) return false;
365
- const versioned = `${endpoint.page}/${endpoint.name}/${endpoint.version}`;
366
- const versionless = `${endpoint.page}/${endpoint.name}`;
367
- return skip.includes(versioned) || skip.includes(versionless);
368
- };
369
413
  var runRateLimitTests = async (input) => {
370
414
  const endpoints = walkEndpoints(input.apiMethodMap);
371
415
  const skip = input.skip ?? [];
@@ -375,7 +419,7 @@ var runRateLimitTests = async (input) => {
375
419
  for (const endpoint of endpoints) {
376
420
  const rateLimit = getRateLimit(input.apiMetaMap, endpoint);
377
421
  if (rateLimit === null) continue;
378
- if (!isAuthenticatedSweep && requiresLogin2(input.apiMetaMap, endpoint)) {
422
+ if (!isAuthenticatedSweep && requiresLogin(input.apiMetaMap, endpoint)) {
379
423
  const skipped = {
380
424
  endpoint,
381
425
  status: "skipped",
@@ -386,6 +430,17 @@ var runRateLimitTests = async (input) => {
386
430
  input.onResult?.(skipped);
387
431
  continue;
388
432
  }
433
+ if (isAuthenticatedSweep && STATE_CHANGING_METHODS.has(endpoint.method)) {
434
+ const skipped = {
435
+ endpoint,
436
+ status: "skipped",
437
+ durationMs: 0,
438
+ reason: "state-changing route skipped in cookie-mode rate-limit sweep (mutation risk with junk bodies)"
439
+ };
440
+ results.push(skipped);
441
+ input.onResult?.(skipped);
442
+ continue;
443
+ }
389
444
  if (rateLimit > maxRateLimit) {
390
445
  const skipped = {
391
446
  endpoint,
@@ -397,7 +452,7 @@ var runRateLimitTests = async (input) => {
397
452
  input.onResult?.(skipped);
398
453
  continue;
399
454
  }
400
- if (shouldSkip3(endpoint, skip)) {
455
+ if (shouldSkip(endpoint, skip)) {
401
456
  const skipped = {
402
457
  endpoint,
403
458
  status: "skipped",
@@ -421,18 +476,114 @@ var runRateLimitTests = async (input) => {
421
476
  results.push(result);
422
477
  input.onResult?.(result);
423
478
  }
424
- return {
425
- total: results.length,
426
- passed: results.filter((r) => r.status === "pass").length,
427
- failed: results.filter((r) => r.status === "fail").length,
428
- skipped: results.filter((r) => r.status === "skipped").length,
429
- results
430
- };
479
+ return calculateSummary(results);
480
+ };
481
+
482
+ // src/csrfEnforcementCheck.ts
483
+ var DEFAULT_REQUEST_TIMEOUT_MS5 = 5e3;
484
+ var DEFAULT_EXPECTED_ERROR_CODE = "auth.csrfMismatch";
485
+ var DEFAULT_EXPECTED_HTTP_STATUS = 403;
486
+ var runCsrfEnforcementCheck = async (input) => {
487
+ const { endpoint, baseUrl } = input;
488
+ if (endpoint.method === "GET") {
489
+ return {
490
+ endpoint,
491
+ status: "skipped",
492
+ reason: `CSRF enforcement does not apply to ${endpoint.method} requests`,
493
+ durationMs: 0
494
+ };
495
+ }
496
+ const url = `${baseUrl.replace(/\/$/, "")}/${endpoint.fullPath}`;
497
+ const body = input.inputFor ? input.inputFor(endpoint) : {};
498
+ const started = Date.now();
499
+ const expectedErrorCode = input.expectedErrorCode ?? DEFAULT_EXPECTED_ERROR_CODE;
500
+ const expectedHttpStatus = input.expectedHttpStatus ?? DEFAULT_EXPECTED_HTTP_STATUS;
501
+ const result = await sendProbe({
502
+ url,
503
+ method: endpoint.method,
504
+ baseUrl,
505
+ body,
506
+ headers: { Cookie: input.authCookie },
507
+ requestTimeoutMs: input.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS5
508
+ });
509
+ if (!result) {
510
+ return { endpoint, status: "fail", reason: "fetch returned no response", durationMs: Date.now() - started };
511
+ }
512
+ const { httpStatus, parsed } = result;
513
+ const durationMs = Date.now() - started;
514
+ if (parsed?.status === "success") {
515
+ return {
516
+ endpoint,
517
+ status: "fail",
518
+ httpStatus,
519
+ responseStatus: "success",
520
+ reason: "state-changing endpoint accepted an authenticated request with NO CSRF header \u2014 CSRF protection is not enforced",
521
+ durationMs
522
+ };
523
+ }
524
+ if (parsed?.status !== "error" || parsed.errorCode !== expectedErrorCode) {
525
+ const responseStatus = parsed?.status === "success" || parsed?.status === "error" ? parsed.status : "unknown";
526
+ return {
527
+ endpoint,
528
+ status: "fail",
529
+ httpStatus,
530
+ responseStatus,
531
+ errorCode: parsed?.errorCode,
532
+ reason: `expected CSRF rejection '${expectedErrorCode}' but got ${parsed?.status ?? "none"}/${parsed?.errorCode ?? "(none)"}`,
533
+ durationMs
534
+ };
535
+ }
536
+ if (expectedHttpStatus !== false && httpStatus !== expectedHttpStatus) {
537
+ return {
538
+ endpoint,
539
+ status: "fail",
540
+ httpStatus,
541
+ responseStatus: "error",
542
+ errorCode: parsed.errorCode,
543
+ reason: `expected HTTP ${expectedHttpStatus} on CSRF rejection but got ${httpStatus}`,
544
+ durationMs
545
+ };
546
+ }
547
+ return { endpoint, status: "pass", httpStatus, responseStatus: "error", errorCode: parsed.errorCode, durationMs };
548
+ };
549
+
550
+ // src/runCsrfEnforcementTests.ts
551
+ var runCsrfEnforcementTests = async (input) => {
552
+ const endpoints = walkEndpoints(input.apiMethodMap);
553
+ const skip = input.skip ?? [];
554
+ const results = [];
555
+ for (const endpoint of endpoints) {
556
+ if (!hasMetaEntry(input.apiMetaMap, endpoint)) continue;
557
+ if (!requiresLogin(input.apiMetaMap, endpoint)) continue;
558
+ if (!STATE_CHANGING_METHODS.has(endpoint.method)) continue;
559
+ if (shouldSkip(endpoint, skip)) {
560
+ const skipped = {
561
+ endpoint,
562
+ status: "skipped",
563
+ durationMs: 0,
564
+ reason: "Explicitly skipped"
565
+ };
566
+ results.push(skipped);
567
+ input.onResult?.(skipped);
568
+ continue;
569
+ }
570
+ const result = await runCsrfEnforcementCheck({
571
+ endpoint,
572
+ baseUrl: input.baseUrl,
573
+ authCookie: input.authCookie,
574
+ inputFor: input.inputFor,
575
+ requestTimeoutMs: input.requestTimeoutMs,
576
+ expectedErrorCode: input.expectedErrorCode,
577
+ expectedHttpStatus: input.expectedHttpStatus
578
+ });
579
+ results.push(result);
580
+ input.onResult?.(result);
581
+ }
582
+ return calculateSummary(results);
431
583
  };
432
584
 
433
585
  // src/fuzzCheck.ts
434
- import { tryCatch as tryCatch5 } from "@luckystack/core";
435
- var DEFAULT_REQUEST_TIMEOUT_MS4 = 5e3;
586
+ var DEFAULT_REQUEST_TIMEOUT_MS6 = 5e3;
436
587
  var JUNK_PAYLOADS = [
437
588
  null,
438
589
  [],
@@ -441,33 +592,25 @@ var JUNK_PAYLOADS = [
441
592
  1234567890,
442
593
  true,
443
594
  { nested: { deeply: { nested: { value: "x".repeat(1e4) } } } },
444
- { __proto__: { polluted: true } },
595
+ //? `{ __proto__: ... }` serializes to `{}` via JSON.stringify (prototype keys
596
+ //? are not own-enumerable), so it would probe nothing. Use `constructor` instead
597
+ //? — a named own-key that some parsers misinterpret but survives JSON round-trip.
598
+ { constructor: { polluted: true } },
445
599
  { key: null, other: void 0, third: Number.NaN }
446
600
  ];
447
- var probe = async (endpoint, baseUrl, body, headers, requestTimeoutMs) => {
448
- const url = `${baseUrl.replace(/\/$/, "")}/${endpoint.fullPath}`;
449
- const controller = new AbortController();
450
- const timeoutHandle = setTimeout(() => {
451
- controller.abort();
452
- }, requestTimeoutMs);
453
- const [fetchError, response] = await tryCatch5(() => fetch(url, {
454
- method: endpoint.method,
455
- headers: { "Content-Type": "application/json", "Origin": new URL(baseUrl).origin, ...headers },
456
- body: endpoint.method === "GET" ? void 0 : JSON.stringify(body),
457
- signal: controller.signal
458
- }));
459
- clearTimeout(timeoutHandle);
460
- if (fetchError || !response) return null;
461
- const [, parsed] = await tryCatch5(
462
- async () => await response.json()
463
- );
464
- return { httpStatus: response.status, parsed: parsed ?? null };
465
- };
466
601
  var runFuzzCheck = async (input) => {
467
602
  const started = Date.now();
468
- const requestTimeoutMs = input.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS4;
603
+ const requestTimeoutMs = input.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS6;
604
+ const url = `${input.baseUrl.replace(/\/$/, "")}/${input.endpoint.fullPath}`;
469
605
  for (const payload of JUNK_PAYLOADS) {
470
- const result = await probe(input.endpoint, input.baseUrl, payload, input.headers, requestTimeoutMs);
606
+ const result = await sendProbe({
607
+ url,
608
+ method: input.endpoint.method,
609
+ baseUrl: input.baseUrl,
610
+ body: payload,
611
+ headers: input.headers,
612
+ requestTimeoutMs
613
+ });
471
614
  const durationMs = Date.now() - started;
472
615
  if (!result) {
473
616
  return {
@@ -504,18 +647,24 @@ var runFuzzCheck = async (input) => {
504
647
  };
505
648
 
506
649
  // src/runFuzzTests.ts
507
- var shouldSkip4 = (endpoint, skip) => {
508
- if (skip.length === 0) return false;
509
- const versioned = `${endpoint.page}/${endpoint.name}/${endpoint.version}`;
510
- const versionless = `${endpoint.page}/${endpoint.name}`;
511
- return skip.includes(versioned) || skip.includes(versionless);
512
- };
513
650
  var runFuzzTests = async (input) => {
514
651
  const endpoints = walkEndpoints(input.apiMethodMap);
515
652
  const skip = input.skip ?? [];
653
+ const isAuthenticatedSweep = Boolean(input.headers?.Cookie);
516
654
  const results = [];
517
655
  for (const endpoint of endpoints) {
518
- if (shouldSkip4(endpoint, skip)) {
656
+ if (isAuthenticatedSweep && STATE_CHANGING_METHODS.has(endpoint.method)) {
657
+ const skipped = {
658
+ endpoint,
659
+ status: "skipped",
660
+ durationMs: 0,
661
+ reason: "state-changing route skipped in cookie-mode fuzz sweep (mutation risk with junk bodies)"
662
+ };
663
+ results.push(skipped);
664
+ input.onResult?.(skipped);
665
+ continue;
666
+ }
667
+ if (shouldSkip(endpoint, skip)) {
519
668
  const skipped = {
520
669
  endpoint,
521
670
  status: "skipped",
@@ -534,16 +683,11 @@ var runFuzzTests = async (input) => {
534
683
  results.push(result);
535
684
  input.onResult?.(result);
536
685
  }
537
- return {
538
- total: results.length,
539
- passed: results.filter((r) => r.status === "pass").length,
540
- failed: results.filter((r) => r.status === "fail").length,
541
- skipped: results.filter((r) => r.status === "skipped").length,
542
- results
543
- };
686
+ return calculateSummary(results);
544
687
  };
545
688
 
546
689
  // src/schemaSampleInput.ts
690
+ var innerDef = (node) => node ? node.def ?? node._def : void 0;
547
691
  var sampleForDef = (def) => {
548
692
  const d = def;
549
693
  const name = d.typeName ?? d.type;
@@ -576,7 +720,7 @@ var sampleForDef = (def) => {
576
720
  }
577
721
  case "ZodLiteral":
578
722
  case "literal": {
579
- return d.value ?? d.values?.[0];
723
+ return d.value ?? (Array.isArray(d.values) ? d.values[0] : void 0);
580
724
  }
581
725
  case "ZodOptional":
582
726
  case "optional":
@@ -584,9 +728,44 @@ var sampleForDef = (def) => {
584
728
  case "nullable": {
585
729
  return void 0;
586
730
  }
731
+ case "ZodEnum":
732
+ case "enum":
733
+ case "ZodNativeEnum":
734
+ case "nativeEnum": {
735
+ if (Array.isArray(d.values)) return d.values[0];
736
+ const entries = d.entries ?? (d.values && !Array.isArray(d.values) ? d.values : void 0);
737
+ if (entries) {
738
+ const first = Object.values(entries)[0];
739
+ if (first !== void 0) return first;
740
+ }
741
+ return void 0;
742
+ }
743
+ case "ZodDefault":
744
+ case "default":
745
+ case "ZodCatch":
746
+ case "catch": {
747
+ return sampleForDef(innerDef(d.innerType));
748
+ }
749
+ case "ZodEffects":
750
+ case "effects":
751
+ case "ZodPipeline":
752
+ case "pipe":
753
+ case "ZodBranded":
754
+ case "branded":
755
+ case "ZodReadonly":
756
+ case "readonly": {
757
+ return sampleForDef(innerDef(d.schema ?? d.in ?? d.innerType));
758
+ }
759
+ case "ZodTuple":
760
+ case "tuple": {
761
+ const items = d.items ?? [];
762
+ return items.map((item) => sampleForDef(innerDef(item)));
763
+ }
587
764
  case "ZodUnion":
588
- case "union": {
589
- return d.options && d.options.length > 0 && d.options[0] ? sampleForDef(d.options[0]._def) : null;
765
+ case "union":
766
+ case "ZodDiscriminatedUnion":
767
+ case "discriminatedUnion": {
768
+ return d.options && d.options.length > 0 && d.options[0] ? sampleForDef(innerDef(d.options[0])) : void 0;
590
769
  }
591
770
  case "ZodArray":
592
771
  case "array": {
@@ -597,7 +776,7 @@ var sampleForDef = (def) => {
597
776
  const out = {};
598
777
  const shape = typeof d.shape === "function" ? d.shape() : d.shape ?? {};
599
778
  for (const [key, valueSchema] of Object.entries(shape)) {
600
- const sample = sampleForDef(valueSchema._def);
779
+ const sample = sampleForDef(innerDef(valueSchema));
601
780
  if (sample !== void 0) out[key] = sample;
602
781
  }
603
782
  return out;
@@ -611,12 +790,13 @@ var sampleForDef = (def) => {
611
790
  return (/* @__PURE__ */ new Date()).toISOString();
612
791
  }
613
792
  default: {
614
- return null;
793
+ return void 0;
615
794
  }
616
795
  }
617
796
  };
618
797
  var sampleSchemaInput = (schema) => {
619
- return sampleForDef(schema.def);
798
+ const node = schema;
799
+ return sampleForDef(node.def ?? node._def);
620
800
  };
621
801
 
622
802
  // src/reporter.ts
@@ -666,14 +846,14 @@ var logContractSummary = (summary) => {
666
846
  import fs from "fs";
667
847
  import path from "path";
668
848
  import { pathToFileURL } from "url";
669
- import { getCsrfConfig, getPrismaClient, getProjectConfig, getSrcDir, tryCatch as tryCatch7 } from "@luckystack/core";
849
+ import { getCsrfConfig, getPrismaClient, getProjectConfig, getSrcDir, tryCatch as tryCatch4, tryCatchSync } from "@luckystack/core";
670
850
 
671
851
  // src/streamWatcher.ts
672
852
  import { io as createSocketIoClient } from "socket.io-client";
673
853
  import {
674
854
  buildJoinRoomResponseEventName,
675
855
  socketEventNames,
676
- tryCatch as tryCatch6
856
+ tryCatch as tryCatch3
677
857
  } from "@luckystack/core";
678
858
  var DEFAULT_TIMEOUT_MS = 5e3;
679
859
  var JOIN_RESPONSE_TIMEOUT_MS = 3e3;
@@ -757,13 +937,18 @@ var openStreamWatcher = async (input) => {
757
937
  }
758
938
  };
759
939
  socket.on(socketEventNames.sync, handleChunk);
760
- await waitForConnect(socket);
940
+ const [connectError] = await tryCatch3(() => waitForConnect(socket));
941
+ if (connectError) {
942
+ socket.off(socketEventNames.sync, handleChunk);
943
+ socket.disconnect();
944
+ throw connectError;
945
+ }
761
946
  const responseIndex = nextResponseIndex();
762
947
  socket.emit(socketEventNames.joinRoom, {
763
948
  group: input.roomCode,
764
949
  responseIndex
765
950
  });
766
- const [joinError] = await tryCatch6(() => waitForJoinAck(socket, responseIndex));
951
+ const [joinError] = await tryCatch3(() => waitForJoinAck(socket, responseIndex));
767
952
  if (joinError) {
768
953
  socket.off(socketEventNames.sync, handleChunk);
769
954
  socket.disconnect();
@@ -818,16 +1003,24 @@ var openStreamWatcher = async (input) => {
818
1003
  // src/customTests.ts
819
1004
  var API_TEST_FILE_PATTERN = /_v(\d+)\.tests\.ts$/;
820
1005
  var SYNC_SERVER_TEST_FILE_PATTERN = /_server_v(\d+)\.tests\.ts$/;
1006
+ var DEFAULT_CALL_TIMEOUT_MS = 1e4;
821
1007
  var discoverCustomTestFiles = (srcDir = getSrcDir()) => {
822
1008
  if (!fs.existsSync(srcDir)) return [];
823
1009
  const found = [];
824
1010
  const stack = [srcDir];
1011
+ const visited = /* @__PURE__ */ new Set();
825
1012
  while (stack.length > 0) {
826
1013
  const current = stack.pop();
827
1014
  if (current === void 0) break;
828
- const entries = fs.readdirSync(current, { withFileTypes: true });
1015
+ const [realError, realPath] = tryCatchSync(() => fs.realpathSync(current));
1016
+ const canonical = realError || realPath === null ? current : realPath;
1017
+ if (visited.has(canonical)) continue;
1018
+ visited.add(canonical);
1019
+ const [readError, entries] = tryCatchSync(() => fs.readdirSync(current, { withFileTypes: true }));
1020
+ if (readError || entries === null) continue;
829
1021
  for (const entry of entries) {
830
1022
  const fullPath = path.join(current, entry.name);
1023
+ if (entry.isSymbolicLink()) continue;
831
1024
  if (entry.isDirectory()) {
832
1025
  stack.push(fullPath);
833
1026
  continue;
@@ -869,7 +1062,7 @@ var buildExpect = () => ({
869
1062
  if (!value) throw new AssertionError(message ?? `expected truthy value, got ${JSON.stringify(value)}`);
870
1063
  },
871
1064
  throws: async (fn, message) => {
872
- const [err] = await tryCatch7(fn);
1065
+ const [err] = await tryCatch4(fn);
873
1066
  if (!err) throw new AssertionError(message ?? "expected fn to throw, but it did not");
874
1067
  return err;
875
1068
  },
@@ -920,18 +1113,40 @@ var buildSessionHelpers = (state) => {
920
1113
  current: () => ({ token: state.token, userId: state.userId })
921
1114
  };
922
1115
  };
1116
+ var parseResponse = async (response, state) => {
1117
+ const [textError, text] = await tryCatch4(() => response.text());
1118
+ if (textError || text === null) {
1119
+ state.lastResponse = null;
1120
+ throw new Error(`failed to read response body (HTTP ${response.status}): ${textError?.message ?? "unknown"}`);
1121
+ }
1122
+ const [parseError, parsed] = tryCatchSync(() => JSON.parse(text));
1123
+ if (parseError) {
1124
+ const snippet = text.length > 500 ? `${text.slice(0, 500)}\u2026` : text;
1125
+ state.lastResponse = snippet;
1126
+ throw new Error(`response was not valid JSON (HTTP ${response.status}): ${snippet}`);
1127
+ }
1128
+ state.lastResponse = parsed;
1129
+ return parsed;
1130
+ };
923
1131
  var buildCallApi = (baseUrl, routePath, state, cookieName, method) => {
924
1132
  return async (input) => {
925
1133
  const url = `${baseUrl.replace(/\/$/, "")}/api/${routePath}`;
926
1134
  const headers = { "Content-Type": "application/json", Origin: new URL(baseUrl).origin };
927
1135
  if (state.token) headers.Cookie = `${cookieName}=${state.token}`;
928
1136
  if (state.csrfToken) headers[getCsrfConfig().headerName] = state.csrfToken;
929
- const init = { method, headers };
1137
+ const controller = new AbortController();
1138
+ const timeoutHandle = setTimeout(() => {
1139
+ controller.abort();
1140
+ }, DEFAULT_CALL_TIMEOUT_MS);
1141
+ const init = { method, headers, signal: controller.signal };
930
1142
  if (method !== "GET" && method !== "HEAD") init.body = JSON.stringify(input ?? {});
931
- const response = await fetch(url, init);
932
- const json = await response.json();
933
- state.lastResponse = json;
934
- return json;
1143
+ const [fetchError, response] = await tryCatch4(() => fetch(url, init));
1144
+ clearTimeout(timeoutHandle);
1145
+ if (fetchError || !response) {
1146
+ state.lastResponse = null;
1147
+ throw new Error(`callApi ${method} ${routePath} failed: ${fetchError?.message ?? "no response"}`);
1148
+ }
1149
+ return await parseResponse(response, state);
935
1150
  };
936
1151
  };
937
1152
  var buildCallSync = (baseUrl, routePath, state, cookieName) => {
@@ -941,14 +1156,22 @@ var buildCallSync = (baseUrl, routePath, state, cookieName) => {
941
1156
  if (state.token) headers.Cookie = `${cookieName}=${state.token}`;
942
1157
  if (state.csrfToken) headers[getCsrfConfig().headerName] = state.csrfToken;
943
1158
  const body = { data: input ?? {}, receiver: opts?.receiver };
944
- const response = await fetch(url, {
1159
+ const controller = new AbortController();
1160
+ const timeoutHandle = setTimeout(() => {
1161
+ controller.abort();
1162
+ }, DEFAULT_CALL_TIMEOUT_MS);
1163
+ const [fetchError, response] = await tryCatch4(() => fetch(url, {
945
1164
  method: "POST",
946
1165
  headers,
947
- body: JSON.stringify(body)
948
- });
949
- const json = await response.json();
950
- state.lastResponse = json;
951
- return json;
1166
+ body: JSON.stringify(body),
1167
+ signal: controller.signal
1168
+ }));
1169
+ clearTimeout(timeoutHandle);
1170
+ if (fetchError || !response) {
1171
+ state.lastResponse = null;
1172
+ throw new Error(`callSync ${routePath} failed: ${fetchError?.message ?? "no response"}`);
1173
+ }
1174
+ return await parseResponse(response, state);
952
1175
  };
953
1176
  };
954
1177
  var buildContext = (discovery, input) => {
@@ -973,7 +1196,7 @@ var buildContext = (discovery, input) => {
973
1196
  while (watchers.length > 0) {
974
1197
  const watcher = watchers.pop();
975
1198
  if (!watcher) continue;
976
- const [closeError] = await tryCatch7(() => watcher.close());
1199
+ const [closeError] = await tryCatch4(() => watcher.close());
977
1200
  if (closeError) {
978
1201
  }
979
1202
  }
@@ -994,7 +1217,7 @@ var runCustomTests = async (input) => {
994
1217
  for (const discovery of files) {
995
1218
  const routePath = `${discovery.route.page}/${discovery.route.name}/${discovery.route.version}`;
996
1219
  if (input.filter && !routePath.includes(input.filter)) continue;
997
- const [importError, mod] = await tryCatch7(
1220
+ const [importError, mod] = await tryCatch4(
998
1221
  () => import(pathToFileURL(discovery.filePath).href)
999
1222
  );
1000
1223
  if (importError || !mod) {
@@ -1019,8 +1242,15 @@ var runCustomTests = async (input) => {
1019
1242
  const expectedToFail = typeof c.expectedToFail === "string" ? c.expectedToFail : void 0;
1020
1243
  const built = buildContext(discovery, input);
1021
1244
  const started = Date.now();
1022
- const [runError] = await tryCatch7(async () => c.run(built.ctx));
1023
- await tryCatch7(() => built.closeAllWatchers());
1245
+ const [runError] = await tryCatch4(async () => c.run(built.ctx));
1246
+ await tryCatch4(() => built.closeAllWatchers());
1247
+ const residualToken = built.state.token;
1248
+ if (residualToken) {
1249
+ await tryCatch4(async () => {
1250
+ const { deleteSession } = await import("@luckystack/login");
1251
+ await deleteSession(residualToken);
1252
+ });
1253
+ }
1024
1254
  const durationMs = Date.now() - started;
1025
1255
  let r;
1026
1256
  if (runError) {
@@ -1052,7 +1282,7 @@ var runCustomTests = async (input) => {
1052
1282
  };
1053
1283
 
1054
1284
  // src/runAllTests.ts
1055
- import { clearAllRateLimits, getProjectConfig as getProjectConfig2 } from "@luckystack/core";
1285
+ import { clearAllRateLimits, getCsrfConfig as getCsrfConfig2, getProjectConfig as getProjectConfig2 } from "@luckystack/core";
1056
1286
  var inputForEndpoint = (apiInputSchemas) => (endpoint) => {
1057
1287
  const schema = apiInputSchemas[endpoint.page]?.[endpoint.name]?.[endpoint.version];
1058
1288
  return schema ? sampleSchemaInput(schema) : {};
@@ -1064,78 +1294,137 @@ var matchesFilter = (endpoint, filter) => {
1064
1294
  var filterResults = (results, filter) => filter ? results.filter((r) => matchesFilter(r.endpoint, filter)) : results;
1065
1295
  var cloneSummary = (summary, filter) => {
1066
1296
  if (!filter) return summary;
1067
- const results = filterResults(summary.results, filter);
1068
- return {
1069
- total: results.length,
1070
- passed: results.filter((r) => r.status === "pass").length,
1071
- failed: results.filter((r) => r.status === "fail").length,
1072
- skipped: results.filter((r) => r.status === "skipped").length,
1073
- results
1074
- };
1297
+ return calculateSummary(filterResults(summary.results, filter));
1075
1298
  };
1076
- var runAllTests = async (input) => {
1077
- const summary = { totalPassed: 0, totalFailed: 0 };
1299
+ var buildAuthHeaders = async (input) => {
1078
1300
  const headers = {};
1079
- if (input.authToken) {
1080
- const cookieName = input.sessionCookieName ?? getProjectConfig2().http.sessionCookieName;
1081
- headers.Cookie = `${cookieName}=${input.authToken}`;
1301
+ if (!input.authToken) return headers;
1302
+ const cookieName = input.sessionCookieName ?? getProjectConfig2().http.sessionCookieName;
1303
+ headers.Cookie = `${cookieName}=${input.authToken}`;
1304
+ try {
1305
+ const { getSession } = await import("@luckystack/login");
1306
+ const session = await getSession(input.authToken);
1307
+ if (session?.csrfToken) {
1308
+ headers[getCsrfConfig2().headerName] = session.csrfToken;
1309
+ }
1310
+ } catch {
1082
1311
  }
1083
- const inputFor = inputForEndpoint(input.apiInputSchemas);
1084
- if (!input.noSweep) {
1085
- const contract = await runContractTests({
1312
+ return headers;
1313
+ };
1314
+ var runSweepLayers = async (input, summary, headers, inputFor) => {
1315
+ const contract = await runContractTests({
1316
+ apiMethodMap: input.apiMethodMap,
1317
+ baseUrl: input.baseUrl,
1318
+ skip: input.skip,
1319
+ headers,
1320
+ inputFor
1321
+ });
1322
+ summary.contract = cloneSummary(contract, input.filter);
1323
+ const auth = await runAuthEnforcementTests({
1324
+ apiMethodMap: input.apiMethodMap,
1325
+ apiMetaMap: input.apiMetaMap,
1326
+ baseUrl: input.baseUrl,
1327
+ skip: input.skip,
1328
+ inputFor
1329
+ });
1330
+ summary.auth = cloneSummary(auth, input.filter);
1331
+ if (!input.noRateLimit) {
1332
+ const rateLimit = await runRateLimitTests({
1086
1333
  apiMethodMap: input.apiMethodMap,
1334
+ apiMetaMap: input.apiMetaMap,
1087
1335
  baseUrl: input.baseUrl,
1088
1336
  skip: input.skip,
1089
1337
  headers,
1090
- inputFor
1338
+ inputFor,
1339
+ //? Reset the shared per-IP rate-limit bucket between endpoints so a
1340
+ //? neighbour saturating the window doesn't cause false pass/fail on the
1341
+ //? next endpoint. Requires the server to expose /_test/reset (allowed in
1342
+ //? NODE_ENV !== production or behind TEST_RESET_TOKEN).
1343
+ resetBetweenEndpoints: true
1091
1344
  });
1092
- summary.contract = cloneSummary(contract, input.filter);
1093
- const auth = await runAuthEnforcementTests({
1345
+ summary.rateLimit = cloneSummary(rateLimit, input.filter);
1346
+ }
1347
+ if (!input.noCsrf && headers.Cookie) {
1348
+ const csrf = await runCsrfEnforcementTests({
1094
1349
  apiMethodMap: input.apiMethodMap,
1095
1350
  apiMetaMap: input.apiMetaMap,
1096
1351
  baseUrl: input.baseUrl,
1352
+ authCookie: headers.Cookie,
1097
1353
  skip: input.skip,
1098
1354
  inputFor
1099
1355
  });
1100
- summary.auth = cloneSummary(auth, input.filter);
1101
- if (!input.noRateLimit) {
1102
- const rateLimit = await runRateLimitTests({
1103
- apiMethodMap: input.apiMethodMap,
1104
- apiMetaMap: input.apiMetaMap,
1105
- baseUrl: input.baseUrl,
1106
- skip: input.skip,
1107
- headers,
1108
- inputFor
1109
- });
1110
- summary.rateLimit = cloneSummary(rateLimit, input.filter);
1111
- }
1112
- if (!input.noFuzz) {
1113
- const fuzz = await runFuzzTests({
1114
- apiMethodMap: input.apiMethodMap,
1115
- baseUrl: input.baseUrl,
1116
- skip: input.skip,
1117
- headers
1118
- });
1119
- summary.fuzz = cloneSummary(fuzz, input.filter);
1120
- }
1356
+ summary.csrf = cloneSummary(csrf, input.filter);
1121
1357
  }
1122
- if (!input.noCustom) {
1123
- try {
1124
- await clearAllRateLimits();
1125
- } catch {
1126
- }
1127
- const custom = await runCustomTests({
1358
+ if (!input.noFuzz) {
1359
+ const fuzz = await runFuzzTests({
1360
+ apiMethodMap: input.apiMethodMap,
1128
1361
  baseUrl: input.baseUrl,
1129
- sessionCookieName: input.sessionCookieName,
1130
- filter: input.filter,
1131
- //? Pass the generated method map so the harness invokes each API route
1132
- //? with its declared HTTP method (e.g. logout is DELETE, not POST).
1133
- apiMethodMap: input.apiMethodMap
1362
+ skip: input.skip,
1363
+ headers
1134
1364
  });
1135
- summary.custom = custom;
1365
+ summary.fuzz = cloneSummary(fuzz, input.filter);
1366
+ }
1367
+ };
1368
+ var runCustomLayer = async (input, summary) => {
1369
+ try {
1370
+ await clearAllRateLimits();
1371
+ } catch {
1372
+ }
1373
+ const custom = await runCustomTests({
1374
+ baseUrl: input.baseUrl,
1375
+ sessionCookieName: input.sessionCookieName,
1376
+ filter: input.filter,
1377
+ //? Pass the generated method map so the harness invokes each API route
1378
+ //? with its declared HTTP method (e.g. logout is DELETE, not POST).
1379
+ apiMethodMap: input.apiMethodMap
1380
+ });
1381
+ summary.custom = custom;
1382
+ };
1383
+ var SWEEP_LAYER_ORDER = [
1384
+ LAYER_KEYS.contract,
1385
+ LAYER_KEYS.auth,
1386
+ LAYER_KEYS.rateLimit,
1387
+ LAYER_KEYS.csrf,
1388
+ LAYER_KEYS.fuzz
1389
+ ];
1390
+ var SWEEP_SUMMARY_PROP = {
1391
+ [LAYER_KEYS.contract]: "contract",
1392
+ [LAYER_KEYS.auth]: "auth",
1393
+ [LAYER_KEYS.rateLimit]: "rateLimit",
1394
+ [LAYER_KEYS.csrf]: "csrf",
1395
+ [LAYER_KEYS.fuzz]: "fuzz"
1396
+ };
1397
+ var computeTotals = (summary) => {
1398
+ let passed = 0;
1399
+ let failed = 0;
1400
+ for (const key of SWEEP_LAYER_ORDER) {
1401
+ const s = summary[SWEEP_SUMMARY_PROP[key]];
1402
+ passed += s?.passed ?? 0;
1403
+ failed += s?.failed ?? 0;
1404
+ }
1405
+ summary.totalPassed = passed + (summary.custom?.passed ?? 0);
1406
+ summary.totalFailed = failed + (summary.custom?.failed ?? 0);
1407
+ };
1408
+ var runAllTests = async (input) => {
1409
+ const summary = { totalPassed: 0, totalFailed: 0 };
1410
+ if (!input.noSweep) {
1411
+ const hasRoutes = Object.keys(input.apiMethodMap).length > 0;
1412
+ const hasMeta = Object.keys(input.apiMetaMap).length > 0;
1413
+ if (hasRoutes && !hasMeta) {
1414
+ console.warn(
1415
+ "[test-runner] runAllTests: apiMetaMap is empty but apiMethodMap has routes. The auth-enforcement and rate-limit layers will skip every route. Pass the generated apiMetaMap to enable those checks."
1416
+ );
1417
+ }
1418
+ }
1419
+ const headers = await buildAuthHeaders(input);
1420
+ const inputFor = inputForEndpoint(input.apiInputSchemas);
1421
+ if (!input.noSweep) {
1422
+ await runSweepLayers(input, summary, headers, inputFor);
1136
1423
  }
1137
- summary.totalPassed = (summary.contract?.passed ?? 0) + (summary.auth?.passed ?? 0) + (summary.rateLimit?.passed ?? 0) + (summary.fuzz?.passed ?? 0) + (summary.custom?.passed ?? 0);
1138
- summary.totalFailed = (summary.contract?.failed ?? 0) + (summary.auth?.failed ?? 0) + (summary.rateLimit?.failed ?? 0) + (summary.fuzz?.failed ?? 0) + (summary.custom?.failed ?? 0);
1424
+ if (!input.noCustom) {
1425
+ await runCustomLayer(input, summary);
1426
+ }
1427
+ computeTotals(summary);
1139
1428
  return summary;
1140
1429
  };
1141
1430
  var useColor = (() => {
@@ -1149,8 +1438,16 @@ var red = (t) => paint("31", t);
1149
1438
  var yellow = (t) => paint("33", t);
1150
1439
  var dim = (t) => paint("2", t);
1151
1440
  var bold = (t) => paint("1", t);
1152
- var xfailOf = (s) => s && "xfailed" in s ? s.xfailed : 0;
1153
- var skippedOf = (s) => s && "skipped" in s ? s.skipped : 0;
1441
+ var xfailOf = (s) => {
1442
+ if (s === void 0 || !("xfailed" in s)) return 0;
1443
+ const v = s.xfailed;
1444
+ return typeof v === "number" ? v : 0;
1445
+ };
1446
+ var skippedOf = (s) => {
1447
+ if (s === void 0 || !("skipped" in s)) return 0;
1448
+ const v = s.skipped;
1449
+ return typeof v === "number" ? v : 0;
1450
+ };
1154
1451
  var formatLayerLine = (label, s) => {
1155
1452
  if (!s) return ` ${dim("\u2013")} ${label.padEnd(18)} ${dim("(layer skipped)")}`;
1156
1453
  const clean = s.failed === 0;
@@ -1184,35 +1481,37 @@ var printSection = (title, heading, rows, bullet) => {
1184
1481
  };
1185
1482
  var logRunAllSummary = (summary) => {
1186
1483
  console.log("");
1187
- console.log(formatLayerLine("contract", summary.contract));
1188
- console.log(formatLayerLine("auth-enforcement", summary.auth));
1189
- console.log(formatLayerLine("rate-limit", summary.rateLimit));
1190
- console.log(formatLayerLine("fuzz", summary.fuzz));
1191
- console.log(formatLayerLine("custom", summary.custom));
1484
+ for (const key of SWEEP_LAYER_ORDER) {
1485
+ console.log(formatLayerLine(key, summary[SWEEP_SUMMARY_PROP[key]]));
1486
+ }
1487
+ console.log(formatLayerLine(LAYER_KEYS.custom, summary.custom));
1192
1488
  const failed = [
1193
- ...sweepFailRows(summary.contract),
1194
- ...sweepFailRows(summary.auth),
1195
- ...sweepFailRows(summary.rateLimit),
1196
- ...sweepFailRows(summary.fuzz),
1489
+ ...SWEEP_LAYER_ORDER.flatMap((key) => sweepFailRows(summary[SWEEP_SUMMARY_PROP[key]])),
1197
1490
  ...customRows(summary.custom, "fail")
1198
1491
  ];
1199
1492
  printSection(red, "Failed \u2014 must be fixed (real bugs / wrong tests)", failed, "\u2717");
1200
1493
  printSection(yellow, "Expected failures \u2014 known issues, allowed to fail", customRows(summary.custom, "xfail"), "\u26A0");
1201
1494
  printSection(yellow, "Unexpectedly passed \u2014 remove the expectedToFail marker", customRows(summary.custom, "xpass"), "?");
1202
- const skipped = [
1203
- ...sweepSkipRows("contract", summary.contract),
1204
- ...sweepSkipRows("auth", summary.auth),
1205
- ...sweepSkipRows("rate-limit", summary.rateLimit),
1206
- ...sweepSkipRows("fuzz", summary.fuzz)
1207
- ];
1495
+ const skipped = SWEEP_LAYER_ORDER.flatMap(
1496
+ (key) => sweepSkipRows(key, summary[SWEEP_SUMMARY_PROP[key]])
1497
+ );
1208
1498
  printSection(dim, "Skipped \u2014 not run (with reason)", skipped, "\u2013");
1209
- const totalXfail = xfailOf(summary.contract) + xfailOf(summary.auth) + xfailOf(summary.rateLimit) + xfailOf(summary.fuzz) + xfailOf(summary.custom);
1210
- const totalSkipped = skippedOf(summary.contract) + skippedOf(summary.auth) + skippedOf(summary.rateLimit) + skippedOf(summary.fuzz) + skippedOf(summary.custom);
1499
+ let totalXfail = 0;
1500
+ let totalSkipped = 0;
1501
+ for (const key of SWEEP_LAYER_ORDER) {
1502
+ const s = summary[SWEEP_SUMMARY_PROP[key]];
1503
+ totalXfail += xfailOf(s);
1504
+ totalSkipped += skippedOf(s);
1505
+ }
1506
+ totalXfail += xfailOf(summary.custom);
1507
+ totalSkipped += skippedOf(summary.custom);
1508
+ const totalXpass = summary.custom?.xpassed ?? 0;
1211
1509
  console.log("");
1212
1510
  const parts = [
1213
1511
  green(`${summary.totalPassed} passed`),
1214
1512
  summary.totalFailed > 0 ? red(`${summary.totalFailed} failed`) : dim("0 failed"),
1215
1513
  totalXfail > 0 ? yellow(`${totalXfail} expected-fail`) : dim("0 expected-fail"),
1514
+ totalXpass > 0 ? yellow(`${totalXpass} unexpectedly-passed`) : dim("0 unexpectedly-passed"),
1216
1515
  totalSkipped > 0 ? yellow(`${totalSkipped} skipped`) : dim("0 skipped")
1217
1516
  ];
1218
1517
  console.log(`Summary: ${parts.join(dim(" \xB7 "))}`);
@@ -1241,6 +1540,7 @@ var resetTestExtensionsForTests = () => {
1241
1540
  reporter = null;
1242
1541
  };
1243
1542
  export {
1543
+ LAYER_KEYS,
1244
1544
  discoverCustomTestFiles,
1245
1545
  getTestFixture,
1246
1546
  getTestReporter,
@@ -1259,12 +1559,15 @@ export {
1259
1559
  runAuthEnforcementTests,
1260
1560
  runContractCheck,
1261
1561
  runContractTests,
1562
+ runCsrfEnforcementCheck,
1563
+ runCsrfEnforcementTests,
1262
1564
  runCustomTests,
1263
1565
  runFuzzCheck,
1264
1566
  runFuzzTests,
1265
1567
  runRateLimitCheck,
1266
1568
  runRateLimitTests,
1267
1569
  sampleSchemaInput,
1268
- walkEndpoints
1570
+ walkEndpoints,
1571
+ walkSyncEndpoints
1269
1572
  };
1270
1573
  //# sourceMappingURL=index.js.map