@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/CLAUDE.md +98 -98
- package/LICENSE +21 -21
- package/README.md +83 -83
- package/dist/index.d.ts +76 -41
- package/dist/index.js +586 -283
- package/dist/index.js.map +1 -1
- package/docs/auth-tests.md +226 -226
- package/docs/contract-tests.md +259 -259
- package/docs/extension-hooks.md +438 -438
- package/docs/fuzz-tests.md +249 -249
- package/docs/rate-limit-tests.md +256 -256
- package/package.json +5 -4
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,
|
|
9
|
-
if (
|
|
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/
|
|
56
|
+
// src/probeRequest.ts
|
|
24
57
|
import { tryCatch } from "@luckystack/core";
|
|
25
|
-
var
|
|
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:
|
|
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.
|
|
42
|
-
|
|
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:
|
|
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:
|
|
102
|
+
reason: "fetch returned no response",
|
|
55
103
|
durationMs: Date.now() - started
|
|
56
104
|
};
|
|
57
105
|
}
|
|
58
|
-
const
|
|
59
|
-
async () => await response.json()
|
|
60
|
-
);
|
|
106
|
+
const { httpStatus, parsed } = result;
|
|
61
107
|
const durationMs = Date.now() - started;
|
|
62
|
-
if (
|
|
108
|
+
if (!parsed) {
|
|
63
109
|
return {
|
|
64
110
|
endpoint,
|
|
65
111
|
status: "fail",
|
|
66
|
-
httpStatus
|
|
112
|
+
httpStatus,
|
|
67
113
|
responseStatus: "unknown",
|
|
68
|
-
reason:
|
|
114
|
+
reason: "JSON parse failed",
|
|
69
115
|
durationMs
|
|
70
116
|
};
|
|
71
117
|
}
|
|
72
|
-
if (
|
|
118
|
+
if (parsed.status !== "success" && parsed.status !== "error") {
|
|
73
119
|
return {
|
|
74
120
|
endpoint,
|
|
75
121
|
status: "fail",
|
|
76
|
-
httpStatus
|
|
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
|
|
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
|
|
153
|
+
httpStatus,
|
|
96
154
|
responseStatus: parsed.status,
|
|
97
155
|
errorCode: parsed.errorCode,
|
|
98
156
|
durationMs
|
|
99
157
|
};
|
|
100
158
|
};
|
|
101
159
|
|
|
102
|
-
// src/
|
|
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
|
|
153
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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:
|
|
242
|
+
reason: "fetch returned no response",
|
|
171
243
|
durationMs: Date.now() - started
|
|
172
244
|
};
|
|
173
245
|
}
|
|
174
|
-
const
|
|
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
|
|
252
|
+
httpStatus,
|
|
193
253
|
responseStatus: "success",
|
|
194
|
-
reason:
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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 =
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
|
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
|
|
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
|
|
335
|
-
responseStatus
|
|
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
|
|
344
|
-
var
|
|
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
|
|
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 &&
|
|
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 (
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
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__:
|
|
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 ??
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
793
|
+
return void 0;
|
|
615
794
|
}
|
|
616
795
|
}
|
|
617
796
|
};
|
|
618
797
|
var sampleSchemaInput = (schema) => {
|
|
619
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
933
|
-
|
|
934
|
-
|
|
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
|
|
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
|
-
|
|
950
|
-
|
|
951
|
-
|
|
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
|
|
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
|
|
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
|
|
1023
|
-
await
|
|
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
|
-
|
|
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
|
|
1077
|
-
const summary = { totalPassed: 0, totalFailed: 0 };
|
|
1299
|
+
var buildAuthHeaders = async (input) => {
|
|
1078
1300
|
const headers = {};
|
|
1079
|
-
if (input.authToken)
|
|
1080
|
-
|
|
1081
|
-
|
|
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
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
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.
|
|
1093
|
-
|
|
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.
|
|
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.
|
|
1123
|
-
|
|
1124
|
-
|
|
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
|
-
|
|
1130
|
-
|
|
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.
|
|
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
|
-
|
|
1138
|
-
|
|
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) =>
|
|
1153
|
-
|
|
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
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
console.log(formatLayerLine(
|
|
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
|
|
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
|
-
|
|
1204
|
-
|
|
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
|
-
|
|
1210
|
-
|
|
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
|