@testsmith/api-spector 0.3.5 → 0.3.7
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/bin/cli.js +11 -7
- package/out/main/agents.js +1 -1
- package/out/main/chunks/environments-iM3SUM-4.js +28 -0
- package/out/main/chunks/{handle-C0IQL-Vl.js → handle-BCnNIZZr.js} +6 -2
- package/out/main/chunks/{import-C5Ngq8hk.js → import-DUKaMYGO.js} +4 -4
- package/out/main/chunks/{recorder-DFxJgn9c.js → recorder-0Ij921El.js} +1 -1
- package/out/main/chunks/request-collection-CBuPjBwa.js +118 -0
- package/out/main/chunks/{request-collection-8TOVNXE0.js → request-exec-AQD6cEgT.js} +602 -146
- package/out/main/chunks/snapshots-NGkGQNZ4.js +1658 -0
- package/out/main/chunks/{soap-handler-B9x_YCtj.js → soap-handler-h0dWIjOK.js} +3 -3
- package/out/main/contract.js +508 -91
- package/out/main/index.js +112 -77
- package/out/main/record.js +3 -3
- package/out/main/runner.js +23 -20
- package/out/main/wsdl.js +4 -4
- package/out/preload/index.js +10 -2
- package/out/renderer/assets/{index-BFxRlU1d.js → index-SXJZlbGN.js} +1342 -214
- package/out/renderer/assets/index-mDBOHLZ5.css +2 -0
- package/out/renderer/index.html +2 -2
- package/package.json +1 -1
- package/readme.md +2 -2
- package/out/main/chunks/auth-builder-CUs9yzOF.js +0 -623
- package/out/main/chunks/snapshots-UFd3XgSS.js +0 -928
- package/out/renderer/assets/index-DuMAjIiI.css +0 -2
|
@@ -0,0 +1,1658 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const undici = require("undici");
|
|
3
|
+
const requestExec = require("./request-exec-AQD6cEgT.js");
|
|
4
|
+
const promises = require("fs/promises");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const Ajv = require("ajv");
|
|
7
|
+
const jsYaml = require("js-yaml");
|
|
8
|
+
const crypto = require("crypto");
|
|
9
|
+
const MATCH_KEY = "__match";
|
|
10
|
+
function isMatcher(node) {
|
|
11
|
+
return typeof node === "object" && node !== null && !Array.isArray(node) && typeof node[MATCH_KEY] === "string";
|
|
12
|
+
}
|
|
13
|
+
function compileMatcher(m) {
|
|
14
|
+
switch (m[MATCH_KEY]) {
|
|
15
|
+
case "type":
|
|
16
|
+
return compileMatcherExample(m.value, false);
|
|
17
|
+
case "integer":
|
|
18
|
+
return { type: "integer" };
|
|
19
|
+
case "decimal":
|
|
20
|
+
case "number":
|
|
21
|
+
return { type: "number" };
|
|
22
|
+
case "boolean":
|
|
23
|
+
return { type: "boolean" };
|
|
24
|
+
case "string":
|
|
25
|
+
return { type: "string" };
|
|
26
|
+
case "null":
|
|
27
|
+
return { type: "null" };
|
|
28
|
+
case "regex":
|
|
29
|
+
return { type: "string", pattern: m.regex ?? ".*" };
|
|
30
|
+
case "datetime":
|
|
31
|
+
case "timestamp":
|
|
32
|
+
return { type: "string", format: "date-time" };
|
|
33
|
+
case "date":
|
|
34
|
+
return { type: "string", format: "date" };
|
|
35
|
+
case "time":
|
|
36
|
+
return { type: "string", format: "time" };
|
|
37
|
+
case "eachLike": {
|
|
38
|
+
const schema = { type: "array", items: compileMatcherExample(m.value, false) };
|
|
39
|
+
schema["minItems"] = typeof m.min === "number" ? m.min : 1;
|
|
40
|
+
return schema;
|
|
41
|
+
}
|
|
42
|
+
default:
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function compileMatcherExample(node, exact = true) {
|
|
47
|
+
if (isMatcher(node)) return compileMatcher(node);
|
|
48
|
+
if (node === null) return { type: "null" };
|
|
49
|
+
const t = typeof node;
|
|
50
|
+
if (t === "boolean") return exact ? { const: node } : { type: "boolean" };
|
|
51
|
+
if (t === "number") return exact ? { const: node } : Number.isInteger(node) ? { type: "integer" } : { type: "number" };
|
|
52
|
+
if (t === "string") return exact ? { const: node } : { type: "string" };
|
|
53
|
+
if (Array.isArray(node)) {
|
|
54
|
+
if (node.length === 0) return { type: "array" };
|
|
55
|
+
const schema = { type: "array", items: node.map((n) => compileMatcherExample(n, exact)) };
|
|
56
|
+
if (exact) schema["minItems"] = node.length;
|
|
57
|
+
return schema;
|
|
58
|
+
}
|
|
59
|
+
if (t === "object") {
|
|
60
|
+
const obj = node;
|
|
61
|
+
const properties = {};
|
|
62
|
+
const required = [];
|
|
63
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
64
|
+
properties[key] = compileMatcherExample(value, exact);
|
|
65
|
+
required.push(key);
|
|
66
|
+
}
|
|
67
|
+
return { type: "object", properties, required, additionalProperties: true };
|
|
68
|
+
}
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
const ajv$2 = new Ajv({ allErrors: true, strict: false });
|
|
72
|
+
function hasContract(contract) {
|
|
73
|
+
return !!contract && (contract.statusCode !== void 0 || !!contract.bodySchema || !!contract.bodyMatcher || !!contract.headers?.length);
|
|
74
|
+
}
|
|
75
|
+
function validateConsumerResponse(contract, actualStatus, actualHeaders, bodyText) {
|
|
76
|
+
const violations = [];
|
|
77
|
+
if (contract.statusCode !== void 0 && actualStatus !== contract.statusCode) {
|
|
78
|
+
violations.push({
|
|
79
|
+
type: "status_mismatch",
|
|
80
|
+
message: `Expected status ${contract.statusCode}, got ${actualStatus}`,
|
|
81
|
+
expected: String(contract.statusCode),
|
|
82
|
+
actual: String(actualStatus)
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
for (const expected of contract.headers ?? []) {
|
|
86
|
+
if (!expected.required) continue;
|
|
87
|
+
const actual = actualHeaders[expected.key.toLowerCase()];
|
|
88
|
+
if (actual === void 0) {
|
|
89
|
+
violations.push({
|
|
90
|
+
type: "missing_header",
|
|
91
|
+
message: `Required header "${expected.key}" is absent`,
|
|
92
|
+
expected: expected.value || "(any)",
|
|
93
|
+
actual: "(absent)"
|
|
94
|
+
});
|
|
95
|
+
} else if (expected.value && actual.split(";")[0].trim().toLowerCase() !== expected.value.split(";")[0].trim().toLowerCase()) {
|
|
96
|
+
violations.push({
|
|
97
|
+
type: "missing_header",
|
|
98
|
+
message: `Header "${expected.key}" has unexpected value`,
|
|
99
|
+
expected: expected.value,
|
|
100
|
+
actual
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (contract.bodySchema?.trim()) {
|
|
105
|
+
let schema;
|
|
106
|
+
try {
|
|
107
|
+
schema = JSON.parse(contract.bodySchema);
|
|
108
|
+
} catch {
|
|
109
|
+
violations.push({
|
|
110
|
+
type: "schema_violation",
|
|
111
|
+
message: "Contract bodySchema is not valid JSON"
|
|
112
|
+
});
|
|
113
|
+
return violations;
|
|
114
|
+
}
|
|
115
|
+
violations.push(...validateBody(schema, bodyText));
|
|
116
|
+
}
|
|
117
|
+
if (contract.bodyMatcher?.trim()) {
|
|
118
|
+
let example;
|
|
119
|
+
try {
|
|
120
|
+
example = JSON.parse(contract.bodyMatcher);
|
|
121
|
+
} catch {
|
|
122
|
+
violations.push({
|
|
123
|
+
type: "schema_violation",
|
|
124
|
+
message: "Contract bodyMatcher is not valid JSON"
|
|
125
|
+
});
|
|
126
|
+
return violations;
|
|
127
|
+
}
|
|
128
|
+
violations.push(...validateBody(compileMatcherExample(example), bodyText));
|
|
129
|
+
}
|
|
130
|
+
return violations;
|
|
131
|
+
}
|
|
132
|
+
function validateBody(schema, bodyText) {
|
|
133
|
+
const violations = [];
|
|
134
|
+
let data;
|
|
135
|
+
try {
|
|
136
|
+
data = JSON.parse(bodyText);
|
|
137
|
+
} catch {
|
|
138
|
+
violations.push({
|
|
139
|
+
type: "schema_violation",
|
|
140
|
+
message: "Response body is not valid JSON - cannot validate against schema"
|
|
141
|
+
});
|
|
142
|
+
return violations;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
const validate = ajv$2.compile(schema);
|
|
146
|
+
if (!validate(data)) {
|
|
147
|
+
for (const err of validate.errors ?? []) {
|
|
148
|
+
violations.push({
|
|
149
|
+
type: "schema_violation",
|
|
150
|
+
message: err.message ?? "Schema violation",
|
|
151
|
+
path: err.instancePath || "/"
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} catch (e) {
|
|
156
|
+
violations.push({
|
|
157
|
+
type: "schema_violation",
|
|
158
|
+
message: `Schema compile error: ${e instanceof Error ? e.message : String(e)}`
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return violations;
|
|
162
|
+
}
|
|
163
|
+
async function executeContract(req, vars) {
|
|
164
|
+
const url = requestExec.buildUrl(req.url, req.params, vars);
|
|
165
|
+
const start = Date.now();
|
|
166
|
+
try {
|
|
167
|
+
const headers = new undici.Headers();
|
|
168
|
+
for (const h of req.headers) {
|
|
169
|
+
if (h.enabled && h.key) headers.set(requestExec.interpolate(h.key, vars), requestExec.interpolate(h.value, vars));
|
|
170
|
+
}
|
|
171
|
+
const authHeaders = await requestExec.buildAuthHeaders(req.auth, vars);
|
|
172
|
+
for (const [k, v] of Object.entries(authHeaders)) headers.set(k, v);
|
|
173
|
+
let body;
|
|
174
|
+
if (req.body.mode === "json" && req.body.json) {
|
|
175
|
+
body = requestExec.interpolate(req.body.json, vars);
|
|
176
|
+
if (!headers.has("content-type")) headers.set("Content-Type", "application/json");
|
|
177
|
+
} else if (req.body.mode === "raw" && req.body.raw) {
|
|
178
|
+
body = requestExec.interpolate(req.body.raw, vars);
|
|
179
|
+
}
|
|
180
|
+
const resp = await undici.fetch(url, {
|
|
181
|
+
method: req.method,
|
|
182
|
+
headers,
|
|
183
|
+
body: !["GET", "HEAD"].includes(req.method) ? body : void 0
|
|
184
|
+
});
|
|
185
|
+
const bodyText = await resp.text();
|
|
186
|
+
const rawHeaders = {};
|
|
187
|
+
resp.headers.forEach((v, k) => {
|
|
188
|
+
rawHeaders[k] = v;
|
|
189
|
+
});
|
|
190
|
+
const violations = validateConsumerResponse(req.contract, resp.status, rawHeaders, bodyText);
|
|
191
|
+
return {
|
|
192
|
+
requestId: req.id,
|
|
193
|
+
requestName: req.name,
|
|
194
|
+
method: req.method,
|
|
195
|
+
url,
|
|
196
|
+
passed: violations.length === 0,
|
|
197
|
+
violations,
|
|
198
|
+
durationMs: Date.now() - start,
|
|
199
|
+
actualStatus: resp.status
|
|
200
|
+
};
|
|
201
|
+
} catch (err) {
|
|
202
|
+
return {
|
|
203
|
+
requestId: req.id,
|
|
204
|
+
requestName: req.name,
|
|
205
|
+
method: req.method,
|
|
206
|
+
url,
|
|
207
|
+
passed: false,
|
|
208
|
+
violations: [{
|
|
209
|
+
type: "status_mismatch",
|
|
210
|
+
message: `Request failed: ${err instanceof Error ? err.message : String(err)}`
|
|
211
|
+
}],
|
|
212
|
+
durationMs: Date.now() - start
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
async function runConsumerContracts(requests, envVars, collectionVars = {}) {
|
|
217
|
+
const vars = { ...envVars, ...collectionVars };
|
|
218
|
+
const contractRequests = requests.filter((r) => !r.disabled && hasContract(r.contract));
|
|
219
|
+
const start = Date.now();
|
|
220
|
+
const results = await Promise.all(contractRequests.map((r) => executeContract(r, vars)));
|
|
221
|
+
const passed = results.filter((r) => r.passed).length;
|
|
222
|
+
return {
|
|
223
|
+
mode: "consumer",
|
|
224
|
+
total: results.length,
|
|
225
|
+
passed,
|
|
226
|
+
failed: results.length - passed,
|
|
227
|
+
results,
|
|
228
|
+
durationMs: Date.now() - start
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
const ajv$1 = new Ajv({ allErrors: true, strict: false });
|
|
232
|
+
async function loadSpec(specUrl, specPath) {
|
|
233
|
+
if (specUrl) {
|
|
234
|
+
const resp = await undici.fetch(specUrl);
|
|
235
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status} loading spec from ${specUrl}`);
|
|
236
|
+
const text = await resp.text();
|
|
237
|
+
const ct = resp.headers.get("content-type") ?? "";
|
|
238
|
+
return ct.includes("yaml") || specUrl.endsWith(".yaml") || specUrl.endsWith(".yml") ? jsYaml.load(text) : JSON.parse(text);
|
|
239
|
+
}
|
|
240
|
+
if (specPath) {
|
|
241
|
+
const raw = await promises.readFile(specPath, "utf8");
|
|
242
|
+
return specPath.endsWith(".yaml") || specPath.endsWith(".yml") ? jsYaml.load(raw) : JSON.parse(raw);
|
|
243
|
+
}
|
|
244
|
+
throw new Error("Either specUrl or specPath must be provided");
|
|
245
|
+
}
|
|
246
|
+
function resolveRef(spec, ref) {
|
|
247
|
+
const parts = ref.replace(/^#\//, "").split("/");
|
|
248
|
+
return parts.reduce((obj, key) => obj?.[key], spec);
|
|
249
|
+
}
|
|
250
|
+
function resolveSchema(spec, obj, seen = /* @__PURE__ */ new Set()) {
|
|
251
|
+
if (!obj || typeof obj !== "object") return obj;
|
|
252
|
+
if (seen.has(obj)) return {};
|
|
253
|
+
if (Array.isArray(obj)) {
|
|
254
|
+
seen.add(obj);
|
|
255
|
+
return obj.map((i) => resolveSchema(spec, i, seen));
|
|
256
|
+
}
|
|
257
|
+
const o = obj;
|
|
258
|
+
if ("$ref" in o) {
|
|
259
|
+
const target = resolveRef(spec, o["$ref"]);
|
|
260
|
+
return resolveSchema(spec, target, seen);
|
|
261
|
+
}
|
|
262
|
+
seen.add(obj);
|
|
263
|
+
return Object.fromEntries(Object.entries(o).map(([k, v]) => [k, resolveSchema(spec, v, seen)]));
|
|
264
|
+
}
|
|
265
|
+
function getServerBases(spec) {
|
|
266
|
+
const servers = spec["servers"] ?? [];
|
|
267
|
+
if (!servers.length) return [""];
|
|
268
|
+
return servers.map((s) => {
|
|
269
|
+
const raw = s.url ?? "";
|
|
270
|
+
try {
|
|
271
|
+
return new URL(raw).pathname.replace(/\/$/, "");
|
|
272
|
+
} catch {
|
|
273
|
+
return raw.replace(/\/$/, "");
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
function urlPathname(raw, baseUrl) {
|
|
278
|
+
try {
|
|
279
|
+
const pathname = new URL(raw).pathname;
|
|
280
|
+
if (baseUrl) {
|
|
281
|
+
try {
|
|
282
|
+
const basePath = new URL(baseUrl).pathname.replace(/\/$/, "");
|
|
283
|
+
if (basePath && pathname.startsWith(basePath)) return pathname.slice(basePath.length) || "/";
|
|
284
|
+
} catch {
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return pathname;
|
|
288
|
+
} catch {
|
|
289
|
+
return raw.split("?")[0];
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function pathTemplateToRegex(base, template) {
|
|
293
|
+
const combined = (base + template).replace(/\/+/g, "/");
|
|
294
|
+
const pattern = combined.replace(/\{[^}]+\}/g, "[^/]+");
|
|
295
|
+
return new RegExp("^" + pattern + "/?$");
|
|
296
|
+
}
|
|
297
|
+
function findOperation(spec, method, reqUrl, requestBaseUrl) {
|
|
298
|
+
const bases = getServerBases(spec);
|
|
299
|
+
const pathname = urlPathname(reqUrl, requestBaseUrl);
|
|
300
|
+
const paths = spec["paths"] ?? {};
|
|
301
|
+
for (const [template, pathItem] of Object.entries(paths)) {
|
|
302
|
+
const resolved = resolveSchema(spec, pathItem);
|
|
303
|
+
for (const base of bases) {
|
|
304
|
+
if (pathTemplateToRegex(base, template).test(pathname)) {
|
|
305
|
+
const op = resolved[method.toLowerCase()];
|
|
306
|
+
return op ? { pathTemplate: template, operation: op } : null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
function validateRequestAgainstSpec(spec, req, envVars, requestBaseUrl) {
|
|
313
|
+
const violations = [];
|
|
314
|
+
const vars = envVars;
|
|
315
|
+
const url = req.url.replace(/\{\{([^}]+)\}\}/g, (_, k) => vars[k] ?? `{{${k}}}`);
|
|
316
|
+
const match = findOperation(spec, req.method, url, requestBaseUrl);
|
|
317
|
+
if (!match) {
|
|
318
|
+
violations.push({
|
|
319
|
+
type: "unknown_path",
|
|
320
|
+
message: `No operation found in spec for ${req.method} ${url}`
|
|
321
|
+
});
|
|
322
|
+
return violations;
|
|
323
|
+
}
|
|
324
|
+
const { operation } = match;
|
|
325
|
+
if (req.body.mode === "json" && req.body.json?.trim()) {
|
|
326
|
+
const requestBody = resolveSchema(spec, operation["requestBody"]);
|
|
327
|
+
const content = requestBody?.["content"] ?? {};
|
|
328
|
+
const jsonContent = content["application/json"];
|
|
329
|
+
if (jsonContent?.["schema"]) {
|
|
330
|
+
try {
|
|
331
|
+
const data = JSON.parse(requestExec.interpolate(req.body.json, vars));
|
|
332
|
+
const schema = resolveSchema(spec, jsonContent["schema"]);
|
|
333
|
+
const validate = ajv$1.compile(schema);
|
|
334
|
+
if (!validate(data)) {
|
|
335
|
+
for (const err of validate.errors ?? []) {
|
|
336
|
+
violations.push({
|
|
337
|
+
type: "request_body_invalid",
|
|
338
|
+
message: err.message ?? "Request body schema violation",
|
|
339
|
+
path: err.instancePath || "/"
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
} catch (e) {
|
|
344
|
+
violations.push({
|
|
345
|
+
type: "request_body_invalid",
|
|
346
|
+
message: `Could not validate request body: ${e instanceof Error ? e.message : String(e)}`
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const parameters = resolveSchema(spec, operation["parameters"] ?? []);
|
|
352
|
+
for (const param of parameters) {
|
|
353
|
+
const p = param;
|
|
354
|
+
if (p["in"] === "query" && p["required"] === true) {
|
|
355
|
+
const name = p["name"];
|
|
356
|
+
if (!req.params.some((kv) => kv.enabled && kv.key === name)) {
|
|
357
|
+
violations.push({
|
|
358
|
+
type: "request_body_invalid",
|
|
359
|
+
message: `Required query parameter "${name}" is missing`,
|
|
360
|
+
path: `query.${name}`
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return violations;
|
|
366
|
+
}
|
|
367
|
+
async function runProviderVerification(requests, envVars, collectionVars = {}, specUrl, specPath, requestBaseUrl) {
|
|
368
|
+
const spec = await loadSpec(specUrl, specPath);
|
|
369
|
+
const vars = { ...envVars, ...collectionVars };
|
|
370
|
+
const start = Date.now();
|
|
371
|
+
const activeRequests = requests.filter((r) => !r.disabled);
|
|
372
|
+
const results = activeRequests.map((req) => {
|
|
373
|
+
const violations = validateRequestAgainstSpec(spec, req, vars, requestBaseUrl);
|
|
374
|
+
const url = req.url.replace(/\{\{([^}]+)\}\}/g, (_, k) => vars[k] ?? `{{${k}}}`);
|
|
375
|
+
return {
|
|
376
|
+
requestId: req.id,
|
|
377
|
+
requestName: req.name,
|
|
378
|
+
method: req.method,
|
|
379
|
+
url,
|
|
380
|
+
passed: violations.length === 0,
|
|
381
|
+
violations
|
|
382
|
+
};
|
|
383
|
+
});
|
|
384
|
+
const passed = results.filter((r) => r.passed).length;
|
|
385
|
+
return {
|
|
386
|
+
mode: "provider",
|
|
387
|
+
total: results.length,
|
|
388
|
+
passed,
|
|
389
|
+
failed: results.length - passed,
|
|
390
|
+
results,
|
|
391
|
+
durationMs: Date.now() - start
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
function rebaseUrl(fullUrl, providerBaseUrl) {
|
|
395
|
+
if (!providerBaseUrl) return fullUrl;
|
|
396
|
+
try {
|
|
397
|
+
const orig = new URL(fullUrl, "http://placeholder.invalid");
|
|
398
|
+
const base = new URL(providerBaseUrl);
|
|
399
|
+
const basePath = base.pathname.replace(/\/$/, "");
|
|
400
|
+
base.pathname = (basePath + orig.pathname).replace(/\/{2,}/g, "/");
|
|
401
|
+
base.search = orig.search;
|
|
402
|
+
return base.toString();
|
|
403
|
+
} catch {
|
|
404
|
+
return fullUrl;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
async function setupState(stateHandlerUrl, state, action) {
|
|
408
|
+
if (!stateHandlerUrl) {
|
|
409
|
+
return {
|
|
410
|
+
type: "provider_state_failed",
|
|
411
|
+
message: `Interaction requires provider state "${state}" but no --states-url / stateHandlerUrl was configured`,
|
|
412
|
+
expected: state
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
try {
|
|
416
|
+
const resp = await undici.fetch(stateHandlerUrl, {
|
|
417
|
+
method: "POST",
|
|
418
|
+
headers: { "Content-Type": "application/json" },
|
|
419
|
+
body: JSON.stringify({ state, action })
|
|
420
|
+
});
|
|
421
|
+
if (!resp.ok) {
|
|
422
|
+
return {
|
|
423
|
+
type: "provider_state_failed",
|
|
424
|
+
message: `State handler returned HTTP ${resp.status} setting up "${state}"`,
|
|
425
|
+
expected: state,
|
|
426
|
+
actual: String(resp.status)
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
return null;
|
|
430
|
+
} catch (err) {
|
|
431
|
+
return {
|
|
432
|
+
type: "provider_state_failed",
|
|
433
|
+
message: `State handler unreachable for "${state}": ${err instanceof Error ? err.message : String(err)}`,
|
|
434
|
+
expected: state
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
async function verifyInteraction(req, vars, providerBaseUrl, stateHandlerUrl) {
|
|
439
|
+
const url = rebaseUrl(requestExec.buildUrl(req.url, req.params, vars), providerBaseUrl);
|
|
440
|
+
const start = Date.now();
|
|
441
|
+
const violations = [];
|
|
442
|
+
const states = req.contract?.providerStates ?? [];
|
|
443
|
+
for (const state of states) {
|
|
444
|
+
const v = await setupState(stateHandlerUrl, state, "setup");
|
|
445
|
+
if (v) violations.push(v);
|
|
446
|
+
}
|
|
447
|
+
if (violations.length > 0) {
|
|
448
|
+
return { requestId: req.id, requestName: req.name, method: req.method, url, passed: false, violations, durationMs: Date.now() - start };
|
|
449
|
+
}
|
|
450
|
+
try {
|
|
451
|
+
const headers = new undici.Headers();
|
|
452
|
+
for (const h of req.headers) {
|
|
453
|
+
if (h.enabled && h.key) headers.set(requestExec.interpolate(h.key, vars), requestExec.interpolate(h.value, vars));
|
|
454
|
+
}
|
|
455
|
+
const authHeaders = await requestExec.buildAuthHeaders(req.auth, vars);
|
|
456
|
+
for (const [k, v] of Object.entries(authHeaders)) headers.set(k, v);
|
|
457
|
+
let body;
|
|
458
|
+
if (req.body.mode === "json" && req.body.json) {
|
|
459
|
+
body = requestExec.interpolate(req.body.json, vars);
|
|
460
|
+
if (!headers.has("content-type")) headers.set("Content-Type", "application/json");
|
|
461
|
+
} else if (req.body.mode === "raw" && req.body.raw) {
|
|
462
|
+
body = requestExec.interpolate(req.body.raw, vars);
|
|
463
|
+
}
|
|
464
|
+
const resp = await undici.fetch(url, {
|
|
465
|
+
method: req.method,
|
|
466
|
+
headers,
|
|
467
|
+
body: !["GET", "HEAD"].includes(req.method) ? body : void 0
|
|
468
|
+
});
|
|
469
|
+
const bodyText = await resp.text();
|
|
470
|
+
const rawHeaders = {};
|
|
471
|
+
resp.headers.forEach((v, k) => {
|
|
472
|
+
rawHeaders[k] = v;
|
|
473
|
+
});
|
|
474
|
+
violations.push(...validateConsumerResponse(req.contract, resp.status, rawHeaders, bodyText));
|
|
475
|
+
return {
|
|
476
|
+
requestId: req.id,
|
|
477
|
+
requestName: req.name,
|
|
478
|
+
method: req.method,
|
|
479
|
+
url,
|
|
480
|
+
passed: violations.length === 0,
|
|
481
|
+
violations,
|
|
482
|
+
durationMs: Date.now() - start,
|
|
483
|
+
actualStatus: resp.status
|
|
484
|
+
};
|
|
485
|
+
} catch (err) {
|
|
486
|
+
violations.push({
|
|
487
|
+
type: "status_mismatch",
|
|
488
|
+
message: `Request failed: ${err instanceof Error ? err.message : String(err)}`
|
|
489
|
+
});
|
|
490
|
+
return { requestId: req.id, requestName: req.name, method: req.method, url, passed: false, violations, durationMs: Date.now() - start };
|
|
491
|
+
} finally {
|
|
492
|
+
for (const state of states) await setupState(stateHandlerUrl, state, "teardown");
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
async function runLiveProviderVerification(requests, envVars, collectionVars = {}, providerBaseUrl, stateHandlerUrl) {
|
|
496
|
+
const vars = { ...envVars, ...collectionVars };
|
|
497
|
+
const start = Date.now();
|
|
498
|
+
const contractRequests = requests.filter((r) => !r.disabled && hasContract(r.contract));
|
|
499
|
+
const results = [];
|
|
500
|
+
for (const req of contractRequests) {
|
|
501
|
+
results.push(await verifyInteraction(req, vars, providerBaseUrl, stateHandlerUrl));
|
|
502
|
+
}
|
|
503
|
+
const passed = results.filter((r) => r.passed).length;
|
|
504
|
+
return {
|
|
505
|
+
mode: "provider-live",
|
|
506
|
+
total: results.length,
|
|
507
|
+
passed,
|
|
508
|
+
failed: results.length - passed,
|
|
509
|
+
results,
|
|
510
|
+
durationMs: Date.now() - start
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
function checkSchemaCompatibility(consumerSchema, providerSchema, path2 = "") {
|
|
514
|
+
const violations = [];
|
|
515
|
+
if (!consumerSchema || !providerSchema) return violations;
|
|
516
|
+
const typesOf = (s) => {
|
|
517
|
+
const t = s["type"];
|
|
518
|
+
const list = Array.isArray(t) ? t.filter((x) => typeof x === "string") : typeof t === "string" ? [t] : [];
|
|
519
|
+
if (s["nullable"] === true && !list.includes("null")) list.push("null");
|
|
520
|
+
return list;
|
|
521
|
+
};
|
|
522
|
+
const cTypes = typesOf(consumerSchema);
|
|
523
|
+
const pTypes = typesOf(providerSchema);
|
|
524
|
+
if (cTypes.length && pTypes.length) {
|
|
525
|
+
const compatible = (ct, pt) => ct === pt || ct === "integer" && pt === "number" || ct === "number" && pt === "integer";
|
|
526
|
+
const ok = cTypes.every((ct) => pTypes.some((pt) => compatible(ct, pt)));
|
|
527
|
+
if (!ok) {
|
|
528
|
+
violations.push({
|
|
529
|
+
type: "schema_incompatible",
|
|
530
|
+
message: `Type mismatch${path2 ? ` at "${path2}"` : ""}: consumer expects "${cTypes.join(",")}", provider offers "${pTypes.join(",")}"`,
|
|
531
|
+
path: path2 || "/",
|
|
532
|
+
expected: cTypes.join(","),
|
|
533
|
+
actual: pTypes.join(",")
|
|
534
|
+
});
|
|
535
|
+
return violations;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (cTypes.includes("array") || Array.isArray(consumerSchema["items"])) {
|
|
539
|
+
const cItems = consumerSchema["items"];
|
|
540
|
+
const pItems = providerSchema["items"];
|
|
541
|
+
if (cItems && pItems) {
|
|
542
|
+
violations.push(...checkSchemaCompatibility(cItems, pItems, path2 ? `${path2}[]` : "[]"));
|
|
543
|
+
}
|
|
544
|
+
return violations;
|
|
545
|
+
}
|
|
546
|
+
if (cTypes.includes("object") || consumerSchema["properties"]) {
|
|
547
|
+
const cProps = consumerSchema["properties"] ?? {};
|
|
548
|
+
const pProps = providerSchema["properties"] ?? {};
|
|
549
|
+
const cRequired = consumerSchema["required"] ?? [];
|
|
550
|
+
for (const field of cRequired) {
|
|
551
|
+
const fieldPath = path2 ? `${path2}.${field}` : field;
|
|
552
|
+
if (!(field in pProps)) {
|
|
553
|
+
violations.push({
|
|
554
|
+
type: "schema_incompatible",
|
|
555
|
+
message: `Consumer requires field "${fieldPath}" which is not defined in provider schema`,
|
|
556
|
+
path: fieldPath,
|
|
557
|
+
expected: "(defined)",
|
|
558
|
+
actual: "(absent)"
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
for (const [field, cPropSchema] of Object.entries(cProps)) {
|
|
563
|
+
if (field in pProps) {
|
|
564
|
+
const fieldPath = path2 ? `${path2}.${field}` : field;
|
|
565
|
+
violations.push(...checkSchemaCompatibility(
|
|
566
|
+
cPropSchema,
|
|
567
|
+
pProps[field],
|
|
568
|
+
fieldPath
|
|
569
|
+
));
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return violations;
|
|
574
|
+
}
|
|
575
|
+
function getProviderResponseSchema(spec, req, envVars, statusCode, requestBaseUrl) {
|
|
576
|
+
const url = req.url.replace(/\{\{([^}]+)\}\}/g, (_, k) => envVars[k] ?? `{{${k}}}`);
|
|
577
|
+
const match = findOperation(spec, req.method, url, requestBaseUrl);
|
|
578
|
+
if (!match) return null;
|
|
579
|
+
const responses = match.operation["responses"] ?? {};
|
|
580
|
+
const candidates = [String(statusCode), `${String(statusCode)[0]}xx`, "2XX", "2xx", "default"];
|
|
581
|
+
for (const candidate of candidates) {
|
|
582
|
+
const resp = responses[candidate];
|
|
583
|
+
if (resp) {
|
|
584
|
+
const resolved = resolveSchema(spec, resp);
|
|
585
|
+
const content = resolved["content"] ?? {};
|
|
586
|
+
const json = content["application/json"];
|
|
587
|
+
if (json?.["schema"]) {
|
|
588
|
+
return resolveSchema(spec, json["schema"]);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
async function executeRequest(req, vars) {
|
|
595
|
+
const url = requestExec.buildUrl(req.url, req.params, vars);
|
|
596
|
+
const start = Date.now();
|
|
597
|
+
try {
|
|
598
|
+
const headers = new undici.Headers();
|
|
599
|
+
for (const h of req.headers) {
|
|
600
|
+
if (h.enabled && h.key) headers.set(requestExec.interpolate(h.key, vars), requestExec.interpolate(h.value, vars));
|
|
601
|
+
}
|
|
602
|
+
const authHeaders = await requestExec.buildAuthHeaders(req.auth, vars);
|
|
603
|
+
for (const [k, v] of Object.entries(authHeaders)) headers.set(k, v);
|
|
604
|
+
let body;
|
|
605
|
+
if (req.body.mode === "json" && req.body.json) {
|
|
606
|
+
body = requestExec.interpolate(req.body.json, vars);
|
|
607
|
+
if (!headers.has("content-type")) headers.set("Content-Type", "application/json");
|
|
608
|
+
}
|
|
609
|
+
const resp = await undici.fetch(url, {
|
|
610
|
+
method: req.method,
|
|
611
|
+
headers,
|
|
612
|
+
body: !["GET", "HEAD"].includes(req.method) ? body : void 0
|
|
613
|
+
});
|
|
614
|
+
const bodyText = await resp.text();
|
|
615
|
+
const rawHdrs = {};
|
|
616
|
+
resp.headers.forEach((v, k) => {
|
|
617
|
+
rawHdrs[k] = v;
|
|
618
|
+
});
|
|
619
|
+
return { status: resp.status, headers: rawHdrs, body: bodyText, durationMs: Date.now() - start };
|
|
620
|
+
} catch (err) {
|
|
621
|
+
return err instanceof Error ? err : new Error(String(err));
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
async function runBidirectional(requests, envVars, collectionVars = {}, specUrl, specPath, requestBaseUrl) {
|
|
625
|
+
const spec = await loadSpec(specUrl, specPath);
|
|
626
|
+
const vars = { ...envVars, ...collectionVars };
|
|
627
|
+
const start = Date.now();
|
|
628
|
+
const contractRequests = requests.filter((r) => !r.disabled && hasContract(r.contract));
|
|
629
|
+
const results = await Promise.all(contractRequests.map(async (req) => {
|
|
630
|
+
const url = requestExec.buildUrl(req.url, req.params, vars);
|
|
631
|
+
const violations = [];
|
|
632
|
+
const expectedStatus = req.contract.statusCode ?? 200;
|
|
633
|
+
let consumerSchema = null;
|
|
634
|
+
if (req.contract.bodySchema) {
|
|
635
|
+
try {
|
|
636
|
+
consumerSchema = JSON.parse(req.contract.bodySchema);
|
|
637
|
+
} catch {
|
|
638
|
+
}
|
|
639
|
+
} else if (req.contract.bodyMatcher?.trim()) {
|
|
640
|
+
try {
|
|
641
|
+
consumerSchema = compileMatcherExample(JSON.parse(req.contract.bodyMatcher), false);
|
|
642
|
+
} catch {
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
if (consumerSchema) {
|
|
646
|
+
const providerSchema = getProviderResponseSchema(spec, req, vars, expectedStatus, requestBaseUrl);
|
|
647
|
+
if (!providerSchema) {
|
|
648
|
+
violations.push({
|
|
649
|
+
type: "schema_incompatible",
|
|
650
|
+
message: `No response schema found in spec for ${req.method} ${url} → ${expectedStatus}`
|
|
651
|
+
});
|
|
652
|
+
} else {
|
|
653
|
+
violations.push(...checkSchemaCompatibility(consumerSchema, providerSchema));
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
const result = await executeRequest(req, vars);
|
|
657
|
+
if (result instanceof Error) {
|
|
658
|
+
violations.push({ type: "status_mismatch", message: `Request failed: ${result.message}` });
|
|
659
|
+
return { requestId: req.id, requestName: req.name, method: req.method, url, passed: false, violations };
|
|
660
|
+
}
|
|
661
|
+
const liveViolations = validateConsumerResponse(
|
|
662
|
+
req.contract,
|
|
663
|
+
result.status,
|
|
664
|
+
result.headers,
|
|
665
|
+
result.body
|
|
666
|
+
);
|
|
667
|
+
violations.push(...liveViolations);
|
|
668
|
+
return {
|
|
669
|
+
requestId: req.id,
|
|
670
|
+
requestName: req.name,
|
|
671
|
+
method: req.method,
|
|
672
|
+
url,
|
|
673
|
+
passed: violations.length === 0,
|
|
674
|
+
violations,
|
|
675
|
+
durationMs: result.durationMs,
|
|
676
|
+
actualStatus: result.status
|
|
677
|
+
};
|
|
678
|
+
}));
|
|
679
|
+
const passed = results.filter((r) => r.passed).length;
|
|
680
|
+
return {
|
|
681
|
+
mode: "bidirectional",
|
|
682
|
+
total: results.length,
|
|
683
|
+
passed,
|
|
684
|
+
failed: results.length - passed,
|
|
685
|
+
results,
|
|
686
|
+
durationMs: Date.now() - start
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
const RESULTS_DIR = "contracts/results";
|
|
690
|
+
function safe(part) {
|
|
691
|
+
return part.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "unknown";
|
|
692
|
+
}
|
|
693
|
+
function resultPath(dir, pacticipant, version) {
|
|
694
|
+
return path.join(dir, RESULTS_DIR, safe(pacticipant), `${safe(version)}.json`);
|
|
695
|
+
}
|
|
696
|
+
async function recordResult(dir, pacticipant, version, report, now) {
|
|
697
|
+
const file = resultPath(dir, pacticipant, version);
|
|
698
|
+
await promises.mkdir(path.join(dir, RESULTS_DIR, safe(pacticipant)), { recursive: true });
|
|
699
|
+
const record = {
|
|
700
|
+
pacticipant,
|
|
701
|
+
version,
|
|
702
|
+
recordedAt: now,
|
|
703
|
+
passed: report.failed === 0,
|
|
704
|
+
report
|
|
705
|
+
};
|
|
706
|
+
await promises.writeFile(file, JSON.stringify(record, null, 2), "utf8");
|
|
707
|
+
return file;
|
|
708
|
+
}
|
|
709
|
+
async function listResults(dir) {
|
|
710
|
+
const root = path.join(dir, RESULTS_DIR);
|
|
711
|
+
const out = [];
|
|
712
|
+
let pacticipants;
|
|
713
|
+
try {
|
|
714
|
+
pacticipants = await promises.readdir(root);
|
|
715
|
+
} catch {
|
|
716
|
+
return out;
|
|
717
|
+
}
|
|
718
|
+
for (const p of pacticipants) {
|
|
719
|
+
let files;
|
|
720
|
+
try {
|
|
721
|
+
files = await promises.readdir(path.join(root, p));
|
|
722
|
+
} catch {
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
for (const f of files) {
|
|
726
|
+
if (!f.endsWith(".json")) continue;
|
|
727
|
+
try {
|
|
728
|
+
out.push(JSON.parse(await promises.readFile(path.join(root, p, f), "utf8")));
|
|
729
|
+
} catch {
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return out;
|
|
734
|
+
}
|
|
735
|
+
async function canIDeploy(dir, pacticipant, version, env) {
|
|
736
|
+
const file = resultPath(dir, pacticipant, version);
|
|
737
|
+
let currentlyDeployed;
|
|
738
|
+
if (env) {
|
|
739
|
+
const state = await loadEnvironment(dir, env);
|
|
740
|
+
currentlyDeployed = state?.deployed[pacticipant];
|
|
741
|
+
}
|
|
742
|
+
let record;
|
|
743
|
+
try {
|
|
744
|
+
record = JSON.parse(await promises.readFile(file, "utf8"));
|
|
745
|
+
} catch {
|
|
746
|
+
return {
|
|
747
|
+
deployable: false,
|
|
748
|
+
reason: `No verification result recorded for ${pacticipant}@${version}. Run \`contract run … --record --pacticipant ${pacticipant} --app-version ${version}\` first.`,
|
|
749
|
+
currentlyDeployed
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
if (record.passed) {
|
|
753
|
+
return {
|
|
754
|
+
deployable: true,
|
|
755
|
+
reason: `${pacticipant}@${version} passed all ${record.report.total} contract checks (verified ${record.recordedAt}).`,
|
|
756
|
+
record,
|
|
757
|
+
currentlyDeployed
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
return {
|
|
761
|
+
deployable: false,
|
|
762
|
+
reason: `${pacticipant}@${version} has ${record.report.failed}/${record.report.total} failing contract checks (verified ${record.recordedAt}).`,
|
|
763
|
+
record,
|
|
764
|
+
currentlyDeployed
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
const ENV_DIR = "contracts/environments";
|
|
768
|
+
function envPath(dir, env) {
|
|
769
|
+
return path.join(dir, ENV_DIR, `${safe(env)}.json`);
|
|
770
|
+
}
|
|
771
|
+
async function loadEnvironment(dir, env) {
|
|
772
|
+
try {
|
|
773
|
+
return JSON.parse(await promises.readFile(envPath(dir, env), "utf8"));
|
|
774
|
+
} catch {
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
async function recordDeployment(dir, env, pacticipant, version, now) {
|
|
779
|
+
const state = await loadEnvironment(dir, env) ?? { name: env, deployed: {} };
|
|
780
|
+
const previous = state.deployed[pacticipant];
|
|
781
|
+
state.deployed[pacticipant] = { version, recordedAt: now };
|
|
782
|
+
await promises.mkdir(path.join(dir, ENV_DIR), { recursive: true });
|
|
783
|
+
const file = envPath(dir, env);
|
|
784
|
+
await promises.writeFile(file, JSON.stringify(state, null, 2), "utf8");
|
|
785
|
+
return { file, previous };
|
|
786
|
+
}
|
|
787
|
+
async function listEnvironments(dir) {
|
|
788
|
+
const root = path.join(dir, ENV_DIR);
|
|
789
|
+
let files;
|
|
790
|
+
try {
|
|
791
|
+
files = await promises.readdir(root);
|
|
792
|
+
} catch {
|
|
793
|
+
return [];
|
|
794
|
+
}
|
|
795
|
+
const out = [];
|
|
796
|
+
for (const f of files) {
|
|
797
|
+
if (!f.endsWith(".json")) continue;
|
|
798
|
+
try {
|
|
799
|
+
out.push(JSON.parse(await promises.readFile(path.join(root, f), "utf8")));
|
|
800
|
+
} catch {
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
804
|
+
}
|
|
805
|
+
function makeRng(seed) {
|
|
806
|
+
let a = seed >>> 0;
|
|
807
|
+
return () => {
|
|
808
|
+
a |= 0;
|
|
809
|
+
a = a + 1831565813 | 0;
|
|
810
|
+
let t = Math.imul(a ^ a >>> 15, 1 | a);
|
|
811
|
+
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
|
812
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
function schemaType(schema) {
|
|
816
|
+
const t = schema["type"];
|
|
817
|
+
if (Array.isArray(t)) return t.find((x) => x !== "null");
|
|
818
|
+
return typeof t === "string" ? t : void 0;
|
|
819
|
+
}
|
|
820
|
+
function buildBaseline(schema, rng) {
|
|
821
|
+
if (!schema || typeof schema !== "object") return "x";
|
|
822
|
+
if (Array.isArray(schema["enum"]) && schema["enum"].length) return schema["enum"][0];
|
|
823
|
+
if ("const" in schema) return schema["const"];
|
|
824
|
+
const type = schemaType(schema);
|
|
825
|
+
switch (type) {
|
|
826
|
+
case "object": {
|
|
827
|
+
const props = schema["properties"] ?? {};
|
|
828
|
+
const required = new Set(schema["required"] ?? []);
|
|
829
|
+
const out = {};
|
|
830
|
+
for (const [key, propSchema] of Object.entries(props)) {
|
|
831
|
+
if (required.has(key) || rng() > 0.3) out[key] = buildBaseline(propSchema, rng);
|
|
832
|
+
}
|
|
833
|
+
for (const key of required) if (!(key in out)) out[key] = "x";
|
|
834
|
+
return out;
|
|
835
|
+
}
|
|
836
|
+
case "array": {
|
|
837
|
+
const items = schema["items"];
|
|
838
|
+
const min = typeof schema["minItems"] === "number" ? schema["minItems"] : 1;
|
|
839
|
+
const n = Math.max(1, min);
|
|
840
|
+
return Array.from({ length: n }, () => buildBaseline(items, rng));
|
|
841
|
+
}
|
|
842
|
+
case "integer":
|
|
843
|
+
case "number": {
|
|
844
|
+
const min = schema["minimum"];
|
|
845
|
+
const max = schema["maximum"];
|
|
846
|
+
if (min !== void 0 && max !== void 0) return type === "integer" ? Math.floor((min + max) / 2) : (min + max) / 2;
|
|
847
|
+
if (min !== void 0) return min;
|
|
848
|
+
if (max !== void 0) return max;
|
|
849
|
+
return type === "integer" ? 1 : 1.5;
|
|
850
|
+
}
|
|
851
|
+
case "boolean":
|
|
852
|
+
return true;
|
|
853
|
+
case "null":
|
|
854
|
+
return null;
|
|
855
|
+
case "string":
|
|
856
|
+
default:
|
|
857
|
+
return baselineString(schema);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
function baselineString(schema) {
|
|
861
|
+
const format = schema["format"];
|
|
862
|
+
switch (format) {
|
|
863
|
+
case "email":
|
|
864
|
+
return "user@example.com";
|
|
865
|
+
case "uuid":
|
|
866
|
+
return "00000000-0000-4000-8000-000000000000";
|
|
867
|
+
case "date-time":
|
|
868
|
+
return "2020-01-01T00:00:00Z";
|
|
869
|
+
case "date":
|
|
870
|
+
return "2020-01-01";
|
|
871
|
+
case "uri":
|
|
872
|
+
case "url":
|
|
873
|
+
return "https://example.com";
|
|
874
|
+
case "ipv4":
|
|
875
|
+
return "127.0.0.1";
|
|
876
|
+
}
|
|
877
|
+
const min = schema["minLength"];
|
|
878
|
+
if (min && min > 1) return "x".repeat(min);
|
|
879
|
+
return "x";
|
|
880
|
+
}
|
|
881
|
+
const ADVERSARIAL_STRINGS = [
|
|
882
|
+
["very-long", "A".repeat(1e4)],
|
|
883
|
+
["empty", ""],
|
|
884
|
+
["unicode", "𝔘🙈\0�"],
|
|
885
|
+
["whitespace", " "]
|
|
886
|
+
];
|
|
887
|
+
function clone(v) {
|
|
888
|
+
return JSON.parse(JSON.stringify(v));
|
|
889
|
+
}
|
|
890
|
+
function setAtPath(root, path2, value) {
|
|
891
|
+
if (path2.length === 0) return value;
|
|
892
|
+
const copy = clone(root);
|
|
893
|
+
let cur = copy;
|
|
894
|
+
for (let i = 0; i < path2.length - 1; i++) cur = cur[path2[i]];
|
|
895
|
+
cur[path2[path2.length - 1]] = value;
|
|
896
|
+
return copy;
|
|
897
|
+
}
|
|
898
|
+
function deleteAtPath(root, path2) {
|
|
899
|
+
const copy = clone(root);
|
|
900
|
+
let cur = copy;
|
|
901
|
+
for (let i = 0; i < path2.length - 1; i++) cur = cur[path2[i]];
|
|
902
|
+
delete cur[path2[path2.length - 1]];
|
|
903
|
+
return copy;
|
|
904
|
+
}
|
|
905
|
+
function mutate(baseline, schema, label) {
|
|
906
|
+
const cases = [];
|
|
907
|
+
const walk = (schemaNode, path2) => {
|
|
908
|
+
const target = [label, ...path2].join(".");
|
|
909
|
+
const type = schemaNode ? schemaType(schemaNode) : void 0;
|
|
910
|
+
if (type === "object" && schemaNode) {
|
|
911
|
+
const props = schemaNode["properties"] ?? {};
|
|
912
|
+
const required = schemaNode["required"] ?? [];
|
|
913
|
+
const current = getAtPath(baseline, path2);
|
|
914
|
+
for (const key of required) {
|
|
915
|
+
if (current && key in current) {
|
|
916
|
+
cases.push({
|
|
917
|
+
mutation: { target: `${target}.${key}`, kind: "missing-required", description: `omit required field "${key}"` },
|
|
918
|
+
value: deleteAtPath(baseline, [...path2, key])
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
if (schemaNode["additionalProperties"] === false && current) {
|
|
923
|
+
cases.push({
|
|
924
|
+
mutation: { target: `${target}.__fuzz_extra`, kind: "unexpected-field", description: "add a field the schema forbids" },
|
|
925
|
+
value: setAtPath(baseline, [...path2, "__fuzz_extra"], "unexpected")
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
for (const [key, propSchema] of Object.entries(props)) {
|
|
929
|
+
if (current && key in current) walk(propSchema, [...path2, key]);
|
|
930
|
+
}
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
if (type === "array" && schemaNode) {
|
|
934
|
+
const items = schemaNode["items"];
|
|
935
|
+
const arr = getAtPath(baseline, path2);
|
|
936
|
+
if (arr && arr.length) walk(items, [...path2, "0"]);
|
|
937
|
+
cases.push({
|
|
938
|
+
mutation: { target, kind: "type:array→string", description: "send a string where an array is expected" },
|
|
939
|
+
value: setAtPath(baseline, path2, "not-an-array")
|
|
940
|
+
});
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
if (type === "string" || type === void 0) {
|
|
944
|
+
cases.push({
|
|
945
|
+
mutation: { target, kind: "type:string→number", description: "send a number where a string is expected" },
|
|
946
|
+
value: setAtPath(baseline, path2, 123456)
|
|
947
|
+
});
|
|
948
|
+
const format = schemaNode?.["format"];
|
|
949
|
+
if (format) {
|
|
950
|
+
cases.push({
|
|
951
|
+
mutation: { target, kind: `bad-format:${format}`, description: `violate the "${format}" format` },
|
|
952
|
+
value: setAtPath(baseline, path2, "not-a-" + format)
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
if (Array.isArray(schemaNode?.["enum"])) {
|
|
956
|
+
cases.push({
|
|
957
|
+
mutation: { target, kind: "enum-violation", description: "send a value outside the allowed enum" },
|
|
958
|
+
value: setAtPath(baseline, path2, "__not_in_enum__")
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
const maxLen = schemaNode?.["maxLength"];
|
|
962
|
+
if (typeof maxLen === "number") {
|
|
963
|
+
cases.push({
|
|
964
|
+
mutation: { target, kind: "maxLength+1", description: `exceed maxLength (${maxLen})` },
|
|
965
|
+
value: setAtPath(baseline, path2, "x".repeat(maxLen + 1))
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
for (const [name, str] of ADVERSARIAL_STRINGS) {
|
|
969
|
+
cases.push({
|
|
970
|
+
mutation: { target, kind: `adversarial:${name}`, description: `inject a ${name} string` },
|
|
971
|
+
value: setAtPath(baseline, path2, str)
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
} else if (type === "integer" || type === "number") {
|
|
975
|
+
cases.push({
|
|
976
|
+
mutation: { target, kind: "type:number→string", description: "send a string where a number is expected" },
|
|
977
|
+
value: setAtPath(baseline, path2, "not-a-number")
|
|
978
|
+
});
|
|
979
|
+
const min = schemaNode?.["minimum"];
|
|
980
|
+
const max = schemaNode?.["maximum"];
|
|
981
|
+
if (typeof min === "number") {
|
|
982
|
+
cases.push({
|
|
983
|
+
mutation: { target, kind: "below-minimum", description: `send a value below minimum (${min})` },
|
|
984
|
+
value: setAtPath(baseline, path2, min - 1)
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
if (typeof max === "number") {
|
|
988
|
+
cases.push({
|
|
989
|
+
mutation: { target, kind: "above-maximum", description: `send a value above maximum (${max})` },
|
|
990
|
+
value: setAtPath(baseline, path2, max + 1)
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
cases.push({
|
|
994
|
+
mutation: { target, kind: "extreme", description: "send an extreme numeric value" },
|
|
995
|
+
value: setAtPath(baseline, path2, 1e308)
|
|
996
|
+
});
|
|
997
|
+
} else if (type === "boolean") {
|
|
998
|
+
cases.push({
|
|
999
|
+
mutation: { target, kind: "type:boolean→string", description: "send a string where a boolean is expected" },
|
|
1000
|
+
value: setAtPath(baseline, path2, "not-a-bool")
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
const nullable = schemaNode?.["nullable"] === true || Array.isArray(schemaNode?.["type"]) && schemaNode["type"].includes("null");
|
|
1004
|
+
if (!nullable) {
|
|
1005
|
+
cases.push({
|
|
1006
|
+
mutation: { target, kind: "null-injection", description: "send null into a non-nullable field" },
|
|
1007
|
+
value: setAtPath(baseline, path2, null)
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
};
|
|
1011
|
+
walk(schema, []);
|
|
1012
|
+
return cases;
|
|
1013
|
+
}
|
|
1014
|
+
function getAtPath(root, path2) {
|
|
1015
|
+
let cur = root;
|
|
1016
|
+
for (const seg of path2) {
|
|
1017
|
+
if (cur == null) return void 0;
|
|
1018
|
+
cur = cur[seg];
|
|
1019
|
+
}
|
|
1020
|
+
return cur;
|
|
1021
|
+
}
|
|
1022
|
+
const QUERY_ADVERSARIAL = [
|
|
1023
|
+
["empty", ""],
|
|
1024
|
+
["very-long", "A".repeat(4096)],
|
|
1025
|
+
["unicode", "𝔘🙈 �"],
|
|
1026
|
+
["whitespace", " "],
|
|
1027
|
+
["sql", "' OR '1'='1"],
|
|
1028
|
+
["xss", "<script>alert(1)<\/script>"],
|
|
1029
|
+
["traversal", "../../../../etc/passwd"],
|
|
1030
|
+
["null-byte", "x\0y"]
|
|
1031
|
+
];
|
|
1032
|
+
function setParam(base, key, value) {
|
|
1033
|
+
return base.map((p) => p.key === key ? { ...p, value } : p);
|
|
1034
|
+
}
|
|
1035
|
+
function dropParam(base, key) {
|
|
1036
|
+
return base.filter((p) => p.key !== key);
|
|
1037
|
+
}
|
|
1038
|
+
function mutateQueryParams(base, schemas = [], label = "query") {
|
|
1039
|
+
const cases = [];
|
|
1040
|
+
const schemaByName = new Map(schemas.map((s) => [s.name, s]));
|
|
1041
|
+
const target = (name) => `${label}.${name}`;
|
|
1042
|
+
for (const s of schemas) {
|
|
1043
|
+
if (s.required && base.some((p) => p.enabled && p.key === s.name)) {
|
|
1044
|
+
cases.push({
|
|
1045
|
+
mutation: { target: target(s.name), kind: "missing-required", description: `omit required query param "${s.name}"` },
|
|
1046
|
+
params: dropParam(base, s.name)
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
for (const p of base) {
|
|
1051
|
+
if (!p.enabled || !p.key) continue;
|
|
1052
|
+
const schema = schemaByName.get(p.key)?.schema;
|
|
1053
|
+
const t = target(p.key);
|
|
1054
|
+
if (schema && Array.isArray(schema["enum"])) {
|
|
1055
|
+
cases.push({ mutation: { target: t, kind: "enum-violation", description: "query value outside the allowed enum" }, params: setParam(base, p.key, "__not_in_enum__") });
|
|
1056
|
+
}
|
|
1057
|
+
if (schema?.["format"]) {
|
|
1058
|
+
const format = schema["format"];
|
|
1059
|
+
cases.push({ mutation: { target: t, kind: `bad-format:${format}`, description: `violate the "${format}" format` }, params: setParam(base, p.key, `not-a-${format}`) });
|
|
1060
|
+
}
|
|
1061
|
+
const stype = schema ? schemaType(schema) : void 0;
|
|
1062
|
+
if (stype === "integer" || stype === "number") {
|
|
1063
|
+
cases.push({ mutation: { target: t, kind: "not-a-number", description: "non-numeric value for a numeric query param" }, params: setParam(base, p.key, "not-a-number") });
|
|
1064
|
+
const min = schema?.["minimum"];
|
|
1065
|
+
const max = schema?.["maximum"];
|
|
1066
|
+
if (typeof min === "number") cases.push({ mutation: { target: t, kind: "below-minimum", description: `value below minimum (${min})` }, params: setParam(base, p.key, String(min - 1)) });
|
|
1067
|
+
if (typeof max === "number") cases.push({ mutation: { target: t, kind: "above-maximum", description: `value above maximum (${max})` }, params: setParam(base, p.key, String(max + 1)) });
|
|
1068
|
+
}
|
|
1069
|
+
for (const [name, val] of QUERY_ADVERSARIAL) {
|
|
1070
|
+
cases.push({ mutation: { target: t, kind: `adversarial:${name}`, description: `inject a ${name} value` }, params: setParam(base, p.key, val) });
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
cases.push({
|
|
1074
|
+
mutation: { target: `${label}.__fuzz`, kind: "unexpected-param", description: "add a query param the endpoint does not declare" },
|
|
1075
|
+
params: [...base, { key: "__fuzz", value: "1", enabled: true }]
|
|
1076
|
+
});
|
|
1077
|
+
return cases;
|
|
1078
|
+
}
|
|
1079
|
+
function inferSchema(value) {
|
|
1080
|
+
if (value === null) return { type: "null" };
|
|
1081
|
+
if (Array.isArray(value)) {
|
|
1082
|
+
return { type: "array", items: value.length ? inferSchema(value[0]) : { type: "string" } };
|
|
1083
|
+
}
|
|
1084
|
+
switch (typeof value) {
|
|
1085
|
+
case "object": {
|
|
1086
|
+
const props = {};
|
|
1087
|
+
const required = [];
|
|
1088
|
+
for (const [k, v] of Object.entries(value)) {
|
|
1089
|
+
props[k] = inferSchema(v);
|
|
1090
|
+
required.push(k);
|
|
1091
|
+
}
|
|
1092
|
+
return { type: "object", properties: props, required };
|
|
1093
|
+
}
|
|
1094
|
+
case "number":
|
|
1095
|
+
return { type: Number.isInteger(value) ? "integer" : "number" };
|
|
1096
|
+
case "boolean":
|
|
1097
|
+
return { type: "boolean" };
|
|
1098
|
+
default:
|
|
1099
|
+
return { type: "string" };
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
function sampleApplied(arr, n, rng) {
|
|
1103
|
+
if (arr.length <= n) return arr;
|
|
1104
|
+
const a = [...arr];
|
|
1105
|
+
for (let i = a.length - 1; i > 0; i--) {
|
|
1106
|
+
const j = Math.floor(rng() * (i + 1));
|
|
1107
|
+
[a[i], a[j]] = [a[j], a[i]];
|
|
1108
|
+
}
|
|
1109
|
+
return a.slice(0, n);
|
|
1110
|
+
}
|
|
1111
|
+
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
1112
|
+
const WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
1113
|
+
function specContextFor(spec, req, vars, requestBaseUrl) {
|
|
1114
|
+
if (!spec) return null;
|
|
1115
|
+
const url = req.url.replace(/\{\{([^}]+)\}\}/g, (_, k) => vars[k] ?? `{{${k}}}`);
|
|
1116
|
+
const match = findOperation(spec, req.method, url, requestBaseUrl);
|
|
1117
|
+
if (!match) return null;
|
|
1118
|
+
const op = match.operation;
|
|
1119
|
+
const requestBody = resolveSchema(spec, op["requestBody"]);
|
|
1120
|
+
const jsonContent = requestBody?.["content"]?.["application/json"];
|
|
1121
|
+
const requestBodySchema = jsonContent?.["schema"] ? resolveSchema(spec, jsonContent["schema"]) : void 0;
|
|
1122
|
+
const parameters = resolveSchema(spec, op["parameters"] ?? []);
|
|
1123
|
+
const queryParams = [];
|
|
1124
|
+
for (const param of parameters) {
|
|
1125
|
+
const p = resolveSchema(spec, param);
|
|
1126
|
+
if (p["in"] !== "query" || typeof p["name"] !== "string") continue;
|
|
1127
|
+
queryParams.push({
|
|
1128
|
+
name: p["name"],
|
|
1129
|
+
required: p["required"] === true,
|
|
1130
|
+
schema: p["schema"] ? resolveSchema(spec, p["schema"]) : void 0
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
const responses = op["responses"] ?? {};
|
|
1134
|
+
const documentedStatuses = [];
|
|
1135
|
+
const responseSchemas = {};
|
|
1136
|
+
for (const [code, respVal] of Object.entries(responses)) {
|
|
1137
|
+
const n = Number(code);
|
|
1138
|
+
if (!Number.isNaN(n)) documentedStatuses.push(n);
|
|
1139
|
+
const resp = resolveSchema(spec, respVal);
|
|
1140
|
+
const schema = resp?.["content"]?.["application/json"]?.["schema"];
|
|
1141
|
+
if (schema) responseSchemas[code] = resolveSchema(spec, schema);
|
|
1142
|
+
}
|
|
1143
|
+
return { requestBodySchema, queryParams, documentedStatuses, responseSchemas };
|
|
1144
|
+
}
|
|
1145
|
+
function statusDocumented(status, documented) {
|
|
1146
|
+
if (documented.includes(status)) return true;
|
|
1147
|
+
if (documented.length === 0) return true;
|
|
1148
|
+
return false;
|
|
1149
|
+
}
|
|
1150
|
+
function judge(status, responseBody, bodyIsSchemaInvalid, spec, opts) {
|
|
1151
|
+
if (status >= 500) {
|
|
1152
|
+
return { oracle: "never-5xx", message: `Server returned ${status} on a malformed request (should reject with 4xx, not crash)` };
|
|
1153
|
+
}
|
|
1154
|
+
if (spec && bodyIsSchemaInvalid && status >= 200 && status < 300) {
|
|
1155
|
+
return { oracle: "accepted-invalid", message: `Server accepted a schema-invalid request with ${status} (missing input validation)` };
|
|
1156
|
+
}
|
|
1157
|
+
if (opts.strictStatus && spec && !statusDocumented(status, spec.documentedStatuses)) {
|
|
1158
|
+
return { oracle: "undocumented-status", message: `Response status ${status} is not documented in the spec for this operation` };
|
|
1159
|
+
}
|
|
1160
|
+
if (opts.checkResponses && spec && status >= 200 && status < 300) {
|
|
1161
|
+
const schema = spec.responseSchemas[String(status)] ?? spec.responseSchemas["default"];
|
|
1162
|
+
if (schema) {
|
|
1163
|
+
try {
|
|
1164
|
+
const validate = ajv.compile(schema);
|
|
1165
|
+
if (!validate(JSON.parse(responseBody))) {
|
|
1166
|
+
return { oracle: "response-schema", message: `2xx response body does not match the documented schema: ${ajv.errorsText(validate.errors)}` };
|
|
1167
|
+
}
|
|
1168
|
+
} catch {
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
return null;
|
|
1173
|
+
}
|
|
1174
|
+
async function runFuzz(opts) {
|
|
1175
|
+
const spec = opts.specUrl || opts.specPath ? await loadSpec(opts.specUrl, opts.specPath) : null;
|
|
1176
|
+
const seed = opts.seed ?? 1;
|
|
1177
|
+
const casesPerOp = opts.casesPerOperation ?? 40;
|
|
1178
|
+
const vars = requestExec.mergeVars(opts.envVars, opts.collectionVars ?? {}, {}, {}, await requestExec.buildDynamicVars());
|
|
1179
|
+
const dispatcher = await requestExec.buildDispatcher(void 0, void 0);
|
|
1180
|
+
const start = Date.now();
|
|
1181
|
+
const results = [];
|
|
1182
|
+
let totalCases = 0;
|
|
1183
|
+
let totalFindings = 0;
|
|
1184
|
+
let skippedWrites = 0;
|
|
1185
|
+
let skippedNoBody = 0;
|
|
1186
|
+
const active = opts.requests.filter((r) => !r.disabled);
|
|
1187
|
+
let done = 0;
|
|
1188
|
+
for (const req of active) {
|
|
1189
|
+
opts.onProgress?.(done++, active.length, req.name);
|
|
1190
|
+
if (WRITE_METHODS.has(req.method) && !opts.includeWrites) {
|
|
1191
|
+
skippedWrites++;
|
|
1192
|
+
continue;
|
|
1193
|
+
}
|
|
1194
|
+
const specCtx = specContextFor(spec, req, vars, opts.requestBaseUrl);
|
|
1195
|
+
let baselineBody;
|
|
1196
|
+
let bodySchema;
|
|
1197
|
+
if (specCtx?.requestBodySchema) {
|
|
1198
|
+
bodySchema = specCtx.requestBodySchema;
|
|
1199
|
+
baselineBody = buildBaseline(bodySchema, makeRng(seed));
|
|
1200
|
+
} else if (req.body.mode === "json" && req.body.json?.trim()) {
|
|
1201
|
+
try {
|
|
1202
|
+
baselineBody = JSON.parse(requestExec.interpolate(req.body.json, vars));
|
|
1203
|
+
bodySchema = inferSchema(baselineBody);
|
|
1204
|
+
} catch {
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
const hasBody = bodySchema !== void 0;
|
|
1208
|
+
const baselineBodyJson = hasBody ? JSON.stringify(baselineBody) : void 0;
|
|
1209
|
+
const baselineParams = req.params.filter((p) => p.enabled && p.key).map((p) => ({ ...p }));
|
|
1210
|
+
for (const qp of specCtx?.queryParams ?? []) {
|
|
1211
|
+
if (qp.required && !baselineParams.some((p) => p.key === qp.name)) {
|
|
1212
|
+
baselineParams.push({ key: qp.name, value: String(buildBaseline(qp.schema, makeRng(seed)) ?? "1"), enabled: true });
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
const canFuzzQuery = baselineParams.length > 0 || (specCtx?.queryParams.length ?? 0) > 0;
|
|
1216
|
+
if (!hasBody && !canFuzzQuery) {
|
|
1217
|
+
skippedNoBody++;
|
|
1218
|
+
continue;
|
|
1219
|
+
}
|
|
1220
|
+
const applied = [];
|
|
1221
|
+
if (hasBody) {
|
|
1222
|
+
for (const c of mutate(baselineBody, bodySchema, "body")) {
|
|
1223
|
+
applied.push({ mutation: c.mutation, bodyJson: JSON.stringify(c.value), bodyValue: c.value, params: baselineParams, isBody: true });
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
for (const c of mutateQueryParams(baselineParams, specCtx?.queryParams ?? [], "query")) {
|
|
1227
|
+
applied.push({ mutation: c.mutation, bodyJson: baselineBodyJson, params: c.params, isBody: false });
|
|
1228
|
+
}
|
|
1229
|
+
const cases = sampleApplied(applied, casesPerOp, makeRng(seed + 1));
|
|
1230
|
+
const findings = [];
|
|
1231
|
+
const trace = opts.trace ? [] : void 0;
|
|
1232
|
+
const validateBody2 = specCtx?.requestBodySchema ? (() => {
|
|
1233
|
+
try {
|
|
1234
|
+
return ajv.compile(specCtx.requestBodySchema);
|
|
1235
|
+
} catch {
|
|
1236
|
+
return null;
|
|
1237
|
+
}
|
|
1238
|
+
})() : null;
|
|
1239
|
+
for (const c of cases) {
|
|
1240
|
+
const fuzzReq = {
|
|
1241
|
+
...req,
|
|
1242
|
+
params: c.params,
|
|
1243
|
+
body: c.bodyJson !== void 0 ? { mode: "json", json: c.bodyJson } : req.body
|
|
1244
|
+
};
|
|
1245
|
+
const resolvedUrl = rebaseUrl(requestExec.buildUrl(fuzzReq.url, fuzzReq.params, vars), opts.providerBaseUrl);
|
|
1246
|
+
let sentSnapshot = { method: req.method, url: resolvedUrl, headers: {} };
|
|
1247
|
+
try {
|
|
1248
|
+
const ex = await requestExec.performHttpExchange({
|
|
1249
|
+
req: fuzzReq,
|
|
1250
|
+
vars,
|
|
1251
|
+
resolvedUrl,
|
|
1252
|
+
dispatcher,
|
|
1253
|
+
onSent: (s) => {
|
|
1254
|
+
sentSnapshot = { method: s.method, url: s.url, headers: s.headers, body: s.body };
|
|
1255
|
+
}
|
|
1256
|
+
});
|
|
1257
|
+
const bodyIsSchemaInvalid = c.isBody && validateBody2 ? !validateBody2(c.bodyValue) : false;
|
|
1258
|
+
const verdict = judge(ex.status, ex.responseBody, bodyIsSchemaInvalid, specCtx, opts);
|
|
1259
|
+
if (verdict) {
|
|
1260
|
+
findings.push({
|
|
1261
|
+
oracle: verdict.oracle,
|
|
1262
|
+
message: verdict.message,
|
|
1263
|
+
status: ex.status,
|
|
1264
|
+
mutation: c.mutation,
|
|
1265
|
+
request: sentSnapshot,
|
|
1266
|
+
responseSample: ex.responseBody.slice(0, 400)
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
trace?.push({
|
|
1270
|
+
mutation: c.mutation,
|
|
1271
|
+
status: ex.status,
|
|
1272
|
+
finding: Boolean(verdict),
|
|
1273
|
+
request: { method: sentSnapshot.method, url: sentSnapshot.url, body: sentSnapshot.body },
|
|
1274
|
+
responseSample: ex.responseBody.slice(0, 2e3)
|
|
1275
|
+
});
|
|
1276
|
+
} catch (err) {
|
|
1277
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1278
|
+
const dropped = /socket hang up|ECONNRESET|other side closed/i.test(msg);
|
|
1279
|
+
if (dropped) {
|
|
1280
|
+
findings.push({
|
|
1281
|
+
oracle: "never-5xx",
|
|
1282
|
+
message: `Connection dropped on a malformed request: ${msg}`,
|
|
1283
|
+
status: 0,
|
|
1284
|
+
mutation: c.mutation,
|
|
1285
|
+
request: sentSnapshot
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
trace?.push({
|
|
1289
|
+
mutation: c.mutation,
|
|
1290
|
+
status: 0,
|
|
1291
|
+
finding: dropped,
|
|
1292
|
+
request: { method: sentSnapshot.method, url: sentSnapshot.url, body: sentSnapshot.body },
|
|
1293
|
+
responseSample: `(transport error) ${msg}`.slice(0, 300)
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
totalCases += cases.length;
|
|
1298
|
+
totalFindings += findings.length;
|
|
1299
|
+
results.push({
|
|
1300
|
+
requestId: req.id,
|
|
1301
|
+
requestName: req.name,
|
|
1302
|
+
method: req.method,
|
|
1303
|
+
url: rebaseUrl(requestExec.buildUrl(req.url, req.params, vars), opts.providerBaseUrl),
|
|
1304
|
+
cases: cases.length,
|
|
1305
|
+
findings,
|
|
1306
|
+
trace
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
opts.onProgress?.(active.length, active.length, "done");
|
|
1310
|
+
return {
|
|
1311
|
+
inputSource: spec ? "spec" : "request",
|
|
1312
|
+
oracleUsesContracts: false,
|
|
1313
|
+
seed,
|
|
1314
|
+
totalCases,
|
|
1315
|
+
totalFindings,
|
|
1316
|
+
results,
|
|
1317
|
+
durationMs: Date.now() - start,
|
|
1318
|
+
skippedWrites,
|
|
1319
|
+
skippedNoBody
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
function esc(s) {
|
|
1323
|
+
return String(s ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1324
|
+
}
|
|
1325
|
+
function modeLabel(mode) {
|
|
1326
|
+
if (mode === "bidirectional") return "Bi-directional";
|
|
1327
|
+
if (mode === "provider-live") return "Provider (live)";
|
|
1328
|
+
return mode.charAt(0).toUpperCase() + mode.slice(1);
|
|
1329
|
+
}
|
|
1330
|
+
const STYLE = `
|
|
1331
|
+
:root { color-scheme: dark; }
|
|
1332
|
+
* { box-sizing: border-box; }
|
|
1333
|
+
body { margin: 0; font: 14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
|
1334
|
+
background: #272630; color: #e4e3ea; }
|
|
1335
|
+
.wrap { max-width: 960px; margin: 0 auto; padding: 32px 24px 64px; }
|
|
1336
|
+
.brand { display: flex; align-items: center; gap: 8px; margin-bottom: 20px; color: #9d9aa8; font-size: 12px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; }
|
|
1337
|
+
.brand .dot { width: 10px; height: 10px; border-radius: 3px; background: #205d96; box-shadow: 0 0 0 2px rgba(32,93,150,.35); }
|
|
1338
|
+
h1 { font-size: 20px; margin: 0 0 4px; }
|
|
1339
|
+
.sub { color: #9d9aa8; font-size: 13px; margin: 0 0 24px; }
|
|
1340
|
+
.meta { display: flex; flex-wrap: wrap; gap: 8px 20px; color: #9d9aa8; font-size: 12px; margin-bottom: 12px; }
|
|
1341
|
+
.meta b { color: #c4c2cb; font-weight: 600; }
|
|
1342
|
+
.headline { font-size: 24px; font-weight: 700; }
|
|
1343
|
+
.headline.ok { color: #9fc93c; } .headline.bad { color: #f87171; }
|
|
1344
|
+
.bar { display: flex; height: 10px; border-radius: 6px; overflow: hidden; background: #3d3b48; margin: 14px 0 6px; }
|
|
1345
|
+
.bar > i { display: block; height: 100%; }
|
|
1346
|
+
.bar .ok { background: #9fc93c; } .bar .bad { background: #f87171; }
|
|
1347
|
+
.cards { display: flex; flex-direction: column; gap: 10px; margin-top: 24px; }
|
|
1348
|
+
.card { border: 1px solid #3d3b48; border-radius: 10px; overflow: hidden; background: #312f3b; }
|
|
1349
|
+
.card.fail { border-color: #7f1d1d; }
|
|
1350
|
+
.row { display: flex; align-items: center; gap: 12px; padding: 12px 16px; }
|
|
1351
|
+
.card.fail summary .row { background: rgba(127,29,29,.18); }
|
|
1352
|
+
.pill { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 5px; flex-shrink: 0; letter-spacing: .03em; }
|
|
1353
|
+
.pill.ok { background: #2d3a12; color: #9fc93c; }
|
|
1354
|
+
.pill.bad { background: rgba(127,29,29,.55); color: #f87171; }
|
|
1355
|
+
.pill.pend { background: rgba(180,83,9,.35); color: #fbbf24; }
|
|
1356
|
+
.card.pend { border-color: #92600a; }
|
|
1357
|
+
.method { font: 700 12px ui-monospace,SFMono-Regular,Menlo,monospace; width: 56px; flex-shrink: 0; color: #6aa3c8; }
|
|
1358
|
+
.name { flex: 1; color: #e4e3ea; font-weight: 500; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1359
|
+
.url { color: #7a7785; font: 11px ui-monospace,monospace; max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1360
|
+
.dur { color: #7a7785; font-size: 11px; }
|
|
1361
|
+
.code { font: 700 12px ui-monospace,monospace; }
|
|
1362
|
+
.code.s2 { color: #9fc93c; } .code.s3 { color: #fbbf24; } .code.s4, .code.s5 { color: #f87171; }
|
|
1363
|
+
.viol { padding: 14px 16px; border-top: 1px solid #3d3b48; display: flex; flex-direction: column; gap: 10px; }
|
|
1364
|
+
.v { border-left: 2px solid #dc2626; background: rgba(127,29,29,.12); border-radius: 0 6px 6px 0; padding: 8px 12px; }
|
|
1365
|
+
.v .t { font: 700 10px ui-monospace,monospace; color: #f87171; text-transform: uppercase; letter-spacing: .04em; }
|
|
1366
|
+
.v .path { font: 10px ui-monospace,monospace; color: #9d9aa8; background: #272630; padding: 1px 6px; border-radius: 4px; margin-left: 8px; }
|
|
1367
|
+
.v .m { color: #fecaca; font-size: 13px; margin: 4px 0 0; }
|
|
1368
|
+
.v .ea { font: 11px ui-monospace,monospace; margin-top: 4px; display: flex; gap: 18px; }
|
|
1369
|
+
.v .ea .lab { color: #7a7785; }
|
|
1370
|
+
.v .ea .exp { color: #9fc93c; } .v .ea .act { color: #f87171; }
|
|
1371
|
+
.pass-note { color: #9fc93c; font-size: 13px; padding: 12px 16px; border-top: 1px solid #3d3b48; }
|
|
1372
|
+
table { border-collapse: collapse; width: 100%; margin-top: 16px; font-size: 13px; }
|
|
1373
|
+
th, td { border: 1px solid #3d3b48; padding: 8px 12px; text-align: left; }
|
|
1374
|
+
th { background: #312f3b; color: #c4c2cb; font-weight: 600; }
|
|
1375
|
+
td.cell { text-align: center; }
|
|
1376
|
+
.b { display: inline-block; min-width: 56px; padding: 2px 8px; border-radius: 5px; font-size: 11px; font-weight: 700; }
|
|
1377
|
+
.b.ok { background: #2d3a12; color: #9fc93c; }
|
|
1378
|
+
.b.bad { background: rgba(127,29,29,.55); color: #f87171; }
|
|
1379
|
+
.b.na { background: #272630; color: #7a7785; }
|
|
1380
|
+
a.b { text-decoration: none; }
|
|
1381
|
+
a.b.ok:hover { background: #4a5e1d; } a.b.bad:hover { background: rgba(127,29,29,.8); }
|
|
1382
|
+
.foot { color: #7a7785; font-size: 11px; margin-top: 40px; text-align: center; }
|
|
1383
|
+
summary { cursor: pointer; list-style: none; }
|
|
1384
|
+
summary::-webkit-details-marker { display: none; }
|
|
1385
|
+
`;
|
|
1386
|
+
function page(title, body) {
|
|
1387
|
+
return `<!doctype html>
|
|
1388
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
1389
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1390
|
+
<title>${esc(title)}</title>
|
|
1391
|
+
<style>${STYLE}</style>
|
|
1392
|
+
</head><body><div class="wrap">
|
|
1393
|
+
<div class="brand"><span class="dot"></span>API Spector</div>${body}
|
|
1394
|
+
<p class="foot">Generated by API Spector · contract reporting</p>
|
|
1395
|
+
</div></body></html>`;
|
|
1396
|
+
}
|
|
1397
|
+
function statusClass(code) {
|
|
1398
|
+
if (code === void 0) return "";
|
|
1399
|
+
return "s" + String(code)[0];
|
|
1400
|
+
}
|
|
1401
|
+
function violationHtml(r) {
|
|
1402
|
+
if (r.violations.length === 0) return `<div class="pass-note">All expectations met.</div>`;
|
|
1403
|
+
const items = r.violations.map((v) => {
|
|
1404
|
+
const path2 = v.path ? `<span class="path">${esc(v.path)}</span>` : "";
|
|
1405
|
+
const ea = v.expected || v.actual ? `<div class="ea">${v.expected ? `<span><span class="lab">expected </span><span class="exp">${esc(v.expected)}</span></span>` : ""}${v.actual ? `<span><span class="lab">actual </span><span class="act">${esc(v.actual)}</span></span>` : ""}</div>` : "";
|
|
1406
|
+
return `<div class="v"><div><span class="t">${esc(v.type.replace(/_/g, " "))}</span>${path2}</div><p class="m">${esc(v.message)}</p>${ea}</div>`;
|
|
1407
|
+
}).join("");
|
|
1408
|
+
return `<div class="viol">${items}</div>`;
|
|
1409
|
+
}
|
|
1410
|
+
function cardHtml(r) {
|
|
1411
|
+
const pill = r.passed ? `<span class="pill ok">PASS</span>` : r.pending ? `<span class="pill pend">PENDING</span>` : `<span class="pill bad">FAIL</span>`;
|
|
1412
|
+
const status = r.actualStatus !== void 0 ? `<span class="code ${statusClass(r.actualStatus)}">${r.actualStatus}</span>` : "";
|
|
1413
|
+
const dur = r.durationMs !== void 0 ? `<span class="dur">${r.durationMs}ms</span>` : "";
|
|
1414
|
+
return `<details class="card ${r.passed ? "" : r.pending ? "pend" : "fail"}" ${r.passed ? "" : "open"}>
|
|
1415
|
+
<summary><div class="row">${pill}<span class="method">${esc(r.method)}</span><span class="name">${esc(r.requestName)}</span><span class="url">${esc(r.url)}</span>${status}${dur}</div></summary>
|
|
1416
|
+
${violationHtml(r)}
|
|
1417
|
+
</details>`;
|
|
1418
|
+
}
|
|
1419
|
+
function reportToHtml(report, meta = {}) {
|
|
1420
|
+
const okPct = report.total ? Math.round(report.passed / report.total * 100) : 100;
|
|
1421
|
+
const badPct = 100 - okPct;
|
|
1422
|
+
const headlineCls = report.failed === 0 ? "ok" : "bad";
|
|
1423
|
+
const headline = report.failed === 0 ? "✓ All passed" : `✗ ${report.failed} failed`;
|
|
1424
|
+
const metaRows = [
|
|
1425
|
+
`<span><b>Mode</b> ${esc(modeLabel(report.mode))}</span>`,
|
|
1426
|
+
meta.provider ? `<span><b>Provider</b> ${esc(meta.provider)}</span>` : "",
|
|
1427
|
+
meta.consumer ? `<span><b>Consumer</b> ${esc(meta.consumer)}</span>` : "",
|
|
1428
|
+
meta.spec ? `<span><b>Spec</b> ${esc(meta.spec)}</span>` : "",
|
|
1429
|
+
`<span><b>Duration</b> ${report.durationMs}ms</span>`,
|
|
1430
|
+
meta.generatedAt ? `<span><b>Generated</b> ${esc(meta.generatedAt)}</span>` : ""
|
|
1431
|
+
].filter(Boolean).join("");
|
|
1432
|
+
const failed = report.results.filter((r) => !r.passed);
|
|
1433
|
+
const passed = report.results.filter((r) => r.passed);
|
|
1434
|
+
const cards = [...failed, ...passed].map(cardHtml).join("");
|
|
1435
|
+
const body = `
|
|
1436
|
+
<h1>${esc(meta.title ?? "Contract Verification Report")}</h1>
|
|
1437
|
+
<p class="sub"><span class="headline ${headlineCls}">${esc(headline)}</span> ${report.passed} / ${report.total} interactions passed</p>
|
|
1438
|
+
<div class="meta">${metaRows}</div>
|
|
1439
|
+
<div class="bar"><i class="ok" style="width:${okPct}%"></i><i class="bad" style="width:${badPct}%"></i></div>
|
|
1440
|
+
<div class="cards">${cards || '<p class="sub">No interactions ran.</p>'}</div>`;
|
|
1441
|
+
return page(meta.title ?? "Contract Verification Report", body);
|
|
1442
|
+
}
|
|
1443
|
+
function dashboardToHtml(records, generatedAt, opts = {}) {
|
|
1444
|
+
const pacticipants = [...new Set(records.map((r) => r.pacticipant))].sort();
|
|
1445
|
+
const versions = [...new Set(records.map((r) => r.version))].sort();
|
|
1446
|
+
const byKey = new Map(records.map((r) => [`${r.pacticipant}@@${r.version}`, r]));
|
|
1447
|
+
const header = `<tr><th>Pacticipant \\ Version</th>${versions.map((v) => `<th>${esc(v)}</th>`).join("")}</tr>`;
|
|
1448
|
+
const rows = pacticipants.map((p) => {
|
|
1449
|
+
const cells = versions.map((v) => {
|
|
1450
|
+
const rec = byKey.get(`${p}@@${v}`);
|
|
1451
|
+
if (!rec) return `<td class="cell"><span class="b na">-</span></td>`;
|
|
1452
|
+
const cls = rec.passed ? "ok" : "bad";
|
|
1453
|
+
const label = rec.passed ? `${rec.report.total}/${rec.report.total}` : `${rec.report.passed}/${rec.report.total}`;
|
|
1454
|
+
const badge = opts.runLinkBase ? `<a class="b ${cls}" href="${esc(opts.runLinkBase)}/${encodeURIComponent(p)}/${encodeURIComponent(v)}">${esc(label)}</a>` : `<span class="b ${cls}">${esc(label)}</span>`;
|
|
1455
|
+
return `<td class="cell" title="${esc(rec.recordedAt)}">${badge}</td>`;
|
|
1456
|
+
}).join("");
|
|
1457
|
+
return `<tr><td><b>${esc(p)}</b></td>${cells}</tr>`;
|
|
1458
|
+
}).join("");
|
|
1459
|
+
const totalPass = records.filter((r) => r.passed).length;
|
|
1460
|
+
const envs = opts.environments ?? [];
|
|
1461
|
+
const envSection = envs.length ? `
|
|
1462
|
+
<h2 style="font-size:16px;margin:36px 0 4px;">Environments</h2>
|
|
1463
|
+
<p class="sub">Recorded with <code>contract record-deployment</code></p>
|
|
1464
|
+
<table>
|
|
1465
|
+
<tr><th>Environment</th><th>Pacticipant</th><th>Deployed version</th><th>Verification</th><th>Since</th></tr>
|
|
1466
|
+
${envs.flatMap((e) => Object.entries(e.deployed).sort((a, b) => a[0].localeCompare(b[0])).map(([p, d]) => {
|
|
1467
|
+
const rec = byKey.get(`${p}@@${d.version}`);
|
|
1468
|
+
const badge = !rec ? `<span class="b na">not verified</span>` : opts.runLinkBase ? `<a class="b ${rec.passed ? "ok" : "bad"}" href="${esc(opts.runLinkBase)}/${encodeURIComponent(p)}/${encodeURIComponent(d.version)}">${rec.passed ? "passed" : "failed"}</a>` : `<span class="b ${rec.passed ? "ok" : "bad"}">${rec.passed ? "passed" : "failed"}</span>`;
|
|
1469
|
+
return `<tr><td><b>${esc(e.name)}</b></td><td>${esc(p)}</td><td>${esc(d.version)}</td><td class="cell">${badge}</td><td>${esc(d.recordedAt.slice(0, 19).replace("T", " "))}</td></tr>`;
|
|
1470
|
+
})).join("")}
|
|
1471
|
+
</table>` : "";
|
|
1472
|
+
const body = `
|
|
1473
|
+
<h1>Contract Dashboard</h1>
|
|
1474
|
+
<p class="sub">${records.length} recorded verification${records.length === 1 ? "" : "s"} · ${totalPass} passing</p>
|
|
1475
|
+
<div class="meta">${generatedAt ? `<span><b>Generated</b> ${esc(generatedAt)}</span>` : ""}</div>
|
|
1476
|
+
${records.length ? `<table>${header}${rows}</table>` : '<p class="sub">No recorded results yet. Run a verification with <code>--record --app-version <ver></code>.</p>'}
|
|
1477
|
+
${envSection}`;
|
|
1478
|
+
return page("Contract Dashboard", body);
|
|
1479
|
+
}
|
|
1480
|
+
const ORACLE_LABEL = {
|
|
1481
|
+
"never-5xx": "server error",
|
|
1482
|
+
"accepted-invalid": "accepted invalid",
|
|
1483
|
+
"undocumented-status": "undocumented status",
|
|
1484
|
+
"response-schema": "bad response body"
|
|
1485
|
+
};
|
|
1486
|
+
function fuzzReportToHtml(report, generatedAt) {
|
|
1487
|
+
const clean = report.totalFindings === 0;
|
|
1488
|
+
const headlineCls = clean ? "ok" : "bad";
|
|
1489
|
+
const headline = clean ? "✓ No findings" : `✗ ${report.totalFindings} finding${report.totalFindings === 1 ? "" : "s"}`;
|
|
1490
|
+
const metaRows = [
|
|
1491
|
+
`<span><b>Input</b> ${report.inputSource === "spec" ? "OpenAPI spec" : "request bodies"}</span>`,
|
|
1492
|
+
`<span><b>Cases</b> ${report.totalCases}</span>`,
|
|
1493
|
+
`<span><b>Seed</b> ${report.seed}</span>`,
|
|
1494
|
+
`<span><b>Duration</b> ${report.durationMs}ms</span>`,
|
|
1495
|
+
report.skippedWrites ? `<span><b>Skipped writes</b> ${report.skippedWrites}</span>` : "",
|
|
1496
|
+
report.skippedNoBody ? `<span><b>No body</b> ${report.skippedNoBody}</span>` : "",
|
|
1497
|
+
generatedAt ? `<span><b>Generated</b> ${esc(generatedAt)}</span>` : ""
|
|
1498
|
+
].filter(Boolean).join("");
|
|
1499
|
+
const withFindings = report.results.filter((r) => r.findings.length > 0);
|
|
1500
|
+
const cleanOps = report.results.length - withFindings.length;
|
|
1501
|
+
const hasTrace = report.results.some((r) => r.trace && r.trace.length > 0);
|
|
1502
|
+
const traceSection = hasTrace ? `
|
|
1503
|
+
<h2 style="font-size:16px;margin:36px 0 8px;">All cases sent</h2>
|
|
1504
|
+
${report.results.filter((r) => r.trace?.length).map((op) => `
|
|
1505
|
+
<details class="card"><summary><div class="row"><span class="method">${esc(op.method)}</span><span class="name">${esc(op.requestName)}</span><span class="dur">${op.trace.length} cases</span></div></summary>
|
|
1506
|
+
<table><tr><th>Status</th><th>Field</th><th>Mutation</th><th>Request body</th><th>Response</th></tr>
|
|
1507
|
+
${op.trace.map((t) => `<tr><td class="cell"><span class="b ${t.finding ? "bad" : "ok"}">${t.status}</span></td><td>${esc(t.mutation.target)}</td><td>${esc(t.mutation.kind)}</td><td class="url" style="max-width:260px">${esc((t.request.body ?? "").slice(0, 200))}</td><td class="url" style="max-width:260px">${esc((t.responseSample ?? "").slice(0, 200))}</td></tr>`).join("")}
|
|
1508
|
+
</table></details>`).join("")}` : "";
|
|
1509
|
+
const cards = withFindings.map((op) => {
|
|
1510
|
+
const items = op.findings.map((f) => {
|
|
1511
|
+
const oracle = ORACLE_LABEL[f.oracle] ?? f.oracle;
|
|
1512
|
+
const reqBody = f.request.body ? `<div class="ea"><span class="lab">body </span><span class="path">${esc(f.request.body.slice(0, 300))}</span></div>` : "";
|
|
1513
|
+
const respSample = f.responseSample ? `<div class="ea"><span class="lab">response </span><span class="path">${esc(f.responseSample.slice(0, 300))}</span></div>` : "";
|
|
1514
|
+
return `<div class="v">
|
|
1515
|
+
<div><span class="t">${esc(oracle)}</span><span class="path">${esc(f.mutation.target)} · ${esc(f.mutation.kind)}</span></div>
|
|
1516
|
+
<p class="m">HTTP ${f.status}: ${esc(f.message)}</p>
|
|
1517
|
+
<div class="ea"><span class="lab">mutation </span><span>${esc(f.mutation.description)}</span></div>
|
|
1518
|
+
<div class="ea"><span class="lab">request </span><span class="path">${esc(f.request.method)} ${esc(f.request.url)}</span></div>
|
|
1519
|
+
${reqBody}${respSample}
|
|
1520
|
+
</div>`;
|
|
1521
|
+
}).join("");
|
|
1522
|
+
return `<details class="card fail" open>
|
|
1523
|
+
<summary><div class="row"><span class="pill bad">${op.findings.length}</span><span class="method">${esc(op.method)}</span><span class="name">${esc(op.requestName)}</span><span class="url">${esc(op.url)}</span><span class="dur">${op.cases} cases</span></div></summary>
|
|
1524
|
+
<div class="viol">${items}</div>
|
|
1525
|
+
</details>`;
|
|
1526
|
+
}).join("");
|
|
1527
|
+
const body = `
|
|
1528
|
+
<h1>Fuzz Report</h1>
|
|
1529
|
+
<p class="sub"><span class="headline ${headlineCls}">${esc(headline)}</span> ${report.totalCases} malformed cases across ${report.results.length} operation${report.results.length === 1 ? "" : "s"}</p>
|
|
1530
|
+
<div class="meta">${metaRows}</div>
|
|
1531
|
+
<div class="cards">${cards || '<p class="pass-note">All operations handled every malformed input safely.</p>'}</div>
|
|
1532
|
+
${traceSection}
|
|
1533
|
+
${cleanOps ? `<p class="foot">${cleanOps} operation${cleanOps === 1 ? "" : "s"} had no findings.</p>` : ""}`;
|
|
1534
|
+
return page("Fuzz Report", body);
|
|
1535
|
+
}
|
|
1536
|
+
const SNAPSHOT_DIR = "contracts";
|
|
1537
|
+
function safeName(raw) {
|
|
1538
|
+
return raw.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "spec";
|
|
1539
|
+
}
|
|
1540
|
+
function sha256(text) {
|
|
1541
|
+
return crypto.createHash("sha256").update(text).digest("hex");
|
|
1542
|
+
}
|
|
1543
|
+
function detectFormat(source, contentType) {
|
|
1544
|
+
const lc = source.toLowerCase();
|
|
1545
|
+
if (lc.endsWith(".yaml") || lc.endsWith(".yml")) return "yaml";
|
|
1546
|
+
if (lc.endsWith(".json")) return "json";
|
|
1547
|
+
if (contentType.includes("yaml")) return "yaml";
|
|
1548
|
+
return "json";
|
|
1549
|
+
}
|
|
1550
|
+
function tryExtractSpecVersion(specText, format) {
|
|
1551
|
+
try {
|
|
1552
|
+
const parsed = format === "yaml" ? jsYaml.load(specText) : JSON.parse(specText);
|
|
1553
|
+
const info = parsed?.info;
|
|
1554
|
+
if (info && typeof info.version === "string") return info.version;
|
|
1555
|
+
} catch {
|
|
1556
|
+
}
|
|
1557
|
+
return void 0;
|
|
1558
|
+
}
|
|
1559
|
+
async function captureSnapshot(workspaceDir, opts) {
|
|
1560
|
+
const { specUrl, specPath } = opts;
|
|
1561
|
+
if (!specUrl && !specPath) throw new Error("captureSnapshot: specUrl or specPath is required");
|
|
1562
|
+
let specText;
|
|
1563
|
+
let format;
|
|
1564
|
+
let source;
|
|
1565
|
+
if (specUrl) {
|
|
1566
|
+
const resp = await undici.fetch(specUrl);
|
|
1567
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status} loading spec from ${specUrl}`);
|
|
1568
|
+
specText = await resp.text();
|
|
1569
|
+
format = detectFormat(specUrl, resp.headers.get("content-type") ?? "");
|
|
1570
|
+
source = specUrl;
|
|
1571
|
+
} else {
|
|
1572
|
+
specText = await promises.readFile(specPath, "utf8");
|
|
1573
|
+
format = detectFormat(specPath, "");
|
|
1574
|
+
source = specPath;
|
|
1575
|
+
}
|
|
1576
|
+
const id = crypto.randomUUID();
|
|
1577
|
+
const specVersion = tryExtractSpecVersion(specText, format);
|
|
1578
|
+
let name = opts.name?.trim();
|
|
1579
|
+
if (!name) {
|
|
1580
|
+
try {
|
|
1581
|
+
name = new URL(source).hostname;
|
|
1582
|
+
} catch {
|
|
1583
|
+
name = source.split(/[\\/]/).pop() ?? "spec";
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
if (specVersion && !name.includes(specVersion)) name = `${name} ${specVersion}`;
|
|
1587
|
+
const snapshot = {
|
|
1588
|
+
version: "1.0",
|
|
1589
|
+
id,
|
|
1590
|
+
name,
|
|
1591
|
+
source,
|
|
1592
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1593
|
+
format,
|
|
1594
|
+
specVersion,
|
|
1595
|
+
spec: specText,
|
|
1596
|
+
sha256: sha256(specText)
|
|
1597
|
+
};
|
|
1598
|
+
const relPath = path.join(SNAPSHOT_DIR, `${safeName(name)}-${id.slice(0, 8)}.contract.json`);
|
|
1599
|
+
const absPath = path.resolve(workspaceDir, relPath);
|
|
1600
|
+
await promises.mkdir(path.join(workspaceDir, SNAPSHOT_DIR), { recursive: true });
|
|
1601
|
+
await promises.writeFile(absPath, JSON.stringify(snapshot, null, 2), "utf8");
|
|
1602
|
+
Object.defineProperty(snapshot, "__relPath", { value: relPath, enumerable: false });
|
|
1603
|
+
return snapshot;
|
|
1604
|
+
}
|
|
1605
|
+
function relPathOf(snapshot) {
|
|
1606
|
+
const hidden = snapshot.__relPath;
|
|
1607
|
+
return hidden;
|
|
1608
|
+
}
|
|
1609
|
+
async function loadSnapshot(workspaceDir, relPath) {
|
|
1610
|
+
const raw = await promises.readFile(path.resolve(workspaceDir, relPath), "utf8");
|
|
1611
|
+
return JSON.parse(raw);
|
|
1612
|
+
}
|
|
1613
|
+
async function listSnapshots(workspaceDir, registered = []) {
|
|
1614
|
+
const seen = new Set(registered);
|
|
1615
|
+
try {
|
|
1616
|
+
const entries = await promises.readdir(path.join(workspaceDir, SNAPSHOT_DIR));
|
|
1617
|
+
for (const f of entries) {
|
|
1618
|
+
if (f.endsWith(".contract.json")) seen.add(path.join(SNAPSHOT_DIR, f));
|
|
1619
|
+
}
|
|
1620
|
+
} catch {
|
|
1621
|
+
}
|
|
1622
|
+
const out = [];
|
|
1623
|
+
for (const relPath of seen) {
|
|
1624
|
+
try {
|
|
1625
|
+
const snapshot = await loadSnapshot(workspaceDir, relPath);
|
|
1626
|
+
out.push({ relPath, snapshot });
|
|
1627
|
+
} catch {
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
out.sort((a, b) => b.snapshot.capturedAt.localeCompare(a.snapshot.capturedAt));
|
|
1631
|
+
return out;
|
|
1632
|
+
}
|
|
1633
|
+
async function deleteSnapshot(workspaceDir, relPath) {
|
|
1634
|
+
try {
|
|
1635
|
+
await promises.unlink(path.resolve(workspaceDir, relPath));
|
|
1636
|
+
} catch {
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
exports.MATCH_KEY = MATCH_KEY;
|
|
1640
|
+
exports.canIDeploy = canIDeploy;
|
|
1641
|
+
exports.captureSnapshot = captureSnapshot;
|
|
1642
|
+
exports.dashboardToHtml = dashboardToHtml;
|
|
1643
|
+
exports.deleteSnapshot = deleteSnapshot;
|
|
1644
|
+
exports.fuzzReportToHtml = fuzzReportToHtml;
|
|
1645
|
+
exports.hasContract = hasContract;
|
|
1646
|
+
exports.listEnvironments = listEnvironments;
|
|
1647
|
+
exports.listResults = listResults;
|
|
1648
|
+
exports.listSnapshots = listSnapshots;
|
|
1649
|
+
exports.loadSnapshot = loadSnapshot;
|
|
1650
|
+
exports.recordDeployment = recordDeployment;
|
|
1651
|
+
exports.recordResult = recordResult;
|
|
1652
|
+
exports.relPathOf = relPathOf;
|
|
1653
|
+
exports.reportToHtml = reportToHtml;
|
|
1654
|
+
exports.runBidirectional = runBidirectional;
|
|
1655
|
+
exports.runConsumerContracts = runConsumerContracts;
|
|
1656
|
+
exports.runFuzz = runFuzz;
|
|
1657
|
+
exports.runLiveProviderVerification = runLiveProviderVerification;
|
|
1658
|
+
exports.runProviderVerification = runProviderVerification;
|