@nifrajs/testing 1.7.0 → 1.10.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.
@@ -0,0 +1,1130 @@
1
+ /**
2
+ * Contract-derived adversarial tests.
3
+ *
4
+ * A contract witness is a request known to satisfy a route's input schemas. From that witness this
5
+ * module derives small hostile mutations, proves each mutation invalid with the route's own Standard
6
+ * Schema validator, and then drives it through one or more real fetch runtimes. Declared success
7
+ * responses are validated off the hot path as part of the same laboratory run.
8
+ */
9
+
10
+ import {
11
+ type JsonSchema,
12
+ type ReflectedRoute,
13
+ reflectRoutes,
14
+ type SchemaReflection,
15
+ } from "@nifrajs/core/reflection"
16
+ import { generateMockValue } from "@nifrajs/mock"
17
+
18
+ const DEFAULT_ORIGIN = "http://nifra.contract"
19
+ const DEFAULT_SEED = 0x4e_49_46_52
20
+ const DEFAULT_MAX_MUTATIONS = 32
21
+ const DEFAULT_MAX_SHRINK_ATTEMPTS = 12
22
+ const DEFAULT_MAX_WITNESS_BYTES = 256 * 1024
23
+ const NO_BODY_METHODS: ReadonlySet<string> = new Set(["GET", "HEAD"])
24
+ type HeaderSource = ConstructorParameters<typeof Headers>[0]
25
+
26
+ type InputTarget = "body" | "query"
27
+ export type ContractTarget = "body" | "query" | "response"
28
+ export type ContractCaseKind = "input-rejection" | "response-conformance"
29
+
30
+ /** Anything that exposes reflected routes and a Web-standard in-process fetch handler. */
31
+ export interface ContractTestApp {
32
+ routes(): readonly unknown[]
33
+ fetch(request: Request): Response | Promise<Response>
34
+ }
35
+
36
+ /** A runtime target for the same generated contract cases (for example Bun, Node, and Workers). */
37
+ export interface ContractRuntime {
38
+ readonly name: string
39
+ fetch(request: Request): Response | Promise<Response>
40
+ }
41
+
42
+ /** A known-good request. Missing body/query values are synthesized from inspectable JSON Schema. */
43
+ export interface ContractWitness {
44
+ readonly params?: Readonly<Record<string, string>>
45
+ readonly query?: unknown
46
+ readonly body?: unknown
47
+ readonly headers?: HeaderSource
48
+ }
49
+
50
+ /** Stable context passed to request/rejection hooks. It contains no request payloads or secrets. */
51
+ export interface ContractCaseContext {
52
+ readonly seed: number
53
+ readonly caseId: string
54
+ readonly route: ReflectedRoute
55
+ readonly runtime: string
56
+ readonly kind: ContractCaseKind
57
+ readonly target: ContractTarget
58
+ readonly mutation?: string
59
+ }
60
+
61
+ export interface AdversarialContractOptions {
62
+ /** Base origin for generated requests. Default `http://nifra.contract`. */
63
+ readonly origin?: string
64
+ /** Replayable seed used for deterministic witness generation. */
65
+ readonly seed?: number
66
+ /** Known-good request values keyed by `"METHOD /path"`. Required for opaque Standard Schemas. */
67
+ readonly witnesses?: Readonly<Record<string, ContractWitness>>
68
+ /** Select a subset of reflected routes. */
69
+ readonly include?: (route: ReflectedRoute) => boolean
70
+ /** Execute every case against these targets. Default: the supplied app, named `in-process`. */
71
+ readonly runtimes?: readonly ContractRuntime[]
72
+ /** Maximum validator-proven mutations per body/query schema. Default 32. */
73
+ readonly maxMutationsPerInput?: number
74
+ /** Maximum serialized witness size. Default 256 KiB. */
75
+ readonly maxWitnessBytes?: number
76
+ /** Statuses that prove an invalid input was rejected. Default `[422]`. */
77
+ readonly expectedValidationStatuses?: readonly number[]
78
+ /**
79
+ * Attach auth, tenant, or runtime-specific state. Return a new Request; the input Request is already
80
+ * fresh for this case and runtime.
81
+ */
82
+ readonly prepareRequest?: (
83
+ request: Request,
84
+ context: ContractCaseContext,
85
+ ) => Request | Promise<Request>
86
+ /** Override rejection semantics (for apps with a custom validation response). */
87
+ readonly isRejected?: (
88
+ response: Response,
89
+ context: ContractCaseContext,
90
+ ) => boolean | Promise<boolean>
91
+ /** Validate a real successful response for every declared response schema. Default true. */
92
+ readonly validateResponses?: boolean
93
+ /** Fail the report when a contracted target cannot be exercised. Default true. */
94
+ readonly requireCoverage?: boolean
95
+ /** Greedily minimize an invalid input that unexpectedly reaches the app. Default true. */
96
+ readonly shrinkFailures?: boolean
97
+ /** Cap shrink probes for one failure. Default 12. */
98
+ readonly maxShrinkAttempts?: number
99
+ /** Replay only these stable case IDs. Coverage gaps are advisory when this is set. */
100
+ readonly only?: string | readonly string[]
101
+ }
102
+
103
+ export type ContractCoverageGapCode =
104
+ | "NO_CONTRACT_TARGETS"
105
+ | "NO_VALIDATOR"
106
+ | "NO_WITNESS"
107
+ | "INVALID_WITNESS"
108
+ | "WITNESS_TOO_LARGE"
109
+ | "UNSUPPORTED_BODY_METHOD"
110
+ | "NO_REJECTED_MUTATION"
111
+ | "NO_RUNTIME"
112
+ | "INVALID_RUNTIME"
113
+ | "CASE_NOT_FOUND"
114
+
115
+ export interface ContractCoverageGap {
116
+ readonly route?: string
117
+ readonly target?: ContractTarget
118
+ readonly code: ContractCoverageGapCode
119
+ readonly message: string
120
+ }
121
+
122
+ export interface ContractReplay {
123
+ readonly seed: number
124
+ readonly caseId: string
125
+ readonly runtime: string
126
+ }
127
+
128
+ export interface AdversarialContractResult {
129
+ readonly id: string
130
+ readonly route: string
131
+ readonly runtime: string
132
+ readonly kind: ContractCaseKind
133
+ readonly target: ContractTarget
134
+ readonly mutation?: string
135
+ readonly ok: boolean
136
+ readonly status?: number
137
+ readonly message: string
138
+ /** Number of successful greedy reductions applied to a failing hostile input. */
139
+ readonly shrinkSteps?: number
140
+ readonly replay: ContractReplay
141
+ }
142
+
143
+ export interface AdversarialContractReport {
144
+ readonly ok: boolean
145
+ readonly seed: number
146
+ readonly routeCount: number
147
+ readonly targetCount: number
148
+ readonly runtimeCount: number
149
+ readonly results: readonly AdversarialContractResult[]
150
+ readonly failures: readonly AdversarialContractResult[]
151
+ readonly gaps: readonly ContractCoverageGap[]
152
+ readonly counts: {
153
+ readonly passed: number
154
+ readonly failed: number
155
+ readonly gaps: number
156
+ }
157
+ }
158
+
159
+ export class AdversarialContractError extends Error {
160
+ constructor(readonly report: AdversarialContractReport) {
161
+ const details = [
162
+ ...report.failures
163
+ .slice(0, 5)
164
+ .map(
165
+ (failure) =>
166
+ `${failure.runtime}: ${failure.id} — ${failure.message} (replay seed ${report.seed})`,
167
+ ),
168
+ ...report.gaps
169
+ .slice(0, 5)
170
+ .map(
171
+ (gap) =>
172
+ `${gap.route ?? "contract"}${gap.target ? ` ${gap.target}` : ""}: ${gap.message}`,
173
+ ),
174
+ ]
175
+ super(
176
+ `Adversarial contract failed: ${report.failures.length} failure(s), ${report.gaps.length} coverage gap(s)${details.length > 0 ? `\n${details.join("\n")}` : ""}`,
177
+ )
178
+ this.name = "AdversarialContractError"
179
+ }
180
+ }
181
+
182
+ interface Mutation {
183
+ readonly value: unknown
184
+ readonly path: readonly (string | number)[]
185
+ readonly reason: string
186
+ }
187
+
188
+ interface ResolvedInput {
189
+ readonly value: unknown
190
+ readonly reflection: SchemaReflection
191
+ }
192
+
193
+ type ResolveInput =
194
+ | { readonly ok: true; readonly input: ResolvedInput }
195
+ | { readonly ok: false; readonly gap: ContractCoverageGap }
196
+
197
+ interface RouteLaboratory {
198
+ readonly route: ReflectedRoute
199
+ readonly routeKey: string
200
+ readonly witness: ContractWitness
201
+ readonly body?: ResolvedInput
202
+ readonly query?: ResolvedInput
203
+ readonly bodyMutations: readonly Mutation[]
204
+ readonly queryMutations: readonly Mutation[]
205
+ }
206
+
207
+ const recordOf = (value: unknown): Readonly<Record<string, unknown>> | undefined =>
208
+ value !== null && typeof value === "object" && !Array.isArray(value)
209
+ ? (value as Readonly<Record<string, unknown>>)
210
+ : undefined
211
+
212
+ const own = (value: object, key: PropertyKey): boolean => Object.hasOwn(value, key)
213
+
214
+ const routeKeyOf = (route: ReflectedRoute): string => `${route.method.toUpperCase()} ${route.path}`
215
+
216
+ const normalizeSeed = (seed: number | undefined): number =>
217
+ Number.isFinite(seed) ? Math.trunc(seed as number) >>> 0 : DEFAULT_SEED
218
+
219
+ const positiveInteger = (value: number | undefined, fallback: number): number =>
220
+ Number.isFinite(value) && (value as number) > 0 ? Math.trunc(value as number) : fallback
221
+
222
+ const seededRandom = (seed: number): (() => number) => {
223
+ let state = seed >>> 0
224
+ return () => {
225
+ state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0
226
+ return state / 0x1_0000_0000
227
+ }
228
+ }
229
+
230
+ const hash = (value: string): number => {
231
+ let out = 2_166_136_261
232
+ for (let index = 0; index < value.length; index += 1) {
233
+ out ^= value.charCodeAt(index)
234
+ out = Math.imul(out, 16_777_619)
235
+ }
236
+ return out >>> 0
237
+ }
238
+
239
+ const jsonClone = (value: unknown): unknown => {
240
+ const serialized = JSON.stringify(value)
241
+ if (serialized === undefined) throw new TypeError("value is not JSON serializable")
242
+ return JSON.parse(serialized) as unknown
243
+ }
244
+
245
+ const valueSize = (value: unknown): number => new TextEncoder().encode(JSON.stringify(value)).length
246
+
247
+ const displayPath = (target: InputTarget, path: readonly (string | number)[]): string =>
248
+ path.reduce<string>(
249
+ (out, segment) => (typeof segment === "number" ? `${out}[${segment}]` : `${out}.${segment}`),
250
+ target,
251
+ )
252
+
253
+ const mutationId = (routeKey: string, target: InputTarget, mutation: Mutation): string =>
254
+ `${routeKey} :: ${displayPath(target, mutation.path)} :: ${mutation.reason}`
255
+
256
+ const responseId = (routeKey: string): string => `${routeKey} :: response-conformance`
257
+
258
+ const typeKind = (value: unknown): string => {
259
+ if (value === null) return "null"
260
+ if (Array.isArray(value)) return "array"
261
+ return typeof value === "object" ? "object" : typeof value
262
+ }
263
+
264
+ const replacementValues = (value: unknown): readonly unknown[] => {
265
+ const current = typeKind(value)
266
+ const values: readonly unknown[] = [null, "", 0, false, [], {}]
267
+ return values.filter((candidate) => typeKind(candidate) !== current)
268
+ }
269
+
270
+ const replaceAt = (
271
+ value: unknown,
272
+ path: readonly (string | number)[],
273
+ replacement: unknown,
274
+ remove = false,
275
+ ): unknown => {
276
+ if (path.length === 0) return replacement
277
+ const [head, ...tail] = path
278
+ if (Array.isArray(value)) {
279
+ const clone = value.slice()
280
+ if (typeof head !== "number") return value
281
+ if (tail.length === 0 && remove) clone.splice(head, 1)
282
+ else clone[head] = replaceAt(clone[head], tail, replacement, remove)
283
+ return clone
284
+ }
285
+ const record = recordOf(value)
286
+ if (record === undefined || typeof head !== "string") return value
287
+ const clone: Record<string, unknown> = Object.create(null) as Record<string, unknown>
288
+ for (const [key, item] of Object.entries(record)) clone[key] = item
289
+ if (tail.length === 0 && remove) delete clone[head]
290
+ else clone[head] = replaceAt(clone[head], tail, replacement, remove)
291
+ return clone
292
+ }
293
+
294
+ const schemaRecord = (
295
+ schema: JsonSchema | undefined,
296
+ ): Readonly<Record<string, unknown>> | undefined =>
297
+ schema === false || schema === true ? undefined : schema
298
+
299
+ function candidateMutations(
300
+ root: unknown,
301
+ schema: JsonSchema | undefined,
302
+ maxCandidates: number,
303
+ ): Mutation[] {
304
+ const candidates: Mutation[] = []
305
+ const add = (path: readonly (string | number)[], reason: string, value: unknown): void => {
306
+ if (candidates.length < maxCandidates * 6) candidates.push({ path, reason, value })
307
+ }
308
+
309
+ const visit = (
310
+ current: unknown,
311
+ currentSchema: JsonSchema | undefined,
312
+ path: readonly (string | number)[],
313
+ depth: number,
314
+ ): void => {
315
+ if (depth > 8 || candidates.length >= maxCandidates * 6) return
316
+ for (const replacement of replacementValues(current)) {
317
+ add(path, `wrong-type-${typeKind(replacement)}`, replaceAt(root, path, replacement))
318
+ }
319
+
320
+ const raw = schemaRecord(currentSchema)
321
+ if (raw !== undefined) {
322
+ if (own(raw, "const")) {
323
+ add(path, "const", replaceAt(root, path, "__nifra_not_const__"))
324
+ }
325
+ if (Array.isArray(raw.enum)) {
326
+ add(path, "enum", replaceAt(root, path, "__nifra_not_enum__"))
327
+ }
328
+ if (typeof current === "string") {
329
+ if (typeof raw.minLength === "number" && raw.minLength > 0 && raw.minLength <= 4096) {
330
+ add(
331
+ path,
332
+ "below-minLength",
333
+ replaceAt(root, path, "a".repeat(Math.max(0, raw.minLength - 1))),
334
+ )
335
+ }
336
+ if (typeof raw.maxLength === "number" && raw.maxLength >= 0 && raw.maxLength < 4096) {
337
+ add(path, "above-maxLength", replaceAt(root, path, "a".repeat(raw.maxLength + 1)))
338
+ }
339
+ if (typeof raw.pattern === "string") add(path, "pattern", replaceAt(root, path, ""))
340
+ if (typeof raw.format === "string") {
341
+ add(path, `format-${raw.format}`, replaceAt(root, path, "not-a-valid-format"))
342
+ }
343
+ }
344
+ if (typeof current === "number") {
345
+ if (typeof raw.minimum === "number") {
346
+ add(path, "below-minimum", replaceAt(root, path, raw.minimum - 1))
347
+ }
348
+ if (typeof raw.maximum === "number") {
349
+ add(path, "above-maximum", replaceAt(root, path, raw.maximum + 1))
350
+ }
351
+ if (typeof raw.exclusiveMinimum === "number") {
352
+ add(path, "exclusive-minimum", replaceAt(root, path, raw.exclusiveMinimum))
353
+ }
354
+ if (typeof raw.exclusiveMaximum === "number") {
355
+ add(path, "exclusive-maximum", replaceAt(root, path, raw.exclusiveMaximum))
356
+ }
357
+ if (raw.type === "integer") add(path, "non-integer", replaceAt(root, path, current + 0.5))
358
+ }
359
+ if (Array.isArray(current)) {
360
+ if (typeof raw.minItems === "number" && raw.minItems > 0) {
361
+ add(path, "below-minItems", replaceAt(root, path, current.slice(0, raw.minItems - 1)))
362
+ }
363
+ if (typeof raw.maxItems === "number" && raw.maxItems < 128) {
364
+ const expanded = current.slice()
365
+ const item = current[0] ?? null
366
+ while (expanded.length <= raw.maxItems) expanded.push(item)
367
+ add(path, "above-maxItems", replaceAt(root, path, expanded))
368
+ }
369
+ if (raw.uniqueItems === true && current.length > 0) {
370
+ add(path, "duplicate-item", replaceAt(root, path, [current[0], current[0]]))
371
+ }
372
+ }
373
+ }
374
+
375
+ const currentRecord = recordOf(current)
376
+ if (currentRecord !== undefined) {
377
+ const properties = recordOf(raw?.properties)
378
+ const required = Array.isArray(raw?.required)
379
+ ? raw.required.filter((key): key is string => typeof key === "string")
380
+ : []
381
+ for (const key of required) {
382
+ if (own(currentRecord, key)) {
383
+ add([...path, key], "required-property", replaceAt(root, [...path, key], undefined, true))
384
+ }
385
+ }
386
+ add(
387
+ [...path, "__nifra_unexpected"],
388
+ "additional-property",
389
+ replaceAt(root, [...path, "__nifra_unexpected"], true),
390
+ )
391
+ for (const [key, item] of Object.entries(currentRecord)) {
392
+ if (!required.includes(key)) {
393
+ add([...path, key], "missing-property", replaceAt(root, [...path, key], undefined, true))
394
+ }
395
+ const propertySchema = properties?.[key]
396
+ visit(
397
+ item,
398
+ typeof propertySchema === "boolean" || recordOf(propertySchema) !== undefined
399
+ ? (propertySchema as JsonSchema)
400
+ : undefined,
401
+ [...path, key],
402
+ depth + 1,
403
+ )
404
+ }
405
+ } else if (Array.isArray(current) && current.length > 0) {
406
+ add(path, "missing-array-item", replaceAt(root, path, current.slice(0, -1)))
407
+ const itemSchema = raw?.items
408
+ visit(
409
+ current[0],
410
+ typeof itemSchema === "boolean" || recordOf(itemSchema) !== undefined
411
+ ? (itemSchema as JsonSchema)
412
+ : undefined,
413
+ [...path, 0],
414
+ depth + 1,
415
+ )
416
+ }
417
+ }
418
+
419
+ visit(root, schema, [], 0)
420
+ return candidates
421
+ }
422
+
423
+ async function rejectedByValidator(reflection: SchemaReflection, value: unknown): Promise<boolean> {
424
+ const validator = reflection.standard
425
+ if (validator === undefined) return false
426
+ const result = await validator["~standard"].validate(value)
427
+ return result.issues !== undefined
428
+ }
429
+
430
+ async function provenMutations(
431
+ input: ResolvedInput,
432
+ target: InputTarget,
433
+ maxMutations: number,
434
+ ): Promise<Mutation[]> {
435
+ const candidates = candidateMutations(input.value, input.reflection.jsonSchema, maxMutations)
436
+ const mutations: Mutation[] = []
437
+ const seen = new Set<string>()
438
+ for (const candidate of candidates) {
439
+ let serialized: string
440
+ let value: unknown
441
+ try {
442
+ value = target === "query" ? queryInput(candidate.value) : jsonClone(candidate.value)
443
+ if (value === undefined) continue
444
+ serialized = JSON.stringify(value)
445
+ } catch {
446
+ continue
447
+ }
448
+ if (seen.has(serialized)) continue
449
+ seen.add(serialized)
450
+ try {
451
+ if (await rejectedByValidator(input.reflection, value))
452
+ mutations.push({ ...candidate, value })
453
+ } catch {
454
+ // A validator that throws for one hostile value violates Standard Schema, but do not let it
455
+ // abort discovery of other mutations. If none are usable the route gets a fail-closed gap.
456
+ }
457
+ if (mutations.length >= maxMutations) break
458
+ }
459
+ return mutations
460
+ }
461
+
462
+ const queryPairs = (value: unknown): readonly [string, string][] | undefined => {
463
+ const record = recordOf(value)
464
+ if (record === undefined) return undefined
465
+ const pairs: [string, string][] = []
466
+ for (const [key, item] of Object.entries(record)) {
467
+ if (item === undefined) continue
468
+ const values = Array.isArray(item) ? item : [item]
469
+ for (const entry of values) {
470
+ const encoded =
471
+ entry !== null && typeof entry === "object" ? JSON.stringify(entry) : String(entry)
472
+ pairs.push([key, encoded])
473
+ }
474
+ }
475
+ return pairs
476
+ }
477
+
478
+ const queryInput = (value: unknown): unknown | undefined => {
479
+ const pairs = queryPairs(value)
480
+ if (pairs === undefined) return undefined
481
+ const result: Record<string, string | string[]> = Object.create(null) as Record<
482
+ string,
483
+ string | string[]
484
+ >
485
+ for (const [key, value] of pairs) {
486
+ const previous = result[key]
487
+ if (previous === undefined) result[key] = value
488
+ else if (Array.isArray(previous)) previous.push(value)
489
+ else result[key] = [previous, value]
490
+ }
491
+ return result
492
+ }
493
+
494
+ async function resolveInput(
495
+ routeKey: string,
496
+ target: InputTarget,
497
+ reflection: SchemaReflection,
498
+ witness: ContractWitness,
499
+ seed: number,
500
+ maxWitnessBytes: number,
501
+ ): Promise<ResolveInput> {
502
+ if (reflection.standard === undefined) {
503
+ return {
504
+ ok: false,
505
+ gap: {
506
+ route: routeKey,
507
+ target,
508
+ code: "NO_VALIDATOR",
509
+ message: `${target} metadata has no Standard Schema validator`,
510
+ },
511
+ }
512
+ }
513
+
514
+ let value: unknown
515
+ if (own(witness, target)) {
516
+ value = witness[target]
517
+ } else if (reflection.jsonSchema !== undefined) {
518
+ try {
519
+ value = generateMockValue(
520
+ reflection.jsonSchema,
521
+ target,
522
+ seededRandom(seed ^ hash(`${routeKey}:${target}`)),
523
+ )
524
+ } catch (error) {
525
+ return {
526
+ ok: false,
527
+ gap: {
528
+ route: routeKey,
529
+ target,
530
+ code: "NO_WITNESS",
531
+ message: `could not synthesize a ${target} witness: ${error instanceof Error ? error.message : String(error)}`,
532
+ },
533
+ }
534
+ }
535
+ } else {
536
+ return {
537
+ ok: false,
538
+ gap: {
539
+ route: routeKey,
540
+ target,
541
+ code: "NO_WITNESS",
542
+ message: `opaque ${target} schema needs witnesses[${JSON.stringify(routeKey)}].${target}`,
543
+ },
544
+ }
545
+ }
546
+
547
+ try {
548
+ value = target === "query" ? queryInput(value) : jsonClone(value)
549
+ if (value === undefined) throw new TypeError(`${target} witness is not transport-serializable`)
550
+ if (valueSize(value) > maxWitnessBytes) {
551
+ return {
552
+ ok: false,
553
+ gap: {
554
+ route: routeKey,
555
+ target,
556
+ code: "WITNESS_TOO_LARGE",
557
+ message: `${target} witness exceeds maxWitnessBytes (${maxWitnessBytes})`,
558
+ },
559
+ }
560
+ }
561
+ if (await rejectedByValidator(reflection, value)) {
562
+ return {
563
+ ok: false,
564
+ gap: {
565
+ route: routeKey,
566
+ target,
567
+ code: "INVALID_WITNESS",
568
+ message: `${target} witness is rejected by its Standard Schema validator`,
569
+ },
570
+ }
571
+ }
572
+ return { ok: true, input: { value, reflection } }
573
+ } catch (error) {
574
+ return {
575
+ ok: false,
576
+ gap: {
577
+ route: routeKey,
578
+ target,
579
+ code: "INVALID_WITNESS",
580
+ message: `could not validate ${target} witness: ${error instanceof Error ? error.message : String(error)}`,
581
+ },
582
+ }
583
+ }
584
+ }
585
+
586
+ const materializePath = (
587
+ path: string,
588
+ params: Readonly<Record<string, string>> | undefined,
589
+ ): string =>
590
+ path
591
+ .split("/")
592
+ .map((segment) => {
593
+ if (segment.startsWith(":")) {
594
+ const name = segment.slice(1)
595
+ return encodeURIComponent(params?.[name] ?? `${name || "param"}-contract`)
596
+ }
597
+ if (segment.startsWith("*")) {
598
+ const name = segment.slice(1)
599
+ const value = params?.[name] ?? `${name || "path"}/contract`
600
+ return value.split("/").filter(Boolean).map(encodeURIComponent).join("/")
601
+ }
602
+ return segment
603
+ })
604
+ .join("/")
605
+
606
+ function requestFor(
607
+ laboratory: RouteLaboratory,
608
+ origin: string,
609
+ body: unknown,
610
+ query: unknown,
611
+ ): Request {
612
+ const path = materializePath(laboratory.route.path, laboratory.witness.params)
613
+ const url = new URL(path, origin)
614
+ for (const [key, value] of queryPairs(query) ?? []) url.searchParams.append(key, value)
615
+ const headers = new Headers(laboratory.witness.headers)
616
+ const init: RequestInit = { method: laboratory.route.method, headers }
617
+ if (
618
+ laboratory.route.schema?.body !== undefined &&
619
+ !NO_BODY_METHODS.has(laboratory.route.method)
620
+ ) {
621
+ if (!headers.has("content-type")) headers.set("content-type", "application/json")
622
+ init.body = JSON.stringify(body)
623
+ }
624
+ return new Request(url.toString(), init)
625
+ }
626
+
627
+ const onlySet = (only: string | readonly string[] | undefined): ReadonlySet<string> | undefined =>
628
+ only === undefined ? undefined : new Set(typeof only === "string" ? [only] : only)
629
+
630
+ const simplerValues = (value: unknown): readonly unknown[] => {
631
+ if (typeof value === "string")
632
+ return value.length === 0 ? [] : ["", value.slice(0, value.length / 2)]
633
+ if (typeof value === "number") return value === 0 ? [] : [0, Math.trunc(value / 2)]
634
+ if (Array.isArray(value)) return value.length === 0 ? [] : [[], value.slice(0, value.length / 2)]
635
+ const record = recordOf(value)
636
+ if (record !== undefined) {
637
+ const entries = Object.entries(record)
638
+ if (entries.length === 0) return []
639
+ return [
640
+ {},
641
+ ...entries.map(([removed]) => {
642
+ const clone: Record<string, unknown> = Object.create(null) as Record<string, unknown>
643
+ for (const [key, item] of entries) if (key !== removed) clone[key] = item
644
+ return clone
645
+ }),
646
+ ]
647
+ }
648
+ return value === null || value === false ? [] : [null, false]
649
+ }
650
+
651
+ async function preparedRequest(
652
+ request: Request,
653
+ context: ContractCaseContext,
654
+ prepare: AdversarialContractOptions["prepareRequest"],
655
+ ): Promise<Request> {
656
+ return prepare === undefined ? request : await prepare(request, context)
657
+ }
658
+
659
+ async function discardResponse(response: Response): Promise<void> {
660
+ if (response.body === null || response.bodyUsed) return
661
+ try {
662
+ await response.body.cancel()
663
+ } catch {
664
+ // A custom rejection predicate may have locked the stream. Disposal is best-effort only.
665
+ }
666
+ }
667
+
668
+ async function executeRejection(
669
+ runtime: ContractRuntime,
670
+ laboratory: RouteLaboratory,
671
+ target: InputTarget,
672
+ mutation: Mutation,
673
+ id: string,
674
+ options: AdversarialContractOptions,
675
+ seed: number,
676
+ origin: string,
677
+ statuses: ReadonlySet<number>,
678
+ ): Promise<{ readonly rejected: boolean; readonly status?: number; readonly error?: string }> {
679
+ const context: ContractCaseContext = {
680
+ seed,
681
+ caseId: id,
682
+ route: laboratory.route,
683
+ runtime: runtime.name,
684
+ kind: "input-rejection",
685
+ target,
686
+ mutation: `${displayPath(target, mutation.path)} ${mutation.reason}`,
687
+ }
688
+ try {
689
+ const body = target === "body" ? mutation.value : laboratory.body?.value
690
+ const query = target === "query" ? mutation.value : laboratory.query?.value
691
+ const request = await preparedRequest(
692
+ requestFor(laboratory, origin, body, query),
693
+ context,
694
+ options.prepareRequest,
695
+ )
696
+ const response = await runtime.fetch(request)
697
+ const rejected =
698
+ options.isRejected === undefined
699
+ ? statuses.has(response.status)
700
+ : await options.isRejected(response, context)
701
+ await discardResponse(response)
702
+ return { rejected, status: response.status }
703
+ } catch (error) {
704
+ return { rejected: false, error: error instanceof Error ? error.message : String(error) }
705
+ }
706
+ }
707
+
708
+ async function shrinkFailure(
709
+ runtime: ContractRuntime,
710
+ laboratory: RouteLaboratory,
711
+ target: InputTarget,
712
+ mutation: Mutation,
713
+ id: string,
714
+ options: AdversarialContractOptions,
715
+ seed: number,
716
+ origin: string,
717
+ statuses: ReadonlySet<number>,
718
+ maxAttempts: number,
719
+ ): Promise<{ readonly mutation: Mutation; readonly steps: number; readonly status?: number }> {
720
+ let current = mutation
721
+ let steps = 0
722
+ let attempts = 0
723
+ let status: number | undefined
724
+ while (attempts < maxAttempts) {
725
+ let improved = false
726
+ for (const value of simplerValues(current.value)) {
727
+ attempts += 1
728
+ if (attempts > maxAttempts) break
729
+ const reflection =
730
+ target === "body" ? laboratory.body?.reflection : laboratory.query?.reflection
731
+ const transported = target === "query" ? queryInput(value) : jsonClone(value)
732
+ if (transported === undefined) continue
733
+ if (reflection === undefined) continue
734
+ try {
735
+ if (!(await rejectedByValidator(reflection, transported))) continue
736
+ } catch {
737
+ continue
738
+ }
739
+ const candidate = { ...current, value: transported }
740
+ const result = await executeRejection(
741
+ runtime,
742
+ laboratory,
743
+ target,
744
+ candidate,
745
+ id,
746
+ options,
747
+ seed,
748
+ origin,
749
+ statuses,
750
+ )
751
+ status = result.status
752
+ if (!result.rejected && result.error === undefined) {
753
+ current = candidate
754
+ steps += 1
755
+ improved = true
756
+ break
757
+ }
758
+ }
759
+ if (!improved) break
760
+ }
761
+ return { mutation: current, steps, ...(status === undefined ? {} : { status }) }
762
+ }
763
+
764
+ const resultForFailure = (
765
+ base: Omit<AdversarialContractResult, "ok" | "message" | "replay">,
766
+ seed: number,
767
+ message: string,
768
+ ): AdversarialContractResult => ({
769
+ ...base,
770
+ ok: false,
771
+ message,
772
+ replay: { seed, caseId: base.id, runtime: base.runtime },
773
+ })
774
+
775
+ async function buildLaboratories(
776
+ routes: readonly ReflectedRoute[],
777
+ options: AdversarialContractOptions,
778
+ seed: number,
779
+ gaps: ContractCoverageGap[],
780
+ ): Promise<{ readonly laboratories: RouteLaboratory[]; readonly targetCount: number }> {
781
+ const laboratories: RouteLaboratory[] = []
782
+ const maxMutations = positiveInteger(options.maxMutationsPerInput, DEFAULT_MAX_MUTATIONS)
783
+ const maxWitnessBytes = positiveInteger(options.maxWitnessBytes, DEFAULT_MAX_WITNESS_BYTES)
784
+ let targetCount = 0
785
+
786
+ for (const route of routes) {
787
+ const routeKey = routeKeyOf(route)
788
+ const witness = options.witnesses?.[routeKey] ?? {}
789
+ let body: ResolvedInput | undefined
790
+ let query: ResolvedInput | undefined
791
+
792
+ if (route.schema?.body !== undefined) {
793
+ targetCount += 1
794
+ if (NO_BODY_METHODS.has(route.method)) {
795
+ gaps.push({
796
+ route: routeKey,
797
+ target: "body",
798
+ code: "UNSUPPORTED_BODY_METHOD",
799
+ message: `${route.method} cannot carry a request body`,
800
+ })
801
+ } else {
802
+ const resolved = await resolveInput(
803
+ routeKey,
804
+ "body",
805
+ route.schema.body,
806
+ witness,
807
+ seed,
808
+ maxWitnessBytes,
809
+ )
810
+ if (resolved.ok) body = resolved.input
811
+ else gaps.push(resolved.gap)
812
+ }
813
+ }
814
+ if (route.schema?.query !== undefined) {
815
+ targetCount += 1
816
+ const resolved = await resolveInput(
817
+ routeKey,
818
+ "query",
819
+ route.schema.query,
820
+ witness,
821
+ seed,
822
+ maxWitnessBytes,
823
+ )
824
+ if (resolved.ok) query = resolved.input
825
+ else gaps.push(resolved.gap)
826
+ }
827
+ if (options.validateResponses !== false && route.schema?.response !== undefined)
828
+ targetCount += 1
829
+
830
+ const allInputsResolved =
831
+ (route.schema?.body === undefined || body !== undefined) &&
832
+ (route.schema?.query === undefined || query !== undefined)
833
+ const bodyMutations =
834
+ body !== undefined && allInputsResolved
835
+ ? await provenMutations(body, "body", maxMutations)
836
+ : []
837
+ const queryMutations =
838
+ query !== undefined && allInputsResolved
839
+ ? await provenMutations(query, "query", maxMutations)
840
+ : []
841
+ if (body !== undefined && allInputsResolved && bodyMutations.length === 0) {
842
+ gaps.push({
843
+ route: routeKey,
844
+ target: "body",
845
+ code: "NO_REJECTED_MUTATION",
846
+ message: "the body validator accepted every generated hostile mutation",
847
+ })
848
+ }
849
+ if (query !== undefined && allInputsResolved && queryMutations.length === 0) {
850
+ gaps.push({
851
+ route: routeKey,
852
+ target: "query",
853
+ code: "NO_REJECTED_MUTATION",
854
+ message: "the query validator accepted every generated hostile mutation",
855
+ })
856
+ }
857
+ laboratories.push({
858
+ route,
859
+ routeKey,
860
+ witness,
861
+ ...(body === undefined ? {} : { body }),
862
+ ...(query === undefined ? {} : { query }),
863
+ bodyMutations,
864
+ queryMutations,
865
+ })
866
+ }
867
+ return { laboratories, targetCount }
868
+ }
869
+
870
+ /**
871
+ * Execute contract-derived hostile inputs and declared-response conformance against a runtime matrix.
872
+ * Runtime/request failures are captured in the report; inspect `report.ok`, `failures`, and `gaps`
873
+ * (or use {@link assertAdversarialContract} for a throwing test assertion).
874
+ */
875
+ export async function runAdversarialContract(
876
+ app: ContractTestApp,
877
+ options: AdversarialContractOptions = {},
878
+ ): Promise<AdversarialContractReport> {
879
+ const seed = normalizeSeed(options.seed)
880
+ const origin = options.origin ?? DEFAULT_ORIGIN
881
+ const routes = reflectRoutes(app).filter((route) => options.include?.(route) ?? true)
882
+ const runtimes = options.runtimes ?? [
883
+ { name: "in-process", fetch: (request) => app.fetch(request) },
884
+ ]
885
+ const statuses = new Set(options.expectedValidationStatuses ?? [422])
886
+ const gaps: ContractCoverageGap[] = []
887
+ const results: AdversarialContractResult[] = []
888
+ const only = onlySet(options.only)
889
+ const { laboratories, targetCount } = await buildLaboratories(routes, options, seed, gaps)
890
+
891
+ if (runtimes.length === 0) {
892
+ gaps.push({ code: "NO_RUNTIME", message: "at least one contract runtime is required" })
893
+ }
894
+ const runtimeNames = new Set<string>()
895
+ for (const runtime of runtimes) {
896
+ const name = runtime.name.trim()
897
+ if (!name || runtimeNames.has(name)) {
898
+ gaps.push({
899
+ code: "INVALID_RUNTIME",
900
+ message: !name
901
+ ? "contract runtime names must be non-empty"
902
+ : `duplicate contract runtime name ${JSON.stringify(name)}`,
903
+ })
904
+ }
905
+ runtimeNames.add(name)
906
+ }
907
+ if (targetCount === 0) {
908
+ gaps.push({
909
+ code: "NO_CONTRACT_TARGETS",
910
+ message: "selected routes declare no body, query, or response contracts",
911
+ })
912
+ }
913
+
914
+ for (const laboratory of laboratories) {
915
+ for (const target of ["body", "query"] as const) {
916
+ const mutations = target === "body" ? laboratory.bodyMutations : laboratory.queryMutations
917
+ for (const mutation of mutations) {
918
+ const id = mutationId(laboratory.routeKey, target, mutation)
919
+ if (only !== undefined && !only.has(id)) continue
920
+ for (const runtime of runtimes) {
921
+ const base = {
922
+ id,
923
+ route: laboratory.routeKey,
924
+ runtime: runtime.name,
925
+ kind: "input-rejection" as const,
926
+ target,
927
+ mutation: `${displayPath(target, mutation.path)} ${mutation.reason}`,
928
+ }
929
+ const outcome = await executeRejection(
930
+ runtime,
931
+ laboratory,
932
+ target,
933
+ mutation,
934
+ id,
935
+ options,
936
+ seed,
937
+ origin,
938
+ statuses,
939
+ )
940
+ if (outcome.rejected) {
941
+ results.push({
942
+ ...base,
943
+ ok: true,
944
+ ...(outcome.status === undefined ? {} : { status: outcome.status }),
945
+ message: "hostile input rejected at the request boundary",
946
+ replay: { seed, caseId: id, runtime: runtime.name },
947
+ })
948
+ continue
949
+ }
950
+
951
+ let shrinkSteps = 0
952
+ let status = outcome.status
953
+ if (options.shrinkFailures !== false && outcome.error === undefined) {
954
+ const shrunk = await shrinkFailure(
955
+ runtime,
956
+ laboratory,
957
+ target,
958
+ mutation,
959
+ id,
960
+ options,
961
+ seed,
962
+ origin,
963
+ statuses,
964
+ positiveInteger(options.maxShrinkAttempts, DEFAULT_MAX_SHRINK_ATTEMPTS),
965
+ )
966
+ shrinkSteps = shrunk.steps
967
+ status = shrunk.status ?? status
968
+ }
969
+ results.push(
970
+ resultForFailure(
971
+ {
972
+ ...base,
973
+ ...(status === undefined ? {} : { status }),
974
+ ...(shrinkSteps === 0 ? {} : { shrinkSteps }),
975
+ },
976
+ seed,
977
+ outcome.error === undefined
978
+ ? `expected validation status ${[...statuses].join("/")}, received ${status ?? "no response"}`
979
+ : `runtime threw: ${outcome.error}`,
980
+ ),
981
+ )
982
+ }
983
+ }
984
+ }
985
+
986
+ const responseSchema = laboratory.route.schema?.response
987
+ if (options.validateResponses === false || responseSchema === undefined) continue
988
+ const id = responseId(laboratory.routeKey)
989
+ if (only !== undefined && !only.has(id)) continue
990
+ if (responseSchema.standard === undefined) {
991
+ gaps.push({
992
+ route: laboratory.routeKey,
993
+ target: "response",
994
+ code: "NO_VALIDATOR",
995
+ message: "response metadata has no Standard Schema validator",
996
+ })
997
+ continue
998
+ }
999
+ const allInputsResolved =
1000
+ (laboratory.route.schema?.body === undefined || laboratory.body !== undefined) &&
1001
+ (laboratory.route.schema?.query === undefined || laboratory.query !== undefined)
1002
+ if (!allInputsResolved) continue
1003
+
1004
+ for (const runtime of runtimes) {
1005
+ const context: ContractCaseContext = {
1006
+ seed,
1007
+ caseId: id,
1008
+ route: laboratory.route,
1009
+ runtime: runtime.name,
1010
+ kind: "response-conformance",
1011
+ target: "response",
1012
+ }
1013
+ const base = {
1014
+ id,
1015
+ route: laboratory.routeKey,
1016
+ runtime: runtime.name,
1017
+ kind: "response-conformance" as const,
1018
+ target: "response" as const,
1019
+ }
1020
+ try {
1021
+ const request = await preparedRequest(
1022
+ requestFor(laboratory, origin, laboratory.body?.value, laboratory.query?.value),
1023
+ context,
1024
+ options.prepareRequest,
1025
+ )
1026
+ const response = await runtime.fetch(request)
1027
+ const status = response.status
1028
+ if (!response.ok) {
1029
+ await discardResponse(response)
1030
+ results.push(
1031
+ resultForFailure(
1032
+ { ...base, status },
1033
+ seed,
1034
+ `valid witness returned non-success status ${status}`,
1035
+ ),
1036
+ )
1037
+ continue
1038
+ }
1039
+ const text = await response.text()
1040
+ let value: unknown
1041
+ try {
1042
+ value = text.length === 0 ? undefined : (JSON.parse(text) as unknown)
1043
+ } catch {
1044
+ results.push(
1045
+ resultForFailure(
1046
+ { ...base, status },
1047
+ seed,
1048
+ "declared response contract received a non-JSON response body",
1049
+ ),
1050
+ )
1051
+ continue
1052
+ }
1053
+ const validation = await responseSchema.standard["~standard"].validate(value)
1054
+ if (validation.issues !== undefined) {
1055
+ const first = validation.issues[0]
1056
+ const at = first?.path?.map((segment) =>
1057
+ typeof segment === "object" && segment !== null && "key" in segment
1058
+ ? String(segment.key)
1059
+ : String(segment),
1060
+ )
1061
+ results.push(
1062
+ resultForFailure(
1063
+ { ...base, status },
1064
+ seed,
1065
+ `response violates its contract${at && at.length > 0 ? ` at ${at.join(".")}` : ""}${first?.message ? `: ${first.message}` : ""}`,
1066
+ ),
1067
+ )
1068
+ continue
1069
+ }
1070
+ results.push({
1071
+ ...base,
1072
+ status,
1073
+ ok: true,
1074
+ message: "successful response satisfies its declared contract",
1075
+ replay: { seed, caseId: id, runtime: runtime.name },
1076
+ })
1077
+ } catch (error) {
1078
+ results.push(
1079
+ resultForFailure(
1080
+ base,
1081
+ seed,
1082
+ `runtime threw: ${error instanceof Error ? error.message : String(error)}`,
1083
+ ),
1084
+ )
1085
+ }
1086
+ }
1087
+ }
1088
+
1089
+ const failures = results.filter((result) => !result.ok)
1090
+ if (only !== undefined) {
1091
+ for (const id of only) {
1092
+ if (!results.some((result) => result.id === id)) {
1093
+ gaps.push({
1094
+ code: "CASE_NOT_FOUND",
1095
+ message: `replay case ${JSON.stringify(id)} was not generated for the selected contract`,
1096
+ })
1097
+ }
1098
+ }
1099
+ }
1100
+ const coverageRequired = options.requireCoverage ?? options.only === undefined
1101
+ const fatalGap = gaps.some(
1102
+ (gap) =>
1103
+ gap.code === "NO_RUNTIME" || gap.code === "INVALID_RUNTIME" || gap.code === "CASE_NOT_FOUND",
1104
+ )
1105
+ return {
1106
+ ok: failures.length === 0 && !fatalGap && (!coverageRequired || gaps.length === 0),
1107
+ seed,
1108
+ routeCount: routes.length,
1109
+ targetCount,
1110
+ runtimeCount: runtimes.length,
1111
+ results,
1112
+ failures,
1113
+ gaps,
1114
+ counts: {
1115
+ passed: results.length - failures.length,
1116
+ failed: failures.length,
1117
+ gaps: gaps.length,
1118
+ },
1119
+ }
1120
+ }
1121
+
1122
+ /** Run the contract laboratory and throw an {@link AdversarialContractError} unless it is fully green. */
1123
+ export async function assertAdversarialContract(
1124
+ app: ContractTestApp,
1125
+ options: AdversarialContractOptions = {},
1126
+ ): Promise<AdversarialContractReport> {
1127
+ const report = await runAdversarialContract(app, options)
1128
+ if (!report.ok) throw new AdversarialContractError(report)
1129
+ return report
1130
+ }