@luckystack/test-runner 0.14.0 → 0.15.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/CHANGELOG.md +17 -0
- package/dist/contractCheck.d.ts +6 -0
- package/dist/customTests.d.ts +22 -7
- package/dist/index.d.ts +1 -1
- package/dist/index.js +247 -126
- package/dist/index.js.map +1 -1
- package/dist/probeRequest.d.ts +2 -2
- package/dist/runAllTests.d.ts +19 -2
- package/dist/runAuthEnforcementTests.d.ts +3 -1
- package/dist/runContractTests.d.ts +15 -1
- package/dist/runFuzzTests.d.ts +9 -1
- package/dist/runRateLimitTests.d.ts +3 -1
- package/dist/testLayerHelpers.d.ts +12 -2
- package/dist/testSocket.d.ts +13 -0
- package/docs/contract-tests.md +7 -1
- package/docs/fuzz-tests.md +1 -1
- package/package.json +4 -4
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/
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
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 ??
|
|
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
|
|
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 ??
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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 ??
|
|
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
|
-
|
|
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 = "
|
|
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
|
-
|
|
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
|
|
913
|
-
|
|
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
|
|
1402
|
-
|
|
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: "
|
|
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 (
|
|
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 ??
|
|
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
|
-
|
|
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
|
|
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 ??
|
|
1661
|
-
const
|
|
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,
|
|
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 || !
|
|
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 =
|
|
1676
|
-
const [joinError] = await tryCatch8(() => joinTestRoom(
|
|
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
|
-
|
|
1683
|
-
|
|
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
|
|
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
|
|
1698
|
-
const
|
|
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({
|
|
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.
|
|
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
|
-
...
|
|
1848
|
+
...routing,
|
|
1729
1849
|
apiMetaMap: input.apiMetaMap,
|
|
1730
1850
|
authCookie: headers.Cookie,
|
|
1731
1851
|
inputFor,
|
|
1732
|
-
|
|
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
|
|
1860
|
+
await transports.close();
|
|
1740
1861
|
if (resetBookendToken) {
|
|
1741
1862
|
await resetServerState({ baseUrl: input.baseUrl, token: resetBookendToken, only: ["rateLimits"] });
|
|
1742
1863
|
}
|