@nifrajs/testing 4.0.2 → 5.0.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.
@@ -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
- async function provenMutations(input, target, maxMutations) {
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 === "query" ? queryInput(candidate.value) : jsonClone(candidate.value);
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, witness, seed, maxWitnessBytes) {
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 (own(witness, target)) {
327
- value = witness[target];
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
- value = generateMockValue(reflection.jsonSchema, target, seededRandom(seed ^ hash(`${routeKey}:${target}`)));
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 === "query" ? queryInput(value) : jsonClone(value);
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
- function requestFor(laboratory, origin, body, query) {
440
- const path = materializePath(laboratory.route.path, laboratory.witness.params);
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 body = target === "body" ? mutation.value : laboratory.body?.value;
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 = target === "body" ? laboratory.body?.reflection : laboratory.query?.reflection;
531
- const transported = target === "query" ? queryInput(value) : jsonClone(value);
532
- if (transported === undefined)
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, transported)))
691
+ if (!(await rejectedByValidator(reflection, carried)))
538
692
  continue;
539
693
  }
540
694
  catch {
541
695
  continue;
542
696
  }
543
- const candidate = { ...current, value: transported };
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
- let body;
573
- let query;
574
- if (route.schema?.body !== undefined) {
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 (NO_BODY_METHODS.has(route.method)) {
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
- else {
585
- const resolved = await resolveInput(routeKey, "body", withReflectedJsonSchema(route.schema.body, options.reflectJsonSchema ?? autoReflectJsonSchema), witness, seed, maxWitnessBytes);
586
- if (resolved.ok)
587
- body = resolved.input;
588
- else
589
- gaps.push(resolved.gap);
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
- if (route.schema?.query !== undefined) {
593
- targetCount += 1;
594
- const resolved = await resolveInput(routeKey, "query", withReflectedJsonSchema(route.schema.query, options.reflectJsonSchema ?? autoReflectJsonSchema), witness, seed, maxWitnessBytes);
595
- if (resolved.ok)
596
- query = resolved.input;
597
- else
598
- gaps.push(resolved.gap);
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
- ? route.schema?.errors?.[expectedStatus]
603
- : route.schema?.response;
828
+ ? schema?.errors?.[expectedStatus]
829
+ : schema?.response;
604
830
  if (options.validateResponses !== false && expectedResponseSchema !== undefined)
605
831
  targetCount += 1;
606
- const allInputsResolved = (route.schema?.body === undefined || body !== undefined) &&
607
- (route.schema?.query === undefined || query !== undefined);
608
- const bodyMutations = body !== undefined && allInputsResolved
609
- ? await provenMutations(body, "body", maxMutations)
610
- : [];
611
- const queryMutations = query !== undefined && allInputsResolved
612
- ? await provenMutations(query, "query", maxMutations)
613
- : [];
614
- if (body !== undefined && allInputsResolved && bodyMutations.length === 0) {
615
- gaps.push({
616
- route: routeKey,
617
- target: "body",
618
- code: "NO_REJECTED_MUTATION",
619
- message: "the body validator accepted every generated hostile mutation",
620
- });
621
- }
622
- if (query !== undefined && allInputsResolved && queryMutations.length === 0) {
623
- gaps.push({
624
- route: routeKey,
625
- target: "query",
626
- code: "NO_REJECTED_MUTATION",
627
- message: "the query validator accepted every generated hostile mutation",
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 ["body", "query"]) {
683
- const mutations = target === "body" ? laboratory.bodyMutations : laboratory.queryMutations;
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
- const allInputsResolved = (laboratory.route.schema?.body === undefined || laboratory.body !== undefined) &&
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.body?.value, laboratory.query?.value), context, options.prepareRequest);
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,