@nifrajs/testing 1.6.0 → 1.9.1
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/README.md +75 -4
- package/dist/adversarial.d.ts +129 -0
- package/dist/adversarial.d.ts.map +1 -0
- package/dist/adversarial.js +763 -0
- package/dist/adversarial.js.map +1 -0
- package/dist/incident.d.ts +97 -0
- package/dist/incident.d.ts.map +1 -0
- package/dist/incident.js +214 -0
- package/dist/incident.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +5 -6
- package/src/adversarial.ts +1130 -0
- package/src/incident.ts +300 -0
- package/src/index.ts +33 -0
|
@@ -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
|
+
}
|