@nifrajs/testing 4.0.2 → 5.1.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/LICENSE +21 -0
- package/README.md +17 -4
- package/dist/adversarial.d.ts +6 -38
- package/dist/adversarial.d.ts.map +1 -1
- package/dist/adversarial.js +304 -83
- package/dist/adversarial.js.map +1 -1
- package/dist/agent-eval.d.ts +25 -8
- package/dist/agent-eval.d.ts.map +1 -1
- package/dist/agent-eval.js +11 -6
- package/dist/agent-eval.js.map +1 -1
- package/dist/certification.d.ts +11 -1
- package/dist/certification.d.ts.map +1 -1
- package/dist/certification.js +136 -49
- package/dist/certification.js.map +1 -1
- package/dist/cookies.d.ts +1 -1
- package/dist/cookies.d.ts.map +1 -1
- package/dist/cookies.js +5 -3
- package/dist/cookies.js.map +1 -1
- package/dist/data-conformance.d.ts.map +1 -1
- package/dist/data-conformance.js +20 -8
- package/dist/data-conformance.js.map +1 -1
- package/dist/incident.d.ts +21 -6
- package/dist/incident.d.ts.map +1 -1
- package/dist/incident.js +99 -23
- package/dist/incident.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/trajectory.d.ts.map +1 -1
- package/dist/trajectory.js +31 -21
- package/dist/trajectory.js.map +1 -1
- package/dist/zod.d.ts +2 -2
- package/package.json +7 -7
- package/src/adversarial.ts +378 -147
- package/src/agent-eval.ts +30 -8
- package/src/certification.ts +137 -45
- package/src/cookies.ts +4 -2
- package/src/data-conformance.ts +23 -7
- package/src/incident.ts +121 -20
- package/src/index.ts +1 -0
- package/src/trajectory.ts +28 -28
- package/src/zod.ts +2 -2
package/dist/adversarial.js
CHANGED
|
@@ -7,14 +7,34 @@
|
|
|
7
7
|
* responses are validated off the hot path as part of the same laboratory run.
|
|
8
8
|
*/
|
|
9
9
|
import { compileRoutePattern, } from "@nifrajs/core/pattern";
|
|
10
|
-
import { reflectRoutes, } from "@nifrajs/core/reflection";
|
|
11
|
-
import { autoReflectJsonSchema, generateMockValue } from "@nifrajs/mock";
|
|
10
|
+
import { reflectMounts, reflectRoutes, } from "@nifrajs/core/reflection";
|
|
11
|
+
import { autoReflectJsonSchema, explainAutoReflectJsonSchema, generateMockValue, } from "@nifrajs/mock";
|
|
12
12
|
const DEFAULT_ORIGIN = "http://nifra.contract";
|
|
13
13
|
const DEFAULT_SEED = 0x4e_49_46_52;
|
|
14
14
|
const DEFAULT_MAX_MUTATIONS = 32;
|
|
15
15
|
const DEFAULT_MAX_SHRINK_ATTEMPTS = 12;
|
|
16
16
|
const DEFAULT_MAX_WITNESS_BYTES = 256 * 1024;
|
|
17
17
|
const NO_BODY_METHODS = new Set(["GET", "HEAD"]);
|
|
18
|
+
/** In the order a route validates them, so the first input that cannot resolve is the one named. */
|
|
19
|
+
const INPUT_TARGETS = ["headers", "params", "body", "query"];
|
|
20
|
+
/**
|
|
21
|
+
* Header names the harness never invents or mutates: a credential belongs to the witness or
|
|
22
|
+
* `prepareRequest`, and the rest are owned by the fetch runtime.
|
|
23
|
+
*/
|
|
24
|
+
const CREDENTIAL_HEADER = /^(?:authorization|proxy-authorization|cookie|x-api-key)$|-key$|token|secret|signature/;
|
|
25
|
+
const RUNTIME_HEADERS = new Set([
|
|
26
|
+
"connection",
|
|
27
|
+
"content-length",
|
|
28
|
+
"expect",
|
|
29
|
+
"host",
|
|
30
|
+
"keep-alive",
|
|
31
|
+
"proxy-connection",
|
|
32
|
+
"te",
|
|
33
|
+
"trailer",
|
|
34
|
+
"transfer-encoding",
|
|
35
|
+
"upgrade",
|
|
36
|
+
]);
|
|
37
|
+
const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
18
38
|
export class AdversarialContractError extends Error {
|
|
19
39
|
report;
|
|
20
40
|
constructor(report) {
|
|
@@ -228,15 +248,29 @@ async function rejectedByValidator(reflection, value) {
|
|
|
228
248
|
const result = await validator["~standard"].validate(value);
|
|
229
249
|
return result.issues !== undefined;
|
|
230
250
|
}
|
|
231
|
-
|
|
251
|
+
/**
|
|
252
|
+
* Validator-proven hostile values for one input. A header mutation touches only a field the harness
|
|
253
|
+
* owns, and an extra header proves nothing (servers tolerate them). `constrained` reports whether any
|
|
254
|
+
* transportable candidate came from a schema constraint rather than a bare type swap: a params or
|
|
255
|
+
* headers validator that accepts every type swap may simply accept any text.
|
|
256
|
+
*/
|
|
257
|
+
async function provenMutations(input, target, maxMutations, paramNames) {
|
|
232
258
|
const candidates = candidateMutations(input.value, input.reflection.jsonSchema, maxMutations);
|
|
233
259
|
const mutations = [];
|
|
234
260
|
const seen = new Set();
|
|
261
|
+
let constrained = false;
|
|
235
262
|
for (const candidate of candidates) {
|
|
263
|
+
if (target === "headers") {
|
|
264
|
+
const name = candidate.path[0];
|
|
265
|
+
if (typeof name !== "string" || !harnessOwnsHeader(name))
|
|
266
|
+
continue;
|
|
267
|
+
if (candidate.reason === "additional-property")
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
236
270
|
let serialized;
|
|
237
271
|
let value;
|
|
238
272
|
try {
|
|
239
|
-
value = target
|
|
273
|
+
value = transported(target, candidate.value, paramNames);
|
|
240
274
|
if (value === undefined)
|
|
241
275
|
continue;
|
|
242
276
|
serialized = JSON.stringify(value);
|
|
@@ -247,6 +281,8 @@ async function provenMutations(input, target, maxMutations) {
|
|
|
247
281
|
if (seen.has(serialized))
|
|
248
282
|
continue;
|
|
249
283
|
seen.add(serialized);
|
|
284
|
+
if (!candidate.reason.startsWith("wrong-type-"))
|
|
285
|
+
constrained = true;
|
|
250
286
|
try {
|
|
251
287
|
if (await rejectedByValidator(input.reflection, value))
|
|
252
288
|
mutations.push({ ...candidate, value });
|
|
@@ -258,7 +294,102 @@ async function provenMutations(input, target, maxMutations) {
|
|
|
258
294
|
if (mutations.length >= maxMutations)
|
|
259
295
|
break;
|
|
260
296
|
}
|
|
261
|
-
return mutations;
|
|
297
|
+
return { mutations, constrained };
|
|
298
|
+
}
|
|
299
|
+
/** The header names the harness may set or mutate. */
|
|
300
|
+
const harnessOwnsHeader = (name) => {
|
|
301
|
+
const lower = name.toLowerCase();
|
|
302
|
+
return !CREDENTIAL_HEADER.test(lower) && !RUNTIME_HEADERS.has(lower);
|
|
303
|
+
};
|
|
304
|
+
const scalarText = (value) => typeof value === "string"
|
|
305
|
+
? value
|
|
306
|
+
: (typeof value === "number" && Number.isFinite(value)) || typeof value === "boolean"
|
|
307
|
+
? String(value)
|
|
308
|
+
: undefined;
|
|
309
|
+
/** Path params as the router hands them over: exactly the route's names, each a non-empty segment
|
|
310
|
+
* text that survives URL normalization (no `.` or `..` part). Anything else cannot be sent. */
|
|
311
|
+
const paramsInput = (value, names) => {
|
|
312
|
+
const record = recordOf(value);
|
|
313
|
+
if (record === undefined || Object.keys(record).length !== names.length)
|
|
314
|
+
return undefined;
|
|
315
|
+
const entries = [];
|
|
316
|
+
for (const name of names) {
|
|
317
|
+
const text = own(record, name) ? scalarText(record[name]) : undefined;
|
|
318
|
+
if (text === undefined ||
|
|
319
|
+
text.split("/").some((part) => part === "" || part === "." || part === ".."))
|
|
320
|
+
return undefined;
|
|
321
|
+
entries.push([name, text]);
|
|
322
|
+
}
|
|
323
|
+
return Object.fromEntries(entries);
|
|
324
|
+
};
|
|
325
|
+
/** Headers as a server reads them: lower-case names to values `Headers` carries unchanged. */
|
|
326
|
+
const headersInput = (value) => {
|
|
327
|
+
const record = recordOf(value);
|
|
328
|
+
if (record === undefined)
|
|
329
|
+
return undefined;
|
|
330
|
+
const entries = [];
|
|
331
|
+
for (const [name, item] of Object.entries(record)) {
|
|
332
|
+
if (item === undefined)
|
|
333
|
+
continue;
|
|
334
|
+
const text = scalarText(item);
|
|
335
|
+
if (text === undefined ||
|
|
336
|
+
!HEADER_NAME.test(name) ||
|
|
337
|
+
text !== text.trim() ||
|
|
338
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: a header value may carry none of these
|
|
339
|
+
/[\r\n\u0000]|[^\u0000-\u00ff]/.test(text))
|
|
340
|
+
return undefined;
|
|
341
|
+
entries.push([name.toLowerCase(), text]);
|
|
342
|
+
}
|
|
343
|
+
return Object.fromEntries(entries);
|
|
344
|
+
};
|
|
345
|
+
/** The names a route pattern binds, each with a value its own matcher accepts. */
|
|
346
|
+
function placeholderParams(path) {
|
|
347
|
+
let segments;
|
|
348
|
+
try {
|
|
349
|
+
segments = compileRoutePattern(path).segments;
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
return {};
|
|
353
|
+
}
|
|
354
|
+
const entries = [];
|
|
355
|
+
for (const segment of segments) {
|
|
356
|
+
if (segment.kind === "param")
|
|
357
|
+
entries.push([segment.name, `${segment.name}-contract`]);
|
|
358
|
+
else if (segment.kind === "wildcard") {
|
|
359
|
+
entries.push([segment.name, `${segment.name === "*" ? "path" : segment.name}/contract`]);
|
|
360
|
+
}
|
|
361
|
+
else if (segment.kind === "mixed") {
|
|
362
|
+
for (const part of segment.parts) {
|
|
363
|
+
if (part.t !== "lit") {
|
|
364
|
+
entries.push([
|
|
365
|
+
part.name,
|
|
366
|
+
part.c === undefined ? `${part.name}-contract` : satisfying(part.c),
|
|
367
|
+
]);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return Object.fromEntries(entries);
|
|
373
|
+
}
|
|
374
|
+
/** `value` when it is a record of strings, as a resolved params input always is. */
|
|
375
|
+
const stringRecordOf = (value) => {
|
|
376
|
+
const record = recordOf(value);
|
|
377
|
+
if (record === undefined)
|
|
378
|
+
return undefined;
|
|
379
|
+
const entries = Object.entries(record);
|
|
380
|
+
return entries.every((entry) => typeof entry[1] === "string")
|
|
381
|
+
? Object.fromEntries(entries)
|
|
382
|
+
: undefined;
|
|
383
|
+
};
|
|
384
|
+
/** `value` in the form `target` travels in, or `undefined` when no request could carry it. */
|
|
385
|
+
function transported(target, value, paramNames) {
|
|
386
|
+
if (target === "query")
|
|
387
|
+
return queryInput(value);
|
|
388
|
+
if (target === "headers")
|
|
389
|
+
return headersInput(value);
|
|
390
|
+
if (target === "params")
|
|
391
|
+
return paramsInput(value, paramNames);
|
|
392
|
+
return jsonClone(value);
|
|
262
393
|
}
|
|
263
394
|
const queryPairs = (value) => {
|
|
264
395
|
const record = recordOf(value);
|
|
@@ -310,7 +441,7 @@ const withReflectedJsonSchema = (reflection, reflect) => {
|
|
|
310
441
|
return reflection;
|
|
311
442
|
}
|
|
312
443
|
};
|
|
313
|
-
async function resolveInput(routeKey, target, reflection,
|
|
444
|
+
async function resolveInput(routeKey, target, reflection, source, seed, maxWitnessBytes) {
|
|
314
445
|
if (reflection.standard === undefined) {
|
|
315
446
|
return {
|
|
316
447
|
ok: false,
|
|
@@ -323,12 +454,13 @@ async function resolveInput(routeKey, target, reflection, witness, seed, maxWitn
|
|
|
323
454
|
};
|
|
324
455
|
}
|
|
325
456
|
let value;
|
|
326
|
-
if (
|
|
327
|
-
value =
|
|
457
|
+
if (source.given !== undefined && source.merge === undefined) {
|
|
458
|
+
value = source.given;
|
|
328
459
|
}
|
|
329
460
|
else if (reflection.jsonSchema !== undefined) {
|
|
330
461
|
try {
|
|
331
|
-
|
|
462
|
+
const synthesized = generateMockValue(reflection.jsonSchema, target, seededRandom(seed ^ hash(`${routeKey}:${target}`)));
|
|
463
|
+
value = source.merge === undefined ? synthesized : source.merge(synthesized);
|
|
332
464
|
}
|
|
333
465
|
catch (error) {
|
|
334
466
|
return {
|
|
@@ -342,6 +474,9 @@ async function resolveInput(routeKey, target, reflection, witness, seed, maxWitn
|
|
|
342
474
|
};
|
|
343
475
|
}
|
|
344
476
|
}
|
|
477
|
+
else if (source.merge !== undefined && source.given !== undefined) {
|
|
478
|
+
value = source.merge(undefined);
|
|
479
|
+
}
|
|
345
480
|
else {
|
|
346
481
|
return {
|
|
347
482
|
ok: false,
|
|
@@ -349,12 +484,12 @@ async function resolveInput(routeKey, target, reflection, witness, seed, maxWitn
|
|
|
349
484
|
route: routeKey,
|
|
350
485
|
target,
|
|
351
486
|
code: "NO_WITNESS",
|
|
352
|
-
message: `opaque ${target} schema needs witnesses[${JSON.stringify(routeKey)}].${target}`,
|
|
487
|
+
message: `opaque ${target} schema${source.opaqueReason === undefined ? "" : ` (${source.opaqueReason})`} needs witnesses[${JSON.stringify(routeKey)}].${target}`,
|
|
353
488
|
},
|
|
354
489
|
};
|
|
355
490
|
}
|
|
356
491
|
try {
|
|
357
|
-
value = target
|
|
492
|
+
value = transported(target, value, source.paramNames);
|
|
358
493
|
if (value === undefined)
|
|
359
494
|
throw new TypeError(`${target} witness is not transport-serializable`);
|
|
360
495
|
if (valueSize(value) > maxWitnessBytes) {
|
|
@@ -436,18 +571,39 @@ function materializePath(path, params) {
|
|
|
436
571
|
});
|
|
437
572
|
return `/${filled.join("/")}`;
|
|
438
573
|
}
|
|
439
|
-
|
|
440
|
-
|
|
574
|
+
const paramNamesOf = (laboratory) => Object.keys(recordOf(laboratory.inputs.params?.value) ?? {});
|
|
575
|
+
/** The laboratory's resolved inputs, with `target` replaced by `value` when one is given. */
|
|
576
|
+
function inputValues(laboratory, target, value) {
|
|
577
|
+
const values = {};
|
|
578
|
+
for (const name of INPUT_TARGETS) {
|
|
579
|
+
const current = name === target ? value : laboratory.inputs[name]?.value;
|
|
580
|
+
if (current !== undefined)
|
|
581
|
+
values[name] = current;
|
|
582
|
+
}
|
|
583
|
+
return values;
|
|
584
|
+
}
|
|
585
|
+
function requestFor(laboratory, origin, values) {
|
|
586
|
+
const params = stringRecordOf(values.params);
|
|
587
|
+
const path = materializePath(laboratory.route.path, params ?? laboratory.witness.params);
|
|
441
588
|
const url = new URL(path, origin);
|
|
442
|
-
for (const [key, value] of queryPairs(query) ?? [])
|
|
589
|
+
for (const [key, value] of queryPairs(values.query) ?? [])
|
|
443
590
|
url.searchParams.append(key, value);
|
|
444
591
|
const headers = new Headers(laboratory.witness.headers);
|
|
592
|
+
const headerValues = recordOf(values.headers);
|
|
593
|
+
if (headerValues !== undefined) {
|
|
594
|
+
// The resolved record holds the witness's headers too, so a mutation that drops one drops it here.
|
|
595
|
+
for (const name of Object.keys(recordOf(laboratory.inputs.headers?.value) ?? {})) {
|
|
596
|
+
headers.delete(name);
|
|
597
|
+
}
|
|
598
|
+
for (const [name, value] of Object.entries(headerValues))
|
|
599
|
+
headers.set(name, String(value));
|
|
600
|
+
}
|
|
445
601
|
const init = { method: laboratory.route.method, headers };
|
|
446
602
|
if (laboratory.route.schema?.body !== undefined &&
|
|
447
603
|
!NO_BODY_METHODS.has(laboratory.route.method)) {
|
|
448
604
|
if (!headers.has("content-type"))
|
|
449
605
|
headers.set("content-type", "application/json");
|
|
450
|
-
init.body = JSON.stringify(body);
|
|
606
|
+
init.body = JSON.stringify(values.body);
|
|
451
607
|
}
|
|
452
608
|
return new Request(url.toString(), init);
|
|
453
609
|
}
|
|
@@ -502,9 +658,7 @@ async function executeRejection(runtime, laboratory, target, mutation, id, optio
|
|
|
502
658
|
mutation: `${displayPath(target, mutation.path)} ${mutation.reason}`,
|
|
503
659
|
};
|
|
504
660
|
try {
|
|
505
|
-
const
|
|
506
|
-
const query = target === "query" ? mutation.value : laboratory.query?.value;
|
|
507
|
-
const request = await preparedRequest(requestFor(laboratory, origin, body, query), context, options.prepareRequest);
|
|
661
|
+
const request = await preparedRequest(requestFor(laboratory, origin, inputValues(laboratory, target, mutation.value)), context, options.prepareRequest);
|
|
508
662
|
const response = await runtime.fetch(request);
|
|
509
663
|
const rejected = options.isRejected === undefined
|
|
510
664
|
? statuses.has(response.status)
|
|
@@ -527,20 +681,20 @@ async function shrinkFailure(runtime, laboratory, target, mutation, id, options,
|
|
|
527
681
|
attempts += 1;
|
|
528
682
|
if (attempts > maxAttempts)
|
|
529
683
|
break;
|
|
530
|
-
const reflection =
|
|
531
|
-
const
|
|
532
|
-
if (
|
|
684
|
+
const reflection = laboratory.inputs[target]?.reflection;
|
|
685
|
+
const carried = transported(target, value, paramNamesOf(laboratory));
|
|
686
|
+
if (carried === undefined)
|
|
533
687
|
continue;
|
|
534
688
|
if (reflection === undefined)
|
|
535
689
|
continue;
|
|
536
690
|
try {
|
|
537
|
-
if (!(await rejectedByValidator(reflection,
|
|
691
|
+
if (!(await rejectedByValidator(reflection, carried)))
|
|
538
692
|
continue;
|
|
539
693
|
}
|
|
540
694
|
catch {
|
|
541
695
|
continue;
|
|
542
696
|
}
|
|
543
|
-
const candidate = { ...current, value:
|
|
697
|
+
const candidate = { ...current, value: carried };
|
|
544
698
|
const result = await executeRejection(runtime, laboratory, target, candidate, id, options, seed, origin, statuses);
|
|
545
699
|
status = result.status;
|
|
546
700
|
if (!result.rejected && result.error === undefined) {
|
|
@@ -561,6 +715,21 @@ const resultForFailure = (base, seed, message) => ({
|
|
|
561
715
|
message,
|
|
562
716
|
replay: { seed, caseId: base.id, runtime: base.runtime },
|
|
563
717
|
});
|
|
718
|
+
/** A witness's headers as the lower-case record a server validates. */
|
|
719
|
+
function headerRecordOf(init) {
|
|
720
|
+
const entries = [];
|
|
721
|
+
new Headers(init).forEach((value, name) => {
|
|
722
|
+
entries.push([name, value]);
|
|
723
|
+
});
|
|
724
|
+
return Object.fromEntries(entries);
|
|
725
|
+
}
|
|
726
|
+
/** The `required` property names a JSON Schema object states. */
|
|
727
|
+
const requiredNames = (schema) => {
|
|
728
|
+
const required = schemaRecord(schema)?.required;
|
|
729
|
+
return Array.isArray(required)
|
|
730
|
+
? required.filter((name) => typeof name === "string")
|
|
731
|
+
: [];
|
|
732
|
+
};
|
|
564
733
|
async function buildLaboratories(routes, options, seed, gaps) {
|
|
565
734
|
const laboratories = [];
|
|
566
735
|
const maxMutations = positiveInteger(options.maxMutationsPerInput, DEFAULT_MAX_MUTATIONS);
|
|
@@ -569,73 +738,122 @@ async function buildLaboratories(routes, options, seed, gaps) {
|
|
|
569
738
|
for (const route of routes) {
|
|
570
739
|
const routeKey = routeKeyOf(route);
|
|
571
740
|
const witness = options.witnesses?.[routeKey] ?? {};
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
741
|
+
const schema = route.schema;
|
|
742
|
+
const witnessKey = `witnesses[${JSON.stringify(routeKey)}]`;
|
|
743
|
+
const witnessHeaders = headerRecordOf(witness.headers);
|
|
744
|
+
const placeholders = placeholderParams(route.path);
|
|
745
|
+
const paramNames = Object.keys(placeholders);
|
|
746
|
+
const sendsBody = !NO_BODY_METHODS.has(route.method);
|
|
747
|
+
const inputs = {};
|
|
748
|
+
let resolved = true;
|
|
749
|
+
if (schema?.cookies !== undefined && witnessHeaders.cookie === undefined) {
|
|
750
|
+
resolved = false;
|
|
751
|
+
gaps.push({
|
|
752
|
+
route: routeKey,
|
|
753
|
+
target: "cookies",
|
|
754
|
+
code: "NO_WITNESS",
|
|
755
|
+
message: `cookies are credentials and are never synthesized: set ${witnessKey}.headers.cookie`,
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
for (const target of INPUT_TARGETS) {
|
|
759
|
+
const declared = schema?.[target];
|
|
760
|
+
if (declared === undefined)
|
|
761
|
+
continue;
|
|
575
762
|
targetCount += 1;
|
|
576
|
-
if (
|
|
763
|
+
if (target === "body" && !sendsBody) {
|
|
764
|
+
resolved = false;
|
|
577
765
|
gaps.push({
|
|
578
766
|
route: routeKey,
|
|
579
767
|
target: "body",
|
|
580
768
|
code: "UNSUPPORTED_BODY_METHOD",
|
|
581
769
|
message: `${route.method} cannot carry a request body`,
|
|
582
770
|
});
|
|
771
|
+
continue;
|
|
583
772
|
}
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
773
|
+
const reflection = withReflectedJsonSchema(declared, options.reflectJsonSchema ?? autoReflectJsonSchema);
|
|
774
|
+
const opaqueReason = reflection.jsonSchema === undefined && options.reflectJsonSchema === undefined
|
|
775
|
+
? explainAutoReflectJsonSchema(reflection.standard)
|
|
776
|
+
: undefined;
|
|
777
|
+
const source = target === "params"
|
|
778
|
+
? {
|
|
779
|
+
given: witness.params,
|
|
780
|
+
merge: (synthesized) => ({
|
|
781
|
+
...placeholders,
|
|
782
|
+
...Object.fromEntries(Object.entries(recordOf(synthesized) ?? {}).filter(([name]) => own(placeholders, name))),
|
|
783
|
+
...witness.params,
|
|
784
|
+
}),
|
|
785
|
+
paramNames,
|
|
786
|
+
opaqueReason,
|
|
787
|
+
}
|
|
788
|
+
: target === "headers"
|
|
789
|
+
? {
|
|
790
|
+
given: witness.headers === undefined ? undefined : witnessHeaders,
|
|
791
|
+
merge: (synthesized) => {
|
|
792
|
+
const merged = {
|
|
793
|
+
...Object.fromEntries(Object.entries(recordOf(synthesized) ?? {}).filter(([name]) => harnessOwnsHeader(name) && !own(witnessHeaders, name.toLowerCase()))),
|
|
794
|
+
...witnessHeaders,
|
|
795
|
+
};
|
|
796
|
+
// The request carries this for a body, so the validator must see it too.
|
|
797
|
+
if (schema?.body !== undefined && sendsBody && !own(merged, "content-type"))
|
|
798
|
+
merged["content-type"] = "application/json";
|
|
799
|
+
return merged;
|
|
800
|
+
},
|
|
801
|
+
paramNames,
|
|
802
|
+
opaqueReason,
|
|
803
|
+
}
|
|
804
|
+
: {
|
|
805
|
+
given: own(witness, target) ? witness[target] : undefined,
|
|
806
|
+
paramNames,
|
|
807
|
+
opaqueReason,
|
|
808
|
+
};
|
|
809
|
+
const result = await resolveInput(routeKey, target, reflection, source, seed, maxWitnessBytes);
|
|
810
|
+
if (result.ok) {
|
|
811
|
+
inputs[target] = result.input;
|
|
812
|
+
continue;
|
|
590
813
|
}
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
814
|
+
resolved = false;
|
|
815
|
+
const credentials = target === "headers" && result.gap.code === "INVALID_WITNESS"
|
|
816
|
+
? requiredNames(reflection.jsonSchema).filter((name) => !harnessOwnsHeader(name) && !own(witnessHeaders, name.toLowerCase()))
|
|
817
|
+
: [];
|
|
818
|
+
gaps.push(credentials.length === 0
|
|
819
|
+
? result.gap
|
|
820
|
+
: {
|
|
821
|
+
...result.gap,
|
|
822
|
+
code: "NO_WITNESS",
|
|
823
|
+
message: `the harness never invents credentials: set ${credentials.join(", ")} in ${witnessKey}.headers (prepareRequest may replace them per runtime)`,
|
|
824
|
+
});
|
|
599
825
|
}
|
|
600
826
|
const expectedStatus = witness.expected?.status;
|
|
601
827
|
const expectedResponseSchema = expectedStatus !== undefined && (expectedStatus < 200 || expectedStatus >= 300)
|
|
602
|
-
?
|
|
603
|
-
:
|
|
828
|
+
? schema?.errors?.[expectedStatus]
|
|
829
|
+
: schema?.response;
|
|
604
830
|
if (options.validateResponses !== false && expectedResponseSchema !== undefined)
|
|
605
831
|
targetCount += 1;
|
|
606
|
-
const
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
: []
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
}
|
|
832
|
+
const mutations = {
|
|
833
|
+
headers: [],
|
|
834
|
+
params: [],
|
|
835
|
+
body: [],
|
|
836
|
+
query: [],
|
|
837
|
+
};
|
|
838
|
+
for (const target of INPUT_TARGETS) {
|
|
839
|
+
const input = inputs[target];
|
|
840
|
+
if (input === undefined || !resolved)
|
|
841
|
+
continue;
|
|
842
|
+
const proven = await provenMutations(input, target, maxMutations, paramNames);
|
|
843
|
+
mutations[target] = proven.mutations;
|
|
844
|
+
// A params or headers field typed as bare text accepts every type swap, so only a constraint
|
|
845
|
+
// the schema states is a rejection to expect there.
|
|
846
|
+
const expectRejection = target === "body" || target === "query" || proven.constrained;
|
|
847
|
+
if (proven.mutations.length === 0 && expectRejection) {
|
|
848
|
+
gaps.push({
|
|
849
|
+
route: routeKey,
|
|
850
|
+
target,
|
|
851
|
+
code: "NO_REJECTED_MUTATION",
|
|
852
|
+
message: `the ${target} validator accepted every generated hostile mutation`,
|
|
853
|
+
});
|
|
854
|
+
}
|
|
629
855
|
}
|
|
630
|
-
laboratories.push({
|
|
631
|
-
route,
|
|
632
|
-
routeKey,
|
|
633
|
-
witness,
|
|
634
|
-
...(body === undefined ? {} : { body }),
|
|
635
|
-
...(query === undefined ? {} : { query }),
|
|
636
|
-
bodyMutations,
|
|
637
|
-
queryMutations,
|
|
638
|
-
});
|
|
856
|
+
laboratories.push({ route, routeKey, witness, inputs, mutations, resolved });
|
|
639
857
|
}
|
|
640
858
|
return { laboratories, targetCount };
|
|
641
859
|
}
|
|
@@ -675,13 +893,12 @@ export async function runAdversarialContract(app, options = {}) {
|
|
|
675
893
|
if (targetCount === 0) {
|
|
676
894
|
gaps.push({
|
|
677
895
|
code: "NO_CONTRACT_TARGETS",
|
|
678
|
-
message: "selected routes declare no body, query, or response contracts",
|
|
896
|
+
message: "selected routes declare no headers, params, body, query, or response contracts",
|
|
679
897
|
});
|
|
680
898
|
}
|
|
681
899
|
for (const laboratory of laboratories) {
|
|
682
|
-
for (const target of
|
|
683
|
-
const
|
|
684
|
-
for (const mutation of mutations) {
|
|
900
|
+
for (const target of INPUT_TARGETS) {
|
|
901
|
+
for (const mutation of laboratory.mutations[target]) {
|
|
685
902
|
const id = mutationId(laboratory.routeKey, target, mutation);
|
|
686
903
|
if (only !== undefined && !only.has(id))
|
|
687
904
|
continue;
|
|
@@ -751,9 +968,7 @@ export async function runAdversarialContract(app, options = {}) {
|
|
|
751
968
|
});
|
|
752
969
|
continue;
|
|
753
970
|
}
|
|
754
|
-
|
|
755
|
-
(laboratory.route.schema?.query === undefined || laboratory.query !== undefined);
|
|
756
|
-
if (!allInputsResolved)
|
|
971
|
+
if (!laboratory.resolved)
|
|
757
972
|
continue;
|
|
758
973
|
for (const runtime of runtimes) {
|
|
759
974
|
const context = {
|
|
@@ -773,7 +988,7 @@ export async function runAdversarialContract(app, options = {}) {
|
|
|
773
988
|
target: "response",
|
|
774
989
|
};
|
|
775
990
|
try {
|
|
776
|
-
const request = await preparedRequest(requestFor(laboratory, origin, laboratory
|
|
991
|
+
const request = await preparedRequest(requestFor(laboratory, origin, inputValues(laboratory)), context, options.prepareRequest);
|
|
777
992
|
const response = await runtime.fetch(request);
|
|
778
993
|
const status = response.status;
|
|
779
994
|
if (expected === undefined ? !response.ok : response.status !== expected.status) {
|
|
@@ -854,6 +1069,12 @@ export async function runAdversarialContract(app, options = {}) {
|
|
|
854
1069
|
"conformance checked 0 targets. Response types inferred from a handler's return are a client-side " +
|
|
855
1070
|
"contract only - declare `response:` or `errors:` on the routes you want the laboratory to verify.");
|
|
856
1071
|
}
|
|
1072
|
+
// Declared on an opaque mount's word: listed so the run states what it could not test.
|
|
1073
|
+
for (const mount of reflectMounts(app)) {
|
|
1074
|
+
for (const route of mount.routes ?? []) {
|
|
1075
|
+
advisories.push(`${route.method} ${route.path} is declared by the opaque mount ${mount.path} (${mount.opaque}) and has no contract to test`);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
857
1078
|
return {
|
|
858
1079
|
ok: failures.length === 0 && !fatalGap && (!coverageRequired || gaps.length === 0),
|
|
859
1080
|
seed,
|