@luckystack/test-runner 0.14.0 → 0.16.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
@@ -95,8 +95,70 @@ var createHttpProbeTransport = (options = {}) => ({
95
95
  });
96
96
  var defaultProbeTransport = createHttpProbeTransport();
97
97
 
98
+ // src/testLayerHelpers.ts
99
+ var LAYER_KEYS = {
100
+ contract: "contract",
101
+ auth: "auth-enforcement",
102
+ rateLimit: "rate-limit",
103
+ csrf: "csrf-enforcement",
104
+ fuzz: "fuzz",
105
+ custom: "custom"
106
+ };
107
+ var STATE_CHANGING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "DELETE"]);
108
+ var routeKey = (endpoint) => `${endpoint.page}/${endpoint.name}/${endpoint.version}`;
109
+ var matchesFilter = (endpoint, filter) => !filter || routeKey(endpoint).includes(filter);
110
+ var selectEndpoints = (input) => [
111
+ ...walkEndpoints(input.apiMethodMap),
112
+ ...input.syncMetaMap ? walkSyncEndpoints(input.syncMetaMap) : []
113
+ ].filter((endpoint) => matchesFilter(endpoint, input.filter));
114
+ var metaMapFor = (endpoint, apiMetaMap, syncMetaMap) => endpoint.kind === "sync" ? syncMetaMap ?? {} : apiMetaMap;
115
+ var transportFor = (endpoint, syncTransport, apiTransport) => (endpoint.kind === "sync" ? syncTransport : apiTransport) ?? defaultProbeTransport;
116
+ var hasSessionHeaders = (headers) => Boolean(headers?.Cookie ?? headers?.Authorization);
117
+ var isRateLimitedCode = (errorCode) => errorCode?.endsWith(".rateLimitExceeded") ?? false;
118
+ var shouldSkip = (endpoint, skip) => {
119
+ if (skip.length === 0) return false;
120
+ const versioned = routeKey(endpoint);
121
+ const versionless = `${endpoint.page}/${endpoint.name}`;
122
+ return skip.includes(versioned) || skip.includes(versionless);
123
+ };
124
+ var requiresLogin = (metaMap, endpoint) => {
125
+ const meta = metaMap[endpoint.page]?.[endpoint.name]?.[endpoint.version];
126
+ return meta?.auth?.login ?? false;
127
+ };
128
+ var hasAuthRequirement = (metaMap, endpoint) => {
129
+ const meta = metaMap[endpoint.page]?.[endpoint.name]?.[endpoint.version];
130
+ return (meta?.auth?.login ?? false) || (meta?.auth?.hasAdditional ?? false);
131
+ };
132
+ var hasMetaEntry = (metaMap, endpoint) => metaMap[endpoint.page]?.[endpoint.name]?.[endpoint.version] !== void 0;
133
+ var getRateLimit = (metaMap, endpoint) => {
134
+ const meta = metaMap[endpoint.page]?.[endpoint.name]?.[endpoint.version];
135
+ if (!meta || meta.rateLimit === false || meta.rateLimit === void 0) return null;
136
+ return meta.rateLimit;
137
+ };
138
+ var calculateSummary = (results) => ({
139
+ total: results.length,
140
+ passed: results.filter((r) => r.status === "pass").length,
141
+ failed: results.filter((r) => r.status === "fail").length,
142
+ skipped: results.filter((r) => r.status === "skipped").length,
143
+ results
144
+ });
145
+
98
146
  // src/contractCheck.ts
99
147
  var DEFAULT_REQUEST_TIMEOUT_MS = 5e3;
148
+ var ROUTING_ERROR_CODES = /* @__PURE__ */ new Set([
149
+ "api.notFound",
150
+ "sync.notFound",
151
+ "api.methodNotAllowed",
152
+ "sync.methodNotAllowed",
153
+ "api.invalidName",
154
+ "sync.invalidName"
155
+ ]);
156
+ var SERVER_ERROR_CODES = /* @__PURE__ */ new Set([
157
+ "api.serverExecutionFailed",
158
+ "sync.serverExecutionFailed",
159
+ "api.internalServerError",
160
+ "sync.internalServerError"
161
+ ]);
100
162
  var runContractCheck = async (input) => {
101
163
  const { endpoint, baseUrl } = input;
102
164
  const body = input.inputFor ? input.inputFor(endpoint) : {};
@@ -148,6 +210,40 @@ var runContractCheck = async (input) => {
148
210
  durationMs
149
211
  };
150
212
  }
213
+ if (isRateLimitedCode(parsed.errorCode)) {
214
+ return {
215
+ endpoint,
216
+ status: "skipped",
217
+ httpStatus,
218
+ responseStatus: "error",
219
+ errorCode: parsed.errorCode,
220
+ reason: "rate-limited before the route ran (not exercised); pass a reset token so the sweep resets rate limits between routes",
221
+ durationMs
222
+ };
223
+ }
224
+ if (parsed.errorCode !== void 0 && ROUTING_ERROR_CODES.has(parsed.errorCode)) {
225
+ return {
226
+ endpoint,
227
+ status: "fail",
228
+ httpStatus,
229
+ responseStatus: "error",
230
+ errorCode: parsed.errorCode,
231
+ reason: "route not reachable as generated (routing error) \u2014 the generated route map and the live server disagree",
232
+ durationMs
233
+ };
234
+ }
235
+ const isServerError = httpStatus !== void 0 && httpStatus >= 500 || parsed.errorCode !== void 0 && SERVER_ERROR_CODES.has(parsed.errorCode);
236
+ if (isServerError) {
237
+ return {
238
+ endpoint,
239
+ status: input.allowServerError ? "pass" : "fail",
240
+ httpStatus,
241
+ responseStatus: "error",
242
+ errorCode: parsed.errorCode,
243
+ reason: input.allowServerError ? "expected server error (listed in expectServerError)" : "server error on a well-formed probe \u2014 list the route in expectServerError if it is designed to fail",
244
+ durationMs
245
+ };
246
+ }
151
247
  const bodyIsNonEmpty = body !== null && typeof body === "object" && !Array.isArray(body) && Object.keys(body).length > 0;
152
248
  if (input.inputFor && bodyIsNonEmpty && parsed.status === "error") {
153
249
  return {
@@ -170,51 +266,23 @@ var runContractCheck = async (input) => {
170
266
  };
171
267
  };
172
268
 
173
- // src/testLayerHelpers.ts
174
- var LAYER_KEYS = {
175
- contract: "contract",
176
- auth: "auth-enforcement",
177
- rateLimit: "rate-limit",
178
- csrf: "csrf-enforcement",
179
- fuzz: "fuzz",
180
- custom: "custom"
181
- };
182
- var STATE_CHANGING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "DELETE"]);
183
- var routeKey = (endpoint) => `${endpoint.page}/${endpoint.name}/${endpoint.version}`;
184
- var matchesFilter = (endpoint, filter) => !filter || routeKey(endpoint).includes(filter);
185
- var selectEndpoints = (input) => [
186
- ...walkEndpoints(input.apiMethodMap),
187
- ...input.syncMetaMap ? walkSyncEndpoints(input.syncMetaMap) : []
188
- ].filter((endpoint) => matchesFilter(endpoint, input.filter));
189
- var metaMapFor = (endpoint, apiMetaMap, syncMetaMap) => endpoint.kind === "sync" ? syncMetaMap ?? {} : apiMetaMap;
190
- var transportFor = (endpoint, syncTransport) => endpoint.kind === "sync" && syncTransport ? syncTransport : defaultProbeTransport;
191
- var shouldSkip = (endpoint, skip) => {
192
- if (skip.length === 0) return false;
193
- const versioned = routeKey(endpoint);
194
- const versionless = `${endpoint.page}/${endpoint.name}`;
195
- return skip.includes(versioned) || skip.includes(versionless);
196
- };
197
- var requiresLogin = (metaMap, endpoint) => {
198
- const meta = metaMap[endpoint.page]?.[endpoint.name]?.[endpoint.version];
199
- return meta?.auth?.login ?? false;
200
- };
201
- var hasAuthRequirement = (metaMap, endpoint) => {
202
- const meta = metaMap[endpoint.page]?.[endpoint.name]?.[endpoint.version];
203
- return (meta?.auth?.login ?? false) || (meta?.auth?.hasAdditional ?? false);
204
- };
205
- var hasMetaEntry = (metaMap, endpoint) => metaMap[endpoint.page]?.[endpoint.name]?.[endpoint.version] !== void 0;
206
- var getRateLimit = (metaMap, endpoint) => {
207
- const meta = metaMap[endpoint.page]?.[endpoint.name]?.[endpoint.version];
208
- if (!meta || meta.rateLimit === false || meta.rateLimit === void 0) return null;
209
- return meta.rateLimit;
269
+ // src/resetServerState.ts
270
+ import { getProjectConfig, TEST_RESET_ONLY_PARAM, tryCatch as tryCatch2 } from "@luckystack/core";
271
+ var DEFAULT_REQUEST_TIMEOUT_MS2 = 1e4;
272
+ var resetServerState = async ({ baseUrl, token, requestTimeoutMs, only }) => {
273
+ const endpoint = `${baseUrl.replace(/\/$/, "")}${getProjectConfig().http.testResetEndpoint}`;
274
+ const url = only && only.length > 0 ? `${endpoint}?${new URLSearchParams({ [TEST_RESET_ONLY_PARAM]: only.join(",") }).toString()}` : endpoint;
275
+ const headers = { "Content-Type": "application/json" };
276
+ if (token) headers["X-Test-Reset-Token"] = token;
277
+ const controller = new AbortController();
278
+ const timeoutHandle = setTimeout(() => {
279
+ controller.abort();
280
+ }, requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS2);
281
+ const [error, response] = await tryCatch2(() => fetch(url, { method: "POST", headers, signal: controller.signal }));
282
+ clearTimeout(timeoutHandle);
283
+ if (error || !response) return false;
284
+ return response.ok;
210
285
  };
211
- var calculateSummary = (results) => ({
212
- total: results.length,
213
- passed: results.filter((r) => r.status === "pass").length,
214
- failed: results.filter((r) => r.status === "fail").length,
215
- skipped: results.filter((r) => r.status === "skipped").length,
216
- results
217
- });
218
286
 
219
287
  // src/runContractTests.ts
220
288
  var runContractTests = async (input) => {
@@ -233,12 +301,16 @@ var runContractTests = async (input) => {
233
301
  input.onResult?.(skipped);
234
302
  continue;
235
303
  }
304
+ if (input.resetToken) {
305
+ await resetServerState({ baseUrl: input.baseUrl, token: input.resetToken, only: ["rateLimits"] });
306
+ }
236
307
  const result = await runContractCheck({
237
308
  endpoint,
238
309
  baseUrl: input.baseUrl,
239
310
  inputFor: input.inputFor,
240
311
  headers: input.headers,
241
- transport: transportFor(endpoint, input.syncTransport)
312
+ transport: transportFor(endpoint, input.syncTransport, input.apiTransport),
313
+ allowServerError: shouldSkip(endpoint, input.expectServerError ?? [])
242
314
  });
243
315
  results.push(result);
244
316
  input.onResult?.(result);
@@ -247,7 +319,7 @@ var runContractTests = async (input) => {
247
319
  };
248
320
 
249
321
  // src/authEnforcementCheck.ts
250
- var DEFAULT_REQUEST_TIMEOUT_MS2 = 5e3;
322
+ var DEFAULT_REQUEST_TIMEOUT_MS3 = 5e3;
251
323
  var EXPECTED_ERROR_CODES = /* @__PURE__ */ new Set(["auth.required", "auth.forbidden"]);
252
324
  var runAuthEnforcementCheck = async (input) => {
253
325
  const { endpoint, baseUrl } = input;
@@ -257,7 +329,7 @@ var runAuthEnforcementCheck = async (input) => {
257
329
  endpoint,
258
330
  baseUrl,
259
331
  body,
260
- requestTimeoutMs: input.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS2
332
+ requestTimeoutMs: input.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS3
261
333
  });
262
334
  if (!result) {
263
335
  return {
@@ -346,7 +418,7 @@ var runAuthEnforcementTests = async (input) => {
346
418
  endpoint,
347
419
  baseUrl: input.baseUrl,
348
420
  inputFor: input.inputFor,
349
- transport: transportFor(endpoint, input.syncTransport)
421
+ transport: transportFor(endpoint, input.syncTransport, input.apiTransport)
350
422
  });
351
423
  results.push(result);
352
424
  input.onResult?.(result);
@@ -355,7 +427,7 @@ var runAuthEnforcementTests = async (input) => {
355
427
  };
356
428
 
357
429
  // src/rateLimitCheck.ts
358
- var DEFAULT_REQUEST_TIMEOUT_MS3 = 1e4;
430
+ var DEFAULT_REQUEST_TIMEOUT_MS4 = 1e4;
359
431
  var expectedErrorCodeFor = (endpoint) => endpoint.kind === "sync" ? "sync.rateLimitExceeded" : "api.rateLimitExceeded";
360
432
  var PRE_LIMITER_ERROR_CODES = /* @__PURE__ */ new Set([
361
433
  "auth.required",
@@ -369,7 +441,7 @@ var runRateLimitCheck = async (input) => {
369
441
  const { endpoint, baseUrl, rateLimit } = input;
370
442
  const body = input.inputFor ? input.inputFor(endpoint) : {};
371
443
  const started = Date.now();
372
- const requestTimeoutMs = input.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS3;
444
+ const requestTimeoutMs = input.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS4;
373
445
  const expectedErrorCode = expectedErrorCodeFor(endpoint);
374
446
  const transport = input.transport ?? defaultProbeTransport;
375
447
  const send = () => transport.send({
@@ -425,30 +497,12 @@ var runRateLimitCheck = async (input) => {
425
497
  };
426
498
  };
427
499
 
428
- // src/resetServerState.ts
429
- import { getProjectConfig, TEST_RESET_ONLY_PARAM, tryCatch as tryCatch2 } from "@luckystack/core";
430
- var DEFAULT_REQUEST_TIMEOUT_MS4 = 1e4;
431
- var resetServerState = async ({ baseUrl, token, requestTimeoutMs, only }) => {
432
- const endpoint = `${baseUrl.replace(/\/$/, "")}${getProjectConfig().http.testResetEndpoint}`;
433
- const url = only && only.length > 0 ? `${endpoint}?${new URLSearchParams({ [TEST_RESET_ONLY_PARAM]: only.join(",") }).toString()}` : endpoint;
434
- const headers = { "Content-Type": "application/json" };
435
- if (token) headers["X-Test-Reset-Token"] = token;
436
- const controller = new AbortController();
437
- const timeoutHandle = setTimeout(() => {
438
- controller.abort();
439
- }, requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS4);
440
- const [error, response] = await tryCatch2(() => fetch(url, { method: "POST", headers, signal: controller.signal }));
441
- clearTimeout(timeoutHandle);
442
- if (error || !response) return false;
443
- return response.ok;
444
- };
445
-
446
500
  // src/runRateLimitTests.ts
447
501
  var runRateLimitTests = async (input) => {
448
502
  const endpoints = selectEndpoints(input);
449
503
  const skip = input.skip ?? [];
450
504
  const maxRateLimit = input.maxRateLimitToTest ?? 50;
451
- const isAuthenticatedSweep = Boolean(input.headers?.Cookie);
505
+ const isAuthenticatedSweep = hasSessionHeaders(input.headers);
452
506
  const results = [];
453
507
  for (const endpoint of endpoints) {
454
508
  const metaMap = metaMapFor(endpoint, input.apiMetaMap, input.syncMetaMap);
@@ -470,7 +524,7 @@ var runRateLimitTests = async (input) => {
470
524
  endpoint,
471
525
  status: "skipped",
472
526
  durationMs: 0,
473
- reason: "state-changing route skipped in cookie-mode rate-limit sweep (mutation risk with junk bodies)"
527
+ reason: "state-changing route skipped in authenticated rate-limit sweep (mutation risk with junk bodies)"
474
528
  };
475
529
  results.push(skipped);
476
530
  input.onResult?.(skipped);
@@ -507,7 +561,7 @@ var runRateLimitTests = async (input) => {
507
561
  rateLimit,
508
562
  inputFor: input.inputFor,
509
563
  headers: input.headers,
510
- transport: transportFor(endpoint, input.syncTransport)
564
+ transport: transportFor(endpoint, input.syncTransport, input.apiTransport)
511
565
  });
512
566
  results.push(result);
513
567
  input.onResult?.(result);
@@ -647,7 +701,7 @@ var runFuzzCheck = async (input) => {
647
701
  const started = Date.now();
648
702
  const requestTimeoutMs = input.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS6;
649
703
  const transport = input.transport ?? defaultProbeTransport;
650
- for (const payload of JUNK_PAYLOADS) {
704
+ for (const [index, payload] of JUNK_PAYLOADS.entries()) {
651
705
  const result = await transport.send({
652
706
  endpoint: input.endpoint,
653
707
  baseUrl: input.baseUrl,
@@ -673,6 +727,16 @@ var runFuzzCheck = async (input) => {
673
727
  durationMs
674
728
  };
675
729
  }
730
+ if (isRateLimitedCode(result.parsed.errorCode)) {
731
+ return {
732
+ endpoint: input.endpoint,
733
+ status: "skipped",
734
+ httpStatus: result.httpStatus,
735
+ errorCode: result.parsed.errorCode,
736
+ reason: `rate-limited after ${String(index)} of ${String(JUNK_PAYLOADS.length)} fuzz payloads (not exercised); pass a reset token so the sweep resets rate limits between routes`,
737
+ durationMs
738
+ };
739
+ }
676
740
  if (result.parsed.status === "error" && typeof result.parsed.errorCode !== "string") {
677
741
  return {
678
742
  endpoint: input.endpoint,
@@ -694,7 +758,7 @@ var runFuzzCheck = async (input) => {
694
758
  var runFuzzTests = async (input) => {
695
759
  const endpoints = selectEndpoints(input);
696
760
  const skip = input.skip ?? [];
697
- const isAuthenticatedSweep = Boolean(input.headers?.Cookie);
761
+ const isAuthenticatedSweep = hasSessionHeaders(input.headers);
698
762
  const results = [];
699
763
  for (const endpoint of endpoints) {
700
764
  if (isAuthenticatedSweep && STATE_CHANGING_METHODS.has(endpoint.method)) {
@@ -702,7 +766,7 @@ var runFuzzTests = async (input) => {
702
766
  endpoint,
703
767
  status: "skipped",
704
768
  durationMs: 0,
705
- reason: "state-changing route skipped in cookie-mode fuzz sweep (mutation risk with junk bodies)"
769
+ reason: "state-changing route skipped in authenticated fuzz sweep (mutation risk with junk bodies)"
706
770
  };
707
771
  results.push(skipped);
708
772
  input.onResult?.(skipped);
@@ -719,11 +783,14 @@ var runFuzzTests = async (input) => {
719
783
  input.onResult?.(skipped);
720
784
  continue;
721
785
  }
786
+ if (input.resetToken) {
787
+ await resetServerState({ baseUrl: input.baseUrl, token: input.resetToken, only: ["rateLimits"] });
788
+ }
722
789
  const result = await runFuzzCheck({
723
790
  endpoint,
724
791
  baseUrl: input.baseUrl,
725
792
  headers: input.headers,
726
- transport: transportFor(endpoint, input.syncTransport)
793
+ transport: transportFor(endpoint, input.syncTransport, input.apiTransport)
727
794
  });
728
795
  results.push(result);
729
796
  input.onResult?.(result);
@@ -737,6 +804,8 @@ import { tryCatch as tryCatch4 } from "@luckystack/core";
737
804
  // src/testSocket.ts
738
805
  import { io as createSocketIoClient } from "socket.io-client";
739
806
  import {
807
+ applyCookiePrefixConstraints,
808
+ buildApiResponseEventName,
740
809
  buildJoinRoomResponseEventName,
741
810
  buildSyncResponseEventName,
742
811
  getProjectConfig as getProjectConfig2,
@@ -745,6 +814,10 @@ import {
745
814
  } from "@luckystack/core";
746
815
  var DEFAULT_CONNECT_TIMEOUT_MS = 3e3;
747
816
  var DEFAULT_JOIN_TIMEOUT_MS = 3e3;
817
+ var projectSessionCookieName = () => {
818
+ const { http } = getProjectConfig2();
819
+ return applyCookiePrefixConstraints(http.sessionCookieName, http.sessionCookiePrefix).name;
820
+ };
748
821
  var responseIndexCounter = 0;
749
822
  var nextResponseIndex = () => {
750
823
  responseIndexCounter += 1;
@@ -778,7 +851,7 @@ var waitForConnect = (socket, label, timeoutMs) => {
778
851
  });
779
852
  };
780
853
  var connectTestSocket = async (input) => {
781
- const sessionCookieName = input.sessionCookieName ?? getProjectConfig2().http.sessionCookieName;
854
+ const sessionCookieName = input.sessionCookieName ?? projectSessionCookieName();
782
855
  const socket = createSocketIoClient(input.baseUrl, {
783
856
  transports: ["websocket"],
784
857
  forceNew: true,
@@ -835,20 +908,22 @@ var joinTestRoom = (socket, room, options) => {
835
908
  socket.emit(socketEventNames.joinRoom, { group: room, responseIndex });
836
909
  });
837
910
  };
911
+ var emitAndAwaitReply = (socket, replyEvent, emit, timeoutMs) => new Promise((resolve) => {
912
+ const timer = setTimeout(() => {
913
+ socket.off(replyEvent, onResponse);
914
+ resolve({ kind: "timeout" });
915
+ }, timeoutMs);
916
+ const onResponse = (payload) => {
917
+ clearTimeout(timer);
918
+ socket.off(replyEvent, onResponse);
919
+ resolve({ kind: "response", payload });
920
+ };
921
+ socket.on(replyEvent, onResponse);
922
+ emit();
923
+ });
838
924
  var emitSyncOverSocket = (socket, input) => {
839
925
  const responseIndex = nextResponseIndex();
840
- const eventName = buildSyncResponseEventName(responseIndex);
841
- return new Promise((resolve) => {
842
- const timer = setTimeout(() => {
843
- socket.off(eventName, onResponse);
844
- resolve({ kind: "timeout" });
845
- }, input.timeoutMs);
846
- const onResponse = (payload) => {
847
- clearTimeout(timer);
848
- socket.off(eventName, onResponse);
849
- resolve({ kind: "response", payload });
850
- };
851
- socket.on(eventName, onResponse);
926
+ return emitAndAwaitReply(socket, buildSyncResponseEventName(responseIndex), () => {
852
927
  socket.emit(socketEventNames.sync, {
853
928
  name: `sync/${input.routePath}`,
854
929
  //? Only `undefined` defaults — a fuzz probe's `null` must reach the server as-is.
@@ -861,11 +936,22 @@ var emitSyncOverSocket = (socket, input) => {
861
936
  responseIndex,
862
937
  ignoreSelf: input.ignoreSelf ?? false
863
938
  });
864
- });
939
+ }, input.timeoutMs);
940
+ };
941
+ var emitApiOverSocket = (socket, input) => {
942
+ const responseIndex = nextResponseIndex();
943
+ return emitAndAwaitReply(socket, buildApiResponseEventName(responseIndex), () => {
944
+ socket.emit(socketEventNames.apiRequest, {
945
+ name: `api/${input.routePath}`,
946
+ //? Only `undefined` defaults — a fuzz probe's `null` must reach the server as-is.
947
+ data: input.data === void 0 ? {} : input.data,
948
+ responseIndex
949
+ });
950
+ }, input.timeoutMs);
865
951
  };
866
952
 
867
953
  // src/socketProbeTransport.ts
868
- var LABEL = "sync probe";
954
+ var LABEL = "socket probe";
869
955
  var tokenFromCookieHeader = (cookieHeader, cookieName) => {
870
956
  if (!cookieHeader) return null;
871
957
  for (const part of cookieHeader.split(";")) {
@@ -874,10 +960,17 @@ var tokenFromCookieHeader = (cookieHeader, cookieName) => {
874
960
  }
875
961
  return null;
876
962
  };
963
+ var tokenFromHeaders = (headers, cookieName) => {
964
+ const cookieToken = tokenFromCookieHeader(headers?.Cookie, cookieName);
965
+ if (cookieToken) return cookieToken;
966
+ const authorization = headers?.Authorization;
967
+ return authorization?.startsWith("Bearer ") ? authorization.slice("Bearer ".length) : null;
968
+ };
877
969
  var toProbeResponse = (payload) => {
878
970
  if (typeof payload !== "object" || payload === null) return { parsed: null };
879
971
  const record = payload;
880
972
  return {
973
+ ...typeof record.httpStatus === "number" ? { httpStatus: record.httpStatus } : {},
881
974
  parsed: {
882
975
  status: typeof record.status === "string" ? record.status : void 0,
883
976
  errorCode: typeof record.errorCode === "string" ? record.errorCode : void 0
@@ -885,7 +978,6 @@ var toProbeResponse = (payload) => {
885
978
  };
886
979
  };
887
980
  var createSocketProbeTransport = (options) => {
888
- const http = createHttpProbeTransport({ syncReceiver: options.receiver });
889
981
  const sockets = /* @__PURE__ */ new Map();
890
982
  const socketFor = (token) => {
891
983
  const key = token ?? "";
@@ -906,15 +998,15 @@ var createSocketProbeTransport = (options) => {
906
998
  return opening;
907
999
  };
908
1000
  const send = async (request) => {
909
- if (request.endpoint.kind !== "sync") return http.send(request);
910
- const socket = await socketFor(tokenFromCookieHeader(request.headers?.Cookie, options.sessionCookieName));
1001
+ const socket = await socketFor(tokenFromHeaders(request.headers, options.sessionCookieName));
911
1002
  if (!socket) return null;
912
- const outcome = await emitSyncOverSocket(socket, {
913
- routePath: `${request.endpoint.page}/${request.endpoint.name}/${request.endpoint.version}`,
1003
+ const routePath = `${request.endpoint.page}/${request.endpoint.name}/${request.endpoint.version}`;
1004
+ const outcome = request.endpoint.kind === "sync" ? await emitSyncOverSocket(socket, {
1005
+ routePath,
914
1006
  data: request.body,
915
1007
  receiver: options.receiver,
916
1008
  timeoutMs: request.requestTimeoutMs
917
- });
1009
+ }) : await emitApiOverSocket(socket, { routePath, data: request.body, timeoutMs: request.requestTimeoutMs });
918
1010
  return outcome.kind === "response" ? toProbeResponse(outcome.payload) : null;
919
1011
  };
920
1012
  const close = async () => {
@@ -1398,8 +1490,19 @@ var parseResponse = async (response, state) => {
1398
1490
  state.lastResponse = parsed;
1399
1491
  return parsed;
1400
1492
  };
1401
- var buildCallApi = (baseUrl, routePath, state, cookieName, method) => {
1402
- return async (input) => {
1493
+ var resolveCallTransport = (opts) => opts?.transport ?? (getProjectConfig3().transport.invocation === "routed-http" ? "http" : "socket");
1494
+ var buildCallApi = (baseUrl, routePath, state, cookieName, method, caseSocket) => {
1495
+ return async (input, opts) => {
1496
+ if (resolveCallTransport(opts) === "socket") {
1497
+ const { socket } = await caseSocket.get(state.token);
1498
+ const outcome = await emitApiOverSocket(socket, { routePath, data: input, timeoutMs: DEFAULT_CALL_TIMEOUT_MS });
1499
+ if (outcome.kind === "timeout") {
1500
+ state.lastResponse = null;
1501
+ throw new Error(`callApi ${routePath} failed: no socket response within ${DEFAULT_CALL_TIMEOUT_MS}ms`);
1502
+ }
1503
+ state.lastResponse = outcome.payload;
1504
+ return outcome.payload;
1505
+ }
1403
1506
  const path4 = `${baseUrl.replace(/\/$/, "")}/api/${routePath}`;
1404
1507
  const headers = { "Content-Type": "application/json", Origin: new URL(baseUrl).origin };
1405
1508
  if (state.token) headers.Cookie = `${cookieName}=${state.token}`;
@@ -1431,17 +1534,16 @@ var createCaseSyncSocket = (baseUrl, cookieName) => {
1431
1534
  get: async (token) => {
1432
1535
  if (current?.token === token && current.socket.connected) return current;
1433
1536
  close();
1434
- const socket = await connectTestSocket({ baseUrl, token, label: "callSync", sessionCookieName: cookieName });
1537
+ const socket = await connectTestSocket({ baseUrl, token, label: "case socket", sessionCookieName: cookieName });
1435
1538
  current = { socket, token, joinedRooms: /* @__PURE__ */ new Set() };
1436
1539
  return current;
1437
1540
  },
1438
1541
  close
1439
1542
  };
1440
1543
  };
1441
- var resolveSyncTransport = (opts) => opts?.transport ?? (getProjectConfig3().transport.invocation === "routed-http" ? "http" : "socket");
1442
1544
  var buildCallSync = (baseUrl, routePath, state, cookieName, syncSocket) => {
1443
1545
  return async (input, opts) => {
1444
- if (resolveSyncTransport(opts) === "socket") {
1546
+ if (resolveCallTransport(opts) === "socket") {
1445
1547
  const { socket, joinedRooms } = await syncSocket.get(state.token);
1446
1548
  const { receiver, joinRoom = true } = opts ?? {};
1447
1549
  if (receiver && receiver !== "all" && joinRoom && !joinedRooms.has(receiver)) {
@@ -1486,7 +1588,7 @@ var buildCallSync = (baseUrl, routePath, state, cookieName, syncSocket) => {
1486
1588
  };
1487
1589
  };
1488
1590
  var buildContext = (discovery, input) => {
1489
- const cookieName = input.sessionCookieName ?? getProjectConfig3().http.sessionCookieName;
1591
+ const cookieName = input.sessionCookieName ?? projectSessionCookieName();
1490
1592
  const routePath = `${discovery.route.page}/${discovery.route.name}/${discovery.route.version}`;
1491
1593
  const routeFullName = `sync/${routePath}`;
1492
1594
  const apiMethod = input.apiMethodMap?.[discovery.route.page]?.[discovery.route.name]?.[discovery.route.version] ?? "POST";
@@ -1514,7 +1616,7 @@ var buildContext = (discovery, input) => {
1514
1616
  }
1515
1617
  };
1516
1618
  const ctx = {
1517
- callApi: buildCallApi(input.baseUrl, routePath, state, cookieName, apiMethod),
1619
+ callApi: buildCallApi(input.baseUrl, routePath, state, cookieName, apiMethod, syncSocket),
1518
1620
  callSync: buildCallSync(input.baseUrl, routePath, state, cookieName, syncSocket),
1519
1621
  watchStream,
1520
1622
  session: buildSessionHelpers(state),
@@ -1638,7 +1740,11 @@ var resolveResetBookendToken = (input) => {
1638
1740
  var buildAuthHeaders = async (input) => {
1639
1741
  const headers = {};
1640
1742
  if (!input.authToken) return headers;
1641
- const cookieName = input.sessionCookieName ?? getProjectConfig4().http.sessionCookieName;
1743
+ if (getProjectConfig4().session.basedToken) {
1744
+ headers.Authorization = `Bearer ${input.authToken}`;
1745
+ return headers;
1746
+ }
1747
+ const cookieName = input.sessionCookieName ?? projectSessionCookieName();
1642
1748
  headers.Cookie = `${cookieName}=${input.authToken}`;
1643
1749
  try {
1644
1750
  const { getSession } = await import("@luckystack/login");
@@ -1655,34 +1761,37 @@ var buildAuthHeaders = async (input) => {
1655
1761
  }
1656
1762
  return headers;
1657
1763
  };
1658
- var prepareSyncSweep = async (input) => {
1764
+ var resolveProbeMode = (override) => override ?? (getProjectConfig4().transport.invocation === "routed-http" ? "http" : "socket");
1765
+ var prepareSweepTransports = async (input, hasSyncRoutes) => {
1659
1766
  const receiver = `lstest-room-${crypto2.randomUUID()}`;
1660
- const sessionCookieName = input.sessionCookieName ?? getProjectConfig4().http.sessionCookieName;
1661
- const mode = input.syncTransport ?? (getProjectConfig4().transport.invocation === "routed-http" ? "http" : "socket");
1767
+ const sessionCookieName = input.sessionCookieName ?? projectSessionCookieName();
1768
+ const syncMode = resolveProbeMode(input.syncTransport);
1769
+ const apiMode = resolveProbeMode(input.apiTransport);
1662
1770
  const http = createHttpProbeTransport({ syncReceiver: receiver });
1771
+ const socket = apiMode === "socket" || hasSyncRoutes && syncMode === "socket" ? createSocketProbeTransport({ baseUrl: input.baseUrl, receiver, sessionCookieName }) : null;
1663
1772
  let memberSocket = null;
1664
- if (input.authToken) {
1773
+ if (hasSyncRoutes && input.authToken) {
1665
1774
  const authToken = input.authToken;
1666
- const [connectError, socket] = await tryCatch8(() => connectTestSocket({
1775
+ const [connectError, socket2] = await tryCatch8(() => connectTestSocket({
1667
1776
  baseUrl: input.baseUrl,
1668
1777
  token: authToken,
1669
1778
  label: "sync sweep",
1670
1779
  sessionCookieName
1671
1780
  }));
1672
- if (connectError || !socket) {
1781
+ if (connectError || !socket2) {
1673
1782
  console.warn(`[test-runner] runAllTests: could not open a socket for the sync sweep \u2014 ${connectError?.message ?? "no socket"}; sync probes will be rejected as non-members.`);
1674
1783
  } else {
1675
- memberSocket = socket;
1676
- const [joinError] = await tryCatch8(() => joinTestRoom(socket, receiver, { label: "sync sweep" }));
1784
+ memberSocket = socket2;
1785
+ const [joinError] = await tryCatch8(() => joinTestRoom(socket2, receiver, { label: "sync sweep" }));
1677
1786
  if (joinError) console.warn(`[test-runner] runAllTests: ${joinError.message}; sync probes will be rejected as non-members.`);
1678
1787
  }
1679
1788
  }
1680
- const sweep = mode === "socket" ? createSocketProbeTransport({ baseUrl: input.baseUrl, receiver, sessionCookieName }) : http;
1681
1789
  return {
1682
- sweep,
1683
- csrf: http,
1790
+ sync: hasSyncRoutes ? syncMode === "socket" && socket ? socket : http : void 0,
1791
+ csrfSync: hasSyncRoutes ? http : void 0,
1792
+ api: apiMode === "socket" && socket ? socket : void 0,
1684
1793
  close: async () => {
1685
- await sweep.close?.();
1794
+ await socket?.close?.();
1686
1795
  if (memberSocket) await releaseTestSocket(memberSocket);
1687
1796
  }
1688
1797
  };
@@ -1694,17 +1803,26 @@ var runSweepLayers = async (input, summary, headers, inputFor) => {
1694
1803
  await resetServerState({ baseUrl: input.baseUrl, token: resetBookendToken, only: ["rateLimits"] });
1695
1804
  }
1696
1805
  const hasSyncRoutes = Object.keys(input.syncMetaMap ?? {}).length > 0;
1697
- const sync = hasSyncRoutes ? await prepareSyncSweep(input) : null;
1698
- const shared = {
1806
+ const transports = await prepareSweepTransports(input, hasSyncRoutes);
1807
+ const routing = {
1699
1808
  apiMethodMap: input.apiMethodMap,
1700
1809
  syncMetaMap: input.syncMetaMap,
1701
1810
  baseUrl: input.baseUrl,
1702
1811
  skip: input.skip,
1703
- filter: input.filter,
1704
- syncTransport: sync?.sweep
1812
+ filter: input.filter
1705
1813
  };
1814
+ const shared = { ...routing, syncTransport: transports.sync, apiTransport: transports.api };
1706
1815
  const [layerError] = await tryCatch8(async () => {
1707
- summary.contract = await runContractTests({ ...shared, headers, inputFor });
1816
+ summary.contract = await runContractTests({
1817
+ ...shared,
1818
+ headers,
1819
+ inputFor,
1820
+ expectServerError: input.expectServerError,
1821
+ //? Reset limiter buckets between routes when a token is available: the
1822
+ //? global per-IP bucket is checked before validation, so once it fills,
1823
+ //? every later probe would be a limiter rejection, not a route test.
1824
+ resetToken
1825
+ });
1708
1826
  summary.auth = await runAuthEnforcementTests({ ...shared, apiMetaMap: input.apiMetaMap, inputFor });
1709
1827
  if (!input.noRateLimit) {
1710
1828
  summary.rateLimit = await runRateLimitTests({
@@ -1723,20 +1841,23 @@ var runSweepLayers = async (input, summary, headers, inputFor) => {
1723
1841
  resetToken
1724
1842
  });
1725
1843
  }
1726
- if (!input.noCsrf && headers.Cookie) {
1844
+ if (!input.noCsrf && headers.Authorization) {
1845
+ console.log("[test-runner] runAllTests: CSRF layer skipped \u2014 token-mode sessions (session.basedToken) are not subject to CSRF enforcement.");
1846
+ } else if (!input.noCsrf && headers.Cookie) {
1727
1847
  summary.csrf = await runCsrfEnforcementTests({
1728
- ...shared,
1848
+ ...routing,
1729
1849
  apiMetaMap: input.apiMetaMap,
1730
1850
  authCookie: headers.Cookie,
1731
1851
  inputFor,
1732
- syncTransport: sync?.csrf
1852
+ //? CSRF is an HTTP-only guard: both route kinds probe over HTTP.
1853
+ syncTransport: transports.csrfSync
1733
1854
  });
1734
1855
  }
1735
1856
  if (!input.noFuzz) {
1736
- summary.fuzz = await runFuzzTests({ ...shared, headers });
1857
+ summary.fuzz = await runFuzzTests({ ...shared, headers, resetToken });
1737
1858
  }
1738
1859
  });
1739
- await sync?.close();
1860
+ await transports.close();
1740
1861
  if (resetBookendToken) {
1741
1862
  await resetServerState({ baseUrl: input.baseUrl, token: resetBookendToken, only: ["rateLimits"] });
1742
1863
  }