@kirrosh/zond 0.22.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +811 -0
- package/README.md +59 -6
- package/package.json +9 -7
- package/src/CLAUDE.md +112 -0
- package/src/cli/argv.ts +122 -0
- package/src/cli/commands/add-api.ts +146 -0
- package/src/cli/commands/api/annotate/idempotency.ts +59 -0
- package/src/cli/commands/api/annotate/index.ts +880 -0
- package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
- package/src/cli/commands/api/annotate/overlay.ts +206 -0
- package/src/cli/commands/api/annotate/pagination.ts +64 -0
- package/src/cli/commands/api/annotate/prompts.ts +220 -0
- package/src/cli/commands/api/annotate/readback.ts +58 -0
- package/src/cli/commands/api/annotate/resources.ts +91 -0
- package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
- package/src/cli/commands/audit.ts +786 -0
- package/src/cli/commands/catalog.ts +35 -0
- package/src/cli/commands/check.ts +361 -0
- package/src/cli/commands/checks.ts +1072 -0
- package/src/cli/commands/ci-init.ts +43 -0
- package/src/cli/commands/clean.ts +212 -0
- package/src/cli/commands/cleanup.ts +236 -0
- package/src/cli/commands/completions.ts +16 -0
- package/src/cli/commands/coverage.ts +823 -132
- package/src/cli/commands/db.ts +486 -12
- package/src/cli/commands/describe.ts +37 -2
- package/src/cli/commands/discover.ts +1356 -0
- package/src/cli/commands/doctor.ts +661 -0
- package/src/cli/commands/fixtures.ts +402 -0
- package/src/cli/commands/generate.ts +438 -47
- package/src/cli/commands/init/bootstrap.ts +34 -2
- package/src/cli/commands/{init.ts → init/index.ts} +99 -5
- package/src/cli/commands/init/skills.ts +99 -3
- package/src/cli/commands/init/templates/agents.md +77 -64
- package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
- package/src/cli/commands/init/templates/skills/zond-checks.md +621 -0
- package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
- package/src/cli/commands/init/templates/skills/zond-triage.md +272 -0
- package/src/cli/commands/init/templates/skills/zond.md +802 -125
- package/src/cli/commands/init/templates/zond-config.yml +8 -9
- package/src/cli/commands/prepare-fixtures.ts +97 -0
- package/src/cli/commands/probe/_seed-bodies.ts +52 -0
- package/src/cli/commands/probe/mass-assignment.ts +594 -0
- package/src/cli/commands/probe/security.ts +537 -0
- package/src/cli/commands/probe/static.ts +255 -0
- package/src/cli/commands/probe/webhooks.ts +163 -0
- package/src/cli/commands/probe.ts +535 -0
- package/src/cli/commands/reference.ts +87 -0
- package/src/cli/commands/refresh-api.ts +227 -0
- package/src/cli/commands/remove-api.ts +150 -0
- package/src/cli/commands/report-bundle.ts +310 -0
- package/src/cli/commands/report.ts +241 -0
- package/src/cli/commands/request.ts +495 -4
- package/src/cli/commands/run.ts +870 -53
- package/src/cli/commands/schema-from-runs.ts +128 -0
- package/src/cli/commands/secrets.ts +133 -0
- package/src/cli/commands/session.ts +244 -0
- package/src/cli/commands/use.ts +18 -1
- package/src/cli/index.ts +20 -3
- package/src/cli/json-envelope.ts +92 -3
- package/src/cli/json-schemas.ts +314 -0
- package/src/cli/output.ts +17 -1
- package/src/cli/program.ts +199 -635
- package/src/cli/resolve.ts +105 -0
- package/src/cli/safe-live.ts +24 -0
- package/src/cli/status-filter.ts +114 -0
- package/src/cli/util/api-context.ts +85 -0
- package/src/cli/version.ts +5 -0
- package/src/core/audit/persist.ts +183 -0
- package/src/core/checks/budget.ts +59 -0
- package/src/core/checks/checks/_crud-helpers.ts +133 -0
- package/src/core/checks/checks/_negative_mutator.ts +133 -0
- package/src/core/checks/checks/_readback-helpers.ts +133 -0
- package/src/core/checks/checks/content_type_conformance.ts +39 -0
- package/src/core/checks/checks/cross_call_references.ts +147 -0
- package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
- package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
- package/src/core/checks/checks/idempotency_replay.ts +242 -0
- package/src/core/checks/checks/ignored_auth.ts +254 -0
- package/src/core/checks/checks/index.ts +68 -0
- package/src/core/checks/checks/lifecycle_transitions.ts +416 -0
- package/src/core/checks/checks/missing_required_header.ts +40 -0
- package/src/core/checks/checks/negative_data_rejection.ts +148 -0
- package/src/core/checks/checks/not_a_server_error.ts +35 -0
- package/src/core/checks/checks/open_cors_on_sensitive.ts +160 -0
- package/src/core/checks/checks/pagination_invariants.ts +419 -0
- package/src/core/checks/checks/positive_data_acceptance.ts +33 -0
- package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
- package/src/core/checks/checks/response_headers_conformance.ts +74 -0
- package/src/core/checks/checks/response_schema_conformance.ts +30 -0
- package/src/core/checks/checks/status_code_conformance.ts +132 -0
- package/src/core/checks/checks/unsupported_method.ts +63 -0
- package/src/core/checks/checks/use_after_free.ts +78 -0
- package/src/core/checks/index.ts +30 -0
- package/src/core/checks/mode.ts +82 -0
- package/src/core/checks/recommended-action.ts +68 -0
- package/src/core/checks/registry.ts +78 -0
- package/src/core/checks/runner.ts +1461 -0
- package/src/core/checks/sarif.ts +230 -0
- package/src/core/checks/spec-findings.ts +308 -0
- package/src/core/checks/stateful.ts +121 -0
- package/src/core/checks/types.ts +305 -0
- package/src/core/checks/zond-extensions.ts +73 -0
- package/src/core/classifier/recommended-action.ts +251 -0
- package/src/core/context/current.ts +22 -6
- package/src/core/context/session.ts +78 -0
- package/src/core/coverage/loader.ts +216 -0
- package/src/core/coverage/reasons.ts +300 -0
- package/src/core/diagnostics/db-analysis.ts +293 -59
- package/src/core/diagnostics/failure-class.ts +140 -0
- package/src/core/diagnostics/failure-hints.ts +88 -89
- package/src/core/diagnostics/spec-pointer.ts +99 -0
- package/src/core/diagnostics/suggested-fixes.ts +155 -0
- package/src/core/exporter/case-study/index.ts +270 -0
- package/src/core/exporter/curl.ts +40 -0
- package/src/core/exporter/exporter.ts +48 -0
- package/src/core/exporter/html-report/escape.ts +24 -0
- package/src/core/exporter/html-report/index.ts +479 -0
- package/src/core/exporter/html-report/script.ts +100 -0
- package/src/core/exporter/html-report/styles.ts +408 -0
- package/src/core/generator/chunker.ts +38 -19
- package/src/core/generator/coverage-phase.ts +0 -0
- package/src/core/generator/data-factory.ts +586 -22
- package/src/core/generator/describe.ts +1 -1
- package/src/core/generator/fixtures-builder.ts +332 -0
- package/src/core/generator/index.ts +5 -5
- package/src/core/generator/openapi-reader.ts +135 -7
- package/src/core/generator/path-param-disambig.ts +140 -0
- package/src/core/generator/resources-builder.ts +898 -0
- package/src/core/generator/schema-utils.ts +33 -3
- package/src/core/generator/serializer.ts +103 -13
- package/src/core/generator/suite-generator.ts +583 -122
- package/src/core/generator/types.ts +14 -0
- package/src/core/identity/identity-file.ts +0 -0
- package/src/core/lint/affects.ts +28 -0
- package/src/core/lint/config.ts +96 -0
- package/src/core/lint/format.ts +42 -0
- package/src/core/lint/index.ts +94 -0
- package/src/core/lint/reporter.ts +128 -0
- package/src/core/lint/rules/consistency.ts +158 -0
- package/src/core/lint/rules/heuristics.ts +97 -0
- package/src/core/lint/rules/strictness.ts +109 -0
- package/src/core/lint/types.ts +96 -0
- package/src/core/lint/walker.ts +248 -0
- package/src/core/meta/meta-store.ts +6 -73
- package/src/core/output/README.md +73 -0
- package/src/core/output/index.ts +13 -0
- package/src/core/output/run.ts +91 -0
- package/src/core/output/types.ts +122 -0
- package/src/core/parser/dynamic-values.ts +160 -0
- package/src/core/parser/env-interpolation.ts +104 -0
- package/src/core/parser/filter.ts +57 -0
- package/src/core/parser/schema.ts +129 -4
- package/src/core/parser/types.ts +19 -1
- package/src/core/parser/variables.ts +0 -0
- package/src/core/parser/yaml-parser.ts +58 -12
- package/src/core/probe/bootstrap.ts +34 -0
- package/src/core/probe/dry-run-envelope.ts +61 -0
- package/src/core/probe/mass-assignment/classify.ts +175 -0
- package/src/core/probe/mass-assignment/cleanup.ts +52 -0
- package/src/core/probe/mass-assignment/digest.ts +114 -0
- package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
- package/src/core/probe/mass-assignment/regression.ts +141 -0
- package/src/core/probe/mass-assignment/suspects.ts +92 -0
- package/src/core/probe/mass-assignment/types.ts +135 -0
- package/src/core/probe/mass-assignment-probe-class.ts +198 -0
- package/src/core/probe/mass-assignment-probe.ts +27 -0
- package/src/core/probe/mass-assignment-template.ts +240 -0
- package/src/core/probe/method-probe.ts +43 -76
- package/src/core/probe/method-shared.ts +69 -0
- package/src/core/probe/negative-probe.ts +183 -149
- package/src/core/probe/orphan-tracker.ts +188 -0
- package/src/core/probe/path-discovery.ts +439 -0
- package/src/core/probe/probe-harness.ts +119 -0
- package/src/core/probe/registry.ts +89 -0
- package/src/core/probe/runner.ts +136 -0
- package/src/core/probe/security/baseline.ts +174 -0
- package/src/core/probe/security/classify.ts +341 -0
- package/src/core/probe/security/cleanup.ts +125 -0
- package/src/core/probe/security/detectors.ts +71 -0
- package/src/core/probe/security/digest.ts +104 -0
- package/src/core/probe/security/orchestrator.ts +398 -0
- package/src/core/probe/security/regression.ts +103 -0
- package/src/core/probe/security/types.ts +151 -0
- package/src/core/probe/security-probe-class.ts +207 -0
- package/src/core/probe/security-probe.ts +32 -0
- package/src/core/probe/shared.ts +531 -0
- package/src/core/probe/static-probe-class.ts +125 -0
- package/src/core/probe/types.ts +165 -0
- package/src/core/probe/verdict-aggregator.ts +33 -0
- package/src/core/probe/webhooks-probe.ts +282 -0
- package/src/core/reporter/console.ts +41 -2
- package/src/core/reporter/index.ts +2 -3
- package/src/core/reporter/json.ts +11 -1
- package/src/core/reporter/junit.ts +27 -12
- package/src/core/reporter/ndjson.ts +37 -0
- package/src/core/reporter/types.ts +3 -0
- package/src/core/runner/assertions.ts +59 -2
- package/src/core/runner/async-pool.ts +108 -0
- package/src/core/runner/auth-path.ts +8 -0
- package/src/core/runner/ci-context.ts +72 -0
- package/src/core/runner/executor.ts +265 -36
- package/src/core/runner/form-encode.ts +41 -0
- package/src/core/runner/http-client.ts +112 -2
- package/src/core/runner/learn-drift.ts +293 -0
- package/src/core/runner/preflight-vars.ts +153 -0
- package/src/core/runner/progress-tracker.ts +73 -0
- package/src/core/runner/rate-limiter.ts +87 -33
- package/src/core/runner/run-kind.ts +45 -0
- package/src/core/runner/schema-validator.ts +308 -0
- package/src/core/runner/send-request.ts +158 -20
- package/src/core/runner/types.ts +44 -0
- package/src/core/secrets/registry.ts +164 -0
- package/src/core/secrets/secrets-file.ts +115 -0
- package/src/core/selectors/operation-filter.ts +144 -0
- package/src/core/setup-api.ts +457 -20
- package/src/core/severity/category.ts +94 -0
- package/src/core/severity/index.ts +58 -0
- package/src/core/spec/infer-schema.ts +102 -0
- package/src/core/spec/layers.ts +154 -0
- package/src/core/spec/merge-specs.ts +156 -0
- package/src/core/spec/schema-from-runs.ts +117 -0
- package/src/core/spec/schema-overlay.ts +130 -0
- package/src/core/util/ajv.ts +13 -0
- package/src/core/util/format-eta.ts +21 -0
- package/src/core/util/headers.ts +9 -0
- package/src/core/util/url.ts +24 -0
- package/src/core/utils.ts +5 -1
- package/src/core/workspace/config.ts +129 -0
- package/src/core/workspace/fixture-gap-report.ts +84 -0
- package/src/core/workspace/fixture-gaps.ts +71 -0
- package/src/core/workspace/manifest.ts +283 -0
- package/src/core/workspace/output-rotation.ts +62 -0
- package/src/core/workspace/root.ts +13 -11
- package/src/core/workspace/triage-path.ts +87 -0
- package/src/db/lint-runs.ts +47 -0
- package/src/db/migrate.ts +128 -0
- package/src/db/migrations/0001_run_kind.sql +25 -0
- package/src/db/migrations/0002_run_kind_request.sql +59 -0
- package/src/db/migrations/sql.d.ts +4 -0
- package/src/db/queries/collections.ts +133 -0
- package/src/db/queries/coverage.ts +9 -0
- package/src/db/queries/dashboard.ts +59 -0
- package/src/db/queries/results.ts +216 -0
- package/src/db/queries/runs.ts +289 -0
- package/src/db/queries/sessions.ts +42 -0
- package/src/db/queries/settings.ts +28 -0
- package/src/db/queries/types.ts +172 -0
- package/src/db/queries.ts +75 -802
- package/src/db/schema.ts +178 -50
- package/src/cli/commands/export.ts +0 -144
- package/src/cli/commands/guide.ts +0 -127
- package/src/cli/commands/init/templates/skills/scenarios.md +0 -97
- package/src/cli/commands/probe-methods.ts +0 -108
- package/src/cli/commands/probe-validation.ts +0 -124
- package/src/cli/commands/serve.ts +0 -114
- package/src/cli/commands/sync.ts +0 -268
- package/src/cli/commands/update.ts +0 -189
- package/src/cli/commands/validate.ts +0 -34
- package/src/core/diagnostics/render-md.ts +0 -112
- package/src/core/exporter/postman.ts +0 -963
- package/src/core/generator/guide-builder.ts +0 -253
- package/src/core/meta/types.ts +0 -19
- package/src/core/parser/index.ts +0 -21
- package/src/core/runner/execute-run.ts +0 -132
- package/src/core/runner/index.ts +0 -12
- package/src/core/sync/spec-differ.ts +0 -38
- package/src/web/data/collection-state.ts +0 -362
- package/src/web/routes/api.ts +0 -314
- package/src/web/routes/dashboard.ts +0 -350
- package/src/web/routes/runs.ts +0 -64
- package/src/web/schemas.ts +0 -121
- package/src/web/server.ts +0 -134
- package/src/web/static/htmx.min.cjs +0 -1
- package/src/web/static/style.css +0 -1148
- package/src/web/views/endpoints-tab.ts +0 -174
- package/src/web/views/explorer-tab.ts +0 -402
- package/src/web/views/health-strip.ts +0 -92
- package/src/web/views/layout.ts +0 -48
- package/src/web/views/results.ts +0 -210
- package/src/web/views/runs-tab.ts +0 -126
- package/src/web/views/suites-tab.ts +0 -181
|
@@ -3,18 +3,46 @@ import type { OpenAPIV3 } from "openapi-types";
|
|
|
3
3
|
/**
|
|
4
4
|
* Recursively generates test data from an OpenAPI schema.
|
|
5
5
|
* Uses heuristic placeholders ({{$...}} generators) where possible.
|
|
6
|
+
*
|
|
7
|
+
* `forRequest` (default true) toggles request-body filters that strip
|
|
8
|
+
* server-assigned fields the client must not send: properties marked
|
|
9
|
+
* `readOnly: true`, and the literal field name `id` at any object level
|
|
10
|
+
* (universally server-assigned in REST). Pass `forRequest: false` to
|
|
11
|
+
* preserve full schema shape for response-side fixtures.
|
|
6
12
|
*/
|
|
7
13
|
export function generateFromSchema(
|
|
8
14
|
schema: OpenAPIV3.SchemaObject,
|
|
9
15
|
propertyName?: string,
|
|
10
|
-
_depth =
|
|
16
|
+
opts: { _depth?: number; forRequest?: boolean } = {},
|
|
11
17
|
): unknown {
|
|
12
|
-
|
|
18
|
+
const _depth = opts._depth ?? 0;
|
|
19
|
+
const forRequest = opts.forRequest ?? true;
|
|
20
|
+
const recurse = (s: OpenAPIV3.SchemaObject, name?: string) =>
|
|
21
|
+
generateFromSchema(s, name, { _depth: _depth + 1, forRequest });
|
|
22
|
+
|
|
23
|
+
if (_depth > 7) {
|
|
24
|
+
return depthLimitDefault(schema, propertyName);
|
|
25
|
+
}
|
|
13
26
|
|
|
14
27
|
// Highest-priority signal: explicit example from spec.
|
|
15
28
|
// Beats enum, format, heuristics — the spec author told us what to send.
|
|
16
|
-
|
|
17
|
-
|
|
29
|
+
// Two exceptions:
|
|
30
|
+
// 1. `null` examples are noise (often nullable: true with no real example) —
|
|
31
|
+
// skip so we fall through to type/format defaults instead of emitting null.
|
|
32
|
+
// 2. UUID-shaped examples on FK-context fields (name ends with `_id` or
|
|
33
|
+
// schema.format === "uuid") are usually copy-pasted from another tenant's
|
|
34
|
+
// spec. Honoring them leaks foreign IDs and guarantees 422 on real APIs;
|
|
35
|
+
// `{{$uuid}}` is at least an honest test placeholder.
|
|
36
|
+
//
|
|
37
|
+
// OpenAPI 3.1 / JSON Schema also allows `examples: [...]` (plural array). When
|
|
38
|
+
// both are present `example` wins; otherwise pick the first non-null entry
|
|
39
|
+
// from `examples` and apply the same FK-UUID guard. `example` (singular) is
|
|
40
|
+
// still the OpenAPI 3.0 form and remains supported.
|
|
41
|
+
const exampleValue = pickExampleValue(schema);
|
|
42
|
+
if (exampleValue !== undefined) {
|
|
43
|
+
if (!isLikelyForeignFKExample(schema, propertyName, exampleValue)) {
|
|
44
|
+
return exampleValue;
|
|
45
|
+
}
|
|
18
46
|
}
|
|
19
47
|
|
|
20
48
|
// allOf: merge all schemas
|
|
@@ -26,15 +54,31 @@ export function generateFromSchema(
|
|
|
26
54
|
merged.properties = { ...merged.properties, ...s.properties };
|
|
27
55
|
}
|
|
28
56
|
}
|
|
29
|
-
return
|
|
57
|
+
return recurse(merged, propertyName);
|
|
30
58
|
}
|
|
31
59
|
|
|
32
|
-
// oneOf / anyOf:
|
|
60
|
+
// oneOf / anyOf: pick the most informative variant. Prefer objects with
|
|
61
|
+
// properties over loose primitives — APIs that accept `Array<{id}>|Array<string>`
|
|
62
|
+
// need the object variant, not a string that 422s. Falls back to first
|
|
63
|
+
// non-null entry.
|
|
64
|
+
//
|
|
65
|
+
// ARV-78 (feedback round-04 / F25): when the parent schema declares a
|
|
66
|
+
// `discriminator: { propertyName, mapping? }` (typical OpenAPI 3 polymorphism —
|
|
67
|
+
// /automations.steps with type=trigger|action), pick the variant whose
|
|
68
|
+
// discriminator property carries a const/enum-single value and stamp that
|
|
69
|
+
// value into the result. Without this, generator emits a random variant and
|
|
70
|
+
// the API 422s with "Missing <required-by-other-variant>".
|
|
33
71
|
if (schema.oneOf) {
|
|
34
|
-
|
|
72
|
+
const variants = schema.oneOf as OpenAPIV3.SchemaObject[];
|
|
73
|
+
const picked = pickBestVariant(variants, schema.discriminator);
|
|
74
|
+
const result = recurse(picked, propertyName);
|
|
75
|
+
return stampDiscriminator(result, picked, schema.discriminator);
|
|
35
76
|
}
|
|
36
77
|
if (schema.anyOf) {
|
|
37
|
-
|
|
78
|
+
const variants = schema.anyOf as OpenAPIV3.SchemaObject[];
|
|
79
|
+
const picked = pickBestVariant(variants, schema.discriminator);
|
|
80
|
+
const result = recurse(picked, propertyName);
|
|
81
|
+
return stampDiscriminator(result, picked, schema.discriminator);
|
|
38
82
|
}
|
|
39
83
|
|
|
40
84
|
// enum: first value (always valid for the API contract)
|
|
@@ -52,10 +96,23 @@ export function generateFromSchema(
|
|
|
52
96
|
|
|
53
97
|
// OpenAPI 3.1: type can be `["string", "null"]`. Collapse to the first
|
|
54
98
|
// non-null entry so the switch below routes correctly.
|
|
55
|
-
|
|
99
|
+
let effectiveType = Array.isArray(schema.type)
|
|
56
100
|
? (schema.type as string[]).find(t => t !== "null") as OpenAPIV3.SchemaObject["type"] | undefined
|
|
57
101
|
: schema.type;
|
|
58
102
|
|
|
103
|
+
// ARV-67 (feedback round-01 / F7): schemas in the wild routinely omit
|
|
104
|
+
// `type` on nested-object fields and rely on `properties` / `required`
|
|
105
|
+
// / `items` to convey shape. Without the salvage below, the default
|
|
106
|
+
// branch returns "{{$randomString}}" for a missing-type field — which
|
|
107
|
+
// is what made `prepare-fixtures --seed` send a string for nested
|
|
108
|
+
// objects like `automations.config` / `automations.steps` and earn
|
|
109
|
+
// "Expected object, received string" 422s. Infer the type
|
|
110
|
+
// from structural hints when nothing else gives one.
|
|
111
|
+
if (effectiveType === undefined) {
|
|
112
|
+
if ((schema as { items?: unknown }).items !== undefined) effectiveType = "array";
|
|
113
|
+
else if (schema.properties || Array.isArray(schema.required)) effectiveType = "object";
|
|
114
|
+
}
|
|
115
|
+
|
|
59
116
|
switch (effectiveType) {
|
|
60
117
|
case "string":
|
|
61
118
|
return guessStringPlaceholder(schema, propertyName);
|
|
@@ -72,7 +129,7 @@ export function generateFromSchema(
|
|
|
72
129
|
case "array": {
|
|
73
130
|
const arr = schema as OpenAPIV3.ArraySchemaObject;
|
|
74
131
|
if (arr.items) {
|
|
75
|
-
const item =
|
|
132
|
+
const item = recurse(arr.items as OpenAPIV3.SchemaObject, undefined);
|
|
76
133
|
return [item];
|
|
77
134
|
}
|
|
78
135
|
return [];
|
|
@@ -84,17 +141,24 @@ export function generateFromSchema(
|
|
|
84
141
|
if (schema.properties) {
|
|
85
142
|
const obj: Record<string, unknown> = {};
|
|
86
143
|
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
87
|
-
|
|
144
|
+
const ps = propSchema as OpenAPIV3.SchemaObject;
|
|
145
|
+
if (forRequest && shouldSkipForRequest(key, ps)) continue;
|
|
146
|
+
obj[key] = recurse(ps, key);
|
|
88
147
|
}
|
|
89
148
|
return obj;
|
|
90
149
|
}
|
|
91
|
-
// Record type
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
150
|
+
// Record type (additionalProperties only). The historical behavior was
|
|
151
|
+
// to materialize fake `key1`/`key2` entries to make the shape visible.
|
|
152
|
+
// Real APIs reject those — the keys are always domain-specific
|
|
153
|
+
// (filter names, label keys). Emit an empty `{}` instead: it preserves
|
|
154
|
+
// the object type (so type-validators pass) without injecting payloads
|
|
155
|
+
// the server didn't ask for. Callers who need realistic record content
|
|
156
|
+
// should override via fixture-pack/.env.yaml.
|
|
157
|
+
if (
|
|
158
|
+
(schema.additionalProperties && typeof schema.additionalProperties === "object") ||
|
|
159
|
+
schema.additionalProperties === true
|
|
160
|
+
) {
|
|
161
|
+
return {};
|
|
98
162
|
}
|
|
99
163
|
// Bare object with no properties
|
|
100
164
|
if (effectiveType === "object") {
|
|
@@ -105,6 +169,410 @@ export function generateFromSchema(
|
|
|
105
169
|
}
|
|
106
170
|
}
|
|
107
171
|
|
|
172
|
+
/** Fields the client must not send in a request body: explicit `readOnly: true`,
|
|
173
|
+
* or the literal name `id`. The latter is a heuristic for under-specified specs
|
|
174
|
+
* (common in in-house APIs) that don't mark the server-assigned id readOnly
|
|
175
|
+
* but still 4xx on it being present.
|
|
176
|
+
*
|
|
177
|
+
* Also strips Stripe-style `expand` meta-param when it's declared as a string
|
|
178
|
+
* array — Stripe doesn't mark it `readOnly` but rejects synthetic random
|
|
179
|
+
* values with 400 "This property cannot be expanded (<random>)", which kills
|
|
180
|
+
* 50+ baseline POSTs in mass-assignment / stateful checks on Stripe specs.
|
|
181
|
+
* The shape check (array of strings on a request body) keeps false-positives
|
|
182
|
+
* low: APIs using `expand` for real payload fields would normally use an
|
|
183
|
+
* object/enum, not a free-string array. */
|
|
184
|
+
function shouldSkipForRequest(name: string, schema: OpenAPIV3.SchemaObject): boolean {
|
|
185
|
+
if (schema.readOnly === true) return true;
|
|
186
|
+
if (name === "id") return true;
|
|
187
|
+
if (name === "expand" && isStringArray(schema)) return true;
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function isStringArray(schema: OpenAPIV3.SchemaObject): boolean {
|
|
192
|
+
if (schema.type !== "array") return false;
|
|
193
|
+
const items = schema.items as OpenAPIV3.SchemaObject | undefined;
|
|
194
|
+
if (!items) return false;
|
|
195
|
+
return items.type === "string";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** When recursion hits the depth cap, return a type-appropriate placeholder
|
|
199
|
+
* rather than `{}` — `{}` for `array<string>` produces `[{}]` which 422s on
|
|
200
|
+
* every realistic API. */
|
|
201
|
+
function depthLimitDefault(schema: OpenAPIV3.SchemaObject, name?: string): unknown {
|
|
202
|
+
const t = Array.isArray(schema.type)
|
|
203
|
+
? (schema.type as string[]).find(x => x !== "null")
|
|
204
|
+
: schema.type;
|
|
205
|
+
switch (t) {
|
|
206
|
+
case "string": return formatToPlaceholder(schema.format) ?? guessStringPlaceholder(schema, name);
|
|
207
|
+
case "integer": return 1;
|
|
208
|
+
case "number": return 1;
|
|
209
|
+
case "boolean": return true;
|
|
210
|
+
case "array": return [];
|
|
211
|
+
case "object":
|
|
212
|
+
default: return {};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** ARV-135 (m-21): score-based oneOf/anyOf variant selection.
|
|
217
|
+
*
|
|
218
|
+
* Picks the variant most likely to produce a body the server accepts:
|
|
219
|
+
*
|
|
220
|
+
* 1. Drop `type: "null"` shorthands unless they're the only choice
|
|
221
|
+
* (OpenAPI 3.1 nullable spelling).
|
|
222
|
+
* 2. Prefer the variant with the fewest UNRESOLVABLE required fields
|
|
223
|
+
* — i.e. required keys that have no entry in `properties`, which
|
|
224
|
+
* the generator can't synthesise. The historical bug was picking
|
|
225
|
+
* the FIRST variant matching the discriminator filter even when a
|
|
226
|
+
* sibling variant was demonstrably more complete; this leaves
|
|
227
|
+
* `required: [config, event_name]` partially-filled and the API
|
|
228
|
+
* 422s with "Missing config, event_name" (F24/Resend automations).
|
|
229
|
+
* 3. Among ties, prefer object-typed variants over primitives
|
|
230
|
+
* (TASK-222: `Array<{id}>|Array<string>` should pick the object).
|
|
231
|
+
* 4. Prefer the variant with more declared properties (richer surface
|
|
232
|
+
* is closer to what real callers send).
|
|
233
|
+
* 5. When a `discriminator.propertyName` is present, treat variants
|
|
234
|
+
* that carry a single-value enum/const for that property as more
|
|
235
|
+
* authoritative — they tie-break ahead of variants without one.
|
|
236
|
+
*
|
|
237
|
+
* The discriminator's `mapping` is used to derive the stamped value
|
|
238
|
+
* even when the picked variant lacks an inline enum/const: typical
|
|
239
|
+
* specs (Stripe, Linear) declare mapping centrally and omit the value
|
|
240
|
+
* on each variant.
|
|
241
|
+
*/
|
|
242
|
+
function pickBestVariant(
|
|
243
|
+
variants: OpenAPIV3.SchemaObject[],
|
|
244
|
+
discriminator: OpenAPIV3.DiscriminatorObject | undefined,
|
|
245
|
+
): OpenAPIV3.SchemaObject {
|
|
246
|
+
const isNull = (s: OpenAPIV3.SchemaObject) => (s as { type?: unknown }).type === "null";
|
|
247
|
+
const nonNull = variants.filter(v => !isNull(v));
|
|
248
|
+
const pool = nonNull.length > 0 ? nonNull : variants;
|
|
249
|
+
|
|
250
|
+
const discriminatorKey = discriminator?.propertyName;
|
|
251
|
+
const hasDiscriminatorEnum = (v: OpenAPIV3.SchemaObject): boolean => {
|
|
252
|
+
if (!discriminatorKey) return false;
|
|
253
|
+
const prop = v.properties?.[discriminatorKey] as OpenAPIV3.SchemaObject | undefined;
|
|
254
|
+
if (!prop) return false;
|
|
255
|
+
const en = (prop as { enum?: unknown[] }).enum;
|
|
256
|
+
const cn = (prop as { const?: unknown }).const;
|
|
257
|
+
return (Array.isArray(en) && en.length === 1) || (cn !== undefined && cn !== null);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const score = (v: OpenAPIV3.SchemaObject) => {
|
|
261
|
+
const req = (v.required ?? []) as string[];
|
|
262
|
+
const props = v.properties ?? {};
|
|
263
|
+
const unresolvable = req.filter(r => !(r in props)).length;
|
|
264
|
+
const propCount = Object.keys(props).length;
|
|
265
|
+
const isObjectWithProps = v.type === "object" && propCount > 0;
|
|
266
|
+
return {
|
|
267
|
+
unresolvable,
|
|
268
|
+
hasDiscriminator: hasDiscriminatorEnum(v) ? 1 : 0,
|
|
269
|
+
isObjectWithProps: isObjectWithProps ? 1 : 0,
|
|
270
|
+
propCount,
|
|
271
|
+
};
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// Sort: fewer unresolvable → has discriminator → object-with-props → more props.
|
|
275
|
+
// Sort is stable, so original spec order breaks remaining ties.
|
|
276
|
+
const ranked = [...pool].sort((a, b) => {
|
|
277
|
+
const sa = score(a);
|
|
278
|
+
const sb = score(b);
|
|
279
|
+
if (sa.unresolvable !== sb.unresolvable) return sa.unresolvable - sb.unresolvable;
|
|
280
|
+
if (sa.hasDiscriminator !== sb.hasDiscriminator) return sb.hasDiscriminator - sa.hasDiscriminator;
|
|
281
|
+
if (sa.isObjectWithProps !== sb.isObjectWithProps) return sb.isObjectWithProps - sa.isObjectWithProps;
|
|
282
|
+
return sb.propCount - sa.propCount;
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
return ranked[0]!;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Stamp the discriminator key onto a generated object. Without this the
|
|
289
|
+
* variant choice is "anonymous" from the body's point of view — APIs that
|
|
290
|
+
* switch on `type` reject the request even when every other field is
|
|
291
|
+
* perfect.
|
|
292
|
+
*
|
|
293
|
+
* ARV-135: now also honours `discriminator.mapping` — when the picked
|
|
294
|
+
* variant has no inline enum/const for the discriminator property, fall
|
|
295
|
+
* back to the first mapping key. Specs that declare polymorphism via
|
|
296
|
+
* central mapping (rather than inline `enum: ["x"]` on each variant)
|
|
297
|
+
* previously left the body un-stamped. */
|
|
298
|
+
function stampDiscriminator(
|
|
299
|
+
result: unknown,
|
|
300
|
+
variant: OpenAPIV3.SchemaObject,
|
|
301
|
+
discriminator: OpenAPIV3.DiscriminatorObject | undefined,
|
|
302
|
+
): unknown {
|
|
303
|
+
const propertyName = discriminator?.propertyName;
|
|
304
|
+
if (!propertyName) return result;
|
|
305
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) return result;
|
|
306
|
+
let stamp: unknown;
|
|
307
|
+
const prop = variant.properties?.[propertyName] as OpenAPIV3.SchemaObject | undefined;
|
|
308
|
+
if (prop) {
|
|
309
|
+
const en = (prop as { enum?: unknown[] }).enum;
|
|
310
|
+
const cn = (prop as { const?: unknown }).const;
|
|
311
|
+
if (Array.isArray(en) && en.length === 1) stamp = en[0];
|
|
312
|
+
else if (cn !== undefined && cn !== null) stamp = cn;
|
|
313
|
+
}
|
|
314
|
+
if (stamp === undefined && discriminator?.mapping) {
|
|
315
|
+
const keys = Object.keys(discriminator.mapping);
|
|
316
|
+
if (keys.length > 0) stamp = keys[0];
|
|
317
|
+
}
|
|
318
|
+
if (stamp === undefined) return result;
|
|
319
|
+
(result as Record<string, unknown>)[propertyName] = stamp;
|
|
320
|
+
return result;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
324
|
+
|
|
325
|
+
/** Names that strongly imply an email field. Kept in sync with the email
|
|
326
|
+
* branch of `guessStringPlaceholder`/`classifyFieldSource`. Used to gate the
|
|
327
|
+
* description-based domain heuristic so phrases like "verified sending
|
|
328
|
+
* domain" in the description of a `from`/`to` field don't override the
|
|
329
|
+
* email mapping. */
|
|
330
|
+
function isEmailContextName(name?: string): boolean {
|
|
331
|
+
if (!name) return false;
|
|
332
|
+
// Canonical snake_case so camelCase (`replyTo`, `userEmail`) matches the
|
|
333
|
+
// same rules as `reply_to` / `user_email`.
|
|
334
|
+
const lower = canonicalVarName(name);
|
|
335
|
+
return (
|
|
336
|
+
lower === "email" ||
|
|
337
|
+
lower === "from" ||
|
|
338
|
+
lower === "to" ||
|
|
339
|
+
lower === "cc" ||
|
|
340
|
+
lower === "bcc" ||
|
|
341
|
+
lower === "sender" ||
|
|
342
|
+
lower === "recipient" ||
|
|
343
|
+
lower === "reply_to" ||
|
|
344
|
+
lower.endsWith("_email") ||
|
|
345
|
+
lower.endsWith("_reply_to") ||
|
|
346
|
+
lower.endsWith("_from") ||
|
|
347
|
+
lower.endsWith("_to") ||
|
|
348
|
+
lower.endsWith("_cc") ||
|
|
349
|
+
lower.endsWith("_bcc")
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** A schema `pattern` that explicitly allows lowercase but not uppercase
|
|
354
|
+
* letters (typical slug regex like `^[a-z0-9_\-]+$`). Used to switch from
|
|
355
|
+
* mixed-case `{{$randomString}}` to a slug-shaped generator. */
|
|
356
|
+
function isLowercaseOnlyPattern(pattern: string | undefined): boolean {
|
|
357
|
+
if (!pattern) return false;
|
|
358
|
+
return pattern.includes("a-z") && !pattern.includes("A-Z");
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** A string example shaped like a UUID, on a field that looks like a foreign
|
|
362
|
+
* key (name ends with `_id` or schema declares `format: uuid`), is almost
|
|
363
|
+
* always a tenant-specific value the spec author left in `example:`. Sending
|
|
364
|
+
* it verbatim guarantees 422 on a fresh account and leaks foreign IDs. */
|
|
365
|
+
function isLikelyForeignFKExample(
|
|
366
|
+
schema: OpenAPIV3.SchemaObject,
|
|
367
|
+
name?: string,
|
|
368
|
+
value?: unknown,
|
|
369
|
+
): boolean {
|
|
370
|
+
const ex = value !== undefined ? value : schema.example;
|
|
371
|
+
if (typeof ex !== "string") return false;
|
|
372
|
+
if (!UUID_RE.test(ex)) return false;
|
|
373
|
+
const fkByName = !!name && name.toLowerCase().endsWith("_id");
|
|
374
|
+
const fkByFormat = schema.format === "uuid";
|
|
375
|
+
return fkByName || fkByFormat;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Resolve the effective example value from a schema, supporting both
|
|
379
|
+
* OpenAPI 3.0 `example` (singular) and OpenAPI 3.1 / JSON Schema `examples`
|
|
380
|
+
* (plural array). `example` wins when both are set — it's the more
|
|
381
|
+
* intentional, single-source signal. `null` is treated as "no example"
|
|
382
|
+
* (see TASK-221). For `examples`, we pick the first non-null entry. */
|
|
383
|
+
function pickExampleValue(schema: OpenAPIV3.SchemaObject): unknown {
|
|
384
|
+
if (schema.example !== undefined && schema.example !== null) {
|
|
385
|
+
return schema.example;
|
|
386
|
+
}
|
|
387
|
+
const examples = (schema as { examples?: unknown }).examples;
|
|
388
|
+
if (Array.isArray(examples)) {
|
|
389
|
+
for (const ex of examples) {
|
|
390
|
+
if (ex !== null && ex !== undefined) return ex;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return undefined;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* TASK-269 — per-field provenance for `zond generate --explain`.
|
|
398
|
+
*
|
|
399
|
+
* Returns a label describing *why* `generateFromSchema` would emit the
|
|
400
|
+
* value it does for a given (schema, propertyName). Mirrors the dispatch
|
|
401
|
+
* priority in `generateFromSchema` without producing the value, so
|
|
402
|
+
* `--explain` can show "name → {{$randomName}} [heuristic:name]" without
|
|
403
|
+
* re-executing generation.
|
|
404
|
+
*
|
|
405
|
+
* Kept as a parallel function instead of refactoring `generateFromSchema`
|
|
406
|
+
* to record sources — the recursion path-tracking complexity would
|
|
407
|
+
* outweigh the value for what is currently a debug-only surface. The
|
|
408
|
+
* heuristic order here MUST stay in lockstep with the function above; a
|
|
409
|
+
* unit test (data-factory.test.ts) pins the labels for each branch.
|
|
410
|
+
*/
|
|
411
|
+
export type FieldSource =
|
|
412
|
+
| "example"
|
|
413
|
+
| "examples"
|
|
414
|
+
| "enum"
|
|
415
|
+
| "format"
|
|
416
|
+
| "pattern"
|
|
417
|
+
| "min"
|
|
418
|
+
| "max"
|
|
419
|
+
| "random"
|
|
420
|
+
| "default"
|
|
421
|
+
| `heuristic:${string}`;
|
|
422
|
+
|
|
423
|
+
export function classifyFieldSource(
|
|
424
|
+
schema: OpenAPIV3.SchemaObject,
|
|
425
|
+
propertyName?: string,
|
|
426
|
+
): FieldSource {
|
|
427
|
+
// example > examples (3.1) — same FK-UUID guard as generateFromSchema.
|
|
428
|
+
if (schema.example !== undefined && schema.example !== null) {
|
|
429
|
+
if (!isLikelyForeignFKExample(schema, propertyName, schema.example)) {
|
|
430
|
+
return "example";
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const examples = (schema as { examples?: unknown }).examples;
|
|
434
|
+
if (Array.isArray(examples)) {
|
|
435
|
+
for (const ex of examples) {
|
|
436
|
+
if (ex === null || ex === undefined) continue;
|
|
437
|
+
if (!isLikelyForeignFKExample(schema, propertyName, ex)) return "examples";
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (schema.enum && schema.enum.length > 0) return "enum";
|
|
442
|
+
if (formatToPlaceholder(schema.format) !== undefined) return "format";
|
|
443
|
+
|
|
444
|
+
const t = Array.isArray(schema.type)
|
|
445
|
+
? (schema.type as string[]).find(x => x !== "null")
|
|
446
|
+
: schema.type;
|
|
447
|
+
|
|
448
|
+
if (t === "string") {
|
|
449
|
+
// ARV-38: keep --explain in sync with guessStringPlaceholder — when a
|
|
450
|
+
// default is consumed, label the source as "default", not "random".
|
|
451
|
+
if (typeof schema.default === "string" && schema.default.length > 0) return "default";
|
|
452
|
+
if (isLowercaseOnlyPattern(schema.pattern)) return "pattern";
|
|
453
|
+
if (
|
|
454
|
+
schema.description &&
|
|
455
|
+
/\b(domain|hostname|fqdn)\b/i.test(schema.description) &&
|
|
456
|
+
!isEmailContextName(propertyName)
|
|
457
|
+
) {
|
|
458
|
+
return "heuristic:domain-from-description";
|
|
459
|
+
}
|
|
460
|
+
if (propertyName) {
|
|
461
|
+
const lower = canonicalVarName(propertyName);
|
|
462
|
+
if (lower === "slug" || lower.endsWith("_slug")) return "heuristic:slug";
|
|
463
|
+
if (lower === "domain" || lower === "hostname" || lower === "fqdn" || lower.endsWith("_domain")) return "heuristic:domain";
|
|
464
|
+
if (lower === "platform") return "heuristic:platform";
|
|
465
|
+
if (lower === "language" || lower === "lang" || lower === "locale") return "heuristic:locale";
|
|
466
|
+
if (lower === "country" || lower === "country_code" || lower.endsWith("_country") || lower.endsWith("_country_code")) return "heuristic:country";
|
|
467
|
+
if (lower === "timezone" || lower === "time_zone" || lower === "tz") return "heuristic:timezone";
|
|
468
|
+
if (lower === "currency" || lower === "currency_code" || lower.endsWith("_currency") || lower.endsWith("_currency_code")) return "heuristic:currency";
|
|
469
|
+
if (lower === "mcc" || lower.endsWith("_mcc") || lower === "merchant_category_code") return "heuristic:mcc";
|
|
470
|
+
if (lower === "color" || lower.endsWith("_color") || lower === "background_color" || lower === "hex" || lower.endsWith("_hex_color")) return "heuristic:color";
|
|
471
|
+
if (lower === "ip" || lower === "ip_address" || lower.endsWith("_ip") || lower.endsWith("_ip_address")) return "heuristic:ip";
|
|
472
|
+
if (
|
|
473
|
+
lower === "email" || lower === "from" || lower === "to" || lower === "cc" ||
|
|
474
|
+
lower === "bcc" || lower === "sender" || lower === "recipient" ||
|
|
475
|
+
lower === "reply_to" ||
|
|
476
|
+
lower.endsWith("_email") ||
|
|
477
|
+
lower.endsWith("_reply_to") || lower.endsWith("_from") ||
|
|
478
|
+
lower.endsWith("_to") || lower.endsWith("_cc") || lower.endsWith("_bcc")
|
|
479
|
+
) return "heuristic:email";
|
|
480
|
+
if (lower === "id" || lower === "uuid" || lower.endsWith("_id") || lower.endsWith("id")) return "heuristic:id";
|
|
481
|
+
if (lower === "name" || lower.endsWith("_name")) return "heuristic:name";
|
|
482
|
+
if (lower === "url" || lower.endsWith("_url") || lower === "uri" || lower === "href" || lower === "website") return "heuristic:url";
|
|
483
|
+
if (lower === "password" || lower.endsWith("_password")) return "heuristic:password";
|
|
484
|
+
if (lower === "phone" || lower === "telephone" || lower.endsWith("_phone")) return "heuristic:phone";
|
|
485
|
+
}
|
|
486
|
+
return "random";
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (t === "integer") {
|
|
490
|
+
if (schema.maximum !== undefined) return "max";
|
|
491
|
+
if (schema.minimum !== undefined && schema.minimum > 0) return "min";
|
|
492
|
+
return "random";
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (t === "number" || t === "boolean") return "default";
|
|
496
|
+
return "default";
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* ARV-138: canonicalise a body field name to a manifest/fixture var name.
|
|
501
|
+
* Converts camelCase → snake_case + lowercase so spec body fields
|
|
502
|
+
* (`issueId`, `sequenceTypeCode`) collapse onto the same var as the
|
|
503
|
+
* path-param spelling. Idempotent on already-snake_case input. The HTTP
|
|
504
|
+
* request still sends the raw field name — only the {{var}} namespace is
|
|
505
|
+
* normalised. Lives here (leaf module) so both fixtures-builder and
|
|
506
|
+
* suite-generator can share it without an import cycle.
|
|
507
|
+
*/
|
|
508
|
+
export function canonicalVarName(name: string): string {
|
|
509
|
+
return name
|
|
510
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
511
|
+
.replace(/[^a-zA-Z0-9]+/g, "_")
|
|
512
|
+
.toLowerCase();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const FK_ID_SUFFIX = /(?:_id|Id|_uuid)$/;
|
|
516
|
+
const CODE_REF_SUFFIX = /(?:_code|Code)$/;
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* ARV-45: a required request-body field that is a foreign-key / closed-
|
|
520
|
+
* vocabulary reference the generator can't synthesise a valid value for —
|
|
521
|
+
* so the generated test should reference a `{{fixture}}` the user fills in
|
|
522
|
+
* `.env.yaml`, not random junk that 400s and kills the CRUD chain at step 1.
|
|
523
|
+
*
|
|
524
|
+
* - `*_id` / `*Id` / `*_uuid`: always an FK. `generateFromSchema` emits
|
|
525
|
+
* `{{$uuid}}` here — a random id that 404/422s on a live API.
|
|
526
|
+
* - `*_code` / `*Code`: a reference code ONLY when no heuristic resolves
|
|
527
|
+
* it. `countryCode`/`currencyCode`/`mcc`… already map to real literals
|
|
528
|
+
* via `guessStringPlaceholder`, so they're excluded; the domain codes
|
|
529
|
+
* that motivated this (`sequenceTypeCode`, `templateGroupCode`) fall
|
|
530
|
+
* through to `{{$randomString}}` → 400.
|
|
531
|
+
*
|
|
532
|
+
* Enum/format/example-backed fields are never FK fixtures — the spec
|
|
533
|
+
* already told us a valid value.
|
|
534
|
+
*/
|
|
535
|
+
export function isFkFixtureField(name: string, schema: OpenAPIV3.SchemaObject): boolean {
|
|
536
|
+
if (schema.enum && schema.enum.length > 0) return false;
|
|
537
|
+
if (pickExampleValue(schema) !== undefined) return false;
|
|
538
|
+
if (FK_ID_SUFFIX.test(name)) return true;
|
|
539
|
+
if (CODE_REF_SUFFIX.test(name)) {
|
|
540
|
+
return generateFromSchema(schema, name) === "{{$randomString}}";
|
|
541
|
+
}
|
|
542
|
+
return false;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Flatten a request-body schema to its effective `{properties, required}`,
|
|
547
|
+
* merging `allOf` branches. .NET / Swagger-gen specs wrap almost every model
|
|
548
|
+
* in `allOf: [{...}]` (inheritance), so a direct `schema.properties` read sees
|
|
549
|
+
* nothing — `generateFromSchema` already merges allOf when producing the body,
|
|
550
|
+
* and FK wiring / the manifest must resolve the same shape or they silently
|
|
551
|
+
* miss every FK field on these specs. Recurses through nested allOf; ignores
|
|
552
|
+
* oneOf/anyOf (the generator picks one variant there, out of scope for FK
|
|
553
|
+
* wiring until a real spec needs it).
|
|
554
|
+
*/
|
|
555
|
+
export function effectiveObjectShape(
|
|
556
|
+
schema: OpenAPIV3.SchemaObject,
|
|
557
|
+
): { properties: Record<string, OpenAPIV3.SchemaObject>; required: Set<string> } {
|
|
558
|
+
const properties: Record<string, OpenAPIV3.SchemaObject> = {};
|
|
559
|
+
const required = new Set<string>();
|
|
560
|
+
const visit = (s: OpenAPIV3.SchemaObject | undefined) => {
|
|
561
|
+
if (!s || typeof s !== "object") return;
|
|
562
|
+
if (Array.isArray(s.allOf)) {
|
|
563
|
+
for (const sub of s.allOf) visit(sub as OpenAPIV3.SchemaObject);
|
|
564
|
+
}
|
|
565
|
+
if (s.properties) {
|
|
566
|
+
for (const [k, v] of Object.entries(s.properties)) {
|
|
567
|
+
properties[k] = v as OpenAPIV3.SchemaObject;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
if (Array.isArray(s.required)) for (const r of s.required) required.add(r);
|
|
571
|
+
};
|
|
572
|
+
visit(schema);
|
|
573
|
+
return { properties, required };
|
|
574
|
+
}
|
|
575
|
+
|
|
108
576
|
/**
|
|
109
577
|
* Map an OpenAPI `format` value to a zond generator placeholder. Returns
|
|
110
578
|
* undefined when the format is unknown or absent so callers can fall back
|
|
@@ -122,6 +590,20 @@ export function formatToPlaceholder(format: string | undefined): string | undefi
|
|
|
122
590
|
case "ipv4": return "{{$randomIpv4}}";
|
|
123
591
|
case "ipv6": return "::1";
|
|
124
592
|
case "password": return "TestPass123!";
|
|
593
|
+
// ARV-165: format-aware helpers. None of these are standard OpenAPI 3.x
|
|
594
|
+
// formats, but Stripe/GitHub/Shopify/Twilio specs frequently carry them
|
|
595
|
+
// as ad-hoc `format:` tags. Falling through to {{$randomString}} guarantees
|
|
596
|
+
// 400 from format-validated APIs (R09 finding: 199 hit-but-fail Stripe steps).
|
|
597
|
+
case "iso-country-code":
|
|
598
|
+
case "country-code":
|
|
599
|
+
case "country": return "{{$randomCountryCode}}";
|
|
600
|
+
case "iso-currency-code":
|
|
601
|
+
case "currency-code":
|
|
602
|
+
case "currency": return "{{$randomCurrencyCode}}";
|
|
603
|
+
case "mcc": return "{{$randomMCC}}";
|
|
604
|
+
case "color":
|
|
605
|
+
case "hex-color":
|
|
606
|
+
case "rgb-hex": return "{{$randomColorHex}}";
|
|
125
607
|
default: return undefined;
|
|
126
608
|
}
|
|
127
609
|
}
|
|
@@ -139,6 +621,7 @@ export function generateMultipartFromSchema(
|
|
|
139
621
|
|
|
140
622
|
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
141
623
|
const s = propSchema as OpenAPIV3.SchemaObject;
|
|
624
|
+
if (shouldSkipForRequest(key, s)) continue;
|
|
142
625
|
if (s.format === "binary" || s.format === "byte") {
|
|
143
626
|
result[key] = { file: `./fixtures/${key}.bin`, content_type: "application/octet-stream" };
|
|
144
627
|
} else {
|
|
@@ -154,16 +637,97 @@ function guessStringPlaceholder(schema: OpenAPIV3.SchemaObject, name?: string):
|
|
|
154
637
|
// Format-based dispatch already happened earlier in generateFromSchema;
|
|
155
638
|
// this branch only sees strings whose format is empty or unrecognised.
|
|
156
639
|
|
|
157
|
-
//
|
|
640
|
+
// ARV-38: when the spec declares a JSON-Schema `default` for a string-typed
|
|
641
|
+
// field with no enum, prefer it over heuristics. PATCH endpoints in
|
|
642
|
+
// particular rely on this — e.g. a `PATCH /domains/{id}` with
|
|
643
|
+
// `tls: { type: string, default: "opportunistic" }` would otherwise get
|
|
644
|
+
// a random fallback and a guaranteed 422 every run.
|
|
645
|
+
if (typeof schema.default === "string" && schema.default.length > 0) {
|
|
646
|
+
return schema.default;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Pattern-aware: many specs constrain slugs via regex like
|
|
650
|
+
// `^(?![0-9]+$)[a-z0-9_\-]+$` without setting `format`. Default
|
|
651
|
+
// `{{$randomString}}` mixes upper+lower → 400 from the validator.
|
|
652
|
+
// Heuristic: pattern allows `a-z` but forbids `A-Z` → emit a slug.
|
|
653
|
+
if (isLowercaseOnlyPattern(schema.pattern)) {
|
|
654
|
+
return "{{$randomSlug}}";
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Description-aware: when the schema describes a domain/hostname (e.g.
|
|
658
|
+
// a `POST /domains/`-style endpoint or DNS-zone create route) but the
|
|
659
|
+
// field is generically named `name`, the default `{{$randomName}}`
|
|
660
|
+
// returns "Bob Wilson" and the server rejects it. TASK-224.
|
|
661
|
+
// Skip when the field name is clearly in email vocabulary — email-API
|
|
662
|
+
// specs often describe `from`/`to`/etc. with phrases like "verified
|
|
663
|
+
// sending domain" or "Name <user@domain>", which trips the regex but
|
|
664
|
+
// the field is an email, not a domain. Email vocab > domain-from-description.
|
|
665
|
+
if (
|
|
666
|
+
schema.description &&
|
|
667
|
+
/\b(domain|hostname|fqdn)\b/i.test(schema.description) &&
|
|
668
|
+
!isEmailContextName(name)
|
|
669
|
+
) {
|
|
670
|
+
return "{{$randomDomain}}";
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Name-based heuristics. Match on the canonical snake_case form so
|
|
674
|
+
// camelCase fields (`countryCode`, `firstName`, `userEmail`) hit the same
|
|
675
|
+
// rules as their snake_case spelling — otherwise they fall through to
|
|
676
|
+
// {{$randomString}} and 400 on closed-vocab/format-strict APIs.
|
|
158
677
|
if (name) {
|
|
159
|
-
const lower = name
|
|
160
|
-
if (lower === "
|
|
678
|
+
const lower = canonicalVarName(name);
|
|
679
|
+
if (lower === "slug" || lower.endsWith("_slug")) {
|
|
680
|
+
return "{{$randomSlug}}";
|
|
681
|
+
}
|
|
682
|
+
if (lower === "domain" || lower === "hostname" || lower === "fqdn" || lower.endsWith("_domain")) {
|
|
683
|
+
return "{{$randomDomain}}";
|
|
684
|
+
}
|
|
685
|
+
// Closed-vocabulary fields where servers validate against an internal
|
|
686
|
+
// dictionary even when the spec lacks `enum:`. Random strings → 400.
|
|
687
|
+
// Pick the most universally-accepted value per dictionary.
|
|
688
|
+
if (lower === "platform") return "python";
|
|
689
|
+
if (lower === "language" || lower === "lang" || lower === "locale") return "en";
|
|
690
|
+
// ARV-165: country/currency literals (US/USD) were universally accepted
|
|
691
|
+
// but offered zero variety — added endsWith() patterns so nested fields
|
|
692
|
+
// like `bank_account.country`, `payout.currency_code`, `from_country`
|
|
693
|
+
// also resolve. Still emit a literal — picking from the random helper
|
|
694
|
+
// would weaken the "always-valid" property for downstream assertions
|
|
695
|
+
// that pin on the first value.
|
|
696
|
+
if (lower === "country" || lower === "country_code" || lower.endsWith("_country") || lower.endsWith("_country_code")) return "US";
|
|
697
|
+
if (lower === "timezone" || lower === "time_zone" || lower === "tz") return "UTC";
|
|
698
|
+
if (lower === "currency" || lower === "currency_code" || lower.endsWith("_currency") || lower.endsWith("_currency_code")) return "USD";
|
|
699
|
+
// ARV-165: MCC (merchant category code) — Stripe/Square/issuing APIs.
|
|
700
|
+
// Random {{$randomString}} → 400 because it's not a 4-digit code.
|
|
701
|
+
if (lower === "mcc" || lower.endsWith("_mcc") || lower === "merchant_category_code") return "{{$randomMCC}}";
|
|
702
|
+
// ARV-165: hex color — Stripe brand settings, Slack themes, GitHub labels.
|
|
703
|
+
if (lower === "color" || lower.endsWith("_color") || lower === "background_color" || lower === "hex" || lower.endsWith("_hex_color")) return "{{$randomColorHex}}";
|
|
704
|
+
// ARV-165: IP addresses — Stripe tos_acceptance.ip, audit logs, fraud APIs.
|
|
705
|
+
if (lower === "ip" || lower === "ip_address" || lower.endsWith("_ip") || lower.endsWith("_ip_address")) return "{{$randomIpv4}}";
|
|
706
|
+
// Email-context fields. Email-API specs often
|
|
707
|
+
// omit `format: email` on `from`/`to`/`reply_to`/`cc`/`bcc` — the field
|
|
708
|
+
// name is the only clue, and `{{$randomString}}` guarantees a 422.
|
|
709
|
+
if (
|
|
710
|
+
lower === "email" ||
|
|
711
|
+
lower === "from" ||
|
|
712
|
+
lower === "to" ||
|
|
713
|
+
lower === "cc" ||
|
|
714
|
+
lower === "bcc" ||
|
|
715
|
+
lower === "sender" ||
|
|
716
|
+
lower === "recipient" ||
|
|
717
|
+
lower === "reply_to" ||
|
|
718
|
+
lower.endsWith("_email") ||
|
|
719
|
+
lower.endsWith("_reply_to") ||
|
|
720
|
+
lower.endsWith("_from") ||
|
|
721
|
+
lower.endsWith("_to") ||
|
|
722
|
+
lower.endsWith("_cc") ||
|
|
723
|
+
lower.endsWith("_bcc")
|
|
724
|
+
) {
|
|
161
725
|
return "{{$randomEmail}}";
|
|
162
726
|
}
|
|
163
727
|
if (lower === "id" || lower === "uuid" || lower.endsWith("_id") || lower.endsWith("id")) {
|
|
164
728
|
return "{{$uuid}}";
|
|
165
729
|
}
|
|
166
|
-
if (lower === "name" || lower.endsWith("_name")
|
|
730
|
+
if (lower === "name" || lower.endsWith("_name")) {
|
|
167
731
|
return "{{$randomName}}";
|
|
168
732
|
}
|
|
169
733
|
if (lower === "url" || lower.endsWith("_url") || lower === "uri" || lower === "href" || lower === "website") {
|